← Automatización con Scripts en Bash Bloque 3 · Códigos de salida y condicionales · Tema 13 de 42

Tema 13

Elegir entre varias opciones con case

Muchas decisiones de un script tienen la misma forma: un valor, y varios caminos según cuál sea. La respuesta del usuario, la extensión de un archivo, el sistema operativo, la orden que se le pide al script. Una cadena de elif lo resuelve, pero repite la misma variable en cada línea. case lo expresa de forma directa, con patrones, y es la base de los scripts con subcomandos, como servicio iniciar o servicio detener, que se construyen al final del tema.

1. La forma de case

case compara un valor con una serie de patrones, en orden, y ejecuta los comandos del primero que coincide. Se cierra con esac, que es case al revés, como fi lo es de if.

la forma de case
case valor in
  patrón1)
    comandos
    ;;
  patrón2)
    comandos
    ;;
esac

Cada caso empieza con un patrón seguido de un paréntesis de cierre, y termina con ;;. Cuando los comandos son breves, el caso entero cabe en una línea, y es lo habitual:

una respuesta sí o no
read -rp "¿Continuar? [s/n] " respuesta
case "$respuesta" in
  s) echo "Seguimos" ;;
  n) echo "Cancelado"; exit 0 ;;
esac

Si el valor no coincide con ningún patrón, no se ejecuta nada y case termina con código 0. Si coincide con uno, su código de salida es el del último comando ejecutado en ese caso.

2. Patrones y alternativas

Los patrones de case son los mismos patrones de comodines que ya conoces de los nombres de archivo, del tema 6 y de [[ ]] en el tema 11: *, ? y […]. Como en [[ ]], deben coincidir con el valor completo. Además, el símbolo | separa alternativas: el caso se elige si coincide cualquiera de ellas.

PatrónCoincide con
siExactamente si.
s|si|sís, si o sí.
*.txtCualquier valor que termine en .txt.
[0-9]Un solo dígito.
[sS]*Cualquier valor que empiece con s o S.
-*Cualquier valor que empiece con guion: parece una opción.
?Exactamente un carácter.
""La cadena vacía.
terminal
$ for f in a.tar.gz a.tgz a.zip a.gz a.txt; do
>   case $f in
>     *.tar.gz|*.tgz) echo "$f: tar comprimido" ;;
>     *.gz)           echo "$f: gzip" ;;
>     *.zip)          echo "$f: zip" ;;
>   esac
> done
a.tar.gz: tar comprimido
a.tgz: tar comprimido
a.zip: zip
a.gz: gzip

a.txt no coincidió con ningún patrón y no produjo salida. El > al comienzo de las líneas es el indicador que muestra Bash mientras un comando de varias líneas sigue abierto; no se escribe.

3. El caso por defecto

Como * coincide con cualquier cosa, un último caso con ese patrón funciona como el else de un if: atrapa todo lo que no coincidió con los anteriores. Casi todo case debería tenerlo, aunque sea para informar un error, porque un valor inesperado que no hace nada es un fallo silencioso.

con caso por defecto
case "$formato" in
  gzip)  extension=gz ;;
  xz)    extension=xz ;;
  zstd)  extension=zst ;;
  *)
    echo "Formato no admitido: $formato (opciones: gzip, xz, zstd)" >&2
    exit 2
    ;;
esac

El mensaje del caso por defecto debería decir qué valor llegó y cuáles se esperaban. Es lo primero que necesita saber quien se equivocó al escribirlo.

4. El orden importa

case prueba los patrones en el orden en que están escritos, y se detiene en el primero que coincide. Cuando un valor puede coincidir con varios, el resultado depende del orden, y los patrones más específicos deben ir antes que los más generales:

terminal
$ case a.tar.gz in *.gz) echo "gzip" ;; *.tar.gz) echo "tar comprimido" ;; esac
gzip

El caso *.tar.gz nunca se alcanza, porque todo lo que termina en .tar.gz termina también en .gz. En el ejemplo de la sección 2 estaban en el orden correcto. Por la misma razón, el caso * va siempre al final: puesto antes, atraparía todo.

5. Comillas en el valor y en los patrones

El valor que sigue a case no pasa por la división en palabras ni por la expansión de comodines, como el interior de [[ ]]. Por eso case $var in es seguro aunque var tenga espacios. Ponerle comillas igual no hace daño, y en este curso se las ponemos, por costumbre.

En los patrones, en cambio, las comillas tienen el mismo efecto que en el lado derecho de ==: desactivan los comodines. Un patrón entre comillas se compara literalmente:

terminal
$ v="hola"
$ case $v in "*") echo "literalmente un asterisco" ;; *) echo "cualquier cosa" ;; esac
cualquier cosa

Esto importa cuando el patrón viene de una variable. $patron sin comillas se usa como patrón, con sus comodines; "$patron" se compara como texto exacto. Elige según lo que quieras, igual que en el tema 6.

6. Normalizar antes de comparar

Las comparaciones de case distinguen mayúsculas y minúsculas. Para aceptar S, s, Si y SI sin listar todas las combinaciones, se normaliza el valor en la misma línea, con la expansión del tema 6:

terminal
$ for r in s SI no N quizás ""; do
>   case "${r,,}" in
>     s|si|sí) echo "«$r»: sí" ;;
>     n|no)    echo "«$r»: no" ;;
>     *)       echo "«$r»: no entiendo" ;;
>   esac
> done
«s»: sí
«SI»: sí
«no»: no
«N»: no
«quizás»: no entiendo
«»: no entiendo

Lo mismo vale para las extensiones de archivo, que llegan como .JPG, .jpg o .Jpg: se compara "${archivo,,}" con patrones en minúsculas. El valor original no cambia, y puede seguir usándose dentro de cada caso.

7. Los terminadores ;;, ;& y ;;&

Lo normal es que cada caso termine con ;;, que sale del case. Bash tiene otros dos terminadores, de uso poco frecuente:

TerminadorDespués de ejecutar el caso…
;;Sale del case.
;&Ejecuta también los comandos del caso siguiente, sin comprobar su patrón.
;;&Sigue probando los patrones siguientes, y ejecuta los que coincidan.
terminal
$ case abc in a*) echo "empieza con a" ;;& *c) echo "termina con c" ;;& *) echo "siempre" ;; esac
empieza con a
termina con c
siempre

;;& sirve cuando un valor puede tener varias propiedades independientes, cada una con su acción. ;& sirve para niveles acumulativos, donde cada uno incluye lo del siguiente. Son útiles, pero hacen que el flujo sea menos evidente: si los usas, conviene un comentario que lo señale.

8. Patrones extendidos

Los comodines básicos no pueden expresar «uno o más dígitos» ni «cualquier cosa excepto». Bash tiene una sintaxis extendida de patrones, llamada extglob, que agrega esas posibilidades:

PatrónCoincide conEjemplo
@(a|b)Exactamente una de las alternativas.*.@(jpg|jpeg|png)
+(patrón)Una o más repeticiones.+([0-9]): un número entero
*(patrón)Cero o más repeticiones.v*([0-9.])
?(patrón)Cero o una vez.?(-)+([0-9]): entero con signo opcional
!(patrón)Cualquier cosa excepto el patrón.!(*.bak)

Dentro de [[ ]] funcionan siempre, pero en case y en los nombres de archivo hay que activarlos con shopt -s extglob. Y hay una regla traicionera: la opción debe estar activa cuando Bash lee el código, no solo cuando lo ejecuta. Si se escribe shopt -s extglob en la misma línea que el case, o dentro de la misma función, Bash ya analizó el patrón sin la opción y da un error de sintaxis. Lo seguro es ponerla al comienzo del script, en una línea propia:

validar un número con extglob
shopt -s extglob                    # al comienzo del script

case "$dias" in
  +([0-9])) echo "Número válido: $dias" ;;
  *)        echo "No es un número: $dias" >&2; exit 2 ;;
esac

Para validaciones más complejas, las expresiones regulares del tema 28 son más expresivas y más conocidas. Pero para elegir entre alternativas, como varias extensiones, los patrones extendidos son concisos y suficientes.

9. Usos típicos

case aparece una y otra vez en scripts reales. Tres ejemplos frecuentes:

Actuar según el tipo de archivo

descomprimir · cualquier formato con una sola orden
archivo="${1:?Uso: descomprimir ARCHIVO}"

case "${archivo,,}" in
  *.tar.gz|*.tgz)   tar -xzf "$archivo" ;;
  *.tar.xz|*.txz)   tar -xJf "$archivo" ;;
  *.tar.bz2|*.tbz2) tar -xjf "$archivo" ;;
  *.tar)            tar -xf "$archivo" ;;
  *.gz)             gunzip -k "$archivo" ;;
  *.zip)            unzip -q "$archivo" ;;
  *.7z)             7z x "$archivo" ;;
  *)
    echo "No sé descomprimir: $archivo" >&2
    exit 2
    ;;
esac

Las extensiones dobles van antes que las simples, por la regla de la sección 4, y la comparación se hace en minúsculas sin alterar el nombre que se pasa al comando.

Adaptarse al sistema

el comando de instalación según la distribución
source /etc/os-release              # define ID, VERSION_ID y otras variables

case "$ID" in
  ubuntu|debian)   instalar=(sudo apt install -y) ;;
  fedora|rhel|rocky) instalar=(sudo dnf install -y) ;;
  arch)            instalar=(sudo pacman -S --noconfirm) ;;
  *)               echo "Distribución no reconocida: $ID" >&2; exit 1 ;;
esac

"${instalar[@]}" rsync

El archivo /etc/os-release está escrito en formato CLAVE=valor, y cargarlo con source, como vimos en el tema 2, define esas variables en el script. El comando se guarda en un array, como en el tema 8, para ejecutarlo después con sus argumentos intactos.

Reconocer opciones

un primer analizador de argumentos
case "${1:-}" in
  -h|--help)    mostrar_ayuda; exit 0 ;;
  -v|--version) echo "1.4.2"; exit 0 ;;
  -*)           echo "Opción desconocida: $1" >&2; exit 2 ;;
esac

Con un bucle que recorra todos los argumentos, este patrón se convierte en un analizador completo de opciones, como veremos en los temas 20 y 21.

10. case, elif o un array asociativo

Las tres estructuras pueden elegir entre varias alternativas. Cada una tiene su terreno:

SituaciónConvienePor qué
Un mismo valor comparado con textos o patronescaseSe escribe el valor una vez; los patrones y las alternativas son concisos.
Condiciones distintas, sobre distintas variables, o numéricasif/elifcase solo compara un valor con patrones.
Rangos numéricosif/elif con (( ))Los patrones comparan texto: [0-9] es un dígito, no un rango de números.
Traducir un valor a otro dato, sin acciones distintasUn array asociativoEs una tabla de datos, no de código, y se consulta en una línea (tema 8).
Ejecutar acciones distintas según una ordencaseCada caso contiene su propio código.

La penúltima fila merece un ejemplo. Si cada caso solo asigna un valor, como en la sección 3, la tabla es más clara como datos: declare -A extension=([gzip]=gz [xz]=xz [zstd]=zst), y luego ${extension[$formato]}. Cuando cada caso hace algo diferente, case es la herramienta.

11. Un script con subcomandos

Muchas herramientas se usan con la forma programa orden: git commit, systemctl restart, docker ps. Es una forma natural de reunir varias operaciones relacionadas en un solo script, y su núcleo es un case sobre el primer argumento.

Este script administra un servidor web de prueba, el que trae Python, que sirve los archivos de un directorio. Lo inicia en segundo plano, guarda su número de proceso en un archivo, y con ese número puede consultarlo o detenerlo después.

servidor-web
#!/usr/bin/env bash
#
# servidor-web — inicia, detiene y consulta un servidor web de prueba.
#
# Uso:     servidor-web {iniciar|detener|reiniciar|estado|ayuda}
# Entorno: PUERTO (8000), DIRECTORIO (el actual)
# Salida:  estado devuelve 0 si está en ejecución y 3 si no; 2 ante una orden desconocida.
#
set -euo pipefail

readonly PUERTO="${PUERTO:-8000}"
readonly DIRECTORIO="${DIRECTORIO:-.}"
readonly PIDFILE="${XDG_RUNTIME_DIR:-/tmp}/servidor-web-$PUERTO.pid"

uso() {
  cat <<FIN
Uso: ${0##*/} ORDEN

Órdenes:
  iniciar     inicia el servidor en el puerto $PUERTO
  detener     lo detiene
  reiniciar   lo detiene y lo vuelve a iniciar
  estado      informa si está en ejecución
  ayuda       muestra este mensaje
FIN
}

en_ejecucion() {
  [[ -f "$PIDFILE" ]] && kill -0 "$(< "$PIDFILE")" 2> /dev/null
}

iniciar() {
  if en_ejecucion; then
    echo "Ya está en ejecución (PID $(< "$PIDFILE"))"
    return
  fi
  python3 -m http.server "$PUERTO" --directory "$DIRECTORIO" > /dev/null 2>&1 &
  echo "$!" > "$PIDFILE"
  echo "Iniciado en http://localhost:$PUERTO (PID $!)"
}

detener() {
  if ! en_ejecucion; then
    echo "No está en ejecución"
    rm -f "$PIDFILE"
    return
  fi
  kill "$(< "$PIDFILE")"
  rm -f "$PIDFILE"
  echo "Detenido"
}

case "${1:-}" in
  iniciar|start)     iniciar ;;
  detener|stop)      detener ;;
  reiniciar|restart) detener; iniciar ;;
  estado|status)
    if en_ejecucion; then
      echo "En ejecución (PID $(< "$PIDFILE"))"
    else
      echo "Detenido"
      exit 3
    fi
    ;;
  ayuda|-h|--help)   uso ;;
  "")                uso >&2; exit 2 ;;
  *)                 echo "Orden desconocida: $1" >&2; uso >&2; exit 2 ;;
esac
terminal
$ servidor-web estado; echo "código $?"
Detenido
código 3
$ servidor-web iniciar
Iniciado en http://localhost:8000 (PID 48213)
$ servidor-web iniciar
Ya está en ejecución (PID 48213)
$ servidor-web reiniciar
Detenido
Iniciado en http://localhost:8000 (PID 48230)
$ servidor-web arrancar
Orden desconocida: arrancar
Uso: servidor-web ORDEN
…
$ servidor-web detener
Detenido

El script reúne buena parte del bloque, y adelanta piezas que veremos en detalle más adelante:

12. Errores frecuentes

EscribisteQué pasaCorrección
Un caso sin ;;Error de sintaxis al llegar al patrón siguiente.Terminar cada caso con ;;.
*) antes que otros casosLos casos siguientes nunca se alcanzan.*) siempre al final.
*.gz) antes que *.tar.gz)El caso más específico nunca se ejecuta.Lo específico primero.
Sin caso *)Un valor inesperado no hace nada, sin aviso.Un caso por defecto con un mensaje de error.
"*.txt")Compara con el texto literal *.txt.*.txt) sin comillas.
SI|Si|si|sI)Funciona, pero es largo y fácil de olvidar una variante.case "${r,,}" in si)
[1-31]) para un día del mesEs una clase de un carácter: 1, 2, 3 o 1.Un rango numérico con (( )).
shopt -s extglob en la misma línea o función que el patrónError de sintaxis: el patrón se analizó antes.shopt al comienzo del script.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué ocurre si el valor de un case no coincide con ningún patrón?

No se ejecuta ningún caso, y case termina con código 0. Por eso conviene un caso *) que informe el valor inesperado.

2. ¿Qué significa el patrón s|si|sí)?

Que el caso se elige si el valor es exactamente s, si o sí. El símbolo | separa alternativas.

3. ¿Coincide el patrón txt) con el valor informe.txt?

No. Los patrones deben coincidir con el valor completo. Para «termina en txt» se escribe *txt), o mejor *.txt).

4. ¿Por qué *.tar.gz) debe ir antes que *.gz)?

Porque case se detiene en el primer patrón que coincide, y todo lo que termina en .tar.gz termina también en .gz. Si *.gz) estuviera antes, el caso de .tar.gz nunca se ejecutaría.

5. ¿Hace falta poner comillas en case $archivo in si el nombre tiene espacios?

No: el valor de case no pasa por la división en palabras. Ponerlas no hace daño, y mantiene la costumbre.

6. ¿Qué diferencia hay entre los patrones *) y "*")?

*) coincide con cualquier valor. "*") solo coincide con un asterisco literal, porque las comillas desactivan el comodín.

7. ¿Cómo se acepta si escrito con cualquier combinación de mayúsculas y minúsculas?

Normalizando el valor a minúsculas antes de comparar: case "${respuesta,,}" in si) ….

8. ¿Qué hace el terminador ;;&?

Después de ejecutar el caso, sigue probando los patrones siguientes y ejecuta también los que coincidan, en lugar de salir del case.

9. ¿Por qué falla shopt -s extglob; case $x in +([0-9])) … escrito en una sola línea?

Porque Bash lee y analiza la línea completa antes de ejecutarla. Cuando analiza el patrón, extglob todavía no está activo, y +( es un error de sintaxis. La opción debe activarse en una línea anterior.

10. ¿Por qué el patrón [1-31] no sirve para validar un día del mes?

Porque […] es una clase de un solo carácter: [1-31] significa «un carácter entre 1 y 3, o un 1». Los rangos numéricos se comprueban con (( )).

11. ¿Cuándo es preferible un array asociativo a un case?

Cuando cada caso solo traduce un valor a otro dato, sin acciones distintas. Una tabla de datos es más clara y se consulta en una línea.

12. En servidor-web, ¿qué hace kill -0?

No envía ninguna señal: comprueba si el proceso con ese número existe y se le pueden enviar señales. Sirve para saber si el servidor sigue en ejecución.

13. ¿Por qué un script con subcomandos debería terminar con código 2 ante una orden desconocida?

Porque 2 es la convención para un uso incorrecto. Así, quien lo llama puede distinguir un error de uso de un fallo en la operación misma.

Para practicar

  1. Escribe un script que reciba un nombre de archivo y diga si es una imagen, un documento, un video o «otro tipo», según su extensión, sin distinguir mayúsculas.
  2. Reproduce el ejemplo de *.gz y *.tar.gz en los dos órdenes y comprueba la diferencia.
  3. Prueba el terminador ;;& con un valor que cumpla varias propiedades: empieza con mayúscula, contiene un dígito, termina en .txt.
  4. Activa extglob y escribe un case que distinga números enteros, números con signo y cualquier otra cosa.
  5. Lee /etc/os-release con source y escribe un case que muestre qué gestor de paquetes usa tu sistema.
  6. Instala servidor-web y pruébalo con todas sus órdenes. Agrega una orden abrir que muestre la dirección del servidor solo si está en ejecución.
  7. Convierte en un script con subcomandos dos o tres tareas relacionadas que hagas a menudo, por ejemplo notas nueva, notas listar y notas buscar.