trubidata
Lección 12 de 14·18 min

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:

scala
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

scala
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
  • findAllIn devuelve todas las coincidencias (como un iterador; con toList las ves todas).
  • findFirstIn devuelve la primera, como un Option: Some(texto) o None si 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:

scala
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:

scala
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:

scala
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

scala
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ónQué coincideEjemplo
^el inicio del texto^Hola coincide con textos que empiezan con "Hola"
$el final del textoMundo$ coincide con textos que terminan con "Mundo"
.cualquier carácter, salvo un salto de líneaa.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 repeticionesgo*gle coincide con "ggle", "gogle", "google"
re+1 o más repeticionesgo+gle coincide con "gogle", "google", no con "ggle"
re?0 o 1 aparicióncolou?r coincide con "color" y "colour"
re{n}exactamente n repeticioneslo{2} coincide con "loo", no con "lo"
re{n,}n o más repeticionesa{3,} coincide con "aaa", "aaaa"...
re{n,m}entre n y m repeticionesx{2,4} coincide con "xx", "xxx" y "xxxx"
a|ba o bgato|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 / \Dun dígito / algo que no es dígito\d+ coincide con "2023"
\w / \Wcarácter de palabra (letra, dígito o _) / lo contrario\w+ coincide con "Hola123"
\s / \Sespacio en blanco (espacio, tab, salto de línea) / lo contrario\s+ coincide con " "
\bun límite de palabra\bword\b coincide con "word", no dentro de "subword"
\n, \tsalto 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:

scala
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)
scala
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:

scala
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á

  1. 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).

    scala
    val tarjetaEjemplo1 = "4532015119553617" // válida
    val tarjetaEjemplo2 = "3726451932157468" // inválida
    
  2. Códigos de seguridad. Escribí una expresión regular que valide códigos de seguridad (CVV) de exactamente 3 dígitos.

    scala
    val cvvEjemplo1 = "123"  // válido
    val cvvEjemplo2 = "9876" // inválido
    
  3. 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.

    scala
    val 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

scala
// 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.

scala
// Ejercicio 2
val regexCVV = """\d{3}""".r

println(regexCVV.matches("123"))  // true
println(regexCVV.matches("9876")) // false
scala
// 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)
text
$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.
  • findAllIn y findFirstIn buscan en cualquier parte del texto; matches valida que el texto completo cumpla el patrón.
  • Los paréntesis capturan grupos, accesibles con findFirstMatchIn/findAllMatchIn y group(n); (?:...) agrupa sin capturar.
  • Un regex con grupos se puede usar como patrón en un match para desarmar el texto en variables.
  • replaceAllIn reemplaza coincidencias con un texto o con una función.
  • Los caracteres especiales ($, ., (, |) se escapan con \ cuando querés buscarlos literalmente.

← Volver al temario