Expresiones regulares
En esta guía (11)
En la lección 10 viste un adelanto de las expresiones regulares. En esta lección las vas a usar a fondo: cómo crearlas en Scala, cómo buscar todas las coincidencias o la primera, cómo validar que un texto completo cumpla un formato, cómo capturar partes con grupos y cómo usarlas dentro de un match. También vas a tener una referencia de los patrones más comunes. Al final vas a validar números de tarjeta, códigos de seguridad y montos.
Una expresión regular (o regex) es un patrón que describe un conjunto de textos. \d{3} describe "tres dígitos seguidos"; [aeiou] describe "una vocal". Sirven para validar formatos, extraer datos de un texto libre y limpiar información, tareas de todos los días cuando se procesan datos.
Crear una expresión regular
En Scala, una expresión regular se crea agregando .r a un string. Conviene usar triples comillas, porque así las barras invertidas se escriben una sola vez:
val texto = "La fecha de hoy es 2023-10-12."
val patron = """(\d{4})-(\d{2})-(\d{2})""".r // con triples comillas: \d
val patron2 = "(\\d{4})-(\\d{2})-(\\d{2})".r // con comillas comunes: \\d
println(patron.findAllIn(texto).toList) // List(2023-10-12)
Las dos formas son equivalentes. El resultado de .r es un objeto Regex (de scala.util.matching).
Buscar coincidencias
val texto = "Pagos: 1500 el lunes, 300 el martes y 2200 el viernes."
val numeros = """\d+""".r
println(numeros.findAllIn(texto).toList) // List(1500, 300, 2200)
println(numeros.findFirstIn(texto)) // Some(1500)
println(numeros.findFirstIn("sin números")) // None
println(numeros.findAllIn(texto).map(_.toInt).sum) // 4000
findAllIndevuelve todas las coincidencias (como un iterador; contoListlas ves todas).findFirstIndevuelve la primera, como unOption:Some(texto)oNonesi no hay ninguna.
Validar un texto completo
Buscar no es lo mismo que validar. findFirstIn encuentra el patrón en cualquier parte del texto. Para verificar que todo el texto cumple el formato, usá matches:
val cvv = """\d{3}""".r
println(cvv.matches("123")) // true
println(cvv.matches("1234")) // false: sobra un dígito
println(cvv.findFirstIn("1234")) // Some(123): "lo encontró" adentro
Otra forma de exigir que el patrón cubra todo el texto es anclarlo con ^ (inicio) y $ (final): """^\d{3}$""".r.
Grupos
Los paréntesis definen grupos de captura, que te permiten extraer partes específicas de cada coincidencia:
val texto = "Mi número de teléfono es (123) 456-7890."
val patron = """\((\d{3})\) (\d{3})-(\d{4})""".r
patron.findFirstMatchIn(texto).foreach { m =>
println(m.group(0)) // (123) 456-7890: la coincidencia completa
println(m.group(1)) // 123
println(m.group(2)) // 456
println(m.group(3)) // 7890
}
findFirstMatchIn y findAllMatchIn devuelven objetos Match, que tienen los grupos. Fijate que para buscar un paréntesis literal hay que escaparlo: \(.
Regex en pattern matching
Una de las formas más cómodas de usar grupos en Scala es directamente en un match. Cada grupo se convierte en una variable:
val Fecha = """(\d{4})-(\d{2})-(\d{2})""".r
def describir(texto: String): String = texto match {
case Fecha(anio, mes, dia) => s"Día $dia del mes $mes de $anio"
case _ => "No es una fecha con formato AAAA-MM-DD"
}
println(describir("2023-10-12")) // Día 12 del mes 10 de 2023
println(describir("12/10/2023")) // No es una fecha con formato AAAA-MM-DD
En un match, el patrón tiene que coincidir con todo el texto, no con una parte. El nombre del regex empieza con mayúscula (Fecha) por convención: en un case, un nombre en minúscula se interpretaría como una variable nueva.
Reemplazar
val texto = "Cuentas: 1234567890 y 9876543210"
val cuenta = """\d{10}""".r
println(cuenta.replaceAllIn(texto, "##########"))
// Cuentas: ########## y ##########
println(cuenta.replaceAllIn(texto, m => "****" + m.matched.takeRight(4)))
// Cuentas: ****7890 y ****3210
replaceAllIn acepta un texto fijo o una función que recibe cada coincidencia y devuelve el reemplazo.
Referencia de patrones
| Patrón | Qué coincide | Ejemplo |
|---|---|---|
^ | el inicio del texto | ^Hola coincide con textos que empiezan con "Hola" |
$ | el final del texto | Mundo$ coincide con textos que terminan con "Mundo" |
. | cualquier carácter, salvo un salto de línea | a.b coincide con "axb", no con "ab" |
[...] | cualquier carácter de los corchetes | [aeiou] coincide con una vocal |
[^...] | cualquier carácter que no esté en los corchetes | [^0-9] coincide con algo que no es un dígito |
re* | 0 o más repeticiones | go*gle coincide con "ggle", "gogle", "google" |
re+ | 1 o más repeticiones | go+gle coincide con "gogle", "google", no con "ggle" |
re? | 0 o 1 aparición | colou?r coincide con "color" y "colour" |
re{n} | exactamente n repeticiones | lo{2} coincide con "loo", no con "lo" |
re{n,} | n o más repeticiones | a{3,} coincide con "aaa", "aaaa"... |
re{n,m} | entre n y m repeticiones | x{2,4} coincide con "xx", "xxx" y "xxxx" |
a|b | a o b | gato|perro coincide con "gato" o "perro" |
(re) | agrupa y captura | (abc)+ coincide con "abc", "abcabc"... |
(?:re) | agrupa sin capturar | (?:abc)+ igual, pero sin guardar el grupo |
\d / \D | un dígito / algo que no es dígito | \d+ coincide con "2023" |
\w / \W | carácter de palabra (letra, dígito o _) / lo contrario | \w+ coincide con "Hola123" |
\s / \S | espacio en blanco (espacio, tab, salto de línea) / lo contrario | \s+ coincide con " " |
\b | un límite de palabra | \bword\b coincide con "word", no dentro de "subword" |
\n, \t | salto de línea, tabulación |
Por defecto, ^ y $ marcan el inicio y el final de todo el texto. Para que marquen el inicio y el final de cada línea, se activa el modo multilínea agregando (?m) al principio del patrón.
Grupos con y sin captura
Cuando usás findAllIn, obtenés las coincidencias completas. Cuando usás findAllMatchIn, podés acceder a cada grupo capturado. Los grupos (?:...) agrupan (por ejemplo, para aplicar una repetición) sin crear un grupo accesible:
val texto = "Correo de soporte: support@example.com, Consultas de ventas: sales@company.com"
// Con captura: usuario y dominio por separado
val conCaptura = """(\w+)@(\w+\.\w+)""".r
val partes = conCaptura.findAllMatchIn(texto).map(m => (m.group(1), m.group(2))).toList
println(s"Con captura: $partes")
// Sin captura: solo la coincidencia completa
val sinCaptura = """(?:\w+)@(?:\w+\.\w+)""".r
val correos = sinCaptura.findAllIn(texto).toList
println(s"Sin captura: $correos")
// Con captura: List((support,example.com), (sales,company.com))
// Sin captura: List(support@example.com, sales@company.com)
val texto = "Fechas importantes: 25-12-2022 y 01-01-2023"
val conCaptura = """(\d{2})-(\d{2})-(\d{4})""".r
val fechas = conCaptura.findAllMatchIn(texto).map(m => (m.group(1), m.group(2), m.group(3))).toList
println(s"Con captura: $fechas")
val sinCaptura = """(?:\d{2})-(?:\d{2})-(?:\d{4})""".r
println(s"Sin captura: ${sinCaptura.findAllIn(texto).toList}")
// Con captura: List((25,12,2022), (01,01,2023))
// Sin captura: List(25-12-2022, 01-01-2023)
Y un ejemplo de límite de palabra:
val texto = "word subword wordsuper"
val patron = """\bword\b""".r
println(patron.findAllIn(texto).toList) // List(word)
Solo coincide la primera palabra: en "subword" y "wordsuper", "word" está pegada a otras letras, así que no hay límite de palabra donde el patrón lo exige.
Practicá
-
Números de tarjeta de crédito. Escribí una expresión regular que valide números de tarjeta Visa o Mastercard: 16 dígitos que empiezan con 4 (Visa) o con 5 (Mastercard).
scalaval tarjetaEjemplo1 = "4532015119553617" // válida val tarjetaEjemplo2 = "3726451932157468" // inválida -
Códigos de seguridad. Escribí una expresión regular que valide códigos de seguridad (CVV) de exactamente 3 dígitos.
scalaval cvvEjemplo1 = "123" // válido val cvvEjemplo2 = "9876" // inválido -
Montos monetarios (opcional). Escribí una expresión regular que reconozca montos como
$100.00: el signo$, uno o más dígitos y, opcionalmente, un punto con dos decimales.scalaval montos = List("$100.00", "$50", "$1500.75", "$0.50", "100", "$1.5")Los cuatro primeros son válidos;
100(sin$) y$1.5(un solo decimal), no. Como extra, extraé todos los montos que aparecen en el texto"Pagué $100.00 de luz y $50 de gas, total $150.00.".
Soluciones
// Ejercicio 1
val regexTarjeta = """[45]\d{15}""".r
println(regexTarjeta.matches("4532015119553617")) // true
println(regexTarjeta.matches("3726451932157468")) // false: empieza con 3
println(regexTarjeta.matches("453201511955361")) // false: 15 dígitos
[45] es "un 4 o un 5" y \d{15} son los 15 dígitos restantes. Es equivalente a (4|5)\d{15} o a 4\d{15}|5\d{15}. Como matches exige que coincida el texto completo, no hacen falta ^ ni $. Si usás regexTarjeta.pattern.matcher(texto).matches(), que es la forma de Java, el resultado es el mismo.
// Ejercicio 2
val regexCVV = """\d{3}""".r
println(regexCVV.matches("123")) // true
println(regexCVV.matches("9876")) // false
// Ejercicio 3
val montoRegex = """\$\d+(\.\d{2})?""".r
val montos = List("$100.00", "$50", "$1500.75", "$0.50", "100", "$1.5")
montos.foreach(m => println(s"$m -> ${montoRegex.matches(m)}"))
val texto = "Pagué $100.00 de luz y $50 de gas, total $150.00."
println(montoRegex.findAllIn(texto).toList) // List($100.00, $50, $150.00)
$100.00 -> true
$50 -> true
$1500.75 -> true
$0.50 -> true
100 -> false
$1.5 -> false
List($100.00, $50, $150.00)
El $ del monto se escapa (\$) porque, sin escapar, significa "final del texto". El grupo (\.\d{2})? hace opcional la parte decimal, y el punto también va escapado porque . solo significa "cualquier carácter". Para extraer montos de un texto libre se usa el mismo patrón sin anclas: con ^...$, findAllIn solo encontraría algo si el texto entero fuera un monto.
Resumen
- Un regex se crea con
.r; con triples comillas, las barras invertidas se escriben una sola vez. findAllInyfindFirstInbuscan en cualquier parte del texto;matchesvalida que el texto completo cumpla el patrón.- Los paréntesis capturan grupos, accesibles con
findFirstMatchIn/findAllMatchInygroup(n);(?:...)agrupa sin capturar. - Un regex con grupos se puede usar como patrón en un
matchpara desarmar el texto en variables. replaceAllInreemplaza coincidencias con un texto o con una función.- Los caracteres especiales (
$,.,(,|) se escapan con\cuando querés buscarlos literalmente.