← Automatización con Scripts en Bash Bloque 7 · Texto y expresiones regulares · Tema 28 de 42

Tema 28

Expresiones regulares en Bash

Desde el tema 7, varios scripts del curso validaron sus datos con una línea como [[ "$dias" =~ ^[0-9]+$ ]], con la promesa de explicarla más adelante. Este es el momento. Las expresiones regulares describen la forma de un texto, y Bash puede compararlas directamente con =~, sin llamar a grep, y además extraer las partes que interesan. Este tema explica la sintaxis que entiende Bash, cómo validar los datos más habituales y cómo extraer campos de una línea.

1. Expresiones regulares y comodines

Una expresión regular es un patrón que describe un conjunto de textos posibles: «uno o más dígitos», «una fecha con forma AAAA-MM-DD», «una palabra seguida de dos puntos». Sirve para comprobar si un texto tiene una forma determinada, y para encontrar o extraer partes de él.

Ya conoces otra clase de patrones: los comodines de los nombres de archivo, de case y del == de [[ ]]. Se parecen, y eso causa confusiones, porque los mismos símbolos significan cosas distintas:

ComodinesExpresiones regulares
DóndeNombres de archivo, case, [[ == ]], ${var#…}.[[ =~ ]], grep, sed, awk.
Cualquier texto*.*
Un carácter cualquiera?.
Repetir lo anteriorNo se puede (salvo con extglob).*, +, ?, {n,m}
¿Debe coincidir con todo el texto?Sí, siempre.No: basta con una parte, salvo que se anclen con ^ y $.

La diferencia más traicionera es el asterisco: en un comodín significa «cualquier texto», y en una expresión regular, «lo anterior, repetido cero o más veces». *.txt es un comodín correcto; como expresión regular no tiene sentido.

2. El operador =~

Dentro de [[ ]], el operador =~ compara un texto con una expresión regular. Es cierto si alguna parte del texto coincide:

terminal
$ [[ "abc123" =~ [0-9]+ ]] && echo "contiene dígitos"
contiene dígitos
$ [[ "abc123" =~ ^[0-9]+$ ]] || echo "pero no son solo dígitos"
pero no son solo dígitos

La primera expresión no tiene anclas, y encuentra los dígitos en medio del texto. La segunda las tiene: ^ significa «comienzo del texto» y $, «final», de modo que todo el texto debe ser dígitos. Para validar datos, casi siempre se quieren las dos anclas: sin ellas, 4x pasaría como número porque contiene un 4.

Como toda prueba de [[ ]], =~ termina con 0 si coincide y con 1 si no. Si la expresión está mal escrita, termina con 2. Se usa directamente en if, && y ||.

3. Sin comillas, o en una variable

Aquí hay una regla que contradice la costumbre del curso: el lado derecho de =~ no va entre comillas. Las comillas hacen que el texto se tome literalmente, como en el == del tema 11, y los símbolos de la expresión pierden su significado:

terminal
$ [[ "abc" =~ a.c ]] && echo "el punto es «cualquier carácter»"
el punto es «cualquier carácter»
$ [[ "abc" =~ "a.c" ]] || echo "entre comillas, el punto es un punto"
entre comillas, el punto es un punto

Escribir la expresión sin comillas directamente en [[ ]] funciona con las simples, pero se complica en cuanto tiene espacios, <, > o paréntesis, que Bash intenta interpretar. La forma recomendada, y la que usa el curso, es guardar la expresión en una variable con comillas simples, y usar la variable sin comillas:

la expresión en una variable
readonly RE_FECHA='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'

if [[ "$fecha" =~ $RE_FECHA ]]; then
  …
fi

Las comillas simples protegen la expresión al asignarla, sin necesidad de escapar nada; $RE_FECHA sin comillas en el =~ hace que se use como expresión. El lado izquierdo, el texto, sí va entre comillas, como siempre. Además, con un nombre como RE_FECHA, la condición se lee sola.

4. La sintaxis que entiende Bash

Bash usa las expresiones regulares extendidas de POSIX (ERE), las mismas de grep -E y awk. Estos son sus elementos:

ElementoCoincide conEjemplo
aEse carácter.abc: el texto «abc».
.Un carácter cualquiera.a.c: «abc», «a-c», «a c»…
[abc]Uno de esos caracteres.[sS]í: «sí» o «Sí».
[a-z], [0-9]Uno del rango.[0-9]: un dígito.
[^abc]Cualquier carácter excepto esos.[^,]: cualquier cosa menos una coma.
^ y $El comienzo y el final del texto.^#: empieza con #.
*Lo anterior, cero o más veces.ab*: «a», «ab», «abbb».
+Lo anterior, una o más veces.[0-9]+: un número.
?Lo anterior, cero o una vez.-?[0-9]+: con signo opcional.
{n}, {n,}, {n,m}Lo anterior, exactamente n veces, al menos n, o entre n y m.[0-9]{4}: cuatro dígitos.
( )Agrupa, y captura lo que coincide (sección 6).(ab)+: «ab», «abab».
|Una alternativa u otra.^(si|no)$: «si» o «no».
\., \+…El carácter literal, sin su significado especial.\.txt$: termina en «.txt».
\d, \w y \s no existen aquí

Muchos lenguajes, y muchos tutoriales en Internet, usan \d para un dígito, \w para una letra o \s para un espacio. Son extensiones de Perl, y la sintaxis POSIX de Bash no las tiene: [[ 5 =~ \d ]] es falso. En su lugar se usan rangos, como [0-9], o las clases de la sección siguiente.

5. Las clases de caracteres

Dentro de los corchetes se pueden usar clases con nombre, que describen grupos de caracteres sin depender de rangos:

ClaseCoincide conEquivale a
[[:digit:]]Un dígito.[0-9]
[[:alpha:]]Una letra, incluidas las acentuadas y la eñe.—
[[:alnum:]]Una letra o un dígito.—
[[:upper:]], [[:lower:]]Una mayúscula, una minúscula.—
[[:space:]]Un espacio, tabulador o salto de línea.Lo que en otros lenguajes es \s.
[[:punct:]]Un signo de puntuación.—

Los dobles corchetes no son un error: la clase [:digit:] va dentro de los corchetes de un conjunto, y puede combinarse con otros caracteres, como en [[:alnum:]_-], «letra, dígito, guion bajo o guion». Las clases tienen una ventaja sobre los rangos: respetan la configuración regional. [a-z] no incluye la «ñ» ni las vocales con tilde, y en algunas configuraciones regionales incluye mayúsculas de forma inesperada; [[:lower:]] incluye todas las minúsculas del idioma.

6. Capturar partes: BASH_REMATCH

Cuando =~ encuentra una coincidencia, Bash guarda el resultado en el array BASH_REMATCH. El elemento 0 es todo lo que coincidió, y los siguientes, lo que coincidió con cada grupo entre paréntesis, en el orden en que se abren:

terminal
$ fecha="2026-10-14"
$ re='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
$ [[ $fecha =~ $re ]] && declare -p BASH_REMATCH
declare -a BASH_REMATCH=([0]="2026-10-14" [1]="2026" [2]="10" [3]="14")
$ echo "Día ${BASH_REMATCH[3]} del mes ${BASH_REMATCH[2]}"
Día 14 del mes 10

Así, una sola comparación valida la forma del texto y, a la vez, lo descompone en sus partes, algo que con los recortes del tema 6 requeriría varias líneas. Dos precauciones:

7. Validar los datos más habituales

Estas expresiones cubren las validaciones que más aparecen en scripts. Todas van ancladas, porque validan el texto completo:

DatoExpresiónAceptaRechaza
Entero no negativo^[0-9]+$42, 0074x, -3, vacío
Entero con signo^-?[0-9]+$-3, 42+-3, 3.5
Decimal^-?[0-9]+(\.[0-9]+)?$3.14, -23., .5, 3,14
Fecha AAAA-MM-DD^[0-9]{4}-[0-9]{2}-[0-9]{2}$2026-10-1414/10/2026
Hora HH:MM^([01][0-9]|2[0-3]):[0-5][0-9]$09:30, 23:5924:00, 9:30
Nombre de usuario^[a-z_][a-z0-9_-]{0,31}$ana, web_01Ana, 1ana
Correo (forma general)^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ana@ejemplo.comana@ejemplo, ana @x.com
Dirección IPv4 (forma)^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$192.168.1.101.2.3

Hay que saber qué no comprueban. La expresión de fecha acepta 2026-02-30, porque solo mira la forma. La de IPv4 acepta 300.1.1.1, porque cada número puede tener tres dígitos cualesquiera. Para esos casos, la expresión verifica la forma y captura las partes, y una segunda comprobación verifica el significado:

forma con la expresión, significado con otra prueba
# Fecha: la forma con =~, la validez con date.
[[ "$fecha" =~ $RE_FECHA ]] && date -d "$fecha" > /dev/null 2>&1

# IPv4: la forma con =~, cada número entre 0 y 255 con aritmética.
es_ipv4() {
  local octeto
  [[ "$1" =~ $RE_IPV4 ]] || return 1
  for octeto in "${BASH_REMATCH[@]:1}"; do
    (( 10#$octeto <= 255 )) || return 1
  done
}

El correo merece una advertencia: una expresión que acepte exactamente todas las direcciones válidas y ninguna inválida es enorme e inútil en la práctica. La de la tabla comprueba lo razonable: algo, una arroba, algo con un punto, y sin espacios. La única forma de saber si una dirección existe es enviarle un correo.

8. Extraer campos de una línea

La otra gran aplicación es descomponer líneas con un formato conocido, como las de un registro. Con un grupo por cada campo, una sola comparación los separa a todos:

terminal · una línea de registro
$ linea='2026-10-14 03:15:02 ERROR [db] conexión rechazada'
$ re='^([0-9-]+) ([0-9:]+) ([A-Z]+) \[([a-z]+)\] (.*)$'
$ [[ $linea =~ $re ]] && printf '<%s> ' "${BASH_REMATCH[@]:1}"; echo
<2026-10-14> <03:15:02> <ERROR> <db> <conexión rechazada>

Los corchetes literales del registro se escapan con \[ y \], y el último grupo, (.*), se lleva todo el resto de la línea, espacios incluidos. Cuando los campos están separados por un carácter fijo, separar con IFS y read, como en el tema 9, es más simple; la expresión regular conviene cuando el formato mezcla separadores, como aquí, donde hay espacios, corchetes y un texto libre al final.

Para extraer de muchas líneas de un archivo, un bucle con =~ funciona, pero es lento con archivos grandes. Ahí conviene grep -o, sed o awk, el tema siguiente.

9. Las repeticiones son codiciosas

Cuando una repetición como .* puede coincidir con más o con menos texto, toma siempre lo más que puede. Esto sorprende al extraer algo entre delimitadores:

terminal
$ s='aaa<b>bbb</b>'
$ re='<.*>'; [[ $s =~ $re ]] && echo "${BASH_REMATCH[0]}"
<b>bbb</b>
$ re='<[^>]*>'; [[ $s =~ $re ]] && echo "${BASH_REMATCH[0]}"
<b>

La primera expresión se extendió desde el primer < hasta el último >. La solución, en la sintaxis POSIX, es decir con precisión qué puede haber en medio: [^>]*, «cualquier cosa que no sea >». Otros lenguajes tienen repeticiones no codiciosas, como .*?, pero Bash no.

10. Bash, grep y las demás herramientas

Las expresiones regulares no son exactamente iguales en todas las herramientas. Hay tres dialectos principales:

DialectoDóndeDiferencia principal
Básico (BRE)grep sin opciones, sed sin -E+, ?, |, { } y ( ) necesitan una barra para tener significado especial: \+, \(.
Extendido (ERE)[[ =~ ]], grep -E, sed -E, awkLos símbolos funcionan sin barra. Es el que usa este curso.
Perl (PCRE)grep -P, la mayoría de los lenguajes de programaciónAgrega \d, \w, \s, repeticiones no codiciosas y más.

Para no mezclar dialectos, conviene usar siempre la forma extendida: grep -E y sed -E. Así, una misma expresión puede probarse en la terminal con grep -E y usarse después en un =~ sin cambios.

11. Cuándo no usar expresiones regulares

Las expresiones regulares son poderosas, y por eso tentadoras para todo. Hay casos en que son la herramienta equivocada:

Y una expresión larga se vuelve rápidamente ilegible. Si no se entiende de un vistazo, conviene partirla, comentarla, o guardarla en una variable con un nombre que diga qué valida.

12. Un script completo

Muchos proyectos guardan su número de versión en un archivo VERSION, con el formato de versionado semántico: tres números, MAYOR.MENOR.PARCHE, que se incrementan según el tipo de cambio. Este script valida la versión actual con una expresión regular, la descompone con BASH_REMATCH, incrementa la parte indicada y escribe la nueva de forma atómica.

subir-version
#!/usr/bin/env bash
#
# subir-version — incrementa una versión semántica guardada en un archivo.
#
# Uso: subir-version {mayor|menor|parche} [ARCHIVO]   (por defecto, VERSION)
#      1.4.2 → mayor: 2.0.0 · menor: 1.5.0 · parche: 1.4.3
#
set -euo pipefail

readonly RE_VERSION='^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$'

uso() {
  echo "Uso: ${0##*/} {mayor|menor|parche} [ARCHIVO]   (por defecto, VERSION)" >&2
  exit 2
}

(( $# >= 1 && $# <= 2 )) || uso
parte="$1"
archivo="${2:-VERSION}"
[[ "$parte" =~ ^(mayor|menor|parche)$ ]] || uso
[[ -f "$archivo" ]] || { echo "${0##*/}: no existe $archivo" >&2; exit 1; }

IFS= read -r actual < "$archivo" || true
actual="${actual%$'\r'}"                     # por si el archivo viene de Windows
if [[ ! "$actual" =~ $RE_VERSION ]]; then
  echo "${0##*/}: «$actual» no es una versión válida (se esperaba como 1.4.2)" >&2
  exit 1
fi

mayor=$(( 10#${BASH_REMATCH[1]} ))
menor=$(( 10#${BASH_REMATCH[2]} ))
parche=$(( 10#${BASH_REMATCH[3]} ))
prefijo=""
[[ "$actual" == v* ]] && prefijo="v"           # conservar la «v» inicial si la tenía

case "$parte" in
  mayor)  mayor=$(( mayor + 1 )); menor=0; parche=0 ;;
  menor)  menor=$(( menor + 1 )); parche=0 ;;
  parche) parche=$(( parche + 1 )) ;;
esac

nueva="$prefijo$mayor.$menor.$parche"
tmp=$(mktemp "$archivo.XXXXXX")
trap 'rm -f "$tmp"' EXIT
printf '%s\n' "$nueva" > "$tmp"
mv -f -- "$tmp" "$archivo"
echo "$actual → $nueva"
terminal
$ cat VERSION
1.4.2
$ subir-version parche
1.4.2 → 1.4.3
$ subir-version menor
1.4.3 → 1.5.0
$ subir-version mayor
1.5.0 → 2.0.0
$ echo 1.4 > VERSION; subir-version parche
subir-version: «1.4» no es una versión válida (se esperaba como 1.4.2)

13. Errores frecuentes

EscribisteQué pasaCorrección
[[ $x =~ "^[0-9]+$" ]]Las comillas vuelven literal la expresión.Sin comillas, o en una variable.
[[ $x =~ [0-9]+ ]] para validar4x pasa, porque contiene un dígito.Anclas: ^[0-9]+$.
\d, \w, \sNo existen en la sintaxis POSIX.[0-9], [[:alnum:]_], [[:space:]].
*.txt como expresión regularEl * no significa «cualquier texto».\.txt$, o un comodín con ==.
[a-z] para letras en españolNo incluye «ñ» ni vocales con tilde.[[:lower:]], [[:alpha:]].
Usar BASH_REMATCH varias líneas despuésOtro =~ lo reemplazó.Copiar a variables con nombre enseguida.
<.*> para extraer una etiquetaSe extiende hasta el último >.<[^>]*>
Validar una fecha solo con la expresiónAcepta 2026-02-30.La expresión para la forma; date -d para la validez.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué significa el asterisco en un comodín y en una expresión regular?

En un comodín, cualquier texto. En una expresión regular, que lo anterior se repite cero o más veces; «cualquier texto» se escribe .*.

2. ¿Por qué [[ "4x" =~ [0-9]+ ]] es cierto, y cómo se corrige para validar un número?

Porque sin anclas basta con que una parte del texto coincida, y 4 es un dígito. Con ^[0-9]+$, todo el texto debe ser dígitos.

3. ¿Por qué la expresión de =~ no debe ir entre comillas?

Porque las comillas hacen que se compare literalmente, y los símbolos como . o + pierden su significado especial.

4. ¿Qué ventaja tiene guardar la expresión en una variable?

Se escribe con comillas simples, sin preocuparse por los caracteres que Bash interpretaría, como espacios, < o paréntesis; y un nombre como RE_FECHA hace la condición más legible.

5. ¿Por qué [[ 5 =~ \d ]] es falso?

Porque \d es una extensión de Perl que no existe en la sintaxis POSIX de Bash. Se usa [0-9] o [[:digit:]].

6. ¿Qué contiene BASH_REMATCH después de [[ "ana:1000" =~ ^([a-z]+):([0-9]+)$ ]]?

[0] es ana:1000, [1] es ana y [2] es 1000.

7. ¿Por qué conviene usar [[:lower:]] en lugar de [a-z]?

Porque [a-z] no incluye la «ñ» ni las vocales con tilde, y su significado depende de la configuración regional. [[:lower:]] incluye todas las minúsculas del idioma.

8. ¿Qué no comprueba ^[0-9]{4}-[0-9]{2}-[0-9]{2}$, y cómo se completa la validación?

Que la fecha exista: acepta 2026-02-30 o 2026-13-01. Se completa comprobando con date -d "$fecha", que falla con fechas inexistentes.

9. ¿Por qué <.*> aplicado a <b>x</b> captura todo el texto?

Porque .* es codicioso: toma lo más que puede, hasta el último >. Con <[^>]*> se detiene en el primer >.

10. ¿Qué opción de grep usa el mismo dialecto que =~?

grep -E, que usa las expresiones regulares extendidas de POSIX.

Para practicar

  1. Escribe expresiones para validar un código postal de tu país, un número de teléfono con un formato fijo y una matrícula de vehículo. Pruébalas con casos válidos e inválidos.
  2. Escribe una función es_fecha que valide la forma con =~ y la existencia con date -d.
  3. Completa la función es_ipv4 de la sección 7 y pruébala con 192.168.1.1, 256.1.1.1, 1.2.3 y 01.02.03.04.
  4. Toma una línea de /var/log/syslog o de journalctl y escribe una expresión que separe la fecha, el equipo, el programa y el mensaje.
  5. Comprueba con grep -E en la terminal una expresión, y úsala después sin cambios en un =~.
  6. Agrega a subir-version una opción para fijar un sufijo preliminar, como -rc.1, y valídalo con su propia expresión.