Expresiones regulares avanzadas: clases, límites y PCRE

Las clases de caracteres abreviadas como \d, \w y \s son una trampa clásica en la línea de comandos: funcionan exactamente como esperas en Python, Perl o JavaScript, pero en grep sin -P simplemente no existen o se comportan de forma inesperada. El motivo es que hay varios sabores de regex (flavors) y cada herramienta implementa uno distinto. grep por defecto usa BRE (Basic Regular Expressions), con -E usa ERE (Extended Regular Expressions) de POSIX, y solo con -P activa PCRE (Perl-Compatible Regular Expressions). Las abreviaciones \d, \w, \s y todo lo que viene de Perl —lookaheads, lookbehinds, cuantificadores non-greedy— solo están disponibles en PCRE.

El diseño no es capricho: BRE y ERE son estándares POSIX pensados para portabilidad entre implementaciones Unix. PCRE es una biblioteca separada (en Debian, libpcre3 o libpcre2) que grep enlaza opcionalmente. sed tiene su propio subconjunto de ERE con -E pero sin PCRE. awk tiene un motor propio que ni siquiera es completamente POSIX. Si escribes \d en un sed o en un grep sin -P, en el mejor caso falla silenciosamente coincidiendo con la letra d literal; en el peor, produces falsos positivos sin ningún mensaje de error.

Cuándo usar cada cosa: usa ERE (grep -E, sed -E) para patrones portables que van a vivir en scripts de producción. Usa PCRE (grep -P) para trabajo interactivo, extracción puntual, o cuando genuinamente necesitas lookaheads o backreferences complejas. Las backreferences con \1, \2 sí existen en BRE y ERE de GNU, pero los grupos de captura para lookahead/lookbehind solo en PCRE. Y hay una línea que no debes cruzar: cuando el texto tiene estructura jerárquica real —HTML, JSON, XML, código fuente— ningún sabor de regex es suficiente. Usa un parser (jq, python3 -c, xmllint, ast). Regex sobre HTML produce bugs que aparecen meses después con datos que nadie anticipó.

Si te equivocas eligiendo el flavor, el fallo es casi siempre silencioso: el patrón coincide con cosas que no debería, o no coincide con nada sin error. Eso es peor que un fallo ruidoso.

#!/usr/bin/env bash
# Fichero de prueba con casos reales de log, JSON sucio y texto mixto
cat > /tmp/test_regex.txt <<'EOF'
192.168.1.100 - user1 [10/Oct/2023:13:55:36 +0000] "GET /index.html" 200 1234
10.0.0.5 - admin [10/Oct/2023:13:56:01 +0000] "POST /api/login" 401 89
error: connection refused at line 42 in module_loader.py
warning: disk usage at 87% on /dev/sda1
aabbccdd
abcdef
{"status": "ok", "code": 200}
{"status": "fail", "code": 401}
password=s3cr3t_v4lue
password=otro_valor
EOF

echo "=== 1. \d, \w, \s solo funcionan con -P ==="
# Sin -P, \d no es un dígito: grep lo trata como 'd' literal en BRE
grep '\d\+' /tmp/test_regex.txt | head -3
echo "--- con -P sí funciona ---"
grep -P '\d+' /tmp/test_regex.txt | head -3

echo ""
echo "=== 2. Límite de palabra \b (word boundary) — solo PCRE en grep ==="
# \b en PCRE ancla al límite entre \w y \W
# Queremos "error" como palabra, no como parte de "error_code" o similar
grep -P '\berror\b' /tmp/test_regex.txt

echo ""
echo "=== 3. Backreferences \1 en ERE: caracteres duplicados consecutivos ==="
# (.)\1 captura cualquier carácter y exige que se repita inmediatamente
# Esto SÍ funciona con -E porque GNU grep soporta \1 en ERE
grep -E '(.)\1' /tmp/test_regex.txt

echo ""
echo "=== 4. Lookahead (?=...) — extrae valor tras 'password=' sin incluir la clave ==="
# (?<=password=) es un lookbehind de longitud fija: coincide en la posición
# justo después de "password=", así \w+ captura solo el valor
grep -oP '(?<=password=)\w+' /tmp/test_regex.txt

echo ""
echo "=== 5. Cuantificador non-greedy *? en PCRE ==="
# Greedy: .* entre comillas captura desde la primera hasta la ÚLTIMA comilla
# Non-greedy: .*? se detiene en la primera comilla de cierre
echo 'cita "primera" y "segunda" aquí' | grep -oP '".*"'   # greedy: todo
echo 'cita "primera" y "segunda" aquí' | grep -oP '".*?"'  # non-greedy: cada una

echo ""
echo "=== 6. Misma tarea en sed (ERE, sin PCRE) ==="
# sed -E no tiene lookahead, pero podemos usar grupos de captura para reemplazar
# Enmascaramos el valor de password dejando visible solo los primeros 2 chars
sed -E 's/(password=)([a-zA-Z0-9_]{2})[a-zA-Z0-9_]*/\1\2***/' /tmp/test_regex.txt \
    | grep password

echo ""
echo "=== 7. Lo que NO debes hacer: regex sobre JSON estructurado ==="
# Esto 'funciona' para este JSON simple pero falla con espacios, arrays, escape
grep -oP '(?<="code": )\d+' /tmp/test_regex.txt
# La forma correcta para JSON real:
echo '{"status": "ok", "code": 200}' | python3 -c "import sys,json; print(json.load(sys.stdin)['code'])"

Qué está pasando en cada bloque

El bloque 1 demuestra el fallo silencioso más común: grep '\d\+' sin -P no produce error, simplemente busca la letra d seguida de + escapado. Cuando añades -P, \d+ funciona como esperas porque ahora el motor es PCRE. Fíjate en que con PCRE el + no necesita escapado; con BRE necesitarías \+.

En el bloque 2, \b en PCRE es un aserto de ancho cero (zero-width assertion): no consume caracteres, solo verifica que en esa posición hay una transición entre \w y \W (o inicio/fin de cadena). En GNU grep con ERE, \b también existe como extensión GNU, pero en otros contextos como sed -E o awk no puedes contar con él.

El bloque 3 es el único donde usamos -E en lugar de -P intencionalmente: GNU grep soporta backreferences \1 en ERE, y es la forma portátil de detectar caracteres duplicados consecutivos. (.)\1 funciona porque el motor guarda lo que capturó el grupo 1 y exige encontrarlo de nuevo inmediatamente. Esto no funcionaría con ERE estándar POSIX puro (sin extensiones GNU), pero en Debian con grep de GNU es seguro.

El bloque 4 usa un lookbehind de longitud fija ((?<=password=)). “Longitud fija” es clave: PCRE estándar no permite lookbehinds con cuantificadores variables como (?<=\w+). Si intentas un lookbehind de longitud variable, grep -P lanza un error en tiempo de ejecución. La combinación con -o hace que grep imprima solo la parte coincidente, no la línea entera.

El bloque 5 muestra la diferencia entre greedy y non-greedy: . en regex es ambicioso por defecto e intenta consumir el máximo posible. .* entre dos comillas captura desde la primera apertura hasta la última cierre de toda la línea. Con .*? el motor retrocede (backtrack) lo mínimo necesario para que el patrón completo encaje. Es más costoso computacionalmente, pero a veces imprescindible.

El bloque 6 muestra el límite de sed -E: sin lookahead, usamos dos grupos de captura para descomponer la línea y reconstruirla con \1\2. Es más verboso pero completamente portátil en cualquier sistema con GNU sed.

El bloque 7 es el punto más importante del artículo. El grep -oP sobre JSON funciona con esos dos objetos simples y falla con cualquier variación: espacios diferentes, valores en arrays, strings con caracteres escapados, claves en distinto orden. python3 -c con json.load maneja correctamente todos esos casos porque usa un parser que entiende la gramática de JSON, no un patrón que asume un formato concreto. La regla práctica: si el formato tiene un parser dedicado disponible en una línea, úsalo.

48

Dejar un comentario

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

Scroll al inicio