Shell scripts en Linux — práctica con un corrector que compila, prueba y pone nota a las entregas de C (y la práctica que «compilaba» sin compilar)

La segunda semana del corrector de mi grupo de IC2 pasó algo raro. Pablo me dijo: «oye, tu script dice que mi práctica compila, pero a mí me da error». Y tenía razón: su .c no compilaba. ¿Qué había pasado? Que el día anterior sí había compilado una versión antigua, el ejecutable pablo seguía en la carpeta y mi script, en lugar de mirar si gcc había ido bien, miraba si existía el ejecutable. Estaba corrigiendo el pasado.

La lección se me quedó grabada: en un script, lo que manda es el código de salida de la orden, no lo que crees que ha dejado en la carpeta. Y, de paso, borra lo viejo antes de generar lo nuevo.

Esta práctica pone en marcha todo lo de la teoría de shell scripts con tres programas de nivel medio que, juntos, forman un corrector de prácticas completo: compilar.sh (compila todas las entregas y separa los errores), probar.sh (ejecuta cada una con una entrada fija y compara su salida) y corregir.sh (el corrector final, con opciones, funciones y nota). Los datos se parecen a los de verdad: cinco entregas de la práctica «suma» (leer dos enteros y escribir su suma), y entre ellas hay una que no compila, otra que compila con un aviso y otra que compila pero da un resultado equivocado.

Antes de empezar

Conviene tener claro:

  • Cómo se lanza un script: #!/bin/bash, chmod +x y ./script.sh.
  • Que if mira el código de salida de una orden (0 = bien) y que [ ] es una orden más, con espacios.
  • Los parámetros $1, $# y "$@", y por qué las variables van entre comillas.
  • Las redirecciones de Redirecciones y tuberías: >, 2>, <, /dev/null y |.

Si alguno te suena dudoso, repasa la teoría antes de seguir.

Monta una carpeta de pruebas con una subcarpeta entregas/ y cinco .c (los cinco son el mismo programa con pequeños cambios: dani.c multiplica en vez de sumar, a pablo.c le falta el ; del printf y sergio.c declara un int x; que no usa). Añade entrada.txt con 3 4 y esperado.txt con 7. Las sesiones de este artículo se han ejecutado una sola vez como el usuario sergio, con LANG=C: por eso los mensajes de gcc y de bash salen en inglés.

Programa 1: compilar todas las entregas

compilar.sh recibe una carpeta, compila cada .c con gcc -Wall y escribe una línea por alumno: OK, OK (con avisos) o ERROR. Los mensajes de gcc no se pierden: van a un archivo por alumno en errores/. Lo que lo complica: hay que comprobar que nos han pasado bien la carpeta, distinguir los avisos de los errores y que el script devuelva un código útil para quien lo use.

La idea

El orden de los pasos es el de cualquier script serio:

  1. Comprobar los parámetros al principio. Si no hay exactamente uno, o no es una carpeta, mensaje por stderr y exit 2. Mejor parar al principio que fallar a medias.
  2. Recorrer las entregas con for fuente in "$carpeta"/*.c.
  3. Para cada una: sacar el nombre del alumno con basename, borrar el ejecutable viejo y compilar, mandando stderr a errores/<alumno>.txt.
  4. Decidir con el código de gcc (no mirando si existe el ejecutable): si es 0, compila; si además el archivo de errores no está vacío, es que hubo avisos.
  5. Contar, escribir el resumen y terminar con un código: 0 si todo compiló, 1 si no.

Antes de programar, piensa en qué datos necesitas fuera del bucle (los contadores ok y mal, que empiezan en 0) y cuáles cambian en cada vuelta (fuente, alumno, binario).

El código

#!/bin/bash
# compilar.sh: compila todas las entregas de una carpeta y dice cuáles compilan
# Uso: ./compilar.sh carpeta

if [ $# -ne 1 ]; then                 # exactamente un parámetro: la carpeta
    echo "Uso: $0 carpeta" >&2         # los mensajes de error van por stderr
    exit 2                            # 2 = me has llamado mal
fi
carpeta=$1
if [ ! -d "$carpeta" ]; then          # -d: existe y es una carpeta
    echo "No es una carpeta: $carpeta" >&2
    exit 2
fi

mkdir -p errores                      # -p: no falla si ya existe
ok=0
mal=0
for fuente in "$carpeta"/*.c; do      # comillas en la variable, el * fuera
    alumno=$(basename "$fuente" .c)   # entregas/ana.c -> ana
    binario="$carpeta/$alumno"
    rm -f "$binario"                  # sin esto, un ejecutable viejo se haría pasar por bueno
    if gcc -Wall "$fuente" -o "$binario" 2> "errores/$alumno.txt"; then
        ok=$((ok + 1))
        if [ -s "errores/$alumno.txt" ]; then   # -s: existe y NO está vacío
            echo "OK     $alumno (con avisos)"
        else
            echo "OK     $alumno"
        fi
    else
        mal=$((mal + 1))
        echo "ERROR  $alumno"
    fi
done
echo "Compilan: $ok. No compilan: $mal"
[ "$mal" -eq 0 ]                      # última orden = código del script: 0 si todo compiló

El corazón del script es el bucle. Vamos a verlo de cerca:

for fuente in "$carpeta"/*.c; do      # comillas en la variable, el * fuera
    alumno=$(basename "$fuente" .c)   # entregas/ana.c -> ana
    binario="$carpeta/$alumno"
    rm -f "$binario"                  # sin esto, un ejecutable viejo se haría pasar por bueno
    if gcc -Wall "$fuente" -o "$binario" 2> "errores/$alumno.txt"; then
        ok=$((ok + 1))
        if [ -s "errores/$alumno.txt" ]; then   # -s: existe y NO está vacío
            echo "OK     $alumno (con avisos)"
        else
            echo "OK     $alumno"
        fi
    else
        mal=$((mal + 1))
        echo "ERROR  $alumno"
    fi
done
  • "$carpeta"/*.c: la variable va entre comillas (por si la carpeta tiene espacios) y el * fuera, porque dentro de comillas bash no lo expandiría.
  • basename "$fuente" .c quita la carpeta y, con el segundo parámetro, también la extensión: entregas/ana.c → ana.
  • rm -f "$binario" es el arreglo de mi anécdota. La -f hace que rm no se queje si el archivo no existe (la primera vez nunca existe).
  • if gcc … 2> "errores/$alumno.txt"; then: compila y, a la vez, guarda los mensajes. El if mira el código de gcc.
  • [ -s "errores/$alumno.txt" ]: -s es verdadero si el archivo existe y no está vacío. Si gcc compiló pero escribió algo, son avisos.

Esta es la traza del bucle con nuestras cinco entregas:

Vueltaalumno$? de gcc¿errores vacío?Escribeokmal
1ana0síOK ana10
2dani0síOK dani20
3laura0síOK laura30
4pablo1—ERROR pablo31
5sergio0noOK sergio (con avisos)41
El recorrido de compilar.sh Para cada .c, compilar.sh saca el nombre del alumno, borra el ejecutable viejo y compila guardando los errores. Si gcc devuelve 0 y el archivo de errores está vacío, es OK (ana, dani, laura); si no está vacío, OK con avisos (sergio); si gcc falla, ERROR (pablo). La última línea da el código del script: 1. Lo que hace compilar.sh con cada entrega for fuente in «$carpeta»/*.c basename ana.c -> ana rm -f «$binario» gcc -Wall … 2> errores/ana.txt ¿$? de gcc? 0 ≠ 0 ¿errores/… vacío? no (-s) sí OK ana, dani, laura OK (con avisos) sergio ERROR pablo Compilan: 4. No compilan: 1 [ «$mal» -eq 0 ] → $? = 1
Dos preguntas en cadena: primero el $? de gcc y después si su archivo de errores tiene algo dentro.

Y la última línea, [ "$mal" -eq 0 ], no escribe nada: es la última orden del script, así que su código es el código del script. Con mal = 1, devuelve 1.

Salida

$ chmod +x compilar.sh
$ ./compilar.sh
Uso: ./compilar.sh carpeta
$ echo $?
2
$ ./compilar.sh entregass
No es una carpeta: entregass
$ echo $?
2
$ ./compilar.sh entregas
OK     ana
OK     dani
OK     laura
ERROR  pablo
OK     sergio (con avisos)
Compilan: 4. No compilan: 1
$ echo $?
1
$ ls entregas
ana
ana.c
dani
dani.c
laura
laura.c
pablo.c
sergio
sergio.c
$ ls errores
ana.txt
dani.txt
laura.txt
pablo.txt
sergio.txt
$ cat errores/pablo.txt
entregas/pablo.c: In function 'main':
entregas/pablo.c:6:26: error: expected ';' before 'return'
    6 |     printf("%d\n", a + b)
      |                          ^
      |                          ;
    7 |     return 0;
      |     ~~~~~~                
$ head -n 2 errores/sergio.txt
entregas/sergio.c: In function 'main':
entregas/sergio.c:5:9: warning: unused variable 'x' [-Wunused-variable]

Comprobémoslo a mano. Sin parámetros: mensaje de uso y código 2. Con entregass (mal escrito): no es una carpeta, código 2 otra vez. Con entregas: compilan 4 (ana, dani, laura y sergio) y falla 1 (pablo), 4 + 1 = 5 entregas, y el script devuelve 1 porque mal no es 0.

ls entregas confirma que hay cuatro ejecutables nuevos y que no hay pablo: el rm -f lo habría borrado aunque existiera de antes. En errores/ hay un archivo por alumno (los de ana, dani y laura, vacíos). El de pablo cuenta exactamente qué falla: falta un ; al final de la línea 6. El de sergio es un warning: la variable x no se usa. Compila, pero -Wall lo avisa, y nuestro script lo marca como OK (con avisos).

Dani sale OK aunque su programa esté mal (multiplica en vez de sumar). Compilar no es lo mismo que funcionar. De eso se encarga el programa 2.

Preguntas que te puedes hacer

¿Por qué los mensajes de uso van con >&2? Porque son errores, no resultados. Si alguien hace ./compilar.sh entregas > resultado.txt, en el archivo solo quiere las líneas de OK y ERROR; los avisos de uso tienen que verse en la pantalla. Es la separación stdout/stderr que viste en Redirecciones.

¿Por qué exit 2 y no exit 1? Es una convención muy extendida: 2 suele significar «me has llamado mal» (lo hacen ls, grep, bash -n…) y 1, «he trabajado, pero algo ha fallado». Así, quien use el script puede distinguir un fallo de uso de una entrega que no compila.

¿Por qué no se mira si existe el ejecutable? Por la anécdota del principio: un ejecutable viejo puede seguir ahí. El código de salida de gcc dice lo que ha pasado en esta ejecución, que es lo único que nos interesa. Y el rm -f es la segunda red de seguridad.

¿Qué pasa si la carpeta no tiene ningún .c? Bash deja el patrón tal cual: el bucle da una vuelta con fuente valiendo literalmente entregas/*.c, y gcc falla. Se arregla poniendo al principio del bucle [ -f "$fuente" ] || continue (o con shopt -s nullglob, que hace que un patrón sin coincidencias se convierta en nada).

¿Por qué el resumen dice «No compilan: 1» y no se para en el primer error? Porque un corrector tiene que corregir a todos. Un error de pablo no es motivo para no compilar a sergio. Lo que sí hacemos es recordarlo en mal y reflejarlo en el código de salida al final.

Escribir for fuente in "$carpeta/*.c", con el * dentro de las comillas. Bash no expande comodines dentro de comillas, así que el bucle da una sola vuelta con el texto entregas/*.c. Las comillas, solo alrededor de la variable.

Para ver qué hace un script línea a línea, lánzalo con bash -x compilar.sh entregas. Cada orden aparece con un + delante, ya con las variables sustituidas. Lo verás en la sección de la terminal.

Pruébalo tú

  • Añade una opción -q (quiet) que no escriba nada y solo devuelva el código de salida, para usarlo en un if.
  • Haz que, al final, el script escriba la primera línea de cada archivo de errores que no esté vacío (pista: head -n 1 dentro de un for sobre errores/*.txt).

Programa 2: probar las salidas

probar.sh va un paso más allá: ejecuta cada entrega con la entrada de entrada.txt (3 4) y compara lo que escribe con esperado.txt (7). Para cada alumno dice CORRECTO, SALIDA DISTINTA (y qué ha dado) o SIN EJECUTABLE. Lo que lo complica: la comprobación de cada alumno tiene tres resultados posibles, no dos, y queremos tenerla en una función.

La idea

La función probar hace el trabajo y devuelve un número; el bucle principal convierte ese número en texto con un case:

  • 2 si no hay ejecutable (pablo no compiló).
  • Si lo hay, lo ejecuta con < entrada.txt y guarda lo que escribe en salidas/<alumno>.txt.
  • Después compara con cmp -s, que devuelve 0 si los dos archivos son idénticos y 1 si no. Como es la última orden de la función, su código es el de la función.

Separar «calcular» de «escribir» hace la función reutilizable: el programa 3 podría usarla sin cambiarle nada.

Antes de programar, piensa en el orden de los pasos: no se puede probar lo que no se ha compilado. Por eso la sesión lanza primero compilar.sh.

El código

#!/bin/bash
# probar.sh: ejecuta cada entrega con entrada.txt y compara con esperado.txt
# Uso: ./probar.sh [carpeta]   (después de ./compilar.sh carpeta)

carpeta=${1:-entregas}                # si no me dan carpeta, uso entregas

probar() {                            # probar ALUMNO -> 0 bien, 1 salida distinta, 2 sin ejecutable
    local alumno=$1
    local binario="$carpeta/$alumno"
    if [ ! -x "$binario" ]; then      # -x: existe y se puede ejecutar
        return 2
    fi
    "$binario" < entrada.txt > "salidas/$alumno.txt"
    cmp -s "salidas/$alumno.txt" esperado.txt   # -s: sin mensajes; solo importa su $?
}

mkdir -p salidas
for fuente in "$carpeta"/*.c; do
    alumno=$(basename "$fuente" .c)
    probar "$alumno"
    case $? in                        # case mira el código que devolvió probar
        0) echo "CORRECTO        $alumno" ;;
        1) echo "SALIDA DISTINTA $alumno: da $(cat "salidas/$alumno.txt"), se esperaba $(cat esperado.txt)" ;;
        2) echo "SIN EJECUTABLE  $alumno" ;;
    esac
done

Lo más nuevo está en la función:

probar() {                            # probar ALUMNO -> 0 bien, 1 salida distinta, 2 sin ejecutable
    local alumno=$1
    local binario="$carpeta/$alumno"
    if [ ! -x "$binario" ]; then      # -x: existe y se puede ejecutar
        return 2
    fi
    "$binario" < entrada.txt > "salidas/$alumno.txt"
    cmp -s "salidas/$alumno.txt" esperado.txt   # -s: sin mensajes; solo importa su $?
}
  • carpeta=${1:-entregas} (línea 5 del script): ${1:-valor} significa «$1, y si está vacío, valor». Así el parámetro es opcional.
  • [ ! -x "$binario" ]: -x comprueba que existe y se puede ejecutar. Si no, return 2 y la función termina ahí.
  • "$binario" < entrada.txt > "salidas/$alumno.txt": la entrada sale del archivo (el scanf del programa lee 3 4) y la salida va a otro archivo. Son las redirecciones de siempre, pero sobre un programa nuestro.
  • cmp -s: compara byte a byte; la -s (silent) hace que no escriba nada. Solo nos interesa su código.

Así va la traza de la función para cada alumno:

alumno¿-x?Escribe en salidas/cmp -sDevuelveEl case escribe
anasí7iguales0CORRECTO
danisí12distintos1SALIDA DISTINTA
laurasí7iguales0CORRECTO
pablono——2SIN EJECUTABLE
sergiosí7iguales0CORRECTO
La función probar y su case Cada ejecutable lee entrada.txt (3 4) y su salida se compara con esperado.txt (7) usando cmp -s. ana, laura y sergio dan 7 y devuelven 0 (CORRECTO); dani da 12 y devuelve 1 (SALIDA DISTINTA); pablo no tiene ejecutable y la función devuelve 2 (SIN EJECUTABLE). probar ALUMNO: tres códigos, tres casos entrada.txt: 3 4 «$binario» < … > … cmp -s salidas/… esperado.txt (7) alumno ¿-x? salida return / $? case escribe ana sí 7 0 CORRECTO dani sí 12 1 SALIDA DISTINTA laura sí 7 0 CORRECTO pablo no — 2 SIN EJECUTABLE sergio sí 7 0 CORRECTO
La función no escribe el veredicto: devuelve un número, y el case lo convierte en texto.

Salida

$ chmod +x compilar.sh probar.sh
$ ./compilar.sh entregas > /dev/null
$ ./probar.sh entregas
CORRECTO        ana
SALIDA DISTINTA dani: da 12, se esperaba 7
CORRECTO        laura
SIN EJECUTABLE  pablo
CORRECTO        sergio
$ cat salidas/dani.txt
12
$ ls salidas
ana.txt
dani.txt
laura.txt
sergio.txt

La primera orden compila con > /dev/null: no queremos ver otra vez sus OK, solo que deje los ejecutables preparados. Después, las cuentas: 3 + 4 = 7, y eso es lo que dan ana, laura y sergio. Dani da 3 × 4 = 12, porque multiplica. Pablo no tiene ejecutable, así que ni se intenta. En salidas/ hay cuatro archivos, no cinco: el de pablo no llega a crearse, porque la función hace return 2 antes de ejecutar nada.

Preguntas que te puedes hacer

¿Por qué cmp -s y no comparar con [ "$(cat a)" = "$(cat b)" ]? Las dos formas valen aquí. cmp compara los archivos exactos (incluidos los saltos de línea del final), mientras que $( ) quita los saltos de línea finales. Para un corrector estricto, cmp; para uno que perdone un salto de línea de más, la comparación de cadenas. En el programa 3 usamos la segunda.

¿Por qué el case $? va justo después de probar "$alumno"? Porque $? es el código de la última orden. Si en medio hubiera un echo, el case vería el 0 del echo y todos saldrían CORRECTO.

¿Por qué local? Porque alumno también existe en el bucle principal. Sin local, la función usaría la misma variable; aquí no rompería nada (tienen el mismo valor), pero es un error esperando a pasar el día que cambies algo. Con local, la función no toca nada de fuera.

¿Qué pasa si un alumno entrega un programa que se queda esperando para siempre? El corrector se queda colgado con él. En un corrector real se usa timeout 5 "$binario" < entrada.txt, que lo mata a los 5 segundos y devuelve 124. Sería un cuarto caso para el case.

Olvidar el < entrada.txt. El programa del alumno se queda esperando a que escribas los números por teclado y parece que el script se ha colgado. Cuando un script ejecuta otros programas, decide siempre de dónde leen.

diff salidas/dani.txt esperado.txt te enseña en qué se diferencian dos archivos, línea a línea. Es lo que usarías para dar al alumno una explicación del fallo.

Pruébalo tú

  • Añade el caso del timeout: si el programa tarda más de 2 segundos, que la función devuelva 3 y el case escriba DEMASIADO LENTO.
  • Haz que el script pruebe con varias entradas: entrada1.txt/esperado1.txt, entrada2.txt/esperado2.txt…, y diga cuántas pasa cada alumno.

Programa 3: el corrector completo

corregir.sh junta todo: compila, prueba y pone nota (10 si la salida es correcta, 5 si compila pero da otra cosa, 0 si no compila). Además acepta una opción: -c para solo compilar y -p para compilar y probar (lo que hace si no le dices nada). Lo que lo complica: las opciones van antes de la carpeta, así que la carpeta puede estar en $1 o en $2, y el código de salida tiene que decir si todas las entregas están bien.

La idea

  • Una función uso con el mensaje de error y exit 2, para no repetirlo tres veces.
  • Una función compilar muy corta, cuyo código de salida es el de gcc.
  • Un case "$1" para la opción. Si hay opción, shift la quita y la carpeta pasa a ser $1. Así, el resto del script no tiene que preguntar si había opción o no.
  • El bucle usa continue para saltar al siguiente alumno en cuanto ya sabe su nota.
  • La última orden compara correctas con total para dar el código del script.

Antes de programar, escribe en papel las llamadas que tienen que funcionar (./corregir.sh entregas, ./corregir.sh -c entregas, ./corregir.sh -p entregas) y las que tienen que dar error (./corregir.sh, ./corregir.sh -x entregas, ./corregir.sh a b).

El código

#!/bin/bash
# corregir.sh: el corrector completo de la práctica "suma"
# Uso: ./corregir.sh [-c | -p] carpeta
#   -c  solo compila    -p  compila y prueba (lo que hace si no le dices nada)

uso() {                               # una función para no repetir el mensaje tres veces
    echo "Uso: $0 [-c | -p] carpeta" >&2
    exit 2                            # exit dentro de una función termina TODO el script
}

compilar() {                          # compilar FUENTE BINARIO: devuelve el código de gcc
    gcc -Wall "$1" -o "$2" 2> /dev/null
}

modo=probar
case "$1" in
    -c) modo=compilar; shift ;;       # shift: tira $1 y el resto se mueve ($2 pasa a $1)
    -p) shift ;;
    -*) uso ;;                        # cualquier otra opción que empiece por -
esac
[ $# -eq 1 ] || uso                   # || : si la comprobación falla, uso
[ -d "$1" ] || uso
carpeta=$1

correctas=0
total=0
for fuente in "$carpeta"/*.c; do
    alumno=$(basename "$fuente" .c)
    binario="$carpeta/$alumno"
    total=$((total + 1))
    rm -f "$binario"
    if ! compilar "$fuente" "$binario"; then
        echo "$alumno: 0 (no compila)"
        continue                      # a por el siguiente alumno
    fi
    if [ "$modo" = compilar ]; then   # = compara CADENAS
        echo "$alumno: compila"
        correctas=$((correctas + 1))
        continue
    fi
    salida=$("$binario" < entrada.txt)
    if [ "$salida" = "$(cat esperado.txt)" ]; then
        echo "$alumno: 10"
        correctas=$((correctas + 1))
    else
        echo "$alumno: 5 (compila, pero da $salida)"
    fi
done
echo "Correctas: $correctas de $total"
[ "$correctas" -eq "$total" ]         # código de salida: 0 solo si todas están bien

La parte que más cuesta es la de las opciones:

modo=probar
case "$1" in
    -c) modo=compilar; shift ;;       # shift: tira $1 y el resto se mueve ($2 pasa a $1)
    -p) shift ;;
    -*) uso ;;                        # cualquier otra opción que empiece por -
esac
[ $# -eq 1 ] || uso                   # || : si la comprobación falla, uso
[ -d "$1" ] || uso
carpeta=$1
  • modo=probar es el valor por defecto: si no hay opción, nada lo cambia.
  • -c) cambia el modo y hace shift. -p) solo hace shift, porque el modo ya era probar.
  • -*) encaja con cualquier otra cosa que empiece por -: es una opción que no conocemos, así que uso. Va después de -c y -p porque en un case gana el primer patrón que encaja.
  • Si $1 no empieza por - (por ejemplo, entregas), ninguna rama encaja y no pasa nada.
  • [ $# -eq 1 ] || uso: «o hay exactamente un parámetro, o uso». Se lee igual que un if de una línea.
Lo que hace shift con los parámetros Antes de shift, $1 es -c y $2 es entregas, con $# igual a 2. Después de shift, $1 es entregas y $# vale 1, así que el resto del script trata igual una llamada con opción que una sin ella. ./corregir.sh -c entregas: qué hace shift Antes de shift $1 -c $2 entregas $# = 2 case «$1» ve -c: modo=compilar shift tira $1 y corre los demás una casilla Después de shift $1 entregas $2 (vacío) $# = 1 [ $# -eq 1 ] y [ -d «$1» ] ya funcionan igual con -c que sin -c
Tras el shift, la carpeta siempre está en $1, haya opción o no.

Fíjate en que uso termina con exit 2, no con return: si la llamada está mal, no tiene sentido seguir, y exit dentro de una función termina el script entero.

En el bucle, salida=$("$binario" < entrada.txt) guarda lo que escribe el programa del alumno, y [ "$salida" = "$(cat esperado.txt)" ] compara cadenas con =. La traza en modo probar:

Vueltaalumno¿Compila?salidaNotacorrectastotal
1anasí71011
2danisí12512
3laurasí71023
4pablono—024
5sergiosí71035

Salida

$ chmod +x corregir.sh
$ ./corregir.sh -x entregas
Uso: ./corregir.sh [-c | -p] carpeta
$ echo $?
2
$ ./corregir.sh -c entregas
ana: compila
dani: compila
laura: compila
pablo: 0 (no compila)
sergio: compila
Correctas: 4 de 5
$ ./corregir.sh entregas | tee informe.txt
ana: 10
dani: 5 (compila, pero da 12)
laura: 10
pablo: 0 (no compila)
sergio: 10
Correctas: 3 de 5
$ echo $?
0
$ ./corregir.sh entregas > /dev/null
$ echo $?
1
$ ./corregir.sh entregas > /dev/null && echo "Todo correcto" || echo "Hay que revisar"
Hay que revisar
$ wc -l informe.txt
6 informe.txt

Comprobémoslo. Con -x: opción desconocida, uso y código 2. Con -c: solo compila, así que cuenta como correctas las 4 que compilan (Correctas: 4 de 5). Sin opción: notas 10, 5, 10, 0 y 10, y 3 correctas de 5, que es lo que dice la traza.

Ahora, el detalle más interesante: después de ./corregir.sh entregas | tee informe.txt, echo $? da 0, aunque el corrector haya terminado con 1. ¿Por qué? Porque en una tubería, $? es el código de la última orden, y la última es tee, que ha guardado el informe sin problemas. En cuanto lo lanzamos sin tubería (> /dev/null), echo $? da el 1 de verdad, y el && … || escribe Hay que revisar. Por último, wc -l informe.txt cuenta 6 líneas: 5 alumnos + 1 resumen.

Si necesitas el código de una orden que está en medio de una tubería, bash lo guarda en el array PIPESTATUS: justo después de ./corregir.sh entregas | tee informe.txt, echo "${PIPESTATUS[0]}" daría 1.

Preguntas que te puedes hacer

¿Por qué shift y no mirar $2 cuando hay opción? Porque entonces todo el script tendría que saber si había opción («¿la carpeta está en $1 o en $2?»). Con shift, a partir de la línea 21 la carpeta siempre está en $1 y $# siempre debe valer 1.

¿Por qué if ! compilar …? El ! invierte el código: entra en el then cuando compilar falla. Así el caso raro (no compila) se resuelve primero y con continue, y el resto del bucle se queda sin anidar.

¿Por qué [ "$modo" = compilar ] con = y no con -eq? Porque modo es una palabra. -eq solo sirve para números; con palabras daría integer expression expected, como el siete de la teoría.

¿Por qué dani tiene un 5 y no un 0? Es una decisión de corrección: su programa compila y hace algo, solo que no lo correcto. El script lo refleja con una nota intermedia y, además, dice qué ha dado (da 12) para que se pueda revisar a mano.

¿Por qué el código de salida compara correctas con total? Para que el script se pueda usar desde otro script o desde un if sin leer el texto: if ./corregir.sh entregas > /dev/null; then echo "todo bien"; fi.

Fiarte del $? después de una tubería. ./corregir.sh entregas | tee informe.txt siempre parece ir bien, porque $? es el de tee. Si el código importa, no lo metas en una tubería o usa PIPESTATUS.

shift se puede usar en un while [ $# -gt 0 ] para procesar varias opciones seguidas (-c -v entregas): en cada vuelta miras $1 con un case y haces shift.

Pruébalo tú

  • Añade una opción -o archivo que guarde el informe en ese archivo (pista: después de la opción viene el nombre, así que necesitarás shift 2).
  • Haz que la nota de los que compilan con avisos baje un punto (pista: guarda los errores en un archivo en lugar de en /dev/null y comprueba con -s).

Errores típicos en esta práctica

  • Comillas con el comodín dentro ("$carpeta/*.c"): el * no se expande y el bucle da una sola vuelta con un nombre que no existe.
  • Decidir por la existencia del ejecutable: un ejecutable viejo hace pasar por buena una entrega que ya no compila. Usa el código de gcc y borra lo viejo con rm -f.
  • Ejecutar un programa sin <: se queda esperando al teclado y el script parece colgado.
  • Mirar $? tarde o después de una tubería: lo que ves es el código de otra orden (el echo o el tee).
  • Comparar palabras con -eq (o números con =): -eq, -lt… para números; =, != para cadenas.
  • Usar return en una función que tiene que cortar el script (como uso): return solo sale de la función, y el script seguiría con datos incorrectos.

Pruébalo en tu terminal

Dos herramientas para cuando un script no hace lo que esperas: bash -x, que enseña cada orden antes de ejecutarla, y bash -n, que comprueba la sintaxis sin ejecutar nada. Teclea esto en la carpeta de pruebas:

$ cat > mini.sh << 'EOF'
> #!/bin/bash
> for f in entregas/ana.c entregas/pablo.c; do
>     gcc "$f" -o prog 2> /dev/null && echo "OK $f" || echo "ERROR $f"
> done
> EOF
$ bash -x mini.sh
+ for f in entregas/ana.c entregas/pablo.c
+ gcc entregas/ana.c -o prog
+ echo 'OK entregas/ana.c'
OK entregas/ana.c
+ for f in entregas/ana.c entregas/pablo.c
+ gcc entregas/pablo.c -o prog
+ echo 'ERROR entregas/pablo.c'
ERROR entregas/pablo.c
$ cat > roto.sh << 'EOF'
> #!/bin/bash
> if [ -f entregas/ana.c ]; then
>     echo "ana ha entregado"
> EOF
$ bash -n roto.sh
roto.sh: line 4: syntax error: unexpected end of file
$ echo $?
2

Qué observar:

  1. Con bash -x, cada orden sale con un + delante y con las variables ya sustituidas: ves gcc entregas/ana.c -o prog, no gcc "$f" -o prog.
  2. En la vuelta de pablo, después de gcc viene directamente echo 'ERROR …': el && se ha saltado el OK porque gcc devolvió 1, y el || ha ejecutado su parte.
  3. Las líneas con + van por stderr: si haces bash -x mini.sh 2> traza.txt, la salida normal sale por pantalla y la traza, al archivo.
  4. bash -n roto.sh encuentra que falta el fi (unexpected end of file, en la línea 4, que es donde se acaba el archivo) y devuelve 2, sin haber ejecutado el echo. Es lo primero que conviene hacer con un script nuevo.

Resumen

  • Un script serio comprueba sus parámetros al principio y termina con exit 2 y un mensaje por stderr si lo llaman mal.
  • Las decisiones se toman con códigos de salida (if gcc …, cmp -s, [ -s archivo ]), no mirando qué ha quedado en la carpeta; y lo viejo se borra antes de generar lo nuevo.
  • Una función que devuelve un número con return y un case $? que lo traduce a texto separan el cálculo de la presentación.
  • shift deja los parámetros siempre en el mismo sitio después de procesar las opciones.
  • El script tiene que devolver un código que signifique algo (0 = todo bien), y ojo con $? después de una tubería.
  • bash -n para la sintaxis y bash -x para ver qué hace de verdad, línea a línea.

Checklist: ¿lo has practicado?

  • ¿Has ejecutado los tres programas y comprobado las salidas?
  • ¿Sabes explicar por qué pablo no aparece en ls entregas aunque hubiera compilado otro día?
  • ¿Puedes decir qué devuelve la función probar para cada alumno y por qué?
  • ¿Entiendes por qué echo $? da 0 después de | tee informe.txt?
  • ¿Has intentado los «Pruébalo tú» sin mirar la teoría?
  • ¿Has usado bash -x al menos una vez para seguir uno de tus scripts?

Si has respondido que sí a casi todo, estás listo para los ejercicios de shell scripts, donde ya no te doy el código hecho. ¡Nos vemos allí!

Siguiente paso: ejerciciosShell scripts en Linux — ejercicios con parámetros que llevan espacios, notas que hay que validar y una lista de alumnos que corregir (y el archivo «3» que apareció en la carpeta)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *