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
- Longitud y subcadenas
- Eliminar prefijos y sufijos
- Sustitución de texto
- Mayúsculas y minúsculas
- Concatenación
- Comparación y coincidencia de patrones
- Dividir una cadena en array
printf: anchos, alineación y decimales- Expansiones frente a
sed,cut,basename - Ejemplo integrador
- 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 últimosLa 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.
- 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ónAplicado 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 contactarEse 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.
- 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 FINALResumen: 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.
- 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úsculasEl 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.
- 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 siguienteLas 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.
- 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 ==.
- 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: peligrosaLa 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 | BilbaoNota: 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.
printf: anchos, alineación y decimales
printf: anchos, alineación y decimalesYa 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' hola → hola |
%-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.
- Expansiones frente a
sed, cut, basename
sed, cut, basenameTodo 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 sLa 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.
- 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íamos10:15.$(( 10#$h ))fuerza la interpretación en base 10. Una hora como08se 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 unechoantes 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í tocatr. - 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" # SevillaNormalizar 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 umbralCada 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
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
