Expresiones regulares en Java — práctica con un validador, un lector de logs y un limpiador de texto (y el \r invisible que me rompió todas las líneas)
Mi primer lector de logs en Java no reconocía ninguna línea. Ni una. El patrón estaba bien, lo había probado con una línea copiada a mano y funcionaba. Pero al leer el archivo de verdad, todas las líneas salían como «formato no válido».
El culpable era invisible: el archivo venía de Windows y cada línea terminaba en \r. Como matches() exige que el patrón cubra todo el texto, ese carácter de más al final bastaba para que nada encajara. Un strip() antes de validar y problema resuelto. Desde entonces, antes de culpar a la regex, miro qué tiene de verdad el texto.
En la teoría viste las piezas: matches, Pattern, Matcher, find, grupos y replaceAll. Aquí las juntamos en tres programas completos, de los que podrías usar en una práctica real:
- Un validador de formulario (usuario, correo, teléfono, DNI con su letra y contraseña).
- Un lector de logs que separa cada línea en partes y busca IPs dentro del mensaje.
- Un limpiador de texto que arregla espacios, fechas y teléfonos, y oculta correos.
Tabla de Contenidos
Programa 1: validador de un formulario de registro
Imagina el formulario de alta de una web: cinco campos y, para cada uno, una regla. Queremos una lista con todos los errores de golpe, no solo el primero, porque es lo que espera quien rellena el formulario.
import java.util.regex.Pattern;
public class ValidadorFormulario {
// Compilamos cada patrón UNA vez: se reutiliza en cada validación
static final Pattern USUARIO = Pattern.compile("[a-z][a-z0-9_]{3,15}");
static final Pattern CORREO = Pattern.compile("[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+");
static final Pattern TELEFONO = Pattern.compile("[6-9]\\d{8}");
static final Pattern DNI = Pattern.compile("(\\d{8})([A-Z])");
static final Pattern CLAVE = Pattern.compile("(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).{8,}");
static final String LETRAS_DNI = "TRWAGMYFPDXBNJZSQVHLCKE";
static String validar(String usuario, String correo, String telefono, String dni, String clave) {
String errores = "";
if (!USUARIO.matcher(usuario).matches()) errores += " - usuario no válido\n";
if (!CORREO.matcher(correo).matches()) errores += " - correo no válido\n";
// el usuario puede escribir espacios: los quitamos antes de validar
if (!TELEFONO.matcher(telefono.replaceAll("\\s+", "")).matches()) errores += " - teléfono no válido\n";
if (!dniValido(dni.toUpperCase())) errores += " - DNI no válido\n";
if (!CLAVE.matcher(clave).matches()) errores += " - contraseña débil\n";
return errores;
}
static boolean dniValido(String dni) {
var m = DNI.matcher(dni);
if (!m.matches()) return false; // primero, la FORMA
int numero = Integer.parseInt(m.group(1)); // grupo 1: los 8 dígitos
char letra = m.group(2).charAt(0); // grupo 2: la letra
return LETRAS_DNI.charAt(numero % 23) == letra; // después, el SIGNIFICADO
}
public static void main(String[] args) {
String[][] registros = {
{"sergio_43", "sergio@sergiolearns.com", "612 345 678", "12345678z", "Regex2026"},
{"4dmin", "admin@", "512345678", "12345678A", "admin"},
{"ana", "ana.lopez@alu.ulpgc.es", "928123456", "87654321X", "MiClave1"}
};
for (String[] r : registros) {
String errores = validar(r[0], r[1], r[2], r[3], r[4]);
System.out.println(r[0] + ":" + (errores.isEmpty() ? " todo correcto" : "\n" + errores.stripTrailing()));
}
}
}
Salida:
sergio_43: todo correcto 4dmin: - usuario no válido - correo no válido - teléfono no válido - DNI no válido - contraseña débil ana: - usuario no válido
¿Por qué los patrones son static final?
Porque Pattern.compile traduce la regex a una estructura interna, y eso cuesta. Si lo hiciéramos dentro de validar, se repetiría el trabajo con cada registro. Con static final se compila una sola vez al cargar la clase y después solo creamos un Matcher barato para cada texto. "texto".matches(regex) es cómodo, pero por dentro compila el patrón cada vez que lo llamas.
En cuanto un patrón se use dentro de un bucle o en un método que se llama mucho, sácalo a una constante static final Pattern. Es la costumbre que vas a ver en cualquier código profesional.
¿Por qué ana no es válida si su correo y su DNI están bien?
Su nombre de usuario tiene 3 letras, y el patrón pide una letra inicial más entre 3 y 15 caracteres: 4 como mínimo. Es justo lo que tiene que pasar: la regex no tiene «manga ancha». Si quisieras aceptar nombres de 3 letras, el cambio es de un número: {2,15}.
¿Y ese DNI en minúscula del primer registro?
12345678z pasa porque hacemos dni.toUpperCase() antes de validar. Normalizar primero (mayúsculas, espacios fuera) y validar después deja el patrón sencillo. La alternativa sería meter [A-Za-z] en la regex y luego convertir la letra igualmente para comparar: más lío para lo mismo.
Fíjate también en el reparto de trabajo de dniValido: la regex comprueba la forma (8 dígitos y una letra) y los grupos nos dan las piezas para comprobar el significado (que la letra sea la que toca). 12345678A tiene buena forma, pero la letra correcta es la Z, así que no pasa.
Intentar meter la comprobación de la letra del DNI dentro de la regex. No se puede: una expresión regular no hace cuentas. Cuando una regla necesita calcular algo, la regex extrae los datos y el código hace el cálculo.
Programa 2: lector de logs con grupos con nombre
Un log es un archivo donde un programa apunta lo que va pasando, una línea por evento. Todas las líneas siguen el mismo formato, y eso es perfecto para una regex: una sola expresión nos parte cada línea en sus campos.
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ExtractorLog {
// fecha hora NIVEL [modulo] mensaje
static final Pattern LINEA = Pattern.compile(
"(?<fecha>\\d{4}-\\d{2}-\\d{2}) (?<hora>\\d{2}:\\d{2}:\\d{2}) " +
"(?<nivel>INFO|WARN|ERROR) \\[(?<modulo>\\w+)\\] (?<mensaje>.*)");
static final Pattern IP = Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}\\b");
public static void main(String[] args) {
String[] log = {
"2026-10-07 09:15:02 INFO [auth] Login correcto de sergio desde 192.168.1.20",
"2026-10-07 09:15:40 ERROR [auth] Contraseña incorrecta para admin desde 10.0.0.7",
"2026-10-07 09:16:03 WARN [db] Consulta lenta: 1840 ms",
"línea rota sin formato",
"2026-10-07 09:17:11 ERROR [auth] Contraseña incorrecta para admin desde 10.0.0.7",
"2026-10-07 09:18:55 INFO [web] Página /recursos servida en 35 ms\r"
};
int info = 0, warn = 0, error = 0, rotas = 0;
for (String linea : log) {
Matcher m = LINEA.matcher(linea.strip()); // strip(): fuera espacios y \r del final
if (!m.matches()) { rotas++; continue; }
switch (m.group("nivel")) {
case "INFO" -> info++;
case "WARN" -> warn++;
case "ERROR" -> error++;
}
if (m.group("nivel").equals("ERROR")) {
Matcher ip = IP.matcher(m.group("mensaje")); // buscamos DENTRO del mensaje
String origen = ip.find() ? ip.group() : "sin IP";
System.out.println(m.group("hora") + " ERROR en " + m.group("modulo") + " desde " + origen);
}
}
System.out.println("INFO: " + info + " | WARN: " + warn + " | ERROR: " + error + " | líneas rotas: " + rotas);
}
}
Salida:
09:15:40 ERROR en auth desde 10.0.0.7 09:17:11 ERROR en auth desde 10.0.0.7 INFO: 2 | WARN: 1 | ERROR: 2 | líneas rotas: 1
¿Por qué grupos con nombre y no group(1), group(2)…?
Con cinco grupos, m.group(4) no dice nada a quien lee el código; m.group("modulo") lo dice todo. Y hay una ventaja menos obvia: si mañana añades un grupo nuevo al principio del patrón, los números se desplazan y todo el código que usaba números se rompe sin dar error. Los nombres no se mueven.
¿Por qué matches() para la línea y find() para la IP?
Son dos preguntas distintas. Para la línea preguntamos «¿tiene toda ella el formato de un registro?», y eso es matches(). Para la IP preguntamos «¿hay alguna IP dentro de este mensaje?», y eso es find(). Además, buscamos solo dentro de m.group("mensaje"), no en la línea entera: así una fecha o una hora nunca se confunden con una IP.
El \\b del patrón de la IP es un límite de palabra: exige que antes y después no haya otro dígito o letra. Sin él, en un texto como 1192.168.1.200 encontrarías 192.168.1.200 metido dentro de un número más largo.
¿Qué pasa con la línea que acaba en \r?
Es la de mi historia del principio. Sin strip(), esa última línea contaría como rota, porque .* no acepta \r (es un salto de línea) y matches() necesita llegar hasta el final. Con strip() se cuenta como un INFO más, y por eso salen 2.
Leer líneas de un archivo creado en Windows y validarlas con matches() sin limpiarlas antes. Funciona con el texto que copias a mano en el código y falla con el archivo real. Si «ninguna línea encaja», sospecha primero de espacios y \r invisibles.
Un detalle del switch
Usamos el switch con flechas (case "INFO" -> info++;) que viste en control de flujo. Como el patrón solo acepta INFO, WARN o ERROR, sabemos que no llegará ningún otro valor: la regex ya filtró la entrada.
Programa 3: limpiador de texto con replaceAll
El último programa no valida ni extrae: transforma. Recibe un texto escrito con prisas y lo deja presentable.
import java.util.regex.Pattern;
public class LimpiadorTexto {
static String limpiar(String texto) {
String t = texto.strip();
t = t.replaceAll("\\s+", " "); // 1. un solo espacio entre palabras
t = t.replaceAll(" ([,.;:!?])", "$1"); // 2. sin espacio ANTES de un signo
t = t.replaceAll("\\b(\\d{2})/(\\d{2})/(\\d{4})\\b", "$3-$2-$1"); // 3. fechas a aaaa-mm-dd
t = t.replaceAll("\\b([6-9]\\d{2})[ -]?(\\d{3})[ -]?(\\d{3})\\b", "$1 $2 $3"); // 4. teléfonos
return t;
}
static String ocultarCorreos(String texto) {
// replaceAll con una función: decide el reemplazo de cada coincidencia
return Pattern.compile("([\\w.+-])([\\w.+-]*)@([\\w.-]+)")
.matcher(texto)
.replaceAll(m -> m.group(1) + "*".repeat(m.group(2).length()) + "@" + m.group(3));
}
public static void main(String[] args) {
String sucio = " Hola , soy Sergio . Llámame al 612-345-678 o al 928123456 ;"
+ " examen el 15/01/2027 . Escribe a sergio@sergiolearns.com ";
String limpio = limpiar(sucio);
System.out.println(limpio);
System.out.println(ocultarCorreos(limpio));
}
}
Salida:
Hola, soy Sergio. Llámame al 612 345 678 o al 928 123 456; examen el 2027-01-15. Escribe a sergio@sergiolearns.com Hola, soy Sergio. Llámame al 612 345 678 o al 928 123 456; examen el 2027-01-15. Escribe a s*****@sergiolearns.com
¿Importa el orden de los pasos?
Mucho. El paso 2 busca un espacio delante de un signo; funciona porque el paso 1 ya ha convertido «varios espacios» en uno. Si los cambias de orden, "Hola ," acaba como "Hola ,": el paso 2 solo quita uno de los tres espacios y el paso 1 junta los otros dos en uno, que se queda delante de la coma. Cuando encadenas replaceAll, cada paso trabaja sobre el resultado del anterior, así que piensa en qué estado deja el texto cada uno.
¿Qué es $1 en el reemplazo?
Es «lo que capturó el grupo 1». En el paso 2 capturamos el signo, ([,.;:!?]), y lo devolvemos sin el espacio que tenía delante. En el paso 3 reordenamos las piezas de la fecha: $3-$2-$1 pone el año delante. Fíjate en que dentro de [ ] el punto y la interrogación son literales: no hace falta escaparlos.
Querer poner un $ de verdad en el texto de reemplazo (por ejemplo, un precio) y que Java lo interprete como un grupo. Se escribe \\$, o se protege todo el reemplazo con Matcher.quoteReplacement(texto).
¿Por qué ocultarCorreos usa una función en replaceAll?
Porque el reemplazo depende de lo que se encuentra: queremos tantos asteriscos como letras tenga el nombre, y eso no se puede escribir con $1. Desde Java 9, Matcher.replaceAll acepta una función que recibe cada coincidencia (m) y devuelve el texto que la sustituye. Aquí: la primera letra, tantos * como el resto del nombre, la arroba y el dominio. sergio tiene 6 letras, así que queda s más 5 asteriscos.
Esto ya no es solo «buscar y cambiar»: es poner código Java en cada coincidencia. Lo usarás en el desafío de los ejercicios para convertir fechas con ceros a la izquierda.
Pruébalo tú
Antes de pasar a los ejercicios, toca los programas:
- En el validador, cambia la regla de usuario para que acepte mayúsculas. ¿Qué parte del patrón hay que tocar? ¿Y el resto del código?
- En el lector de logs, añade el nivel
DEBUG. Pista: solo cambia una parte del patrón y elswitch. - En el limpiador, añade un paso que ponga en mayúscula la primera letra después de un punto. Necesitarás la versión de
replaceAllcon función.
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el código línea a línea y dibuja las variables y los objetos en cada paso. Elige Java en el desplegable, pega este programa y avanza con Next:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Tutor {
public static void main(String[] args) {
Matcher m = Pattern.compile("\\d+").matcher("3 gatos y 12 peces");
int total = 0;
while (m.find()) {
int n = Integer.parseInt(m.group());
total += n;
}
System.out.println(total);
}
}
Salida:
15
Qué observar:
- Al crear
m, fíjate en que es un objeto en el montón (heap), no un texto: elMatcherguarda el texto y la posición por la que va. - En la primera vuelta,
find()encuentra3ynvale 3. En la segunda, encuentra12: el mismoMatcherha avanzado, no ha vuelto a empezar. - En la tercera llamada,
find()devuelvefalsey el bucle termina. Si en ese momento llamaras am.group(), saltaríaIllegalStateException. - Mira cómo cambia
total: 0 → 3 → 15. Es el mismo patrón de acumulador que usaste con arrays, solo que ahora los números salen de un texto.
Resumen
| Programa | Herramienta clave | Idea que te llevas |
|---|---|---|
| Validador | static final Pattern + matches() | Normalizar antes de validar; la regex comprueba la forma y el código, el significado |
| Lector de logs | grupos con nombre + find() dentro de un grupo | matches() para la línea entera, find() para buscar dentro; ojo con \r |
| Limpiador | replaceAll con $1 y con función | El orden de los pasos importa; una función decide reemplazos que dependen del texto |
En los ejercicios lo pondrás a prueba tú solo: matrículas, fechas y un log con IPs, más tres fragmentos con trampa.
Siguiente paso: ejerciciosExpresiones regulares en Java — ejercicios con matrículas, fechas y logs (y la matrícula con vocales que se me coló)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
