This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 8d00daa59c fix highlighting on windows, fixes #7971 (#7988)
8d00daa59c is described below
commit 8d00daa59cb3bd1722ae76d400916b20448a835c
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Aug 17 20:53:34 2026 +0200
fix highlighting on windows, fixes #7971 (#7988)
---
.../hop/ui/hopgui/ContentEditorTm4eSupport.java | 205 +++++++++++----------
.../ui/hopgui/ContentEditorTm4eSupportTest.java | 130 +++++++++++++
2 files changed, 235 insertions(+), 100 deletions(-)
diff --git
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
index 928c0765ec..34bcebfe61 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
@@ -214,8 +214,7 @@ final class ContentEditorTm4eSupport {
new org.eclipse.jface.text.presentation.PresentationReconciler();
reconciler.setDocumentPartitioning(
org.eclipse.jface.text.IDocumentExtension3.DEFAULT_PARTITIONING);
- Tm4eDamagerRepairer repairer =
- new Tm4eDamagerRepairer(grammar, support::scopeToAttribute,
support.display);
+ Tm4eDamagerRepairer repairer = new Tm4eDamagerRepairer(grammar,
support::scopeToAttribute);
reconciler.setDamager(repairer,
org.eclipse.jface.text.IDocument.DEFAULT_CONTENT_TYPE);
reconciler.setRepairer(repairer,
org.eclipse.jface.text.IDocument.DEFAULT_CONTENT_TYPE);
return reconciler;
@@ -356,62 +355,80 @@ final class ContentEditorTm4eSupport {
return dark ? D_DEFAULT : L_DEFAULT;
}
+ /** A stretch of text and the TM4E scopes covering it. No scopes means
unscoped text. */
+ record ScopedRange(int offset, int length, List<String> scopes) {}
+
+ /**
+ * Tokenizes the text with the grammar and returns the part covering
[rangeOffset, rangeOffset +
+ * rangeLength) as consecutive scoped ranges.
+ *
+ * <p>The ranges tile that interval: every character is covered exactly
once, including the line
+ * delimiters TM4E doesn't tokenize. That is not cosmetic. {@link
+ * org.eclipse.jface.text.rules.DefaultDamagerRepairer#createPresentation}
merges neighbouring
+ * tokens that share a {@link TextAttribute} by adding up their lengths,
without looking at their
+ * offsets, so a gap in the stream shortens the resulting style range by the
size of that gap.
+ * Leaving the delimiters out cost a run of same-coloured lines one
character per line with LF and
+ * two with CRLF, which is why highlighting stopped short of the end of a
run and why it looked
+ * like a Windows bug (issue #7971).
+ */
+ static List<ScopedRange> tokenize(
+ IGrammar grammar, String text, int rangeOffset, int rangeLength) {
+ int rangeEnd = Math.min(rangeOffset + rangeLength, text.length());
+ List<ScopedRange> ranges = new java.util.ArrayList<>();
+ int cursor = Math.max(rangeOffset, 0);
+
+ for (ScopedRange token : tokenizeLines(grammar, text, cursor, rangeEnd)) {
+ if (token.offset() > cursor) {
+ // Text no token covers, i.e. a line delimiter: keep the stream
contiguous
+ ranges.add(new ScopedRange(cursor, token.offset() - cursor,
List.of()));
+ }
+ ranges.add(token);
+ cursor = token.offset() + token.length();
+ }
+ if (cursor < rangeEnd) {
+ ranges.add(new ScopedRange(cursor, rangeEnd - cursor, List.of()));
+ }
+ return ranges;
+ }
+
/** Damager/repairer that uses TM4E to tokenize and applies our attributes.
*/
private static final class Tm4eDamagerRepairer
extends org.eclipse.jface.text.rules.DefaultDamagerRepairer {
- private final IGrammar grammar;
- private final java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute>
- scopeToAttr;
- private final Display display;
Tm4eDamagerRepairer(
IGrammar grammar,
- java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute> scopeToAttr,
- Display display) {
- super(new Tm4eScanner(grammar, scopeToAttr, display));
- this.grammar = grammar;
- this.scopeToAttr = scopeToAttr;
- this.display = display;
+ java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute>
+ scopeToAttr) {
+ super(new Tm4eScanner(grammar, scopeToAttr));
}
}
/** JFace ITokenScanner that tokenizes with TM4E and returns tokens with our
attributes. */
private static final class Tm4eScanner implements
org.eclipse.jface.text.rules.ITokenScanner {
- private static final int MAX_LINES_TO_TOKENIZE = 100_000;
- private static final int MAX_LINE_LENGTH = 100_000;
-
private final IGrammar grammar;
private final java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute>
scopeToAttr;
- private final Display display;
- private IDocument document;
- private int rangeOffset;
- private int rangeLength;
- private java.util.List<ColoredToken> tokens;
+ private java.util.List<ScopedRange> ranges;
private int index;
private int tokenOffset;
private int tokenLength;
Tm4eScanner(
IGrammar grammar,
- java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute> scopeToAttr,
- Display display) {
+ java.util.function.Function<List<String>,
org.eclipse.jface.text.TextAttribute>
+ scopeToAttr) {
this.grammar = grammar;
this.scopeToAttr = scopeToAttr;
- this.display = display;
}
@Override
public void setRange(IDocument doc, int offset, int length) {
- this.document = doc;
- this.rangeOffset = offset;
- this.rangeLength = length;
try {
- this.tokens = tokenize(doc, offset, length);
+ this.ranges = tokenize(grammar, doc.get(), offset, length);
} catch (Exception ignored) {
- this.tokens = Collections.emptyList();
+ this.ranges = Collections.emptyList();
}
this.index = 0;
this.tokenOffset = 0;
@@ -420,14 +437,14 @@ final class ContentEditorTm4eSupport {
@Override
public IToken nextToken() {
- if (tokens == null || index >= tokens.size()) {
+ if (ranges == null || index >= ranges.size()) {
tokenOffset = tokenLength = 0;
return Token.EOF;
}
- ColoredToken t = tokens.get(index++);
- tokenOffset = t.offset;
- tokenLength = t.length;
- return new Token(t.attribute);
+ ScopedRange range = ranges.get(index++);
+ tokenOffset = range.offset();
+ tokenLength = range.length();
+ return new Token(scopeToAttr.apply(range.scopes()));
}
@Override
@@ -439,84 +456,72 @@ final class ContentEditorTm4eSupport {
public int getTokenLength() {
return tokenLength;
}
+ }
- /** Tokenize document; runs synchronously on the UI thread. */
- private java.util.List<ColoredToken> tokenize(IDocument doc, int
rangeOffset, int rangeLength) {
- String text;
- try {
- text = doc.get();
- } catch (Exception e) {
- return Collections.emptyList();
- }
- return tokenizeLines(text, rangeOffset, rangeLength);
- }
+ private static final int MAX_LINES_TO_TOKENIZE = 100_000;
+ private static final int MAX_LINE_LENGTH = 100_000;
- private java.util.List<ColoredToken> tokenizeLines(
- String text, int rangeOffset, int rangeLength) {
- java.util.List<ColoredToken> result = new java.util.ArrayList<>();
- try {
- int len = text.length();
- String[] lines = text.split("\\n", -1);
- long lineStart = 0;
- IStateStack state = null;
- int rangeEnd = Math.min(rangeOffset + rangeLength, len);
-
- for (int lineIndex = 0; lineIndex < lines.length; lineIndex++) {
- if (lineIndex >= MAX_LINES_TO_TOKENIZE) {
- break;
- }
+ /** The scoped ranges the grammar produces, line by line, clipped to the
requested range. */
+ private static List<ScopedRange> tokenizeLines(
+ IGrammar grammar, String text, int rangeOffset, int rangeEnd) {
+ List<ScopedRange> result = new java.util.ArrayList<>();
+ try {
+ String[] lines = text.split("\\n", -1);
+ long lineStart = 0;
+ IStateStack state = null;
- String raw = lines[lineIndex];
- int rawLen = raw.length();
- String line = raw;
- if (rawLen > 0 && raw.charAt(rawLen - 1) == '\r') {
- line = raw.substring(0, rawLen - 1);
- }
- int lineLen = line.length();
- if (lineLen > MAX_LINE_LENGTH) {
- lineStart += rawLen + 1L;
- continue;
- }
+ for (int lineIndex = 0; lineIndex < lines.length; lineIndex++) {
+ if (lineIndex >= MAX_LINES_TO_TOKENIZE) {
+ break;
+ }
- long lineEnd = lineStart + rawLen;
- if (lineEnd <= rangeOffset) {
- ITokenizeLineResult<org.eclipse.tm4e.core.grammar.IToken[]> res =
- grammar.tokenizeLine(line, state, null);
- state = res.getRuleStack();
- lineStart = lineEnd + 1;
- continue;
- }
- if (lineStart >= rangeEnd) break;
+ String raw = lines[lineIndex];
+ int rawLen = raw.length();
+ String line = raw;
+ if (rawLen > 0 && raw.charAt(rawLen - 1) == '\r') {
+ line = raw.substring(0, rawLen - 1);
+ }
+ if (line.length() > MAX_LINE_LENGTH) {
+ lineStart += rawLen + 1L;
+ continue;
+ }
+ long lineEnd = lineStart + rawLen;
+ if (lineEnd <= rangeOffset) {
ITokenizeLineResult<org.eclipse.tm4e.core.grammar.IToken[]> res =
grammar.tokenizeLine(line, state, null);
state = res.getRuleStack();
-
- for (org.eclipse.tm4e.core.grammar.IToken t : res.getTokens()) {
- long tStartLong = lineStart + t.getStartIndex();
- long tEndLong = lineStart + t.getEndIndex();
- if (tStartLong > Integer.MAX_VALUE || tEndLong >
Integer.MAX_VALUE) continue;
- int tStart = (int) tStartLong;
- int tEnd = (int) tEndLong;
- if (tEnd <= rangeOffset || tStart >= rangeEnd) continue;
- int o = Math.max(tStart, rangeOffset);
- int l = Math.min(tEnd, rangeEnd) - o;
- if (l <= 0) continue;
- List<String> tokenScopes = t.getScopes();
- if (ContentEditorTm4eSupport.TRACE_SCOPES && tokenScopes != null) {
- String joined = String.join(" ", tokenScopes);
- System.err.println("[TM4E token] offset=" + o + " len=" + l + "
| " + joined);
- }
- result.add(new ColoredToken(o, l, scopeToAttr.apply(tokenScopes)));
- }
lineStart = lineEnd + 1;
+ continue;
}
- } catch (Exception ignored) {
- // ignore
+ if (lineStart >= rangeEnd) break;
+
+ ITokenizeLineResult<org.eclipse.tm4e.core.grammar.IToken[]> res =
+ grammar.tokenizeLine(line, state, null);
+ state = res.getRuleStack();
+
+ for (org.eclipse.tm4e.core.grammar.IToken t : res.getTokens()) {
+ long tStartLong = lineStart + t.getStartIndex();
+ long tEndLong = lineStart + t.getEndIndex();
+ if (tStartLong > Integer.MAX_VALUE || tEndLong > Integer.MAX_VALUE)
continue;
+ int tStart = (int) tStartLong;
+ int tEnd = (int) tEndLong;
+ if (tEnd <= rangeOffset || tStart >= rangeEnd) continue;
+ int o = Math.max(tStart, rangeOffset);
+ int l = Math.min(tEnd, rangeEnd) - o;
+ if (l <= 0) continue;
+ List<String> tokenScopes = t.getScopes();
+ if (TRACE_SCOPES && tokenScopes != null) {
+ System.err.println(
+ "[TM4E token] offset=" + o + " len=" + l + " | " +
String.join(" ", tokenScopes));
+ }
+ result.add(new ScopedRange(o, l, tokenScopes == null ? List.of() :
tokenScopes));
+ }
+ lineStart = lineEnd + 1;
}
- return result;
+ } catch (Exception ignored) {
+ // ignore
}
-
- private record ColoredToken(int offset, int length, TextAttribute
attribute) {}
+ return result;
}
}
diff --git
a/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
b/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
index 9a8a8d267b..91fbe1502b 100644
---
a/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
+++
b/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
@@ -161,6 +161,136 @@ class ContentEditorTm4eSupportTest {
assertTrue(result.getTokens().length > 0, "batch line should produce
tokens");
}
+ /**
+ * The scoped ranges have to tile the requested range without holes. JFace
merges neighbouring
+ * tokens that share a TextAttribute by adding up their lengths, so a hole
(a line delimiter that
+ * no token covers) shortens the style range of a run of same-coloured
lines: one character per
+ * line with LF, two with CRLF. That is issue #7971 - highlighting that
stops before the end of a
+ * run, worse on Windows and worse the more lines are involved.
+ */
+ private static void assertTiles(IGrammar grammar, String text) {
+ List<ContentEditorTm4eSupport.ScopedRange> ranges =
+ ContentEditorTm4eSupport.tokenize(grammar, text, 0, text.length());
+
+ int expectedOffset = 0;
+ for (ContentEditorTm4eSupport.ScopedRange range : ranges) {
+ assertEquals(
+ expectedOffset,
+ range.offset(),
+ () ->
+ "gap or overlap before ["
+ + text.substring(range.offset(), range.offset() +
range.length())
+ + "] at offset "
+ + range.offset());
+ assertTrue(range.length() > 0, "empty range at offset " +
range.offset());
+ expectedOffset += range.length();
+ }
+ assertEquals(text.length(), expectedOffset, "ranges stop before the end of
the text");
+ }
+
+ @Test
+ void tokenize_coversEveryCharacter_withUnixLineEndings() throws Exception {
+ IGrammar grammar = loadGrammar("source.sql", "sql.json");
+
+ assertTiles(grammar, "SELECT\n p.CODE\nfrom\n DIM_PRESTATION p\n");
+ }
+
+ @Test
+ void tokenize_coversEveryCharacter_withWindowsLineEndings() throws Exception
{
+ IGrammar grammar = loadGrammar("source.sql", "sql.json");
+
+ assertTiles(grammar, "SELECT\r\n p.CODE\r\nfrom\r\n DIM_PRESTATION
p\r\n");
+ }
+
+ @Test
+ void tokenize_coversEveryCharacter_ofRepeatedHeadings() throws Exception {
+ IGrammar grammar = loadGrammar("text.html.markdown", "markdown.json");
+
+ // Consecutive lines that share a colour are what makes the shortfall add
up
+ assertTiles(grammar, "# h1\n## h2\n### h3\n\n# h1\n## h2\n### h3\n####
h4\n");
+ assertTiles(grammar, "# h1\r\n## h2\r\n### h3\r\n\r\n# h1\r\n## h2\r\n###
h3\r\n#### h4\r\n");
+ }
+
+ @Test
+ void tokenize_coversEveryCharacter_ofBlankAndEmptyLines() throws Exception {
+ IGrammar grammar = loadGrammar("source.json", "json.json");
+
+ assertTiles(grammar, "{\r\n\r\n \"string\" :
\"metadata\"\r\n\r\n}\r\n");
+ }
+
+ /**
+ * What {@link
org.eclipse.jface.text.rules.DefaultDamagerRepairer#createPresentation} does
with a
+ * token stream: neighbouring tokens that share a TextAttribute become one
style range whose
+ * length is the sum of the token lengths, the offsets of the merged tokens
are never consulted.
+ * Returns the resulting {offset, end} pairs.
+ */
+ private static List<int[]> mergeLikeJFace(
+ List<ContentEditorTm4eSupport.ScopedRange> ranges,
+ java.util.function.Function<ContentEditorTm4eSupport.ScopedRange,
String> attribute) {
+ List<int[]> styleRanges = new ArrayList<>();
+ String lastAttribute = null;
+ int start = 0;
+ int length = 0;
+ for (ContentEditorTm4eSupport.ScopedRange range : ranges) {
+ String current = attribute.apply(range);
+ if (current.equals(lastAttribute)) {
+ length += range.length();
+ } else {
+ if (lastAttribute != null) {
+ styleRanges.add(new int[] {start, start + length});
+ }
+ lastAttribute = current;
+ start = range.offset();
+ length = range.length();
+ }
+ }
+ if (lastAttribute != null) {
+ styleRanges.add(new int[] {start, start + length});
+ }
+ return styleRanges;
+ }
+
+ @Test
+ void mergedStyleRuns_endWhereTheirTextEnds() throws Exception {
+ IGrammar grammar = loadGrammar("text.html.markdown", "markdown.json");
+ // Three heading lines in a row: every token on them carries
markup.heading, so JFace merges
+ // them into one style range. Each line delimiter it swallows used to cut
a character off the
+ // end of that range, which is what made the last heading lose its colour
(issue #7971).
+ String text = "# h1\n## h2\n### h3\n";
+
+ List<int[]> styleRanges =
+ mergeLikeJFace(
+ ContentEditorTm4eSupport.tokenize(grammar, text, 0, text.length()),
+ range ->
+ String.join(" ", range.scopes()).contains("markup.heading") ?
"heading" : "other");
+
+ int endOfLastHeading = text.indexOf("### h3") + "### h3".length();
+ assertTrue(
+ styleRanges.stream().anyMatch(r -> r[1] == endOfLastHeading),
+ "no style range ends at the end of '### h3' (offset "
+ + endOfLastHeading
+ + "), ranges: "
+ + styleRanges.stream().map(r -> r[0] + ".." + r[1]).toList());
+ }
+
+ @Test
+ void tokenize_appliesOnlyToTheRequestedRange() throws Exception {
+ IGrammar grammar = loadGrammar("source.sql", "sql.json");
+ String text = "SELECT\r\n p.CODE\r\nfrom\r\n";
+ int lineTwo = text.indexOf(" p.CODE");
+ int lineTwoLength = " p.CODE".length();
+
+ List<ContentEditorTm4eSupport.ScopedRange> ranges =
+ ContentEditorTm4eSupport.tokenize(grammar, text, lineTwo,
lineTwoLength);
+
+ assertEquals(lineTwo, ranges.getFirst().offset(), "should start at the
requested offset");
+ ContentEditorTm4eSupport.ScopedRange last = ranges.getLast();
+ assertEquals(
+ lineTwo + lineTwoLength,
+ last.offset() + last.length(),
+ "should stop at the end of the requested range");
+ }
+
@Test
void scopeForLanguage_markdownAliases_returnTextHtmlMarkdown() {
assertEquals("text.html.markdown",
ContentEditorTm4eSupport.scopeForLanguage("markdown"));