From 94203a560cc848a82919ef9a823cc0693ae9042f Mon Sep 17 00:00:00 2001 From: Dmitry Batrak Date: Fri, 26 Jun 2015 18:27:34 +0300 Subject: [PATCH] IDEA-70451 RegEx case transformation options during replace --- .../com/intellij/find/FindManagerTest.java | 27 ++ .../intellij/find/impl/FindManagerImpl.java | 9 +- .../find/impl/RegExReplacementBuilder.java | 276 ++++++++++++++++++ 3 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 platform/lang-impl/src/com/intellij/find/impl/RegExReplacementBuilder.java diff --git a/java/java-tests/testSrc/com/intellij/find/FindManagerTest.java b/java/java-tests/testSrc/com/intellij/find/FindManagerTest.java index 3b9ab0728637..1fa5ce12b7f9 100644 --- a/java/java-tests/testSrc/com/intellij/find/FindManagerTest.java +++ b/java/java-tests/testSrc/com/intellij/find/FindManagerTest.java @@ -423,6 +423,22 @@ public class FindManagerTest extends DaemonAnalyzerTestCase { assertEquals(text, getEditor().getDocument().getText()); } + public void testReplaceWithLocalLowerCase() { + doTestRegexpReplace("SOMETHING", "ome", "\\l$0", "SoMETHING"); + } + + public void testReplaceWithLocalUpperCase() { + doTestRegexpReplace("something", "ome", "\\u$0", "sOmething"); + } + + public void testReplaceWithRegionLowerCase() { + doTestRegexpReplace("SOMETHING", "ome", "\\L$0\\E", "SomeTHING"); + } + + public void testReplaceWithRegionUpperCase() { + doTestRegexpReplace("something", "ome", "\\U$0\\E", "sOMEthing"); + } + public void testReplaceRegexpWithNewLine() { FindModel findModel = new FindModel(); findModel.setStringToFind("xxx"); @@ -750,4 +766,15 @@ public class FindManagerTest extends DaemonAnalyzerTestCase { DumbServiceImpl.getInstance(getProject()).setDumb(false); } } + + private void doTestRegexpReplace(String initialText, String searchString, String replaceString, String expectedResult) { + configureByText(FileTypes.PLAIN_TEXT, initialText); + FindModel model = new FindModel(); + model.setRegularExpressions(true); + model.setStringToFind(searchString); + model.setStringToReplace(replaceString); + model.setPromptOnReplace(false); + assertTrue(FindUtil.replace(myProject, myEditor, 0, model)); + assertEquals(expectedResult, myEditor.getDocument().getText()); + } } diff --git a/platform/lang-impl/src/com/intellij/find/impl/FindManagerImpl.java b/platform/lang-impl/src/com/intellij/find/impl/FindManagerImpl.java index dc14d5031d14..de950796c0cf 100644 --- a/platform/lang-impl/src/com/intellij/find/impl/FindManagerImpl.java +++ b/platform/lang-impl/src/com/intellij/find/impl/FindManagerImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2000-2014 JetBrains s.r.o. + * Copyright 2000-2015 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -756,13 +756,10 @@ public class FindManagerImpl extends FindManager { } private static String getStringToReplaceByRegexp(@NotNull final FindModel model, Matcher matcher) throws MalformedReplacementStringException{ - StringBuffer replaced = new StringBuffer(); if (matcher == null) return null; try { - String toReplace = StringUtil.unescapeStringCharacters(model.getStringToReplace()); - matcher.appendReplacement(replaced, toReplace); - - return replaced.substring(matcher.start()); + String toReplace = model.getStringToReplace(); + return new RegExReplacementBuilder(matcher).createReplacement(toReplace); } catch (Exception e) { throw createMalformedReplacementException(model, e); diff --git a/platform/lang-impl/src/com/intellij/find/impl/RegExReplacementBuilder.java b/platform/lang-impl/src/com/intellij/find/impl/RegExReplacementBuilder.java new file mode 100644 index 000000000000..f951738798d6 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/find/impl/RegExReplacementBuilder.java @@ -0,0 +1,276 @@ +/* + * Copyright 2000-2015 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.intellij.find.impl; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.SystemInfo; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; + +/** + * Generates a replacement string for search/replace operation using regular expressions. + *

+ * The logic is based on java.util.regex.Matcher.appendReplacement method, special characters (\n, \r, \t, \f, \b, \xNNNN) + * and case conversion characters (\l, \u, \L, \U, \E) are additionally supported. + *

+ * Instances of this class are not safe for use by multiple concurrent threads, just as {@link Matcher} instances are. + */ +public class RegExReplacementBuilder { + private static final Logger LOGGER = Logger.getInstance(RegExReplacementBuilder.class); + + private static Method ourNamedGroupValueRetriever; + + static { + if (SystemInfo.isJavaVersionAtLeast("1.7")) { + try { + Method method = Matcher.class.getMethod("group", String.class); + Class returnType = method.getReturnType(); + if (String.class.equals(returnType)) { + ourNamedGroupValueRetriever = method; + } + else { + throw new RuntimeException("Unexpected method return value: " + returnType); + } + } + catch (Exception e) { + LOGGER.warn("Error accessing method java.util.regex.Matcher.group(java.lang.String)", e); + } + } + } + + @NotNull private final Matcher myMatcher; + + private String myTemplate; + private int myCursor; + private StringBuilder myReplacement; + private List myConversionRegions; + + public RegExReplacementBuilder(@NotNull Matcher matcher) { + myMatcher = matcher; + } + + /** + * Generates a replacement string from provided template value, substituting referenced capturing group values, and processing supported + * special and control characters. + *

+ * Matcher used to create this instance of RegExReplacementBuilder is supposed to be in a state + * created by a successful {@link Matcher#find() find()} or {@link Matcher#find(int) find(int)} invocation. + */ + public String createReplacement(String template) { + myTemplate = template; + resetState(); + while (myCursor < myTemplate.length()) { + char nextChar = myTemplate.charAt(myCursor++); + if (nextChar == '\\') { + processEscapedChar(); + } else if (nextChar == '$') { + processGroupValue(); + } else { + myReplacement.append(nextChar); + } + } + return generateResult(); + } + + private void resetState() { + myCursor = 0; + myReplacement = new StringBuilder(); + myConversionRegions = new ArrayList(); + } + + private void processEscapedChar() { + char nextChar; + if (myCursor == myTemplate.length()) throw new IllegalArgumentException("character to be escaped is missing"); + nextChar = myTemplate.charAt(myCursor++); + switch (nextChar) { + case 'n': + myReplacement.append('\n'); break; + case 'r': + myReplacement.append('\r'); break; + case 'b': + myReplacement.append('\b'); break; + case 't': + myReplacement.append('\t'); break; + case 'f': + myReplacement.append('\f'); break; + case 'x': + if (myCursor + 4 <= myTemplate.length()) { + try { + int code = Integer.parseInt(myTemplate.substring(myCursor, myCursor + 4), 16); + myCursor += 4; + myReplacement.append((char)code); + } + catch (NumberFormatException ignored) {} + } + break; + case 'l': startConversionForCharacter(false); break; + case 'u': startConversionForCharacter(true); break; + case 'L': startConversionForRegion(false); break; + case 'U': startConversionForRegion(true); break; + case 'E': resetConversionState(); break; + default: + myReplacement.append(nextChar); + } + } + + private void processGroupValue() { + char nextChar; + if (myCursor == myTemplate.length()) throw new IllegalArgumentException("Illegal group reference: group index is missing"); + nextChar = myTemplate.charAt(myCursor++); + String group; + if (nextChar == '{') { + StringBuilder gsb = new StringBuilder(); + while (myCursor < myTemplate.length()) { + nextChar = myTemplate.charAt(myCursor); + if (isLatinLetter(nextChar) || isDigit(nextChar)) { + gsb.append(nextChar); + myCursor++; + } else { + break; + } + } + if (gsb.length() == 0) throw new IllegalArgumentException("named capturing group has 0 length name"); + if (nextChar != '}') throw new IllegalArgumentException("named capturing group is missing trailing '}'"); + String gname = gsb.toString(); + if (isDigit(gname.charAt(0))) { + throw new IllegalArgumentException("capturing group name {" + gname + "} starts with digit character"); + } + myCursor++; + group = getNamedGroupValue(myMatcher, gname); + } else { + // The first number is always a group + int refNum = (int)nextChar - '0'; + if (refNum < 0 || refNum > 9) throw new IllegalArgumentException("Illegal group reference"); + // Capture the largest legal group string + while (true) { + if (myCursor >= myTemplate.length()) break; + int nextDigit = myTemplate.charAt(myCursor) - '0'; + if (nextDigit < 0 || nextDigit > 9) break; + int newRefNum = (refNum * 10) + nextDigit; + if (myMatcher.groupCount() < newRefNum) break; + refNum = newRefNum; + myCursor++; + } + group = myMatcher.group(refNum); + } + if (group != null) { + myReplacement.append(group); + } + } + + private String generateResult() { + StringBuilder result; + if (myConversionRegions.isEmpty()) { + result = myReplacement; + } + else { + CaseConversionRegion lastRegion = myConversionRegions.get(myConversionRegions.size() - 1); + if (lastRegion.end < 0 || lastRegion.end > myReplacement.length()) { + lastRegion.end = myReplacement.length(); + } + result = new StringBuilder(); + int currentOffset = 0; + for (CaseConversionRegion conversionRegion : myConversionRegions) { + result.append(myReplacement, currentOffset, conversionRegion.start); + String region = myReplacement.substring(conversionRegion.start, conversionRegion.end); + result.append(conversionRegion.toUpperCase ? region.toUpperCase(Locale.getDefault()) : region.toLowerCase(Locale.getDefault())); + currentOffset = conversionRegion.end; + } + result.append(myReplacement, currentOffset, myReplacement.length()); + } + return result.toString(); + } + + private void startConversionForCharacter(boolean toUpperCase) { + int currentOffset = myReplacement.length(); + CaseConversionRegion lastRegion = myConversionRegions.isEmpty() ? null : myConversionRegions.get(myConversionRegions.size() - 1); + if (lastRegion == null || lastRegion.end >= 0 && lastRegion.end <= currentOffset) { + myConversionRegions.add(new CaseConversionRegion(currentOffset, currentOffset + 1, toUpperCase)); + } + } + + private void startConversionForRegion(boolean toUpperCase) { + int currentOffset = myReplacement.length(); + CaseConversionRegion lastRegion = myConversionRegions.isEmpty() ? null : myConversionRegions.get(myConversionRegions.size() - 1); + if (lastRegion == null) { + myConversionRegions.add(new CaseConversionRegion(currentOffset, -1, toUpperCase)); + } + else if (lastRegion.start == currentOffset) { + lastRegion.end = -1; + lastRegion.toUpperCase = toUpperCase; + } + else { + if (lastRegion.end == -1) { + if (lastRegion.toUpperCase == toUpperCase) { + return; + } + lastRegion.end = currentOffset; + } + myConversionRegions.add(new CaseConversionRegion(currentOffset, -1, toUpperCase)); + } + } + + private void resetConversionState() { + if (!myConversionRegions.isEmpty()) { + int currentOffset = myReplacement.length(); + int lastIndex = myConversionRegions.size() - 1; + CaseConversionRegion lastRegion = myConversionRegions.get(lastIndex); + if (lastRegion.start >= currentOffset) { + myConversionRegions.remove(lastIndex); + } + else if (lastRegion.end == -1) { + lastRegion.end = currentOffset; + } + } + } + + private static String getNamedGroupValue(Matcher matcher, String groupName) { + // Support for named capturing groups was added to Matcher's API only in Java 7, + // so we need to use reflection to access corresponding method + if (ourNamedGroupValueRetriever == null) throw new IllegalArgumentException("Illegal group reference"); + try { + return (String)ourNamedGroupValueRetriever.invoke(matcher, groupName); + } + catch (Exception e) { + throw new RuntimeException("Illegal group reference", e); + } + } + + private static boolean isLatinLetter(int ch) { + return ((ch-'a')|('z'-ch)) >= 0 || ((ch-'A')|('Z'-ch)) >= 0; + } + + private static boolean isDigit(int ch) { + return ((ch-'0')|('9'-ch)) >= 0; + } + + private static class CaseConversionRegion { + private final int start; + private int end; + private boolean toUpperCase; + + private CaseConversionRegion(int start, int end, boolean toUpperCase) { + this.start = start; + this.end = end; + this.toUpperCase = toUpperCase; + } + } +}