Cerrábamos la lección de arrays con dos deudas: ${importe%.*}, que recortaba los céntimos, y ${ENVIOS[$c]:-0}, que ponía un valor por defecto. La segunda ya la conoces de 03-06; la primera pertenece a una familia mucho mayor. Bash puede cortar, recortar, sustituir y transformar cadenas sin llamar a ningún programa externo, usando la misma sintaxis ${...} de siempre. Esto importa por dos motivos: el código queda más corto, y sobre todo es entre diez y cien veces más rápido, porque cada sed o cut dentro de un bucle es un proceso nuevo del sistema operativo. En esta lección completas la familia entera y aprendes a formatear la salida de informe-diario.sh con printf.

Contenido

  1. Longitud y subcadenas
  2. Eliminar prefijos y sufijos
  3. Sustitución de texto
  4. Mayúsculas y minúsculas
  5. Concatenación
  6. Comparación y coincidencia de patrones
  7. Dividir una cadena en array
  8. printf: anchos, alineación y decimales
  9. Expansiones frente a sed, cut, basename
  10. Ejemplo integrador

  1. Longitud y subcadenas

s="2026-08-03 10:15:22 [ERROR] timeout al contactar con la pasarela"

echo "${#s}"          # 62      → número de caracteres
echo "${s:0:10}"      # 2026-08-03   → desde la posición 0, 10 caracteres
echo "${s:11:8}"      # 10:15:22     → desde la 11, 8 caracteres
echo "${s:20}"        # [ERROR] timeout ...  → desde la 20 hasta el final
echo "${s: -7}"       # asarela     → los 7 últimos (ojo: espacio antes del -7)
echo "${s:0:-7}"      # todo menos los 7 últimos

La sintaxis es ${cadena:desplazamiento:longitud}, con el desplazamiento empezando en 0. Tres notas prácticas: el espacio antes de un número negativo es obligatorio, porque sin él ${s:-7} significa "valor por defecto 7", una expansión completamente distinta (también vale ${s:(-7)}); ambos números admiten aritmética, como ${s:i:n} o ${s:0:${#s}-4}; y se cuentan caracteres, no bytes, siempre que el locale sea UTF-8, así que una ñ cuenta 1. Funciona también sobre los argumentos: ${1:0:4} son los cuatro primeros caracteres de $1.

  1. Eliminar prefijos y sufijos

Cuatro operadores que recortan lo que coincida con un patrón glob (los de 02-05: *, ?, [...]), no con una expresión regular:

Expansión Quita Coincidencia
${s#patron} Del principio La más corta
${s##patron} Del principio La más larga
${s%patron} Del final La más corta
${s%%patron} Del final La más larga

Regla mnemotécnica, mirando un teclado: # está a la izquierda del $ y recorta por la izquierda; % está a la derecha y recorta por la derecha. Y doblar el símbolo significa "come todo lo que puedas".

ruta="/var/log/veloz/app.log.3.gz"
echo "${ruta##*/}"       # app.log.3.gz   → nombre del fichero (quita hasta la última /)
echo "${ruta#*/}"        # var/log/...    → quita solo hasta la PRIMERA /
echo "${ruta%/*}"        # /var/log/veloz → directorio (quita desde la última /)
echo "${ruta%%.*}"       # /var/log/veloz/app       → quita desde el primer punto
echo "${ruta%.*}"        # /var/log/veloz/app.log.3 → quita solo la última extensión

Aplicado a nuestros datos:

importe="24.50"
echo "${importe%.*}"     # 24   → parte entera (la deuda de 04-03)
echo "${importe#*.}"     # 50   → céntimos
f="app.log.3.gz"
echo "${f%%.*}"          # app  → nombre base
echo "${f##*.}"          # gz   → extensión

linea="2026-08-03 10:15:22 [ERROR] timeout al contactar"
resto="${linea#* }"                    # quita la fecha
hora="${resto%% *}"                    # 10:15:22
nivel="${linea#*\[}"; nivel="${nivel%%\]*}"   # ERROR
mensaje="${linea#*] }"                 # timeout al contactar

Ese bloque final extrae fecha, hora, nivel y mensaje de una línea de app.log sin lanzar un solo proceso. Los \\[ y \\] van escapados porque en un patrón glob los corchetes definen una clase de caracteres.

  1. Sustitución de texto

s="Valencia,Sevilla,Bilbao,Madrid"
echo "${s/,/ }"          # Valencia Sevilla,Bilbao,Madrid   → solo la PRIMERA
echo "${s//,/ }"         # Valencia Sevilla Bilbao Madrid   → TODAS
echo "${s//,}"           # ValenciaSevillaBilbaoMadrid      → borra (reemplazo vacío)
echo "${s/#Valencia/VLC}"  # VLC,Sevilla,...   → solo si está al PRINCIPIO
echo "${s/%Madrid/MAD}"    # ...,Sevilla,Bilbao,MAD → solo si está al FINAL

Resumen: una barra sustituye la primera coincidencia, dos barras todas, /# ancla al principio y /% al final. El patrón de búsqueda es otra vez un glob, lo que permite cosas como:

echo "${ruta//\//-}"                            # la / se escapa como \/
nombre="  alopez  "
nombre="${nombre#"${nombre%%[![:space:]]*}"}"   # quita espacios iniciales
nombre="${nombre%"${nombre##*[![:space:]]}"}"   # quita espacios finales
echo "[$nombre]"                                # [alopez]

Ese idioma para recortar espacios es feo pero es el estándar en Bash puro; guárdalo como función trim() en tu librería.

  1. Mayúsculas y minúsculas

Cuatro operadores, disponibles desde Bash 4:

ciudad="valencia"
echo "${ciudad^}"     # Valencia   → primera letra a mayúscula
echo "${ciudad^^}"    # VALENCIA   → todo a mayúsculas
c="MADRID"
echo "${c,}"          # mADRID     → primera letra a minúscula
echo "${c,,}"         # madrid     → todo a minúsculas

El uso más frecuente es normalizar antes de comparar, para que el usuario pueda escribir VALENCIA, valencia o Valencia: ciudad="${1,,}"; ciudad="${ciudad^}" deja siempre Valencia. Se puede limitar a ciertos caracteres con un patrón (${s^^[aeiou]} pone en mayúsculas solo las vocales), aunque es poco frecuente.

  1. Concatenación

Bash no tiene operador de concatenación: basta con escribir las cadenas seguidas.

base="/var/log/veloz"; fichero="app.log"
ruta="$base/$fichero"                 # /var/log/veloz/app.log
prefijo="informe"
nombre="${prefijo}_$(date +%F).txt"   # las llaves separan el nombre del texto siguiente

Las llaves de ${prefijo}_ son obligatorias aquí: sin ellas Bash buscaría una variable llamada prefijo_. Y para ir acumulando texto en un bucle:

lista=""
for ciudad in Valencia Sevilla Bilbao; do lista+="$ciudad, "; done   # += añade al final
echo "${lista%, }"              # Valencia, Sevilla, Bilbao  (quita la coma final)

+= sobre una cadena concatena; sobre un array (04-03) añade elementos; sobre una variable declare -i suma. El mismo operador, tres comportamientos según el tipo.

  1. Comparación y coincidencia de patrones

Dentro de [[ ]], el operador == hace algo más que comparar: si el lado derecho no está entrecomillado, se interpreta como un patrón glob.

f="app.log.3.gz"
[[ "$f" == app.log.* ]]         # VERDADERO → coincidencia de patrón
[[ "$f" == *.gz ]]              # VERDADERO → termina en .gz
[[ "$f" == "app.log.*" ]]       # FALSO → con comillas, es literal
[[ "$f" == ?pp* ]]              # VERDADERO → ? es un carácter cualquiera
patron="*.gz"
[[ "$f" == $patron ]]           # VERDADERO → la variable sin comillas actúa de patrón
[[ "$f" == "$patron" ]]         # FALSO → con comillas, compara con el texto "*.gz"

Esta es la única situación del curso en la que quitar las comillas es deliberado y correcto: cuando quieres que el contenido de la variable funcione como patrón. En cualquier otro contexto, comillas.

Otros operadores de cadena en [[ ]], ya vistos en 03-03: !=, < y > (orden alfabético según el locale), -z (vacía) y -n (no vacía).

Para coincidencias que un glob no puede expresar —"tres dígitos seguidos de una letra", "una IP válida"— hace falta el operador =~ con expresiones regulares. Es tema completo de 05-04; por ahora, si el patrón se puede escribir con * y ?, usa ==.

  1. Dividir una cadena en array

Ya lo viste en 04-03; lo recogemos aquí porque es una operación de cadenas de pleno derecho:

linea="E1001,2026-08-03,Valencia,alopez,entregado,24.50"
IFS=',' read -r -a campos <<< "$linea"       # la forma recomendada
echo "${campos[2]}"                          # Valencia
IFS=',' ; campos=( $linea ) ; unset IFS      # más corta, pero aplica globbing: peligrosa

La segunda variante falla si un campo contiene *, porque el globbing lo expandirá a nombres de ficheros. La operación inversa, unir un array en una cadena, se hace con "${arr[*]}" y el IFS adecuado:

ciudades=(Valencia Sevilla Bilbao)
IFS=' | '; echo "${ciudades[*]}"; unset IFS   # Valencia | Sevilla | Bilbao

Nota: solo se usa el primer carácter de IFS como separador en ${arr[*]}, así que el ejemplo anterior une con un espacio. Para separadores de varios caracteres, usa printf o un bucle.

  1. printf: anchos, alineación y decimales

Ya sabes de 03-06 que printf es preferible a echo. Aquí explotamos sus especificadores para construir tablas:

Especificador Significado Ejemplo → salida
%s Cadena printf '%s' holahola
%-10s Cadena, ancho 10, alineada a la izquierda hola
%10s Cadena, ancho 10, alineada a la derecha hola
%5d Entero, ancho 5, a la derecha 128
%05d Entero, relleno con ceros 00128
%.2f Decimal con 2 cifras (redondea) 24.50
%8.2f Decimal, ancho 8, 2 cifras 24.50
%% Un signo de porcentaje literal %
printf '%-10s %8s %13s %9s\n' CIUDAD ENVÍOS INCIDENCIAS IMPORTE
printf '%-10s %8d %13d %8.2f€\n' Valencia 128 11 3204.55
# CIUDAD       ENVÍOS   INCIDENCIAS   IMPORTE
# Valencia        128            11  3204.55€

Tres comportamientos de printf que conviene conocer: reutiliza el formato mientras queden argumentos, de modo que printf '%s\n' "${ciudades[@]}" imprime cada ciudad en su línea sin necesidad de bucle; el ancho puede venir de una variable usando *, como en printf '%-*s\n' 15 "$ciudad"; y %.2f redondea, no trunca (printf '%.2f' 2.005 da 2.01), admitiendo decimales aunque Bash no sepa calcularlos, porque el formateo lo hace printf y no la aritmética del shell (04-06). Con caracteres acentuados el ancho se cuenta en caracteres si el locale es UTF-8; si tus columnas bailan, revisa LANG.

  1. Expansiones frente a sed, cut, basename

Todo lo de esta lección se puede hacer también con herramientas externas. La diferencia es el coste:

Tarea Con herramienta externa Con expansión
Nombre del fichero basename "$ruta" "${ruta##*/}"
Directorio dirname "$ruta" "${ruta%/*}"
Quitar extensión basename "$f" .csv "${f%.csv}"
Campo 3 de un CSV cut -d, -f3 <<< "$l" read -r -a c <<< "$l"; "${c[2]}"
Sustituir texto sed 's/,/;/g' <<< "$s" "${s//,/;}"
A mayúsculas tr a-z A-Z <<< "$s" "${s^^}"

Cada columna izquierda lanza un proceso nuevo: el kernel hace un fork, carga el binario, monta una tubería y espera. Son unos pocos milisegundos... multiplicados por el número de iteraciones.

while IFS= read -r l; do echo "$l" | cut -d, -f3; done < envios.csv  # 5.000 líneas: ~12 s
while IFS=, read -r _ _ ciudad _; do echo "$ciudad"; done < envios.csv  # las mismas: ~0,3 s

La regla práctica: dentro de un bucle, expansión; fuera de un bucle, la herramienta que sea más legible. Y si el trabajo es procesar un fichero entero de una vez, casi siempre gana awk en una sola pasada (06-01); volveremos sobre esto al medir de verdad en 08-02. Un matiz a favor de basename/dirname: manejan los casos raros (rutas terminadas en /, ., ..) según el estándar, mientras que "${ruta%/*}" sobre un archivo.txt sin barras devuelve archivo.txt y no .. Para rutas controladas por ti, la expansión es perfecta; para rutas arbitrarias del usuario, usa las herramientas.

  1. Ejemplo integrador

Un analizador de app.log que extrae los campos con expansiones y formatea una tabla con printf, sin cut, sin awk y sin sed:

# resumen_log — Tabla de errores por hora a partir de app.log.
# Uso: resumen_log <fichero> <fecha>   Devuelve: 0 | 3 si el fichero no es legible
resumen_log() {
    local fichero="${1:?}" fecha="${2:?}" linea resto hora nivel h
    declare -A por_hora
    [[ -r "$fichero" ]] || return 3
    while IFS= read -r linea; do
        [[ "$linea" == "$fecha"* ]] || continue    # patrón glob: empieza por la fecha
        resto="${linea#* }"                        # quita "2026-08-03 "
        hora="${resto%%:*}"                        # "10"
        nivel="${linea#*[}"; nivel="${nivel%%]*}"  # "ERROR"
        [[ "$nivel" == "ERROR" ]] && (( por_hora["$hora"]++ ))
    done < "$fichero"
    printf '%-6s %8s\n' HORA ERRORES
    for h in $(printf '%s\n' "${!por_hora[@]}" | sort); do
        printf '%02d:00  %8d\n' "$(( 10#$h ))" "${por_hora[$h]}"
    done
}

Salida: una tabla HORA / ERRORES con líneas como 10:00 14. Comentarios sobre las partes nuevas:

  • [[ "$linea" == "$fecha"* ]]: la variable va entrecomillada y el * fuera, así se compara literalmente la fecha y se acepta cualquier cosa después. Es el idioma correcto para "empieza por".
  • ${resto%%:*} corta en el primer :, dejando la hora. Con % en lugar de %% habríamos cortado en el último y obtendríamos 10:15.
  • $(( 10#$h )) fuerza la interpretación en base 10. Una hora como 08 se interpretaría como octal inválido y daría error; es una trampa clásica que desmontamos entera en 04-06.

Y en informe-diario.sh, la función de formato queda en una línea: fila_ciudad() { printf '%-10s %8d %13d %10.2f€\n' "${1^}" "$2" "$3" "$4"; }. El ${1^} normaliza la inicial de la ciudad y los anchos fijos garantizan que las columnas cuadren venga como venga el dato.

Errores Comunes y Consejos

  • Olvidar el espacio en ${s: -3}. Sin él, ${s:-3} es "valor por defecto", una expansión distinta que no dará error pero devolverá otra cosa.
  • Confundir #/## y %/%%. Prueba siempre con un echo antes de meterlo en el script; la diferencia entre la coincidencia más corta y la más larga rompe rutas con varios puntos.
  • Creer que los patrones son regex. ${s#[0-9]+} no funciona: + no es un cuantificador en globbing. Para regex, =~ en 05-04.
  • Entrecomillar el patrón en [[ $f == "*.gz" ]]. Lo convierte en literal y siempre da falso. El patrón va sin comillas; la variable, con ellas.
  • Aplicar ${s^^} en Bash 3.2 (macOS). No existe; ahí toca tr.
  • Consejo: cuando una cadena de recortes se vuelva ilegible (${${x#a}%b} ni siquiera es válido en Bash), usa variables intermedias con nombre. Tres líneas claras valen más que una críptica.

Ejercicios

Ejercicio 1. Dada ruta="/srv/veloz/datos/envios-2026-08-03.csv", obtén con expansiones: el nombre del fichero, el directorio, el nombre sin extensión y la fecha 2026-08-03 que lleva dentro.

Ejercicio 2. Escribe normaliza_ciudad() que acepte cualquier combinación de mayúsculas (SEVILLA, sevilla, SeViLla) y devuelva siempre Sevilla; si la ciudad no es de las cuatro, devuelve código 1.

Ejercicio 3. A partir de la línea 2026-08-03 10:15:22 [WARN] cola de reparto por encima del umbral, extrae fecha, hora, nivel y mensaje en cuatro variables sin usar herramientas externas, e imprímelos con printf en columnas alineadas.

Soluciones

Solución 1.

ruta="/srv/veloz/datos/envios-2026-08-03.csv"
fichero="${ruta##*/}"        # envios-2026-08-03.csv
directorio="${ruta%/*}"      # /srv/veloz/datos
base="${fichero%.csv}"       # envios-2026-08-03
fecha="${base#envios-}"      # 2026-08-03
printf '%s | %s | %s | %s\n' "$fichero" "$directorio" "$base" "$fecha"

Solución 2.

normaliza_ciudad() {
    local entrada="${1:?}" c
    entrada="${entrada,,}"                    # todo a minúsculas
    for c in Valencia Sevilla Bilbao Madrid; do
        [[ "$entrada" == "${c,,}" ]] && { printf '%s\n' "$c"; return 0; }
    done
    return 1
}
normaliza_ciudad "SeViLla"                    # Sevilla

Normalizar ambos lados a minúsculas y comparar es más robusto que jugar con ${c^}, porque devuelve la forma canónica exacta de la lista.

Solución 3.

linea="2026-08-03 10:15:22 [WARN] cola de reparto por encima del umbral"
fecha="${linea%% *}"                  # hasta el primer espacio
resto="${linea#* }"                   # quita la fecha
hora="${resto%% *}"                   # hasta el siguiente espacio
nivel="${linea#*\[}"; nivel="${nivel%%\]*}"
mensaje="${linea#*\] }"

printf '%-12s %-10s %-7s %s\n' "$fecha" "$hora" "$nivel" "$mensaje"
# 2026-08-03   10:15:22   WARN    cola de reparto por encima del umbral

Cada línea usa el operador mínimo necesario: %% para "hasta el primero por la derecha", # para "el primero por la izquierda". Cero procesos lanzados.

Conclusión

Ya no necesitas cut, tr ni sed para el trabajo rutinario sobre cadenas. ${#s} mide, ${s:i:n} corta por posición, #/##/%/%% recortan por patrón desde cada extremo, / y // sustituyen, ^/, cambian la caja, += concatena, [[ $s == patron* ]] compara con globs, IFS reparte una línea en un array, y printf con anchos y %.2f produce tablas que cuadran. Todo dentro del mismo proceso, que en un bucle sobre miles de líneas es la diferencia entre segundos y minutos.

Nos queda una pieza estructural. Cuando en el ejercicio 2 recorriste cuatro ciudades con un bucle para ver si una cadena era válida, estabas usando la herramienta equivocada: comparar un valor contra una lista cerrada de alternativas es exactamente lo que hace case, la construcción que en 04-05 recibirá por fin el tratamiento formal que en 03-05 solo usamos de pasada para parsear opciones. Con ella daremos a informe-diario.sh subcomandos (resumen, detalle, ciudades) y construiremos veloz-menu.sh, un menú interactivo del toolkit con select.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados