Al final de la lliçó anterior vas escriure una funció que recorria quatre ciutats amb un bucle només per comprovar si una cadena hi era. Funciona, però és l'eina equivocada: comparar un valor contra una llista tancada d'alternatives és la feina de case. Ja la vas fer servir de passada a 03-05, dins del while [[ $# -gt 0 ]] que analitzava opcions llargues, amb la promesa d'explicar-la del tot més endavant. Aquest moment és ara. A més, case és la meitat d'una parella: l'altra és select, la construcció que genera menús numerats en tres línies i amb la qual construirem veloz-menu.sh, la porta d'entrada interactiva del toolkit.

Contingut

  1. Sintaxi de case
  2. Els patrons són globs, no regex
  3. El comodí *) i l'ordre d'avaluació
  4. Terminadors: ;;, ;& i ;;&
  5. case davant d'if/elif
  6. shopt -s nocasematch
  7. Usos idiomàtics de case
  8. L'anàlisi d'opcions llargues, explicada del tot
  9. Menús amb select
  10. Bones pràctiques de menús
  11. veloz-menu.sh i informe-diari.sh amb subordres

  1. Sintaxi de case

case "$estat" in
    lliurat)         echo "Enviament completat" ;;
    en_repartiment)  echo "En ruta"             ;;
    incidencia)      echo "Requereix atenció"   ;;
    *)               echo "Estat desconegut: $estat" >&2; return 1 ;;
esac

Les peces, una a una: case "$valor" in introdueix la paraula que es compararà (posa-la entre cometes per costum, encara que case és un dels pocs contextos que no fa divisió en paraules); patro) obre una branca, amb el parèntesi de tancament obligatori i el d'obertura opcional; el cos admet qualsevol nombre d'ordres, en una línia o en diverses; ;; tanca la branca, i sense ell hi ha error de sintaxi; i esaccase a l'inrevés— tanca el bloc.

Diverses alternatives a la mateixa branca se separen amb |, que es llegeix com a «o»: s|S|si|SI|y|yes) echo "Confirmat" ;;. Si cap patró no coincideix i no hi ha *), case no fa res i retorna 0: silenci absolut, i per això el *) és gairebé obligatori.

  1. Els patrons són globs, no regex

Aquesta és la confusió número u. Els patrons de case fan servir les mateixes regles que l'expansió de comodins de fitxers (02-05), tot i que aquí s'apliquen a una cadena qualsevol.

Patró Coincideix amb No coincideix amb
Valencia exactament Valencia valencia
*.csv enviaments.csv, a.b.csv csv, enviaments.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

El que no funciona perquè és sintaxi d'expressions regulars: +, {2,3}, \d, ^, $ i els grups (...). Un patró [0-9]+ en case significa literalment «un dígit seguit d'un signe més». Les classes POSIX sí que estan disponibles ([[:digit:]], [[:alpha:]]), i per validar un identificador n'hi ha prou amb case "$id" in E[0-9][0-9][0-9][0-9]) ....

Si necessites de debò una expressió regular —«entre dos i quatre dígits», «una adreça de correu»— l'eina és [[ $s =~ regex ]], tema de 05-04; amb case cobreixes el 90% dels casos reals i amb molta menys complexitat. Un detall important: el valor comparat no se sotmet a globbing, però el patró sí que s'expandeix. Això permet patrons dinàmics: si p="Val*", la branca $p) coincideix amb Valencia. I si vols que un patró sigui literal, posa'l entre cometes: "*") coincideix només amb un asterisc.

  1. El comodí *) i l'ordre d'avaluació

case avalua les branques de dalt a baix i s'atura a la primera que coincideix. L'ordre, per tant, és semàntic: el específic primer, el general després.

case "$fitxer" in
    app.log)      echo "Registre actiu"       ;;
    app.log.*.gz) echo "Rotat i comprimit"    ;;
    app.log.*)    echo "Rotat"                ;;
    *.log)        echo "Un altre registre"    ;;   # el general, DESPRÉS dels específics
    *)            echo "No és un registre"    ;;
esac

Si *.log) fos a dalt de tot, es menjaria app.log) i les branques específiques no s'executarien mai. El *) final és la xarxa de seguretat: cobreix-lo sempre, encara que només sigui per registrar un avís. Un case sense *) que rep un valor inesperat no falla, simplement no fa res, i aquest silenci és el pitjor comportament possible en un script d'operacions.

  1. Terminadors: ;;, ;& i ;;&

Bash 4 va afegir dos terminadors més al clàssic ;;.

Terminador Què fa després d'executar la branca
;; Surt del case. L'habitual.
;& Executa el cos de la branca següent sense comprovar-ne el patró (caiguda, com el fallthrough de C)
;;& Continua comprovant els patrons restants i executa els que coincideixin
nivell="ERROR"
case "$nivell" in
    ERROR)      echo "→ enviar alerta" ;;&   # ;;& → continua comprovant patrons
    ERROR|WARN) echo "→ registrar"     ;;&
    *)          echo "→ comptabilitzar" ;;
esac

Sortida: les tres línies, → enviar alerta, → registrar i → comptabilitzar. Amb ;; a totes les branques només s'hauria imprès la primera línia. ;;& és útil per a accions acumulatives per categoria; ;& és rar i confon qui el llegeix. Recomanació: fes servir ;; per defecte i ;;& només quan l'acumulació sigui l'objectiu explícit, amb un comentari que ho digui.

  1. case davant d'if/elif

Criteri case if/elif
Què compara Una sola variable contra patrons Qualsevol condició, diferent a cada branca
Tipus de prova Coincidència de patró glob Codis de sortida, números, fitxers
Alternatives per branca Sí, amb | Requereix || encadenats
Llegibilitat amb 5+ branques Excel·lent Es degrada de pressa
Rangs numèrics i combinar i/o Maldestre o impossible Natural amb (( ))

Regla: si totes les branques pregunten pel mateix valor i amb igualtat o patró, fes servir case; si cada branca pregunta per una cosa diferent o hi ha comparacions numèriques, fes servir if/elif. Un if [[ "$sub" == "resum" ]]; then ... elif [[ "$sub" == "detall" ]]; then ... repeteix la variable a cada branca; el case equivalent l'anomena una sola vegada i alinea els patrons en columna, cosa que fa evident d'un cop d'ull quins valors es contemplen.

  1. shopt -s nocasematch

Per defecte la comparació distingeix majúscules de minúscules. L'opció nocasematch la desactiva, i afecta també [[ == ]]:

shopt -s nocasematch seguit de case "$1" in valencia) ... fa que hi coincideixin VALENCIA, Valencia i vAlEnCiA, i shopt -u nocasematch la desactiva. És una opció global del shell: mentre és activa, qualsevol comparació posterior deixa de distingir la caixa, incloses les de funcions que no esperaven aquell comportament. Per això és preferible l'alternativa explícita de 04-04, case "${1,,}" in valencia) ...: més curta, sense efectes globals i evident en llegir-la.

  1. Usos idiomàtics de case

a) Validar contra una llista tancada. La funció de l'exercici de 04-04, ara en una línia: es_ciutat_valida() { case "${1,,}" in valencia|sevilla|bilbao|madrid) return 0 ;; *) return 1 ;; esac; }.

b) Confirmació sí/no d'un read (que ja coneixes de 03-05):

read -r -p "Voleu purgar els registres de més de 30 dies? [s/N] " resp
case "${resp,,}" in
    s|si|y|yes) purgar_logs ;;
    *) echo "Operació cancel·lada" ;;   # l'opció segura sempre és la de per defecte
esac

c) Ramificar per extensió de fitxer, aprofitant que els patrons són globs:

processar() {
    case "$1" in
        *.tar.gz)  tar -tzf "$1"          ;;   # ha d'anar ABANS que *.gz!
        *.gz)      zcat "$1" | tail -n 20 ;;
        *.csv)     column -t -s, "$1"     ;;
        *.log)     tail -n 20 "$1"        ;;
        *)         echo "Tipus no admès: $1" >&2; return 1 ;;
    esac
}     # *.tar.gz és més específic que *.gz; col·locat després, no s'assoliria mai

d) Despatx de subordres. L'ús més potent, amb el qual un sol script ofereix diverses operacions (igual que git commit, git push):

sub="${1:-resum}"; shift || true        # el shift consumeix la subordre...
case "$sub" in
    resum)   cmd_resum   "$@" ;;        # ...perquè "$@" porti només ELS SEUS arguments
    detall)  cmd_detall  "$@" ;;
    ciutats) cmd_ciutats "$@" ;;
    ajuda|-h|--help) us; exit 0 ;;
    *) echo "Subordre desconeguda: $sub" >&2; us; exit 2 ;;
esac

El || true evita fallar si no hi havia cap argument per consumir. El desenvolupes del tot a l'apartat 11.

  1. L'anàlisi d'opcions llargues, explicada del tot

Reprenem el bloc de 03-05 i ara s'entén cada línia:

while [[ $# -gt 0 ]]; do
    case "$1" in
        --data)       data="${2:?--data requereix un valor}";    shift 2 ;;
        --data=*)     data="${1#*=}";                            shift   ;;
        --ciutat)     ciutat="${2:?--ciutat requereix un valor}"; shift 2 ;;
        --ciutat=*)   ciutat="${1#*=}";                           shift   ;;
        -v|--verbose) detallat=1; shift ;;
        -h|--help)    us; exit 0       ;;
        --)           shift; break     ;;
        -*)           echo "Opció desconeguda: $1" >&2; exit 2 ;;
        *)            posicionals+=("$1"); shift ;;
    esac
done
set -- "${posicionals[@]}"

Ara cada peça té nom: les branques --data) i --data=*) accepten les dues maneres d'escriure una opció, i la segona fa servir ${1#*=} de 04-04 per quedar-se amb el que hi ha després de l'=; shift 2 descarta opció i valor mentre que shift descarta només l'opció; -- és el marcador convencional d'«aquí s'acaben les opcions», de manera que el que vingui després són arguments encara que comenci per guionet; -*) captura qualsevol opció no reconeguda i falla sorollosament en comptes de tractar-la com a argument; i *) acumula els posicionals en un array (04-03) que set -- "${posicionals[@]}" retorna a $1, $2… ja nets d'opcions. És el patró complet d'anàlisi manual, i ara el pots escriure de memòria.

  1. Menús amb select

select és un for especialitzat: imprimeix una llista numerada, mostra un indicador, llegeix un número, executa el cos amb la variable posada a l'element triat i ho repeteix en bucle.

PS3="Tria una operació (número): "
select opcio in "Informe d'avui" "Veure errors" "Sortir"; do
    case "$opcio" in
        "Informe d'avui") informe-diari.sh ;;
        "Veure errors")   tail -n 20 /var/log/veloz/app.log ;;
        "Sortir")         echo "Fins aviat"; break ;;
        *)                echo "Opció no vàlida: $REPLY" >&2 ;;
    esac
done

A pantalla surt la llista numerada (1) Informe d'avui…) i després l'indicador. El que cal saber:

  • PS3 és la variable que conté l'indicador. Si no la fixes, surt el poc amistós #?.
  • opcio rep el text de l'element triat; REPLY conté el que l'usuari ha teclejat literalment. Si escriu una cosa que no és un número vàlid, opcio queda buida però REPLY conserva l'entrada: per això el *) del case informa fent servir $REPLY.
  • El bucle és implícit i infinit. Només se'n surt amb break, amb Ctrl+D o amb exit. Sense una branca «Sortir» amb break, el menú no s'acaba mai. Prémer Enter en blanc repeteix la llista.
  • La llista pot venir d'un array: select c in "${CIUTATS[@]}" Sortir; do ... done.

  1. Bones pràctiques de menús

Valida sempre amb un *) que capturi entrades fora de rang; no donis mai per fet que l'usuari escriu un número correcte. Inclou una opció de sortida explícita, perquè confiar en Ctrl+C maltracta l'usuari i deixa processos a mitges. I sobretot: els menús són la interfície secundària, mai la principal, ja que un script que només funciona interactivament no es pot ficar al cron (07-01) ni encadenar amb d'altres. La regla és que el comportament per defecte sigui no interactiu, amb opcions i subordres, i que el menú s'ofereixi només quan no hi ha arguments i l'entrada és un terminal. L'idioma és if [[ $# -eq 0 && -t 0 ]]; then menu_principal; else despatxar "$@"; fi. La comprovació [[ -t 0 ]] (03-05) pregunta si l'entrada estàndard és un terminal; des del cron no ho és, així que l'script no es quedarà mai esperant una resposta que no arribarà. Aquest patró és la base del que automatitzaràs a 07-02.

  1. veloz-menu.sh i informe-diari.sh amb subordres

Primer, el menú del toolkit, a ~/veloz-ops/bin/veloz-menu.sh:

#!/usr/bin/env bash
# veloz-menu.sh — Menú interactiu d'operacions de Veloz Envíos.
set -u
readonly LOG_APP="/var/log/veloz/app.log"
menu_principal() {
    local PS3="veloz-ops> " opcio
    select opcio in "Informe diari" "Últims errors" "Estat de l'API" Sortir; do
        case "$opcio" in
            "Informe diari")  informe-diari.sh resum ;;
            "Últims errors")  tail -n 20 "$LOG_APP" | grep --color=auto ERROR ;;
            "Estat de l'API") systemctl is-active veloz-api ;;
            Sortir)           break ;;
            *) printf 'Opció no vàlida: %s\n' "$REPLY" >&2 ;;
        esac
        echo
    done
}
if [[ $# -eq 0 && -t 0 ]]; then menu_principal; else echo "Ús: $0" >&2; exit 2; fi

I el despatxador d'informe-diari.sh, que substitueix el main lineal de 04-02:

us() { cat <<'FI'
Ús: informe-diari.sh <subordre> [opcions]
  resum      Taula de totals per ciutat (per defecte)
  detall     Una línia per enviament amb incidència
  ciutats    Només la llista de ciutats amb activitat
FI
}

main() {
    local sub="${1:-resum}"; [[ $# -gt 0 ]] && shift
    validar_entorn || exit $?
    case "$sub" in
        resum)    acumular_dia "$(date +%F)" && taula_ciutats ;;
        detall)   llistar_incidencies "$@"                    ;;
        ciutats)  printf '%s\n' "${CIUTATS[@]}"               ;;
        ajuda|-h|--help) us; exit 0                           ;;
        *) printf 'Subordre desconeguda: %s\n' "$sub" >&2; us >&2; exit 2 ;;
    esac
}
main "$@"

Un sol script cobreix així tres operacions diferents, cadascuna amb la seva anàlisi d'opcions, i el menú l'embolcalla per a qui prefereixi prémer números. El bloc cat <<'FI' ... FI és un here-document, forma còmoda d'escriure text de diverses línies que veuràs a 05-05.

Errors Habituals i Consells

  • Descuidar el ;; al final d'una branca: és error de sintaxi, i el missatge de Bash apunta a la línia equivocada. Recorda a més que el bloc es tanca amb esac, case a l'inrevés, igual que if/fi.
  • Posar el patró general abans de l'específic. *.gz abans que *.tar.gz deixa morta la segona branca.
  • Fer servir sintaxi de regex als patrons. +, {n} i \d no signifiquen res en un glob.
  • Ometre el *). Un valor inesperat farà que el case no faci absolutament res, en silenci.
  • Menú sense break. select és un bucle infinit; sense una opció de sortida l'usuari queda atrapat.
  • Deixar nocasematch activat. És global; desactiva'l o, millor, fes servir ${var,,}.
  • Consell: quan el cos d'una branca passi de tres línies, extreu-lo a una funció (cmd_resum). Un case de despatx s'ha de llegir com un índex, no com el programa sencer.

Exercicis

Exercici 1. Escriu classifica_estat() que rebi un estat d'enviament i imprimeixi OK, PENDENT o REVISAR segons si és lliurat, en_repartiment o incidencia, acceptant qualsevol combinació de majúscules i retornant codi 1 davant d'un estat desconegut.

Exercici 2. Escriu un case que, donat un nom de fitxer, imprimeixi com caldria llegir-lo: zcat per a .gz, tar -tzf per a .tar.gz, column -t -s, per a .csv i less per a la resta. Comprova l'ordre de les branques.

Exercici 3. Construeix un menú amb select que ofereixi les quatre ciutats més una opció «Totes» i una «Sortir»; en triar una ciutat ha d'imprimir quants enviaments té, i «Totes» les ha de recórrer.

Solucions

Solució 1.

classifica_estat() {
    case "${1,,}" in
        lliurat)         echo "OK"      ;;
        en_repartiment)  echo "PENDENT" ;;
        incidencia)      echo "REVISAR" ;;
        *) printf 'Estat desconegut: %s\n' "$1" >&2; return 1 ;;
    esac
}

${1,,} normalitza sense tocar nocasematch, així que la funció no altera el comportament global del shell.

Solució 2.

case "$fitxer" in
    *.tar.gz|*.tgz) echo "tar -tzf $fitxer"      ;;   # primer, el més específic
    *.gz)           echo "zcat $fitxer"          ;;
    *.csv)          echo "column -t -s, $fitxer" ;;
    *)              echo "less $fitxer"          ;;
esac

Si *.gz) anés abans, enviaments.tar.gz hi cauria i no es detectaria mai el tar.

Solució 3.

readonly CIUTATS=(Valencia Sevilla Bilbao Madrid) CSV="/srv/veloz/dades/enviaments.csv"
comptar_ciutat() { printf '%-10s %4d enviaments\n' "$1" "$(grep -c ",$1," "$CSV")"; }

PS3="Ciutat (número): "
select c in "${CIUTATS[@]}" Totes Sortir; do
    case "$c" in
        Sortir) break ;;
        Totes)  for x in "${CIUTATS[@]}"; do comptar_ciutat "$x"; done ;;
        "")     echo "Entrada no vàlida: $REPLY" >&2 ;;   # número fora de rang o text
        *)      comptar_ciutat "$c" ;;
    esac
done

La branca "") és imprescindible: quan l'entrada no correspon a cap element, select deixa la variable buida i REPLY amb el que s'ha escrit.

Conclusió

case és la manera correcta de comparar un valor contra un conjunt tancat d'alternatives: sintaxi case valor in patro) cos ;; esac, patrons que són globs (amb | per a alternatives i *) com a xarxa de seguretat), avaluació de dalt a baix amb aturada a la primera coincidència, i els terminadors ;;, ;& i ;;& per als casos en què vulguis continuar. El faràs servir sobretot per a tres coses: validar entrades contra llistes tancades, analitzar opcions —el bloc de 03-05 ja no té secrets— i despatxar subordres, que és el que ha convertit informe-diari.sh en una eina amb resum, detall i ciutats. select completa el quadre amb menús numerats en tres línies, sempre com a interfície secundària i mai com a única porta d'entrada.

Queda una sola peça del mòdul, i és la que anem ajornant des del principi: els números. El nostre informe imprimeix 128 enviaments, 11 incidències però és incapaç de dir que això és un 8,59%, perquè hem anat truncant cèntims amb ${import%.*} i sumant només enters. A 04-06 veuràs per què Bash només fa aritmètica entera, com funcionen (( )) i $(( )), per què un 08 pot rebentar un script de dates, i com obtenir decimals de debò amb bc per tancar l'informe amb percentatges, mitjanes i variació respecte al dia anterior.

Curs de Programació en Bash

Mòdul 1: Introducció a Bash

Mòdul 2: Ordres Bàsiques de Bash

Mòdul 3: Fonaments de Scripting

Mòdul 4: Scripting Intermedi

Mòdul 5: Tècniques Avançades de Scripting

Mòdul 6: Treballar amb Eines Externes

Mòdul 7: Automatització i Programació

Mòdul 8: Bones Pràctiques i Optimització

Mòdul 9: Projectes del Món Real

© Copyright 2026. Tots els drets reservats