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

  1. Sintaxis de case
  2. Los patrones son globs, no regex
  3. El comodín *) y el orden de evaluación
  4. Terminadores: ;;, ;& y ;;&
  5. case frente a if/elif
  6. shopt -s nocasematch
  7. Usos idiomáticos de case
  8. El parseo de opciones largas, explicado del todo
  9. Menús con select
  10. Buenas prácticas de menús
  11. veloz-menu.sh e informe-diario.sh con subcomandos

  1. Sintaxis de case

case "$estado" in
    entregado)   echo "Envío completado"  ;;
    en_reparto)  echo "En ruta"           ;;
    incidencia)  echo "Requiere atención" ;;
    *)           echo "Estado desconocido: $estado" >&2; return 1 ;;
esac

Las 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 esaccase 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.

  1. 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.

  1. El comodín *) y el orden de evaluación

case 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"        ;;
esac

Si *.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.

  1. Terminadores: ;;, ;& 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"  ;;
esac

Salida: 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.

  1. 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.

  1. shopt -s nocasematch

Por 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.

  1. Usos idiomáticos de case

a) 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
esac

c) 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ía

d) 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 ;;
esac

El || true evita fallar si no había ningún argumento que consumir. Lo desarrollas del todo en el apartado 11.

  1. 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.

  1. Menús con select

select 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
done

En pantalla sale la lista numerada (1) Informe de hoy…) y luego el prompt. Lo que hay que saber:

  • PS3 es la variable que contiene el prompt. Si no la fijas, sale el poco amistoso #?.
  • opcion recibe el texto del elemento elegido; REPLY contiene lo que el usuario tecleó literalmente. Si escribe algo que no es un número válido, opcion queda vacía pero REPLY conserva la entrada: por eso el *) del case informa usando $REPLY.
  • El bucle es implícito e infinito. Solo se sale con break, con Ctrl+D o con exit. Sin una rama "Salir" con break, 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.

  1. 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.

  1. veloz-menu.sh e informe-diario.sh con subcomandos

Primero, 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; fi

Y 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 con esac, case al revés, igual que if/fi.
  • Poner el patrón general antes del específico. *.gz antes que *.tar.gz deja muerta a la segunda rama.
  • Usar sintaxis de regex en los patrones. +, {n} y \d no significan nada en un glob.
  • Omitir *). Un valor inesperado hará que el case no haga absolutamente nada, en silencio.
  • Menú sin break. select es un bucle infinito; sin una opción de salida el usuario queda atrapado.
  • Dejar nocasematch activado. 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). Un case de 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"          ;;
esac

Si *.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
done

La 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

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