Comment utiliser des expressions régulières pour remplacer des jetons dans des chaînes en Java

1. Vue d'ensemble

Lorsque nous devons rechercher ou remplacer des valeurs dans une chaîne en Java, nous utilisons généralement des expressions régulières. Celles-ci nous permettent de déterminer si tout ou partie d'une chaîne correspond à un modèle. Nous pourrions facilement appliquer le même remplacement à plusieurs jetons dans une chaîne avec la méthode replaceAll dans Matcher et String .

Dans ce didacticiel, nous allons explorer comment appliquer un remplacement différent pour chaque jeton trouvé dans une chaîne. Cela nous permettra de répondre facilement à des cas d'utilisation tels que l'échappement de certains caractères ou le remplacement de valeurs d'espace réservé.

Nous examinerons également quelques astuces pour régler nos expressions régulières afin d'identifier correctement les jetons.

2. Traitement individuel des correspondances

Avant de pouvoir construire notre algorithme de remplacement jeton par jeton, nous devons comprendre l'API Java autour des expressions régulières. Résolvons un problème de correspondance délicat en utilisant des groupes de capture et de non-capture.

2.1. Exemple de cas de titre

Imaginons que nous voulions créer un algorithme pour traiter tous les mots du titre dans une chaîne. Ces mots commencent par un caractère majuscule, puis se terminent ou se poursuivent uniquement avec des caractères minuscules.

Notre contribution pourrait être:

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

À partir de la définition d'un mot de titre, celui-ci contient les correspondances:

  • Première
  • Capitale
  • Mots
  • je
  • A trouvé

Et une expression régulière pour reconnaître ce modèle serait:

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

Pour comprendre cela, décomposons-le en ses composants. Nous allons commencer au milieu:

[A-Z]

reconnaîtra une seule lettre majuscule.

Nous autorisons les mots à un seul caractère ou les mots suivis de minuscules, donc:

[a-z]*

reconnaît zéro ou plusieurs lettres minuscules.

Dans certains cas, les deux classes de caractères ci-dessus suffiraient à reconnaître nos jetons. Malheureusement, dans notre exemple de texte, il y a un mot qui commence par plusieurs lettres majuscules. Par conséquent, nous devons exprimer que la seule lettre majuscule que nous trouvons doit être la première à apparaître après les non-lettres.

De même, comme nous autorisons un seul mot en majuscule, nous devons indiquer que la seule lettre majuscule que nous trouvons ne doit pas être la première d'un mot à plusieurs majuscules.

L'expression [^ A-Za-z] signifie «pas de lettres». Nous en avons mis un au début de l'expression dans un groupe non capturant:

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

Le groupe non capturant, commençant par (? <=, Effectue un regard en arrière pour s'assurer que la correspondance apparaît à la limite correcte. Son homologue à la fin fait le même travail pour les caractères qui suivent.

Cependant, si les mots touchent le tout début ou la fin de la chaîne, nous devons en tenir compte, c'est là que nous avons ajouté ^ | au premier groupe pour que cela signifie «le début de la chaîne ou tout caractère autre qu'une lettre», et nous avons ajouté | $ à la fin du dernier groupe non capturant pour permettre à la fin de la chaîne d'être une limite .

Les caractères trouvés dans des groupes non capturants n'apparaissent pas dans la correspondance lorsque nous utilisons find .

Nous devons noter que même un cas d'utilisation simple comme celui-ci peut avoir de nombreux cas extrêmes, il est donc important de tester nos expressions régulières . Pour cela, nous pouvons écrire des tests unitaires, utiliser les outils intégrés de notre IDE ou utiliser un outil en ligne comme Regexr.

2.2. Tester notre exemple

Avec notre exemple de texte dans une constante appelée EXAMPLE_INPUT et notre expression régulière dans un Pattern appelé TITLE_CASE_PATTERN , utilisons find sur la classe Matcher pour extraire toutes nos correspondances dans un test unitaire:

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");

Ici, nous utilisons la fonction matcher sur Pattern pour produire un Matcher . Ensuite, nous utilisons la méthode find dans une boucle jusqu'à ce qu'elle cesse de retourner true pour itérer sur toutes les correspondances.

Chaque fois que find renvoie true , l' état de l'objet Matcher est défini pour représenter la correspondance actuelle. Nous pouvons inspecter toute la correspondance avec le groupe (0) ou inspecter des groupes de capture particuliers avec leur index basé sur 1 . Dans ce cas, il y a un groupe de capture autour de la pièce que nous voulons, nous utilisons donc group (1) pour ajouter la correspondance à notre liste.

2.3. Inspectant matcher un peu plus

Jusqu'à présent, nous avons réussi à trouver les mots que nous voulons traiter.

Cependant, si chacun de ces mots était un jeton que nous voulions remplacer, nous aurions besoin de plus d'informations sur la correspondance afin de construire la chaîne résultante. Regardons quelques autres propriétés de Matcher qui pourraient nous aider:

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

Ce code nous montrera où se trouve chaque correspondance. Il nous montre également la correspondance de groupe (0) , qui est tout capturé:

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

Dans cet article, nous avons examiné comment utiliser des expressions régulières puissantes pour rechercher des jetons dans nos chaînes. Nous avons appris comment la méthode find fonctionne avec Matcher pour nous montrer les correspondances.

Ensuite, nous avons créé et généralisé un algorithme pour nous permettre d'effectuer un remplacement jeton par jeton.

Enfin, nous avons examiné quelques cas d'utilisation courants pour échapper des caractères et remplir des modèles.

Comme toujours, les exemples de code peuvent être trouvés sur GitHub.