Cómo usar expresiones regulares para reemplazar tokens en cadenas en Java

1. Información general

Cuando necesitamos buscar o reemplazar valores en una cadena en Java, generalmente usamos expresiones regulares. Estos nos permiten determinar si una parte o la totalidad de una cadena coincide con un patrón. Podríamos aplicar fácilmente el mismo reemplazo a múltiples tokens en una cadena con el método replaceAll tanto en Matcher como en String .

En este tutorial, exploraremos cómo aplicar un reemplazo diferente para cada token que se encuentra en una cadena. Esto nos facilitará la satisfacción de casos de uso como escapar de ciertos caracteres o reemplazar valores de marcador de posición.

También veremos algunos trucos para ajustar nuestras expresiones regulares para identificar los tokens correctamente.

2. Procesamiento individual de partidos

Antes de que podamos construir nuestro algoritmo de reemplazo token por token, necesitamos comprender la API de Java en torno a las expresiones regulares. Resolvamos un complicado problema de emparejamiento utilizando grupos de captura y no captura.

2.1. Ejemplo de caso de título

Imaginemos que queremos construir un algoritmo para procesar todas las palabras del título en una cadena. Estas palabras comienzan con un carácter en mayúscula y luego terminan o continúan solo con caracteres en minúscula.

Nuestra aportación podría ser:

"First 3 Capital Words! then 10 TLAs, I Found"

De la definición de una palabra de título, esto contiene las coincidencias:

  • primero
  • Capital
  • Palabras
  • yo
  • Encontró

Y una expresión regular para reconocer este patrón sería:

"(?<=^|[^A-Za-z])([A-Z][a-z]*)(?=[^A-Za-z]|$)"

Para entender esto, vamos a dividirlo en sus partes componentes. Empezaremos por el medio:

[A-Z]

reconocerá una sola letra mayúscula.

Permitimos palabras de un solo carácter o palabras seguidas de minúsculas, por lo que:

[a-z]*

reconoce cero o más letras minúsculas.

En algunos casos, las dos clases de caracteres anteriores serían suficientes para reconocer nuestros tokens. Desafortunadamente, en nuestro texto de ejemplo, hay una palabra que comienza con varias letras mayúsculas. Por lo tanto, debemos expresar que la letra mayúscula única que encontremos debe ser la primera en aparecer después de las no letras.

De manera similar, como permitimos una palabra con una sola letra mayúscula, debemos expresar que la letra mayúscula única que encontremos no debe ser la primera de una palabra con varias letras mayúsculas.

La expresión [^ A-Za-z] significa "sin letras". Hemos puesto uno de estos al comienzo de la expresión en un grupo de no captura:

(?<=^|[^A-Za-z])

El grupo sin fines de captura, a partir de (? <=, Hace una mirada detrás de asegurar los partidos aparece en el límite correcto. Su contraparte al final hace el mismo trabajo para los personajes que siguen.

Sin embargo, si las palabras tocan el comienzo o el final de la cadena, entonces debemos tener en cuenta eso, que es donde agregamos ^ | al primer grupo para que signifique "el comienzo de la cadena o cualquier carácter que no sea una letra", y hemos agregado | $ al final del último grupo que no captura para permitir que el final de la cadena sea un límite .

Los personajes que se encuentran en grupos que no capturan no aparecen en la coincidencia cuando usamos buscar .

Debemos tener en cuenta que incluso un caso de uso simple como este puede tener muchos casos extremos, por lo que es importante probar nuestras expresiones regulares . Para esto, podemos escribir pruebas unitarias, usar las herramientas integradas de nuestro IDE o usar una herramienta en línea como Regexr.

2.2. Probando nuestro ejemplo

Con nuestro texto de ejemplo en una constante llamada EXAMPLE_INPUT y nuestra expresión regular en un patrón llamado TITLE_CASE_PATTERN , usemos find en la clase Matcher para extraer todas nuestras coincidencias en una prueba unitaria:

Matcher matcher = TITLE_CASE_PATTERN.matcher(EXAMPLE_INPUT); List matches = new ArrayList(); while (matcher.find()) { matches.add(matcher.group(1)); } assertThat(matches) .containsExactly("First", "Capital", "Words", "I", "Found");

Aquí usamos la función de emparejamiento en Pattern para producir un Matcher . Luego usamos el método de búsqueda en un bucle hasta que deja de volver verdadero para iterar sobre todas las coincidencias.

Cada vez que find devuelve verdadero , el estado del objeto Matcher se establece para representar la coincidencia actual. Podemos inspeccionar toda la coincidencia con el grupo (0) o inspeccionar grupos de captura particulares con su índice basado en 1 . En este caso, hay un grupo de captura alrededor de la pieza que queremos, así que usamos el grupo (1) para agregar la coincidencia a nuestra lista.

2.3. Inspeccionando Matcher un poco más

Hasta ahora hemos logrado encontrar las palabras que queremos procesar.

Sin embargo, si cada una de estas palabras fuera un token que quisiéramos reemplazar, necesitaríamos tener más información sobre la coincidencia para construir la cadena resultante. Veamos algunas otras propiedades de Matcher que podrían ayudarnos:

while (matcher.find()) { System.out.println("Match: " + matcher.group(0)); System.out.println("Start: " + matcher.start()); System.out.println("End: " + matcher.end()); }

Este código nos mostrará dónde está cada coincidencia. También nos muestra la coincidencia del grupo (0) , que es todo lo capturado:

Match: First Start: 0 End: 5 Match: Capital Start: 8 End: 15 Match: Words Start: 16 End: 21 Match: I Start: 37 End: 38 ... more

Here we can see that each match contains only the words we're expecting. The start property shows the zero-based index of the match within the string. The end shows the index of the character just after. This means we could use substring(start, end-start) to extract each match from the original string. This is essentially how the group method does that for us.

Now that we can use find to iterate over matches, let's process our tokens.

3. Replacing Matches One by One

Let's continue our example by using our algorithm to replace each title word in the original string with its lowercase equivalent. This means our test string will be converted to:

"first 3 capital words! then 10 TLAs, i found"

The Pattern and Matcher class can't do this for us, so we need to construct an algorithm.

3.1. The Replacement Algorithm

Here is the pseudo-code for the algorithm:

  • Start with an empty output string
  • For each match:
    • Add to the output anything that came before the match and after any previous match
    • Process this match and add that to the output
    • Continue until all matches are processed
    • Add anything left after the last match to the output

We should note that the aim of this algorithm is to find all non-matched areas and add them to the output, as well as adding the processed matches.

3.2. The Token Replacer in Java

We want to convert each word to lowercase, so we can write a simple conversion method:

private static String convert(String token) { return token.toLowerCase(); }

Now we can write the algorithm to iterate over the matches. This can use a StringBuilder for the output:

int lastIndex = 0; StringBuilder output = new StringBuilder(); Matcher matcher = TITLE_CASE_PATTERN.matcher(original); while (matcher.find()) { output.append(original, lastIndex, matcher.start()) .append(convert(matcher.group(1))); lastIndex = matcher.end(); } if (lastIndex < original.length()) { output.append(original, lastIndex, original.length()); } return output.toString();

We should note that StringBuilder provides a handy version of append that can extract substrings. This works well with the end property of Matcher to let us pick up all non-matched characters since the last match.

4. Generalizing the Algorithm

Now that we've solved the problem of replacing some specific tokens, why don't we convert the code into a form where it can be used for the general case? The only thing that varies from one implementation to the next is the regular expression to use, and the logic for converting each match into its replacement.

4.1. Use a Function and Pattern Input

We can use a Java Function object to allow the caller to provide the logic to process each match. And we can take an input called tokenPattern to find all the tokens:

// same as before while (matcher.find()) { output.append(original, lastIndex, matcher.start()) .append(converter.apply(matcher)); // same as before

Here, the regular expression is no longer hard-coded. Instead, the converter function is provided by the caller and is applied to each match within the find loop.

4.2. Testing the General Version

Let's see if the general method works as well as the original:

assertThat(replaceTokens("First 3 Capital Words! then 10 TLAs, I Found", TITLE_CASE_PATTERN, match -> match.group(1).toLowerCase())) .isEqualTo("first 3 capital words! then 10 TLAs, i found");

Here we see that calling the code is straightforward. The conversion function is easy to express as a lambda. And the test passes.

Now we have a token replacer, so let's try some other use cases.

5. Some Use Cases

5.1. Escaping Special Characters

Let's imagine we wanted to use the regular expression escape character \ to manually quote each character of a regular expression rather than use the quote method. Perhaps we are quoting a string as part of creating a regular expression to pass to another library or service, so block quoting the expression won't suffice.

If we can express the pattern that means “a regular expression character”, it's easy to use our algorithm to escape them all:

Pattern regexCharacters = Pattern.compile("[]"); assertThat(replaceTokens("A regex character like [", regexCharacters, match -> "\\" + match.group())) .isEqualTo("A regex character like \\[");

For each match, we prefix the \ character. As \ is a special character in Java strings, it's escaped with another \.

Indeed, this example is covered in extra \ characters as the character class in the pattern for regexCharacters has to quote many of the special characters. This shows the regular expression parser that we're using them to mean their literals, not as regular expression syntax.

5.2. Replacing Placeholders

A common way to express a placeholder is to use a syntax like ${name}. Let's consider a use case where the template “Hi ${name} at ${company}” needs to be populated from a map called placeholderValues:

Map placeholderValues = new HashMap(); placeholderValues.put("name", "Bill"); placeholderValues.put("company", "Baeldung");

All we need is a good regular expression to find the ${…} tokens:

"\\$\\{(?[A-Za-z0-9-_]+)}"

is one option. It has to quote the $ and the initial curly brace as they would otherwise be treated as regular expression syntax.

At the heart of this pattern is a capturing group for the name of the placeholder. We've used a character class that allows alphanumeric, dashes, and underscores, which should fit most use-cases.

However, to make the code more readable, we've named this capturing groupplaceholder. Let's see how to use that named capturing group:

assertThat(replaceTokens("Hi ${name} at ${company}", "\\$\\{(?[A-Za-z0-9-_]+)}", match -> placeholderValues.get(match.group("placeholder")))) .isEqualTo("Hi Bill at Baeldung");

Here we can see that getting the value of the named group out of the Matcher just involves using group with the name as the input, rather than the number.

6. Conclusion

En este artículo, analizamos cómo usar expresiones regulares poderosas para encontrar tokens en nuestras cadenas. Aprendimos cómo funciona el método de búsqueda con Matcher para mostrarnos las coincidencias.

Luego, creamos y generalizamos un algoritmo para permitirnos hacer un reemplazo token por token.

Finalmente, analizamos un par de casos de uso comunes para caracteres de escape y plantillas de relleno.

Como siempre, los ejemplos de código se pueden encontrar en GitHub.