Al final de la lección anterior escribiste una función que recorría cuatro ciudades con un bucle solo para comprobar si una cadena estaba entre ellas. Funciona, pero es la herramienta equivocada: comparar un valor contra una lista cerrada de alternativas es el trabajo de case. Ya la usaste de pasada en 03-05, dentro del while [[ $# -gt 0 ]] que parseaba opciones largas, con la promesa de explicarla del todo más adelante. Ese momento es ahora. Además, case es la mitad de una pareja: la otra es select, la construcción que genera menús numerados en tres líneas y con la que construiremos veloz-menu.sh, la puerta de entrada interactiva del toolkit.
Contenido
- Sintaxis de
case - Los patrones son globs, no regex
- El comodín
*)y el orden de evaluación - Terminadores:
;;,;&y;;& casefrente aif/elifshopt -s nocasematch- Usos idiomáticos de
case - El parseo de opciones largas, explicado del todo
- Menús con
select - Buenas prácticas de menús
veloz-menu.sheinforme-diario.shcon subcomandos
- Sintaxis de
case
casecase "$estado" in
entregado) echo "Envío completado" ;;
en_reparto) echo "En ruta" ;;
incidencia) echo "Requiere atención" ;;
*) echo "Estado desconocido: $estado" >&2; return 1 ;;
esacLas piezas, una a una: case "$valor" in introduce la palabra que se va a comparar (entrecomíllala por costumbre, aunque case es uno de los pocos contextos que no hace división en palabras); patron) abre una rama, con el paréntesis de cierre obligatorio y el de apertura opcional; el cuerpo admite cualquier número de comandos, en una línea o en varias; ;; cierra la rama, y sin él hay error de sintaxis; y esac —case al revés— cierra el bloque.
Varias alternativas en la misma rama se separan con |, que se lee como "o": s|S|si|SI|y|yes) echo "Confirmado" ;;. Si ningún patrón coincide y no hay *), case no hace nada y devuelve 0: silencio absoluto, y por eso el *) es casi obligatorio.
- Los patrones son globs, no regex
Esta es la confusión número uno. Los patrones de case usan las mismas reglas que el globbing de ficheros (02-05), aunque aquí se aplican a una cadena cualquiera.
| Patrón | Coincide con | No coincide con |
|---|---|---|
Valencia |
exactamente Valencia |
valencia |
*.csv |
envios.csv, a.b.csv |
csv, envios.CSV |
app.log.? |
app.log.1 |
app.log.12 |
[0-9][0-9] / [!0-9]* |
08, 31 / a12 |
8, 123 / 2026 |
Val*|Sev* |
Valencia, Sevilla |
Bilbao |
Lo que no funciona porque es sintaxis de expresiones regulares: +, {2,3}, \d, ^, $ y los grupos (...). Un patrón [0-9]+ en case significa literalmente "un dígito seguido de un signo más". Las clases POSIX sí están disponibles ([[:digit:]], [[:alpha:]]), y para validar un identificador basta con case "$id" in E[0-9][0-9][0-9][0-9]) ....
Si necesitas de verdad una expresión regular —"entre dos y cuatro dígitos", "una dirección de correo"— la herramienta es [[ $s =~ regex ]], tema de 05-04; con case cubres el 90% de los casos reales y con mucha menos complejidad. Un detalle importante: el valor comparado no se somete a globbing, pero el patrón sí se expande. Eso permite patrones dinámicos: si p="Val*", la rama $p) coincide con Valencia. Y si quieres que un patrón sea literal, entrecomíllalo: "*") coincide solo con un asterisco.
- El comodín
*) y el orden de evaluación
*) y el orden de evaluacióncase evalúa las ramas de arriba abajo y se detiene en la primera que coincide. El orden, por tanto, es semántico: lo específico primero, lo general después.
case "$fichero" in
app.log) echo "Log activo" ;;
app.log.*.gz) echo "Rotado y comprimido" ;;
app.log.*) echo "Rotado" ;;
*.log) echo "Otro log" ;; # el general, DESPUÉS de los específicos
*) echo "No es un log" ;;
esacSi *.log) estuviera arriba del todo, se comería a app.log) y las ramas específicas no se ejecutarían nunca. El *) final es la red de seguridad: cúbrelo siempre, aunque solo sea para registrar un aviso. Un case sin *) que recibe un valor inesperado no falla, simplemente no hace nada, y ese silencio es el peor comportamiento posible en un script de operaciones.
- Terminadores:
;;, ;& y ;;&
;;, ;& y ;;&Bash 4 añadió dos terminadores más al clásico ;;.
| Terminador | Qué hace después de ejecutar la rama |
|---|---|
;; |
Sale del case. El habitual. |
;& |
Ejecuta el cuerpo de la rama siguiente sin comprobar su patrón (caída, como el fallthrough de C) |
;;& |
Sigue comprobando los patrones restantes y ejecuta los que coincidan |
nivel="ERROR"
case "$nivel" in
ERROR) echo "→ enviar alerta" ;;& # ;;& → sigue comprobando patrones
ERROR|WARN) echo "→ registrar" ;;&
*) echo "→ contabilizar" ;;
esacSalida: las tres líneas, → enviar alerta, → registrar y → contabilizar. Con ;; en todas las ramas solo se habría impreso la primera línea. ;;& es útil para acciones acumulativas por categoría; ;& es raro y confunde a quien lee. Recomendación: usa ;; por defecto y ;;& solo cuando la acumulación sea el objetivo explícito, con un comentario que lo diga.
case frente a if/elif
case frente a if/elif| Criterio | case |
if/elif |
|---|---|---|
| Qué compara | Una sola variable contra patrones | Cualquier condición, distinta en cada rama |
| Tipo de prueba | Coincidencia de patrón glob | Códigos de salida, números, ficheros |
| Alternativas por rama | Sí, con | |
Requiere || encadenados |
| Legibilidad con 5+ ramas | Excelente | Se degrada rápido |
Rangos numéricos y combinar y/o |
Torpe o imposible | Natural con (( )) |
Regla: si todas las ramas preguntan por el mismo valor y con igualdad o patrón, usa case; si cada rama pregunta por algo distinto o hay comparaciones numéricas, usa if/elif. Un if [[ "$sub" == "resumen" ]]; then ... elif [[ "$sub" == "detalle" ]]; then ... repite la variable en cada rama; el case equivalente la nombra una sola vez y alinea los patrones en columna, lo que hace evidente de un vistazo qué valores se contemplan.
shopt -s nocasematch
shopt -s nocasematchPor defecto la comparación distingue mayúsculas de minúsculas. La opción nocasematch la desactiva, y afecta también a [[ == ]]:
shopt -s nocasematch seguido de case "$1" in valencia) ... hace que coincidan VALENCIA, Valencia y vAlEnCiA, y shopt -u nocasematch la desactiva. Es una opción global del shell: mientras está activa, cualquier comparación posterior deja de distinguir la caja, incluidas las de funciones que no esperaban ese comportamiento. Por eso es preferible la alternativa explícita de 04-04, case "${1,,}" in valencia) ...: más corta, sin efectos globales y evidente al leerla.
- Usos idiomáticos de
case
casea) Validar contra una lista cerrada. La función del ejercicio de 04-04, ahora en una línea: es_ciudad_valida() { case "${1,,}" in valencia|sevilla|bilbao|madrid) return 0 ;; *) return 1 ;; esac; }.
b) Confirmación sí/no de un read (que ya conoces de 03-05):
read -r -p "¿Purgar los logs de más de 30 días? [s/N] " resp
case "${resp,,}" in
s|si|y|yes) purgar_logs ;;
*) echo "Operación cancelada" ;; # la opción segura es siempre la de por defecto
esacc) Ramificar por extensión de fichero, aprovechando que los patrones son globs:
procesar() {
case "$1" in
*.tar.gz) tar -tzf "$1" ;; # ¡debe ir ANTES que *.gz!
*.gz) zcat "$1" | tail -n 20 ;;
*.csv) column -t -s, "$1" ;;
*.log) tail -n 20 "$1" ;;
*) echo "Tipo no soportado: $1" >&2; return 1 ;;
esac
} # *.tar.gz es más específico que *.gz; colocado después, nunca se alcanzaríad) Despacho de subcomandos. El uso más potente, con el que un solo script ofrece varias operaciones (igual que git commit, git push):
sub="${1:-resumen}"; shift || true # el shift consume el subcomando...
case "$sub" in
resumen) cmd_resumen "$@" ;; # ...para que "$@" traiga solo SUS argumentos
detalle) cmd_detalle "$@" ;;
ciudades) cmd_ciudades "$@" ;;
ayuda|-h|--help) uso; exit 0 ;;
*) echo "Subcomando desconocido: $sub" >&2; uso; exit 2 ;;
esacEl || true evita fallar si no había ningún argumento que consumir. Lo desarrollas del todo en el apartado 11.
- El parseo de opciones largas, explicado del todo
Retomamos el bloque de 03-05 y ahora se entiende cada línea:
while [[ $# -gt 0 ]]; do
case "$1" in
--fecha) fecha="${2:?--fecha requiere un valor}"; shift 2 ;;
--fecha=*) fecha="${1#*=}"; shift ;;
--ciudad) ciudad="${2:?--ciudad requiere un valor}"; shift 2 ;;
--ciudad=*) ciudad="${1#*=}"; shift ;;
-v|--verbose) verboso=1; shift ;;
-h|--help) uso; exit 0 ;;
--) shift; break ;;
-*) echo "Opción desconocida: $1" >&2; exit 2 ;;
*) posicionales+=("$1"); shift ;;
esac
done
set -- "${posicionales[@]}"Ahora cada pieza tiene nombre: las ramas --fecha) y --fecha=*) aceptan las dos formas de escribir una opción, y la segunda usa ${1#*=} de 04-04 para quedarse con lo que hay tras el =; shift 2 descarta opción y valor mientras que shift descarta solo la opción; -- es el marcador convencional de "aquí acaban las opciones", de modo que lo que venga después son argumentos aunque empiece por guion; -*) captura cualquier opción no reconocida y falla ruidosamente en vez de tratarla como argumento; y *) acumula los posicionales en un array (04-03) que set -- "${posicionales[@]}" devuelve a $1, $2… ya limpios de opciones. Es el patrón completo de parseo manual, y ahora puedes escribirlo de memoria.
- Menús con
select
selectselect es un for especializado: imprime una lista numerada, muestra un prompt, lee un número, ejecuta el cuerpo con la variable puesta al elemento elegido y lo repite en bucle.
PS3="Elige una operación (número): "
select opcion in "Informe de hoy" "Ver errores" "Salir"; do
case "$opcion" in
"Informe de hoy") informe-diario.sh ;;
"Ver errores") tail -n 20 /var/log/veloz/app.log ;;
"Salir") echo "Hasta luego"; break ;;
*) echo "Opción no válida: $REPLY" >&2 ;;
esac
doneEn pantalla sale la lista numerada (1) Informe de hoy…) y luego el prompt. Lo que hay que saber:
PS3es la variable que contiene el prompt. Si no la fijas, sale el poco amistoso#?.opcionrecibe el texto del elemento elegido;REPLYcontiene lo que el usuario tecleó literalmente. Si escribe algo que no es un número válido,opcionqueda vacía peroREPLYconserva la entrada: por eso el*)delcaseinforma usando$REPLY.- El bucle es implícito e infinito. Solo se sale con
break, conCtrl+Do conexit. Sin una rama "Salir" conbreak, el menú no termina nunca. Pulsar Enter en blanco repite la lista. - La lista puede venir de un array:
select c in "${CIUDADES[@]}" Salir; do ... done.
- Buenas prácticas de menús
Valida siempre con un *) que capture entradas fuera de rango; nunca supongas que el usuario escribe un número correcto. Incluye una opción de salida explícita, porque confiar en Ctrl+C maltrata al usuario y deja procesos a medias. Y sobre todo: los menús son la interfaz secundaria, nunca la principal, ya que un script que solo funciona interactivamente no se puede meter en cron (07-01) ni encadenar con otros. La regla es que el comportamiento por defecto sea no interactivo, con opciones y subcomandos, y que el menú se ofrezca solo cuando no hay argumentos y la entrada es una terminal. El idioma es if [[ $# -eq 0 && -t 0 ]]; then menu_principal; else despachar "$@"; fi. La comprobación [[ -t 0 ]] (03-05) pregunta si la entrada estándar es una terminal; desde cron no lo es, así que el script nunca se quedará esperando una respuesta que no llegará. Este patrón es la base de lo que automatizarás en 07-02.
veloz-menu.sh e informe-diario.sh con subcomandos
veloz-menu.sh e informe-diario.sh con subcomandosPrimero, el menú del toolkit, en ~/veloz-ops/bin/veloz-menu.sh:
#!/usr/bin/env bash
# veloz-menu.sh — Menú interactivo de operaciones de Veloz Envíos.
set -u
readonly LOG_APP="/var/log/veloz/app.log"
menu_principal() {
local PS3="veloz-ops> " opcion
select opcion in "Informe diario" "Últimos errores" "Estado de la API" Salir; do
case "$opcion" in
"Informe diario") informe-diario.sh resumen ;;
"Últimos errores") tail -n 20 "$LOG_APP" | grep --color=auto ERROR ;;
"Estado de la API") systemctl is-active veloz-api ;;
Salir) break ;;
*) printf 'Opción no válida: %s\n' "$REPLY" >&2 ;;
esac
echo
done
}
if [[ $# -eq 0 && -t 0 ]]; then menu_principal; else echo "Uso: $0" >&2; exit 2; fiY el despachador de informe-diario.sh, que sustituye al main lineal de 04-02:
uso() { cat <<'FIN'
Uso: informe-diario.sh <subcomando> [opciones]
resumen Tabla de totales por ciudad (por defecto)
detalle Una línea por envío con incidencia
ciudades Solo la lista de ciudades con actividad
FIN
}
main() {
local sub="${1:-resumen}"; [[ $# -gt 0 ]] && shift
validar_entorno || exit $?
case "$sub" in
resumen) acumular_dia "$(date +%F)" && tabla_ciudades ;;
detalle) listar_incidencias "$@" ;;
ciudades) printf '%s\n' "${CIUDADES[@]}" ;;
ayuda|-h|--help) uso; exit 0 ;;
*) printf 'Subcomando desconocido: %s\n' "$sub" >&2; uso >&2; exit 2 ;;
esac
}
main "$@"Un solo script cubre así tres operaciones distintas, cada una con su parseo de opciones, y el menú lo envuelve para quien prefiera pulsar números. El bloque cat <<'FIN' ... FIN es un here-document, forma cómoda de escribir texto de varias líneas que verás en 05-05.
Errores Comunes y Consejos
- Olvidar
;;al final de una rama: es error de sintaxis, y el mensaje de Bash apunta a la línea equivocada. Recuerda además que el bloque se cierra conesac,caseal revés, igual queif/fi. - Poner el patrón general antes del específico.
*.gzantes que*.tar.gzdeja muerta a la segunda rama. - Usar sintaxis de regex en los patrones.
+,{n}y\dno significan nada en un glob. - Omitir
*). Un valor inesperado hará que elcaseno haga absolutamente nada, en silencio. - Menú sin
break.selectes un bucle infinito; sin una opción de salida el usuario queda atrapado. - Dejar
nocasematchactivado. Es global; desactívalo o mejor usa${var,,}. - Consejo: cuando el cuerpo de una rama supere tres líneas, extráelo a una función (
cmd_resumen). Uncasede despacho debe leerse como un índice, no como el programa entero.
Ejercicios
Ejercicio 1. Escribe clasifica_estado() que reciba un estado de envío e imprima OK, PENDIENTE o REVISAR según sea entregado, en_reparto o incidencia, aceptando cualquier combinación de mayúsculas y devolviendo código 1 ante un estado desconocido.
Ejercicio 2. Escribe un case que, dado un nombre de fichero, imprima cómo habría que leerlo: zcat para .gz, tar -tzf para .tar.gz, column -t -s, para .csv y less para el resto. Comprueba el orden de las ramas.
Ejercicio 3. Construye un menú con select que ofrezca las cuatro ciudades más una opción "Todas" y una "Salir"; al elegir una ciudad debe imprimir cuántos envíos tiene, y "Todas" debe recorrerlas.
Soluciones
Solución 1.
clasifica_estado() {
case "${1,,}" in
entregado) echo "OK" ;;
en_reparto) echo "PENDIENTE" ;;
incidencia) echo "REVISAR" ;;
*) printf 'Estado desconocido: %s\n' "$1" >&2; return 1 ;;
esac
}${1,,} normaliza sin tocar nocasematch, así que la función no altera el comportamiento global del shell.
Solución 2.
case "$fichero" in
*.tar.gz|*.tgz) echo "tar -tzf $fichero" ;; # primero, el más específico
*.gz) echo "zcat $fichero" ;;
*.csv) echo "column -t -s, $fichero" ;;
*) echo "less $fichero" ;;
esacSi *.gz) fuera antes, envios.tar.gz caería en ella y nunca se detectaría el tar.
Solución 3.
readonly CIUDADES=(Valencia Sevilla Bilbao Madrid) CSV="/srv/veloz/datos/envios.csv"
contar_ciudad() { printf '%-10s %4d envíos\n' "$1" "$(grep -c ",$1," "$CSV")"; }
PS3="Ciudad (número): "
select c in "${CIUDADES[@]}" Todas Salir; do
case "$c" in
Salir) break ;;
Todas) for x in "${CIUDADES[@]}"; do contar_ciudad "$x"; done ;;
"") echo "Entrada no válida: $REPLY" >&2 ;; # número fuera de rango o texto
*) contar_ciudad "$c" ;;
esac
doneLa rama "") es imprescindible: cuando la entrada no corresponde a ningún elemento, select deja la variable vacía y REPLY con lo que se escribió.
Conclusión
case es la forma correcta de comparar un valor contra un conjunto cerrado de alternativas: sintaxis case valor in patron) cuerpo ;; esac, patrones que son globs (con | para alternativas y *) como red de seguridad), evaluación de arriba abajo con parada en la primera coincidencia, y los terminadores ;;, ;& y ;;& para los casos en que quieras seguir. Lo usarás sobre todo para tres cosas: validar entradas contra listas cerradas, parsear opciones —el bloque de 03-05 ya no tiene secretos— y despachar subcomandos, que es lo que ha convertido informe-diario.sh en una herramienta con resumen, detalle y ciudades. select completa el cuadro con menús numerados en tres líneas, siempre como interfaz secundaria y nunca como única puerta de entrada.
Queda una sola pieza del módulo, y es la que llevamos aplazando desde el principio: los números. Nuestro informe imprime 128 envíos, 11 incidencias pero es incapaz de decir que eso es un 8,59%, porque hemos estado truncando céntimos con ${importe%.*} y sumando solo enteros. En 04-06 verás por qué Bash solo hace aritmética entera, cómo funcionan (( )) y $(( )), por qué un 08 puede reventar un script de fechas, y cómo obtener decimales de verdad con bc para cerrar el informe con porcentajes, medias y variación respecto al día anterior.
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
