Skip to content

Commit a569e88

Browse files
authored
feat: support TextMate-aware quote pair matching (#963)
1 parent 9d031aa commit a569e88

File tree

3 files changed

+827
-17
lines changed

3 files changed

+827
-17
lines changed

org.eclipse.tm4e.languageconfiguration.tests/src/main/java/org/eclipse/tm4e/languageconfiguration/tests/TestPartitionAware.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
/**
22
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
*
34
* This program and the accompanying materials are made
45
* available under the terms of the Eclipse Public License 2.0
56
* which is available at https://www.eclipse.org/legal/epl-2.0/
67
*
78
* SPDX-License-Identifier: EPL-2.0
9+
*
10+
* Contributors:
11+
* - Sebastian Thomschke (Vegard IT) - initial implementation
812
*/
913
package org.eclipse.tm4e.languageconfiguration.tests;
1014

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
*
4+
* This program and the accompanying materials are made
5+
* available under the terms of the Eclipse Public License 2.0
6+
* which is available at https://www.eclipse.org/legal/epl-2.0/
7+
*
8+
* SPDX-License-Identifier: EPL-2.0
9+
*
10+
* Contributors:
11+
* - Sebastian Thomschke (Vegard IT) - initial implementation
12+
*/
13+
package org.eclipse.tm4e.languageconfiguration.tests;
14+
15+
import static org.assertj.core.api.Assertions.*;
16+
17+
import java.io.FileOutputStream;
18+
import java.nio.charset.StandardCharsets;
19+
20+
import org.eclipse.jface.text.IDocument;
21+
import org.eclipse.jface.text.IRegion;
22+
import org.eclipse.tm4e.languageconfiguration.internal.LanguageConfigurationCharacterPairMatcher;
23+
import org.eclipse.tm4e.ui.internal.utils.UI;
24+
import org.eclipse.tm4e.ui.tests.support.TestUtils;
25+
import org.eclipse.ui.IEditorDescriptor;
26+
import org.eclipse.ui.ide.IDE;
27+
import org.eclipse.ui.texteditor.ITextEditor;
28+
import org.junit.jupiter.api.AfterEach;
29+
import org.junit.jupiter.api.Test;
30+
31+
/**
32+
* Tests for quote pair matching based on TextMate scopes and language configuration.
33+
*
34+
* These tests exercise LanguageConfigurationCharacterPairMatcher directly on a Java file, using
35+
* the TM model and grammar provided by the language pack.
36+
*/
37+
public class TestQuotePairMatching {
38+
39+
@AfterEach
40+
public void tearDown() throws Exception {
41+
TestUtils.closeEditor(UI.getActivePage().getActiveEditor());
42+
TestUtils.assertNoTM4EThreadsRunning();
43+
}
44+
45+
/**
46+
* Verifies that a single string literal with escaped quotes is matched correctly from both sides
47+
* and that an escaped inner quote does not form its own surrounding pair.
48+
*/
49+
@Test
50+
public void testDoubleQuoteMatchingInString() throws Exception {
51+
final IEditorDescriptor genericEditorDescr = TestUtils.assertHasGenericEditor();
52+
53+
final var tempFile = TestUtils.createTempFile(".java");
54+
final String source = """
55+
class X {
56+
String s = "file \\\"test.txt\\\" not found";
57+
}
58+
""";
59+
try (var out = new FileOutputStream(tempFile)) {
60+
out.write(source.getBytes(StandardCharsets.UTF_8));
61+
}
62+
63+
final var editor = (ITextEditor) IDE.openEditor(UI.getActivePage(), tempFile.toURI(), genericEditorDescr.getId(), true);
64+
final IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
65+
66+
// ensure TM model and grammar are ready for this document
67+
TestUtils.waitForModelReady(document, 10_000);
68+
69+
final var matcher = new LanguageConfigurationCharacterPairMatcher();
70+
final String text = document.get();
71+
72+
final int openingQuote = text.indexOf('"');
73+
final int closingQuote = text.lastIndexOf('"');
74+
final int innerQuote = text.indexOf("\\\"") + 1; // position of inner "
75+
76+
// caret after opening quote
77+
final IRegion regionAfterOpening = matcher.match(document, openingQuote + 1);
78+
assertThat(regionAfterOpening).isNotNull();
79+
assertThat(regionAfterOpening.getOffset()).isEqualTo(openingQuote);
80+
assertThat(regionAfterOpening.getOffset() + regionAfterOpening.getLength() - 1).isEqualTo(closingQuote);
81+
82+
// caret after closing quote
83+
final IRegion regionAfterClosing = matcher.match(document, closingQuote + 1);
84+
assertThat(regionAfterClosing).isNotNull();
85+
assertThat(regionAfterClosing.getOffset()).isEqualTo(openingQuote);
86+
assertThat(regionAfterClosing.getOffset() + regionAfterClosing.getLength() - 1).isEqualTo(closingQuote);
87+
88+
// caret after inner escaped quote should not match a pair
89+
final IRegion regionAfterInner = matcher.match(document, innerQuote + 1);
90+
assertThat(regionAfterInner).isNull();
91+
}
92+
93+
/**
94+
* Verifies that when two string literals appear on the same line, placing the caret after the
95+
* closing quote of the first string highlights the corresponding opening quote of that string
96+
* and does not treat this closing quote as the opening quote of the following string.
97+
*/
98+
@Test
99+
public void testQuoteMatchingAtEndOfFirstStringOnLine() throws Exception {
100+
final IEditorDescriptor genericEditorDescr = TestUtils.assertHasGenericEditor();
101+
102+
final var tempFile = TestUtils.createTempFile(".java");
103+
final String source = """
104+
class X {
105+
String s1 = "foo"; String s2 = "bar";
106+
}
107+
""";
108+
try (var out = new FileOutputStream(tempFile)) {
109+
out.write(source.getBytes(StandardCharsets.UTF_8));
110+
}
111+
112+
final var editor = (ITextEditor) IDE.openEditor(UI.getActivePage(), tempFile.toURI(), genericEditorDescr.getId(), true);
113+
final IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
114+
115+
TestUtils.waitForModelReady(document, 10_000);
116+
117+
final var matcher = new LanguageConfigurationCharacterPairMatcher();
118+
final String text = document.get();
119+
120+
final int firstOpening = text.indexOf('"');
121+
final int firstClosing = text.indexOf('"', firstOpening + 1);
122+
final int secondOpening = text.indexOf('"', firstClosing + 1);
123+
final int secondClosing = text.indexOf('"', secondOpening + 1);
124+
125+
// caret after closing quote of the first string
126+
final IRegion region = matcher.match(document, firstClosing + 1);
127+
assertThat(region).isNotNull();
128+
assertThat(region.getOffset()).isEqualTo(firstOpening);
129+
assertThat(region.getOffset() + region.getLength() - 1).isEqualTo(firstClosing);
130+
131+
// sanity-check second string is not included
132+
assertThat(region.getOffset()).isNotEqualTo(secondOpening);
133+
assertThat(region.getOffset() + region.getLength() - 1).isNotEqualTo(secondClosing);
134+
}
135+
136+
/**
137+
* Verifies that a closing parenthesis inside a string literal (e.g. {@code "1) Welcome"}) is
138+
* not treated as the structural peer for the surrounding call's opening parenthesis, while the
139+
* real call-closing parenthesis still matches correctly.
140+
*/
141+
@Test
142+
public void testParenInsideStringIsNotMatchedAsCallClosingParen() throws Exception {
143+
final IEditorDescriptor genericEditorDescr = TestUtils.assertHasGenericEditor();
144+
145+
final var tempFile = TestUtils.createTempFile(".java");
146+
final String source = """
147+
class X {
148+
void log() {
149+
System.out.println("1) Welcome");
150+
}
151+
}
152+
""";
153+
try (var out = new FileOutputStream(tempFile)) {
154+
out.write(source.getBytes(StandardCharsets.UTF_8));
155+
}
156+
157+
final ITextEditor editor = (ITextEditor) IDE.openEditor(UI.getActivePage(), tempFile.toURI(),
158+
genericEditorDescr.getId(), true);
159+
final IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
160+
161+
TestUtils.waitForModelReady(document, 10_000);
162+
163+
final var matcher = new LanguageConfigurationCharacterPairMatcher();
164+
final String text = document.get();
165+
166+
final int printlnIndex = text.indexOf("System.out.println");
167+
final int callOpeningParen = text.indexOf('(', printlnIndex);
168+
final int innerParenInString = text.indexOf("1) Welcome");
169+
final int innerClosingParen = text.indexOf(')', innerParenInString);
170+
final int callClosingParen = text.indexOf(");", innerClosingParen);
171+
172+
// caret after inner ')' inside the string literal should NOT match the call opening '('
173+
final IRegion innerRegion = matcher.match(document, innerClosingParen + 1);
174+
assertThat(innerRegion).isNull();
175+
176+
// caret after the real call-closing ')' should, if matching is enabled for this language,
177+
// match the call opening '(' rather than the inner one
178+
final IRegion callRegion = matcher.match(document, callClosingParen + 1);
179+
if (callRegion != null) {
180+
assertThat(callRegion.getOffset()).isEqualTo(callOpeningParen);
181+
assertThat(callRegion.getOffset() + callRegion.getLength() - 1).isEqualTo(callClosingParen);
182+
}
183+
}
184+
185+
/**
186+
* Verifies that for calls like {@code System.out.println("Hello World" /*)*/);} the call parentheses
187+
* are preferred over the string quotes when the caret is placed after the opening parenthesis, i.e. the
188+
* matching pair is {@code println( ... )} and not the surrounding string quotes.
189+
*/
190+
@Test
191+
public void testCallParenPreferredOverQuotesWithCommentedParen() throws Exception {
192+
final IEditorDescriptor genericEditorDescr = TestUtils.assertHasGenericEditor();
193+
194+
final var tempFile = TestUtils.createTempFile(".java");
195+
final String source = """
196+
class X {
197+
void log() {
198+
System.out.println("Hello World" /*)*/);
199+
}
200+
}
201+
""";
202+
try (var out = new FileOutputStream(tempFile)) {
203+
out.write(source.getBytes(StandardCharsets.UTF_8));
204+
}
205+
206+
final ITextEditor editor = (ITextEditor) IDE.openEditor(UI.getActivePage(), tempFile.toURI(),
207+
genericEditorDescr.getId(), true);
208+
final IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
209+
210+
TestUtils.waitForModelReady(document, 10_000);
211+
212+
final var matcher = new LanguageConfigurationCharacterPairMatcher();
213+
final String text = document.get();
214+
215+
final int printlnIndex = text.indexOf("System.out.println");
216+
final int callOpeningParen = text.indexOf('(', printlnIndex);
217+
final int callClosingParen = text.lastIndexOf(')');
218+
final int closingQuote = text.indexOf('"', text.indexOf("Hello World") + "Hello World".length());
219+
220+
// caret after opening '(' should match the real call-closing ')', not the closing quote
221+
final IRegion regionAtOpening = matcher.match(document, callOpeningParen + 1);
222+
assertThat(regionAtOpening).isNotNull();
223+
final int regionStart = regionAtOpening.getOffset();
224+
final int regionEnd = regionAtOpening.getOffset() + regionAtOpening.getLength() - 1;
225+
226+
assertThat(document.getChar(regionStart)).isEqualTo('(');
227+
assertThat(document.getChar(regionEnd)).isEqualTo(')');
228+
assertThat(regionEnd).isEqualTo(callClosingParen);
229+
assertThat(regionEnd).isNotEqualTo(closingQuote);
230+
}
231+
232+
/**
233+
* Verifies that in a TypeScript call like
234+
* {@code res.end('Hello World' /*)*/);} the closing parenthesis inside the block comment
235+
* is not used as the structural peer for the call's opening parenthesis, and that the real
236+
* call-closing parenthesis before the semicolon is the one that is paired with {@code res.end(}.
237+
*/
238+
@Test
239+
public void testTsResEndParenWithCommentedParen() throws Exception {
240+
final IEditorDescriptor genericEditorDescr = TestUtils.assertHasGenericEditor();
241+
242+
final var tempFile = TestUtils.createTempFile(".ts");
243+
final String source = """
244+
import * as http from 'http';
245+
246+
const server = http.createServer((req: any, res: any) => {
247+
res.statusCode = 200;
248+
res.setHeader('Content-Type', 'text/plain');
249+
res.end('Hello World');
250+
res.end('Hello World' /*)*/);
251+
res.end('Hello :) World');
252+
});
253+
""";
254+
try (var out = new FileOutputStream(tempFile)) {
255+
out.write(source.getBytes(StandardCharsets.UTF_8));
256+
}
257+
258+
final ITextEditor editor = (ITextEditor) IDE.openEditor(UI.getActivePage(), tempFile.toURI(),
259+
genericEditorDescr.getId(), true);
260+
final IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput());
261+
262+
TestUtils.waitForModelReady(document, 10_000);
263+
264+
final var matcher = new LanguageConfigurationCharacterPairMatcher();
265+
final String text = document.get();
266+
267+
final int resEndIndex = text.indexOf("res.end('Hello World' /*)*/);");
268+
assertThat(resEndIndex).isGreaterThanOrEqualTo(0);
269+
270+
final int callOpeningParen = text.indexOf('(', resEndIndex);
271+
final int callClosingParen = text.indexOf(");", resEndIndex);
272+
final int commentStart = text.indexOf("/*)*/", resEndIndex);
273+
final int innerCommentParen = text.indexOf(')', commentStart);
274+
275+
// caret after the commented ')' must NOT match the res.end '('
276+
final IRegion regionAtCommentParen = matcher.match(document, innerCommentParen + 1);
277+
assertThat(regionAtCommentParen)
278+
.as("commented ')' must not be treated as structural closing paren")
279+
.isNull();
280+
281+
// caret after the real call-closing ')' should match the res.end '('
282+
final IRegion regionAtCallClosing = matcher.match(document, callClosingParen + 1);
283+
if (regionAtCallClosing != null) {
284+
assertThat(regionAtCallClosing.getOffset()).isEqualTo(callOpeningParen);
285+
assertThat(regionAtCallClosing.getOffset() + regionAtCallClosing.getLength() - 1)
286+
.isEqualTo(callClosingParen);
287+
}
288+
}
289+
}

0 commit comments

Comments
 (0)