");
+ }
+
+ @Test
+ public void comment() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void cdataSection() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void processingInstruction() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void multipleRootChildren() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void unclosedTag() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void orphanEndTag() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void complexDocument() {
+ String xml = "\n" +
+ "\n" +
+ " 4.0.0\n" +
+ " com.example\n" +
+ " test\n" +
+ " 1.0\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " junit\n" +
+ " junit\n" +
+ " \n" +
+ " \n" +
+ "";
+ assertRedTreeEquivalent(xml);
+ }
+
+ @Test
+ public void largeDocument() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ for (int i = 0; i < 1000; i++) {
+ sb.append(" value ").append(i).append("\n");
+ }
+ sb.append("");
+ assertRedTreeEquivalent(sb.toString());
+ }
+
+ // --- Edge cases: orphan end tags ---
+
+ @Test
+ public void orphanEndTagAtRoot() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void orphanEndTagEmpty() {
+ assertRedTreeEquivalent(">");
+ }
+
+ @Test
+ public void orphanEndTagWithContent() {
+ assertRedTreeEquivalent("textmore");
+ }
+
+ @Test
+ public void multipleOrphanEndTags() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void orphanEndTagBetweenElements() {
+ assertRedTreeEquivalent("");
+ }
+
+ // --- Edge cases: unclosed tags ---
+
+ @Test
+ public void unclosedNestedTags() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void unclosedWithSiblings() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void unclosedStartTagNoClose() {
+ assertRedTreeEquivalent("");
+ }
+
+ // --- Edge cases: attributes ---
+
+ @Test
+ public void attributeWithoutValue() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void attributeWithoutValueBeforeClose() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void multipleAttributesWithoutValues() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void attributeWithDelimiterNoValue() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void attributeWithDelimiterNoValueAtEOS() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void attributeWithSpacesAroundEquals() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void attributeAtEOS() {
+ assertRedTreeEquivalent("");
+ }
+
+ // --- Edge cases: comments ---
+
+ @Test
+ public void unclosedComment() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void commentSameLineAsEndTag() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void commentOnSameLineEndTag() {
+ assertRedTreeEquivalent("\n \n");
+ }
+
+ @Test
+ public void commentOnDifferentLine() {
+ assertRedTreeEquivalent("\n \n\n");
+ }
+
+ // --- Edge cases: CDATA ---
+
+ @Test
+ public void emptyCDATA() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void unclosedCDATA() {
+ assertRedTreeEquivalent("&\"']]>");
+ }
+
+ // --- Edge cases: processing instructions ---
+
+ @Test
+ public void piWithAttributes() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void piWithAttrNoValue() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void piNonProlog() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void unclosedPI() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void doctypeWithPublicId() {
+ assertRedTreeEquivalent(
+ "" +
+ "");
+ }
+
+ @Test
+ public void doctypeWithSystemId() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void doctypeWithInternalSubset() {
+ assertRedTreeEquivalent(
+ "\n" +
+ " \n" +
+ "]>\n");
+ }
+
+ @Test
+ public void doctypeEmpty() {
+ assertRedTreeEquivalent("\n");
+ }
+
+ @Test
+ public void doctypeUnclosed() {
+ assertRedTreeEquivalent("\n" +
+ "]>\n");
+ }
+
+ @Test
+ public void doctypeWithNotationDecl() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "]>\n");
+ }
+
+ @Test
+ public void doctypeWithMultipleDecls() {
+ assertRedTreeEquivalent(
+ "\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ "]>\n");
+ }
+
+ @Test
+ public void doctypeAttlistMultipleAttributes() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "]>\n");
+ }
+
+ @Test
+ public void doctypeUnrecognizedContent() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "]>\n");
+ }
+
+ // --- Edge cases: end tag with spaces ---
+
+ @Test
+ public void endTagWithSpaces() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void endTagWithNewline() {
+ assertRedTreeEquivalent("");
+ }
+
+ // --- Edge cases: whitespace handling ---
+
+ @Test
+ public void whitespaceOnlyContent() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void whitespaceBeforeAndAfterContent() {
+ assertRedTreeEquivalent(" text ");
+ }
+
+ @Test
+ public void newlinesAndIndentation() {
+ assertRedTreeEquivalent("\n \n text\n \n");
+ }
+
+ // --- Edge cases: malformed XML ---
+
+ @Test
+ public void startTagInsideStartTag() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void closeTagMismatch() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void multipleRoots() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void emptyDocument() {
+ assertRedTreeEquivalent("");
+ }
+
+ @Test
+ public void textOnlyDocument() {
+ assertRedTreeEquivalent("just text");
+ }
+
+ @Test
+ public void openBracketOnly() {
+ assertRedTreeEquivalent("<");
+ }
+
+ @Test
+ public void closeBracketInContent() {
+ assertRedTreeEquivalent("a > b");
+ }
+
+ @Test
+ public void ampersandInContent() {
+ assertRedTreeEquivalent("a & b");
+ }
+
+ // --- Edge cases: mixed real-world patterns ---
+
+ @Test
+ public void xhtmlSelfClosingElements() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "\n" +
+ " Test\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ "");
+ }
+
+ @Test
+ public void mavenPom() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "\n" +
+ " 4.0.0\n" +
+ " com.example\n" +
+ " test\n" +
+ " 1.0-SNAPSHOT\n" +
+ " \n" +
+ " \n" +
+ " junit\n" +
+ " junit\n" +
+ " 4.13.2\n" +
+ " test\n" +
+ " \n" +
+ " \n" +
+ "");
+ }
+
+ @Test
+ public void springConfig() {
+ assertRedTreeEquivalent(
+ "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ "");
+ }
+
+ @Test
+ public void windowsCRLF() {
+ assertRedTreeEquivalent("\r\n \r\n");
+ }
+
+ @Test
+ public void mixedLineEndings() {
+ assertRedTreeEquivalent("\n \r\n \r \n");
+ }
+
+ private void assertRedTreeEquivalent(String xml) {
+ TextDocument textDoc = new TextDocument(xml, "test://test.xml");
+
+ DOMDocument expected = DOMParser.getInstance().parse(textDoc, null);
+
+ GreenDocument greenDoc = GreenTreeBuilder.parse(xml, "test://test.xml", null);
+ DOMDocument actual = RedTreeBuilder.build(greenDoc, textDoc, null);
+
+ assertNodesEqual(expected, actual, xml);
+ }
+
+ private void assertNodesEqual(DOMNode expected, DOMNode actual, String xml) {
+ assertEquals(expected.getNodeType(), actual.getNodeType(),
+ "Node type mismatch at offset " + expected.getStart());
+ assertEquals(expected.getStart(), actual.getStart(),
+ "Start offset mismatch for " + expected.getNodeName());
+ assertEquals(expected.getEnd(), actual.getEnd(),
+ "End offset mismatch for " + expected.getNodeName());
+ assertEquals(expected.isClosed(), actual.isClosed(),
+ "Closed mismatch for " + expected.getNodeName());
+
+ if (expected.isElement()) {
+ assertElementsEqual((DOMElement) expected, (DOMElement) actual);
+ }
+ if (expected.isComment()) {
+ assertCommentsEqual((DOMComment) expected, (DOMComment) actual);
+ }
+ if (expected instanceof DOMCDATASection) {
+ assertCDATAEqual((DOMCDATASection) expected, (DOMCDATASection) actual);
+ }
+ if (expected.isProcessingInstruction() || expected.isProlog()) {
+ assertPIEqual((DOMProcessingInstruction) expected, (DOMProcessingInstruction) actual);
+ }
+ if (expected.isText()) {
+ DOMText expText = (DOMText) expected;
+ DOMText actText = (DOMText) actual;
+ assertEquals(expText.isWhitespace(), actText.isWhitespace(),
+ "Whitespace mismatch for text at " + expected.getStart());
+ }
+
+ List expectedChildren = expected.getChildren();
+ List actualChildren = actual.getChildren();
+ assertEquals(expectedChildren.size(), actualChildren.size(),
+ "Child count mismatch for " + expected.getNodeName()
+ + " at " + expected.getStart());
+
+ for (int i = 0; i < expectedChildren.size(); i++) {
+ assertNodesEqual(expectedChildren.get(i), actualChildren.get(i), xml);
+ }
+ }
+
+ private void assertElementsEqual(DOMElement expected, DOMElement actual) {
+ assertEquals(expected.getTagName(), actual.getTagName(),
+ "Tag name mismatch");
+ assertEquals(expected.isSelfClosed(), actual.isSelfClosed(),
+ "Self-closed mismatch for " + expected.getTagName());
+ assertEquals(expected.getStartTagOpenOffset(), actual.getStartTagOpenOffset(),
+ "StartTagOpen mismatch for " + expected.getTagName());
+ assertEquals(expected.getStartTagCloseOffset(), actual.getStartTagCloseOffset(),
+ "StartTagClose mismatch for " + expected.getTagName());
+ assertEquals(expected.getEndTagOpenOffset(), actual.getEndTagOpenOffset(),
+ "EndTagOpen mismatch for " + expected.getTagName());
+ assertEquals(expected.getEndTagCloseOffset(), actual.getEndTagCloseOffset(),
+ "EndTagClose mismatch for " + expected.getTagName());
+
+ if (expected.hasAttributes()) {
+ assertEquals(expected.getAttributeNodes().size(), actual.getAttributeNodes().size(),
+ "Attribute count mismatch for " + expected.getTagName());
+ for (int i = 0; i < expected.getAttributeNodes().size(); i++) {
+ DOMAttr expAttr = expected.getAttributeNodes().get(i);
+ DOMAttr actAttr = actual.getAttributeNodes().get(i);
+ assertEquals(expAttr.getName(), actAttr.getName(),
+ "Attribute name mismatch");
+ assertEquals(expAttr.getOriginalValue(), actAttr.getOriginalValue(),
+ "Attribute value mismatch for " + expAttr.getName());
+ }
+ }
+ }
+
+ private void assertCommentsEqual(DOMComment expected, DOMComment actual) {
+ assertEquals(expected.getStartContent(), actual.getStartContent(),
+ "Comment startContent mismatch");
+ assertEquals(expected.getEndContent(), actual.getEndContent(),
+ "Comment endContent mismatch");
+ }
+
+ private void assertCDATAEqual(DOMCDATASection expected, DOMCDATASection actual) {
+ assertEquals(expected.getStartContent(), actual.getStartContent(),
+ "CDATA startContent mismatch");
+ assertEquals(expected.getEndContent(), actual.getEndContent(),
+ "CDATA endContent mismatch");
+ }
+
+ private void assertPIEqual(DOMProcessingInstruction expected, DOMProcessingInstruction actual) {
+ assertEquals(expected.getTarget(), actual.getTarget(),
+ "PI target mismatch");
+ assertEquals(expected.isProlog(), actual.isProlog(),
+ "PI prolog mismatch");
+ assertEquals(expected.getStartContent(), actual.getStartContent(),
+ "PI startContent mismatch");
+ assertEquals(expected.getEndContent(), actual.getEndContent(),
+ "PI endContent mismatch");
+ }
+}
From 432395f71f421b91467ee740b5e60c68ee61b0f3 Mon Sep 17 00:00:00 2001
From: azerr
Date: Fri, 4 Sep 2026 20:15:06 +0200
Subject: [PATCH 02/18] Implement incremental reparse and atomic swap for
red-green tree
Add incremental reparsing that reuses unchanged subtrees when editing
a document, avoiding full reparse for single-edit changes. The green
tree is preserved on DOMDocument for structural sharing across versions.
Key changes:
- IncrementalParser: finds reusable prefix/suffix children at document
level, reparses only the affected middle region via parseRange()
- GreenTreeBuilder.parseRange(): parses a subrange of text, enabling
partial reparsing without scanning the full document
- ModelTextDocument: model field is now volatile for thread-safe reads,
preserves previousModel and edit info for incremental path
- DOMDocument stores its GreenDocument for reuse across edits
- GreenNode.childrenStartRel() factored from RedTreeBuilder into each
green node subclass
Tests: 195 tests covering green tree, red tree, incremental parser,
structural sharing, ModelTextDocument edit flow, and DOMParser
integration. All 2461 existing tests pass with zero regressions.
Signed-off-by: azerr
Co-Authored-By: Claude Opus 4.6
---
.../lemminx/XMLTextDocumentService.java | 15 +
.../lemminx/commons/ModelTextDocument.java | 324 +++++---
.../org/eclipse/lemminx/dom/DOMDocument.java | 10 +
.../org/eclipse/lemminx/dom/DOMParser.java | 19 +
.../eclipse/lemminx/dom/RedTreeBuilder.java | 43 +-
.../lemminx/dom/green/GreenDTDDeclNode.java | 12 +
.../lemminx/dom/green/GreenDocumentType.java | 15 +
.../lemminx/dom/green/GreenElement.java | 11 +
.../eclipse/lemminx/dom/green/GreenNode.java | 4 +
.../lemminx/dom/green/GreenTreeBuilder.java | 20 +-
.../lemminx/dom/green/IncrementalParser.java | 131 +++
.../commons/ModelTextDocumentTest.java | 129 +++
.../dom/green/GreenTreeBuilderTest.java | 134 ++++
.../lemminx/dom/green/GreenTreeDebugTest.java | 70 --
.../dom/green/IncrementalParserTest.java | 758 ++++++++++++++++++
.../lemminx/dom/green/RedTreeBuilderTest.java | 23 +
16 files changed, 1477 insertions(+), 241 deletions(-)
create mode 100644 org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
create mode 100644 org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
delete mode 100644 org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeDebugTest.java
create mode 100644 org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
index 8827a1bf9..b427b7584 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
@@ -202,6 +202,21 @@ public XMLTextDocumentService(XMLLanguageServer xmlLanguageServer) {
this.xmlLanguageServer = xmlLanguageServer;
DOMParser parser = DOMParser.getInstance();
this.documents = new ModelTextDocuments((document, cancelChecker) -> {
+ if (document instanceof ModelTextDocument) {
+ @SuppressWarnings("unchecked")
+ ModelTextDocument mtd = (ModelTextDocument) document;
+ DOMDocument prev = mtd.getPreviousModel();
+ ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit();
+ if (prev != null && prev.getGreenDocument() != null && editInfo != null) {
+ return parser.parseIncremental(document,
+ prev.getGreenDocument(),
+ editInfo.getStartOffset(),
+ editInfo.getDeleteLength(),
+ editInfo.getInsertLength(),
+ getXMLLanguageService().getResolverExtensionManager(),
+ true, cancelChecker);
+ }
+ }
return parser.parse(document, getXMLLanguageService().getResolverExtensionManager(), true, cancelChecker);
});
this.sharedSettings = new SharedSettings();
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
index 2e55e2d90..08c703df4 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
@@ -1,121 +1,203 @@
-/*******************************************************************************
-* Copyright (c) 2019 Red Hat Inc. and others.
-* All rights reserved. This program and the accompanying materials
-* which accompanies this distribution, and is available at
-* http://www.eclipse.org/legal/epl-v20.html
-*
-* SPDX-License-Identifier: EPL-2.0
-*
-* Contributors:
-* Red Hat Inc. - initial API and implementation
-*******************************************************************************/
-package org.eclipse.lemminx.commons;
-
-import java.util.concurrent.CancellationException;
-import java.util.function.BiFunction;
-import java.util.logging.Logger;
-
-import org.eclipse.lsp4j.TextDocumentItem;
-import org.eclipse.lsp4j.jsonrpc.CancelChecker;
-
-/**
- * A {@link TextDocument} which is associate to a model loaded in async.
- *
- * @author Angelo ZERR
- *
- * @param the model type (ex : DOM Document)
- */
-public class ModelTextDocument extends TextDocument {
-
- private static final Logger LOGGER = Logger.getLogger(ModelTextDocument.class.getName());
-
- private final BiFunction parse;
-
- private T model;
-
- public ModelTextDocument(TextDocumentItem document, BiFunction parse) {
- super(document);
- this.parse = parse;
- }
-
- public ModelTextDocument(String text, String uri, BiFunction parse) {
- super(text, uri);
- this.parse = parse;
- }
-
- /**
- * Returns the existing parsed model synchronized with last version of the text
- * document and null otherwise.
- *
- * @return the existing parsed model synchronized with last version of the text
- * document and null otherwise.
- */
- public T getExistingModel() {
- return model;
- }
-
- /**
- * Returns the parsed model synchronized with last version of the text document.
- *
- * @return the parsed model synchronized with last version of the text document.
- */
- public T getModel() {
- if (model == null) {
- return getSynchronizedModel();
- }
- return model;
- }
-
- /**
- * Return the existing parsed model synchronized with last version of the text
- * document or parse the model.
- *
- * @return the existing parsed model synchronized with last version of the text
- * document or parse the model.
- */
- private synchronized T getSynchronizedModel() {
- if (model != null) {
- return model;
- }
- int version = super.getVersion();
- long start = System.currentTimeMillis();
- try {
- LOGGER.fine("Start parsing of model with version '" + version);
- // Stop of parse process can be done when completable future is canceled or when
- // version of document changes
- CancelChecker cancelChecker = new TextDocumentVersionChecker(this, version);
- // parse the model
- model = parse.apply(this, cancelChecker);
- } catch (CancellationException e) {
- LOGGER.fine("Stop parsing parsing of model with version '" + version + "' in "
- + (System.currentTimeMillis() - start) + "ms");
- throw e;
- } finally {
- LOGGER.fine("End parse of model with version '" + version + "' in " + (System.currentTimeMillis() - start)
- + "ms");
- }
- return model;
- }
-
- @Override
- public void setText(String text) {
- super.setText(text);
- // text changed, cancel the completable future which load the model
- cancelModel();
- }
-
- @Override
- public void setVersion(int version) {
- super.setVersion(version);
- // version changed, mark the model as dirty
- cancelModel();
- }
-
- /**
- * Mark the model as dirty
- */
- private void cancelModel() {
- model = null;
- }
-
-}
\ No newline at end of file
+/*******************************************************************************
+* Copyright (c) 2019 Red Hat Inc. and others.
+* All rights reserved. This program and the accompanying materials
+* which accompanies this distribution, and is available at
+* http://www.eclipse.org/legal/epl-v20.html
+*
+* SPDX-License-Identifier: EPL-2.0
+*
+* Contributors:
+* Red Hat Inc. - initial API and implementation
+*******************************************************************************/
+package org.eclipse.lemminx.commons;
+
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.function.BiFunction;
+import java.util.logging.Logger;
+
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent;
+import org.eclipse.lsp4j.TextDocumentItem;
+import org.eclipse.lsp4j.jsonrpc.CancelChecker;
+
+/**
+ * A {@link TextDocument} which is associate to a model loaded in async.
+ *
+ * @author Angelo ZERR
+ *
+ * @param the model type (ex : DOM Document)
+ */
+public class ModelTextDocument extends TextDocument {
+
+ private static final Logger LOGGER = Logger.getLogger(ModelTextDocument.class.getName());
+
+ private final BiFunction parse;
+
+ private volatile T model;
+
+ private volatile T previousModel;
+
+ private volatile EditInfo pendingEdit;
+
+ public ModelTextDocument(TextDocumentItem document, BiFunction parse) {
+ super(document);
+ this.parse = parse;
+ }
+
+ public ModelTextDocument(String text, String uri, BiFunction parse) {
+ super(text, uri);
+ this.parse = parse;
+ }
+
+ /**
+ * Returns the existing parsed model synchronized with last version of the text
+ * document and null otherwise.
+ *
+ * @return the existing parsed model synchronized with last version of the text
+ * document and null otherwise.
+ */
+ public T getExistingModel() {
+ return model;
+ }
+
+ /**
+ * Returns the parsed model synchronized with last version of the text document.
+ *
+ * @return the parsed model synchronized with last version of the text document.
+ */
+ public T getModel() {
+ if (model == null) {
+ return getSynchronizedModel();
+ }
+ return model;
+ }
+
+ /**
+ * Return the existing parsed model synchronized with last version of the text
+ * document or parse the model.
+ *
+ * @return the existing parsed model synchronized with last version of the text
+ * document or parse the model.
+ */
+ private synchronized T getSynchronizedModel() {
+ if (model != null) {
+ return model;
+ }
+ int version = super.getVersion();
+ long start = System.currentTimeMillis();
+ try {
+ LOGGER.fine("Start parsing of model with version '" + version);
+ // Stop of parse process can be done when completable future is canceled or when
+ // version of document changes
+ CancelChecker cancelChecker = new TextDocumentVersionChecker(this, version);
+ // parse the model
+ model = parse.apply(this, cancelChecker);
+ } catch (CancellationException e) {
+ LOGGER.fine("Stop parsing parsing of model with version '" + version + "' in "
+ + (System.currentTimeMillis() - start) + "ms");
+ throw e;
+ } finally {
+ previousModel = null;
+ pendingEdit = null;
+ LOGGER.fine("End parse of model with version '" + version + "' in " + (System.currentTimeMillis() - start)
+ + "ms");
+ }
+ return model;
+ }
+
+ @Override
+ public void setText(String text) {
+ super.setText(text);
+ // text changed, cancel the completable future which load the model
+ cancelModel();
+ }
+
+ @Override
+ public void setVersion(int version) {
+ super.setVersion(version);
+ // version changed, mark the model as dirty
+ cancelModel();
+ }
+
+ @Override
+ public void update(List changes) {
+ if (changes != null && changes.size() == 1) {
+ TextDocumentContentChangeEvent change = changes.get(0);
+ Range range = change.getRange();
+ if (range != null) {
+ try {
+ int start = offsetAt(range.getStart());
+ Integer rangeLength = change.getRangeLength();
+ int delLen = rangeLength != null ? rangeLength.intValue()
+ : offsetAt(range.getEnd()) - start;
+ int insLen = change.getText() != null ? change.getText().length() : 0;
+ pendingEdit = new EditInfo(start, delLen, insLen);
+ } catch (BadLocationException e) {
+ pendingEdit = null;
+ }
+ }
+ } else {
+ pendingEdit = null;
+ }
+ super.update(changes);
+ }
+
+ /**
+ * Mark the model as dirty
+ */
+ private void cancelModel() {
+ if (model != null) {
+ previousModel = model;
+ }
+ model = null;
+ }
+
+ /**
+ * Returns the previous model (before the last edit) and null if not available.
+ *
+ * @return the previous model or null
+ */
+ public T getPreviousModel() {
+ return previousModel;
+ }
+
+ /**
+ * Returns the pending edit info (offset, delete/insert lengths) for
+ * the most recent single-change edit, or null if not available.
+ *
+ * @return the edit info or null
+ */
+ public EditInfo getPendingEdit() {
+ return pendingEdit;
+ }
+
+ /**
+ * Information about a single text edit: where it started in the old text,
+ * how many characters were deleted, and how many were inserted.
+ */
+ public static final class EditInfo {
+ private final int startOffset;
+ private final int deleteLength;
+ private final int insertLength;
+
+ public EditInfo(int startOffset, int deleteLength, int insertLength) {
+ this.startOffset = startOffset;
+ this.deleteLength = deleteLength;
+ this.insertLength = insertLength;
+ }
+
+ public int getStartOffset() {
+ return startOffset;
+ }
+
+ public int getDeleteLength() {
+ return deleteLength;
+ }
+
+ public int getInsertLength() {
+ return insertLength;
+ }
+ }
+
+}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
index 28d785af3..ad33b1fee 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
@@ -27,6 +27,7 @@
import org.eclipse.lemminx.commons.BadLocationException;
import org.eclipse.lemminx.commons.TextDocument;
+import org.eclipse.lemminx.dom.green.GreenDocument;
import org.eclipse.lemminx.dom.parser.Constants;
import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager;
import org.eclipse.lemminx.utils.DOMUtils;
@@ -65,6 +66,7 @@ public class DOMDocument extends DOMNode implements Document {
private String schemaPrefix;
private CancelChecker cancelChecker;
private String externalGrammarFromNamespaceURI;
+ private volatile GreenDocument greenDocument;
public DOMDocument(TextDocument textDocument, URIResolverExtensionManager resolverExtensionManager) {
super(0, textDocument.getText().length());
@@ -81,6 +83,14 @@ public CancelChecker getCancelChecker() {
return cancelChecker;
}
+ public GreenDocument getGreenDocument() {
+ return greenDocument;
+ }
+
+ public void setGreenDocument(GreenDocument greenDocument) {
+ this.greenDocument = greenDocument;
+ }
+
public List getRoots() {
return super.getChildren();
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
index 037477093..f8e4372e1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
@@ -20,6 +20,7 @@
import org.eclipse.lemminx.commons.TextDocument;
import org.eclipse.lemminx.dom.green.GreenDocument;
import org.eclipse.lemminx.dom.green.GreenTreeBuilder;
+import org.eclipse.lemminx.dom.green.IncrementalParser;
import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager;
import org.eclipse.lsp4j.jsonrpc.CancelChecker;
@@ -62,8 +63,26 @@ public DOMDocument parse(TextDocument document, URIResolverExtensionManager reso
String text = document.getText();
String uri = document.getUri();
GreenDocument greenDoc = GreenTreeBuilder.parse(text, uri, monitor);
+ return buildDocument(greenDoc, document, resolverExtensionManager, ignoreWhitespaceContent, monitor);
+ }
+
+ public DOMDocument parseIncremental(TextDocument document,
+ GreenDocument previousGreenDoc, int editStart, int deleteLength, int insertLength,
+ URIResolverExtensionManager resolverExtensionManager,
+ boolean ignoreWhitespaceContent, CancelChecker monitor) {
+ String text = document.getText();
+ String uri = document.getUri();
+ GreenDocument greenDoc = IncrementalParser.incrementalParse(
+ previousGreenDoc, text, editStart, deleteLength, insertLength, uri, monitor);
+ return buildDocument(greenDoc, document, resolverExtensionManager, ignoreWhitespaceContent, monitor);
+ }
+
+ private static DOMDocument buildDocument(GreenDocument greenDoc, TextDocument document,
+ URIResolverExtensionManager resolverExtensionManager,
+ boolean ignoreWhitespaceContent, CancelChecker monitor) {
DOMDocument xmlDocument = RedTreeBuilder.build(greenDoc, document,
resolverExtensionManager, ignoreWhitespaceContent);
+ xmlDocument.setGreenDocument(greenDoc);
xmlDocument.setCancelChecker(monitor);
return xmlDocument;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
index 5a6af0319..63f8c7f98 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
@@ -69,7 +69,7 @@ private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsSta
return;
}
- int childrenStartRel = computeChildrenStartRel(greenParent);
+ int childrenStartRel = greenParent.childrenStartRel();
int childAbsStart = parentAbsStart + childrenStartRel;
boolean skipWhitespace = ignoreWhitespaceContent
&& (hasNonWhitespaceChild(children) || greenParent instanceof GreenDocumentType);
@@ -376,47 +376,6 @@ private static void setDTDUnrecognized(DTDDeclNode decl, GreenDTDDeclNode green,
}
}
- private static int computeChildrenStartRel(GreenNode greenParent) {
- if (greenParent instanceof GreenDocument) {
- return 0;
- }
- if (greenParent instanceof GreenElement) {
- GreenElement elem = (GreenElement) greenParent;
- if (elem.contentStartRel() != GreenElement.NULL_VALUE) {
- return elem.contentStartRel();
- }
- if (elem.startTagCloseRel() != GreenElement.NULL_VALUE) {
- return elem.startTagCloseRel() + 1;
- }
- return 0;
- }
- if (greenParent instanceof GreenDocumentType) {
- GreenDocumentType dt = (GreenDocumentType) greenParent;
- if (dt.internalSubset() != null) {
- return dt.internalSubset().startRel() + 1;
- }
- if (dt.childCount() == 0) {
- return 0;
- }
- int childrenWidth = 0;
- for (GreenNode child : dt.children()) {
- childrenWidth += child.width();
- }
- return dt.width() - childrenWidth;
- }
- if (greenParent instanceof GreenDTDDeclNode) {
- if (greenParent.childCount() == 0) {
- return 0;
- }
- int childrenWidth = 0;
- for (GreenNode child : greenParent.children()) {
- childrenWidth += child.width();
- }
- return greenParent.width() - childrenWidth;
- }
- return 0;
- }
-
private static int abs(int relOffset, int absStart) {
return relOffset != GreenElement.NULL_VALUE ? absStart + relOffset : DOMNode.NULL_VALUE;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
index 7113de4aa..67ee849d1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
@@ -63,6 +63,18 @@ public GreenDTDParam[] parameters() {
return parameters;
}
+ @Override
+ public int childrenStartRel() {
+ if (childCount() == 0) {
+ return 0;
+ }
+ int childrenWidth = 0;
+ for (GreenNode child : children()) {
+ childrenWidth += child.width();
+ }
+ return width() - childrenWidth;
+ }
+
@Override
public GreenNode[] children() {
return children;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocumentType.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocumentType.java
index 1c33823f1..bb9deeb01 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocumentType.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocumentType.java
@@ -57,6 +57,21 @@ public GreenDTDParam internalSubset() {
return internalSubset;
}
+ @Override
+ public int childrenStartRel() {
+ if (internalSubset != null) {
+ return internalSubset.startRel() + 1;
+ }
+ if (childCount() == 0) {
+ return 0;
+ }
+ int childrenWidth = 0;
+ for (GreenNode child : children()) {
+ childrenWidth += child.width();
+ }
+ return width() - childrenWidth;
+ }
+
@Override
protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) {
return new GreenDocumentType(newWidth, closed(), unrecognized(), declType(),
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
index 1a04c3b2e..88d6e669b 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
@@ -85,6 +85,17 @@ public int attributeCount() {
return attributes.length;
}
+ @Override
+ public int childrenStartRel() {
+ if (contentStartRel != NULL_VALUE) {
+ return contentStartRel;
+ }
+ if (startTagCloseRel != NULL_VALUE) {
+ return startTagCloseRel + 1;
+ }
+ return 0;
+ }
+
@Override
public GreenNode[] children() {
return children;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
index 63bd3cdfc..aec6f8493 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
@@ -66,5 +66,9 @@ public GreenNode withReplacedChild(int index, GreenNode newChild) {
return replaceChildren(copy, width + deltaWidth);
}
+ public int childrenStartRel() {
+ return 0;
+ }
+
protected abstract GreenNode replaceChildren(GreenNode[] newChildren, int newWidth);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
index cada2a5f6..efe3c37af 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
@@ -43,19 +43,19 @@ private GreenTreeBuilder() {
* @return the root green document node
*/
public static GreenDocument parse(String text, String uri, CancelChecker monitor) {
- return parse(text, uri, true, monitor);
+ return parseRange(text, uri, 0, text.length(), monitor);
}
- public static GreenDocument parse(String text, String uri,
- boolean ignoreWhitespaceContent, CancelChecker monitor) {
+ public static GreenDocument parseRange(String text, String uri,
+ int rangeStart, int rangeEnd, CancelChecker monitor) {
boolean isDTD = DOMUtils.isDTD(uri);
- Scanner scanner = XMLScanner.createScanner(text, 0, isDTD);
+ Scanner scanner = XMLScanner.createScanner(text, rangeStart, isDTD);
Deque stack = new ArrayDeque<>();
List rootChildren = new ArrayList<>();
if (isDTD) {
- NodeBuilder dtdRoot = new NodeBuilder(NodeKind.DOCUMENT_TYPE, 0);
+ NodeBuilder dtdRoot = new NodeBuilder(NodeKind.DOCUMENT_TYPE, rangeStart);
dtdRoot.closed = true;
stack.push(dtdRoot);
}
@@ -67,12 +67,15 @@ public static GreenDocument parse(String text, String uri,
boolean inDTDInternalSubset = false;
GreenText tempWhitespaceContent = null;
int tempWhitespaceStart = -1;
- int[] nextRootChildEnd = { 0 };
+ int[] nextRootChildEnd = { rangeStart };
NodeBuilder lastClosed = null;
TokenType token = scanner.scan();
while (token != TokenType.EOS) {
+ if (rangeEnd < text.length() && scanner.getTokenOffset() >= rangeEnd && stack.isEmpty()) {
+ break;
+ }
if (monitor != null) {
monitor.checkCanceled();
}
@@ -816,13 +819,14 @@ public static GreenDocument parse(String text, String uri,
// Flush remaining open nodes
while (!stack.isEmpty()) {
NodeBuilder top = stack.peek();
- top.nodeEnd = text.length();
+ top.nodeEnd = rangeEnd;
GreenNode built = top.buildGreen();
stack.pop();
addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart);
}
- return new GreenDocument(text.length(),
+ int docWidth = rangeEnd - rangeStart;
+ return new GreenDocument(docWidth,
rootChildren.toArray(GreenNode.EMPTY_CHILDREN));
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
new file mode 100644
index 000000000..7f75bbb19
--- /dev/null
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
@@ -0,0 +1,131 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Red Hat Inc. and others.
+ * All rights reserved. This program and the accompanying materials
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat Inc. - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.lemminx.dom.green;
+
+import org.eclipse.lsp4j.jsonrpc.CancelChecker;
+
+/**
+ * Incremental parser that reparses only the affected region of a document
+ * when an edit is applied, reusing unchanged subtrees via structural sharing.
+ *
+ *
Given an old {@link GreenDocument}, the new text, and edit coordinates,
+ * this class identifies top-level children that are entirely before or after
+ * the edit (prefix/suffix), reparses only the affected middle range using
+ * {@link GreenTreeBuilder#parseRange}, and splices the result together.
+ *
+ *
Falls back to full reparse when the edit crosses all children or when
+ * the reparsed middle ends with an unclosed element (which would invalidate
+ * the suffix).
+ */
+public final class IncrementalParser {
+
+ private IncrementalParser() {
+ }
+
+ /**
+ * Incrementally reparses the document. Returns a new {@link GreenDocument}
+ * that structurally shares unchanged subtrees with the old tree.
+ *
+ * @param oldDoc the previous immutable green tree
+ * @param newText the full new document text (after edit applied)
+ * @param editStart offset in old text where the edit begins
+ * @param deleteLength number of characters deleted from old text
+ * @param insertLength number of characters inserted (length of new text at edit point)
+ * @param uri the document URI
+ * @param monitor optional cancel checker
+ * @return a new GreenDocument (never null)
+ */
+ public static GreenDocument incrementalParse(GreenDocument oldDoc,
+ String newText, int editStart, int deleteLength, int insertLength,
+ String uri, CancelChecker monitor) {
+ GreenDocument result = tryIncremental(oldDoc, newText, editStart,
+ deleteLength, insertLength, uri, monitor);
+ if (result != null) {
+ return result;
+ }
+ return GreenTreeBuilder.parse(newText, uri, monitor);
+ }
+
+ private static GreenDocument tryIncremental(GreenDocument oldDoc,
+ String newText, int editStart, int deleteLength, int insertLength,
+ String uri, CancelChecker monitor) {
+ GreenNode[] oldChildren = oldDoc.children();
+ if (oldChildren.length == 0) {
+ return null;
+ }
+
+ int editEnd = editStart + deleteLength;
+
+ // Find reusable prefix: children entirely before the edit
+ int prefixCount = 0;
+ int prefixWidth = 0;
+ for (int i = 0; i < oldChildren.length; i++) {
+ int childEnd = prefixWidth + oldChildren[i].width();
+ if (childEnd <= editStart) {
+ prefixCount++;
+ prefixWidth = childEnd;
+ } else {
+ break;
+ }
+ }
+
+ // Find reusable suffix: children entirely after the edit in old text
+ int suffixCount = 0;
+ int suffixWidth = 0;
+ int scanPos = oldDoc.width();
+ for (int i = oldChildren.length - 1; i >= prefixCount; i--) {
+ int childStart = scanPos - oldChildren[i].width();
+ if (childStart >= editEnd) {
+ suffixCount++;
+ suffixWidth += oldChildren[i].width();
+ scanPos = childStart;
+ } else {
+ break;
+ }
+ }
+
+ if (prefixCount + suffixCount == 0) {
+ return null;
+ }
+
+ int middleStart = prefixWidth;
+ int middleEnd = newText.length() - suffixWidth;
+ if (middleEnd < middleStart || middleEnd > newText.length()) {
+ return null;
+ }
+
+ GreenDocument middleDoc = GreenTreeBuilder.parseRange(
+ newText, uri, middleStart, middleEnd, monitor);
+ GreenNode[] middleChildren = middleDoc.children();
+
+ if (suffixCount > 0 && middleChildren.length > 0
+ && !middleChildren[middleChildren.length - 1].closed()) {
+ middleDoc = GreenTreeBuilder.parseRange(
+ newText, uri, middleStart, newText.length(), monitor);
+ middleChildren = middleDoc.children();
+ suffixCount = 0;
+ suffixWidth = 0;
+ }
+
+ int total = prefixCount + middleChildren.length + suffixCount;
+ GreenNode[] newChildren = new GreenNode[total];
+ System.arraycopy(oldChildren, 0, newChildren, 0, prefixCount);
+ System.arraycopy(middleChildren, 0, newChildren, prefixCount,
+ middleChildren.length);
+ if (suffixCount > 0) {
+ System.arraycopy(oldChildren, oldChildren.length - suffixCount,
+ newChildren, prefixCount + middleChildren.length, suffixCount);
+ }
+
+ return new GreenDocument(newText.length(), newChildren);
+ }
+}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
new file mode 100644
index 000000000..521c72479
--- /dev/null
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
@@ -0,0 +1,129 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Red Hat Inc. and others.
+ * All rights reserved. This program and the accompanying materials
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat Inc. - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.lemminx.commons;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ModelTextDocument} incremental edit support.
+ */
+public class ModelTextDocumentTest {
+
+ @Test
+ public void editInfoCapturedBeforeTextUpdate() {
+ ModelTextDocument doc = createDoc("text");
+ doc.getModel();
+
+ TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent(
+ new Range(new Position(0, 10), new Position(0, 10)), "X");
+ doc.update(Collections.singletonList(change));
+
+ ModelTextDocument.EditInfo edit = doc.getPendingEdit();
+ assertNotNull(edit);
+ assertEquals(10, edit.getStartOffset());
+ assertEquals(0, edit.getDeleteLength());
+ assertEquals(1, edit.getInsertLength());
+ }
+
+ @Test
+ public void previousModelPreservedOnCancel() {
+ ModelTextDocument doc = createDoc("");
+ String firstModel = doc.getModel();
+ assertNotNull(firstModel);
+
+ doc.setText("changed");
+
+ String prev = doc.getPreviousModel();
+ assertNotNull(prev);
+ assertEquals(firstModel, prev);
+ }
+
+ @Test
+ public void pendingEditClearedAfterGetModel() {
+ ModelTextDocument doc = createDoc("text");
+ doc.getModel();
+
+ TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent(
+ new Range(new Position(0, 10), new Position(0, 10)), "X");
+ doc.update(Collections.singletonList(change));
+ assertNotNull(doc.getPendingEdit());
+
+ doc.getModel();
+
+ assertNull(doc.getPendingEdit());
+ assertNull(doc.getPreviousModel());
+ }
+
+ @Test
+ public void multipleChangesNullifyPendingEdit() {
+ ModelTextDocument doc = createDoc("text");
+ doc.getModel();
+
+ TextDocumentContentChangeEvent change1 = createChange(0, 6, 0, 10, "newtext");
+ TextDocumentContentChangeEvent change2 = createChange(0, 0, 0, 0, "X");
+ doc.update(Arrays.asList(change1, change2));
+
+ assertNull(doc.getPendingEdit());
+ }
+
+ @Test
+ public void fullDocumentChangeNullifyPendingEdit() {
+ ModelTextDocument doc = createDoc("");
+ doc.getModel();
+
+ TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent("");
+ doc.update(Collections.singletonList(change));
+
+ assertNull(doc.getPendingEdit());
+ }
+
+ @Test
+ public void doubleCancelModelPreservesFirstPreviousModel() {
+ ModelTextDocument doc = createDoc("");
+ String firstModel = doc.getModel();
+ assertNotNull(firstModel);
+
+ doc.setText("changed1");
+ String prevAfterFirst = doc.getPreviousModel();
+ assertEquals(firstModel, prevAfterFirst);
+
+ // Second setText without getModel() in between — model is already null,
+ // so cancelModel() does NOT overwrite previousModel
+ doc.setText("changed2");
+ String prevAfterSecond = doc.getPreviousModel();
+ assertEquals(firstModel, prevAfterSecond);
+ }
+
+ private ModelTextDocument createDoc(String text) {
+ ModelTextDocument doc = new ModelTextDocument<>(text, "test://test.xml",
+ (document, cancelChecker) -> document.getText());
+ doc.setIncremental(true);
+ return doc;
+ }
+
+ private TextDocumentContentChangeEvent createChange(
+ int startLine, int startChar, int endLine, int endChar, String text) {
+ return new TextDocumentContentChangeEvent(
+ new Range(new Position(startLine, startChar), new Position(endLine, endChar)),
+ text);
+ }
+}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeBuilderTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeBuilderTest.java
index 044588118..ee32a0fdf 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeBuilderTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeBuilderTest.java
@@ -353,6 +353,140 @@ public void largeDocumentPerformance() {
"Parse took too long: " + (elapsed / 1_000_000) + "ms");
}
+ @Test
+ public void deeplyNestedElements() {
+ StringBuilder open = new StringBuilder();
+ StringBuilder close = new StringBuilder();
+ for (int i = 0; i < 15; i++) {
+ open.append("");
+ close.insert(0, "");
+ }
+ String xml = open.toString() + "deep" + close.toString();
+ GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null);
+ assertEquals(xml.length(), doc.width());
+ assertChildrenWidthSum(doc);
+
+ GreenNode current = doc.child(0);
+ for (int i = 0; i < 14; i++) {
+ GreenElement elem = assertInstanceOf(GreenElement.class, current);
+ assertEquals("n" + i, elem.tag());
+ assertEquals(1, elem.childCount());
+ current = elem.child(0);
+ }
+ GreenElement innermost = assertInstanceOf(GreenElement.class, current);
+ assertEquals("n14", innermost.tag());
+ assertEquals(1, innermost.childCount());
+ assertInstanceOf(GreenText.class, innermost.child(0));
+ }
+
+ @Test
+ public void mixedContentTextAndElements() {
+ String xml = "
Given an old {@link GreenDocument}, the new text, and edit coordinates,
- * this class identifies top-level children that are entirely before or after
- * the edit (prefix/suffix), reparses only the affected middle range using
- * {@link GreenTreeBuilder#parseRange}, and splices the result together.
+ * this class identifies children that are entirely before or after the edit
+ * (prefix/suffix), and reparses only the affected middle range using
+ * {@link GreenTreeBuilder#parseRange}.
*
- *
Falls back to full reparse when the edit crosses all children or when
- * the reparsed middle ends with an unclosed element (which would invalidate
- * the suffix).
+ *
When exactly one child contains the edit and that child is a
+ * {@link GreenElement} with sub-children, the parser recursively descends
+ * into that element to find sharing at deeper levels. This is critical for
+ * documents with a single root element wrapping many children (the common
+ * XML pattern).
+ *
+ *
Falls back to full reparse when no structural sharing is possible or
+ * when the reparsed middle ends with an unclosed element.
*/
public final class IncrementalParser {
@@ -47,41 +52,45 @@ private IncrementalParser() {
public static GreenDocument incrementalParse(GreenDocument oldDoc,
String newText, int editStart, int deleteLength, int insertLength,
String uri, CancelChecker monitor) {
- GreenDocument result = tryIncremental(oldDoc, newText, editStart,
- deleteLength, insertLength, uri, monitor);
+ int delta = insertLength - deleteLength;
+ int editEnd = editStart + deleteLength;
+
+ GreenNode[] result = tryIncrementalOnChildren(
+ oldDoc.children(), 0, childrenWidth(oldDoc.children()),
+ editStart, editEnd, delta, newText, uri, monitor);
+
if (result != null) {
- return result;
+ return new GreenDocument(newText.length(), result);
}
return GreenTreeBuilder.parse(newText, uri, monitor);
}
- private static GreenDocument tryIncremental(GreenDocument oldDoc,
- String newText, int editStart, int deleteLength, int insertLength,
- String uri, CancelChecker monitor) {
- GreenNode[] oldChildren = oldDoc.children();
+ private static GreenNode[] tryIncrementalOnChildren(
+ GreenNode[] oldChildren, int childrenAbsStart, int childrenAreaWidth,
+ int editStart, int editEnd, int delta,
+ String newText, String uri, CancelChecker monitor) {
+
if (oldChildren.length == 0) {
return null;
}
- int editEnd = editStart + deleteLength;
-
- // Find reusable prefix: children entirely before the edit
+ // Find prefix: children entirely before the edit
int prefixCount = 0;
int prefixWidth = 0;
for (int i = 0; i < oldChildren.length; i++) {
- int childEnd = prefixWidth + oldChildren[i].width();
+ int childEnd = childrenAbsStart + prefixWidth + oldChildren[i].width();
if (childEnd <= editStart) {
prefixCount++;
- prefixWidth = childEnd;
+ prefixWidth += oldChildren[i].width();
} else {
break;
}
}
- // Find reusable suffix: children entirely after the edit in old text
+ // Find suffix: children entirely after the edit in old text
int suffixCount = 0;
int suffixWidth = 0;
- int scanPos = oldDoc.width();
+ int scanPos = childrenAbsStart + childrenAreaWidth;
for (int i = oldChildren.length - 1; i >= prefixCount; i--) {
int childStart = scanPos - oldChildren[i].width();
if (childStart >= editEnd) {
@@ -93,12 +102,32 @@ private static GreenDocument tryIncremental(GreenDocument oldDoc,
}
}
+ // Try descent if exactly one child in the middle
+ int middleCount = oldChildren.length - prefixCount - suffixCount;
+ if (middleCount == 1) {
+ GreenNode middleChild = oldChildren[prefixCount];
+ if (middleChild instanceof GreenElement && middleChild.childCount() > 0) {
+ GreenElement elem = (GreenElement) middleChild;
+ int elemAbsStart = childrenAbsStart + prefixWidth;
+ GreenElement newElem = tryDescentIntoElement(
+ elem, elemAbsStart, editStart, editEnd, delta,
+ newText, uri, monitor);
+ if (newElem != null) {
+ return splice(oldChildren, prefixCount, suffixCount,
+ new GreenNode[] { newElem });
+ }
+ }
+ }
+
+ // Need at least some sharing to justify partial reparse
if (prefixCount + suffixCount == 0) {
return null;
}
- int middleStart = prefixWidth;
- int middleEnd = newText.length() - suffixWidth;
+ // Parse the middle range in new text
+ int middleStart = childrenAbsStart + prefixWidth;
+ int middleEnd = childrenAbsStart + childrenAreaWidth + delta - suffixWidth;
+
if (middleEnd < middleStart || middleEnd > newText.length()) {
return null;
}
@@ -107,25 +136,110 @@ private static GreenDocument tryIncremental(GreenDocument oldDoc,
newText, uri, middleStart, middleEnd, monitor);
GreenNode[] middleChildren = middleDoc.children();
+ // If last reparsed child is unclosed, invalidate suffix
if (suffixCount > 0 && middleChildren.length > 0
&& !middleChildren[middleChildren.length - 1].closed()) {
+ int extendedEnd = childrenAbsStart + childrenAreaWidth + delta;
+ if (extendedEnd > newText.length()) {
+ return null;
+ }
middleDoc = GreenTreeBuilder.parseRange(
- newText, uri, middleStart, newText.length(), monitor);
+ newText, uri, middleStart, extendedEnd, monitor);
middleChildren = middleDoc.children();
suffixCount = 0;
- suffixWidth = 0;
}
- int total = prefixCount + middleChildren.length + suffixCount;
- GreenNode[] newChildren = new GreenNode[total];
- System.arraycopy(oldChildren, 0, newChildren, 0, prefixCount);
- System.arraycopy(middleChildren, 0, newChildren, prefixCount,
- middleChildren.length);
+ return splice(oldChildren, prefixCount, suffixCount, middleChildren);
+ }
+
+ private static GreenElement tryDescentIntoElement(
+ GreenElement elem, int elemAbsStart,
+ int editStart, int editEnd, int delta,
+ String newText, String uri, CancelChecker monitor) {
+
+ int csr = elem.childrenStartRel();
+ int childrenAbsStart = elemAbsStart + csr;
+ int childrenAreaWidth = childrenWidth(elem.children());
+
+ // Only descend if edit is entirely within children area
+ if (editStart < childrenAbsStart
+ || editEnd > childrenAbsStart + childrenAreaWidth) {
+ return null;
+ }
+
+ GreenNode[] newChildren = tryIncrementalOnChildren(
+ elem.children(), childrenAbsStart, childrenAreaWidth,
+ editStart, editEnd, delta, newText, uri, monitor);
+
+ if (newChildren == null) {
+ return null;
+ }
+
+ return elem.withNewChildren(newChildren, delta);
+ }
+
+ private static GreenNode[] splice(GreenNode[] oldChildren,
+ int prefixCount, int suffixCount, GreenNode[] middle) {
+ int total = prefixCount + middle.length + suffixCount;
+ GreenNode[] result = new GreenNode[total];
+ System.arraycopy(oldChildren, 0, result, 0, prefixCount);
+ System.arraycopy(middle, 0, result, prefixCount, middle.length);
if (suffixCount > 0) {
System.arraycopy(oldChildren, oldChildren.length - suffixCount,
- newChildren, prefixCount + middleChildren.length, suffixCount);
+ result, prefixCount + middle.length, suffixCount);
+ }
+ return coalesceAdjacentText(result);
+ }
+
+ private static GreenNode[] coalesceAdjacentText(GreenNode[] nodes) {
+ boolean needed = false;
+ for (int i = 0; i < nodes.length - 1; i++) {
+ if (nodes[i] instanceof GreenText && nodes[i + 1] instanceof GreenText) {
+ needed = true;
+ break;
+ }
+ }
+ if (!needed) {
+ return nodes;
+ }
+ int newLen = 0;
+ for (int i = 0; i < nodes.length;) {
+ newLen++;
+ if (nodes[i] instanceof GreenText) {
+ while (++i < nodes.length && nodes[i] instanceof GreenText) {
+ }
+ } else {
+ i++;
+ }
}
+ GreenNode[] result = new GreenNode[newLen];
+ int j = 0;
+ for (int i = 0; i < nodes.length;) {
+ if (nodes[i] instanceof GreenText) {
+ int w = 0;
+ boolean allWhitespace = true;
+ int start = i;
+ while (i < nodes.length && nodes[i] instanceof GreenText) {
+ w += nodes[i].width();
+ if (!((GreenText) nodes[i]).whitespace()) {
+ allWhitespace = false;
+ }
+ i++;
+ }
+ result[j++] = (i - start == 1) ? nodes[start]
+ : new GreenText(w, allWhitespace);
+ } else {
+ result[j++] = nodes[i++];
+ }
+ }
+ return result;
+ }
- return new GreenDocument(newText.length(), newChildren);
+ private static int childrenWidth(GreenNode[] children) {
+ int w = 0;
+ for (GreenNode child : children) {
+ w += child.width();
+ }
+ return w;
}
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java
index 62e097bf5..92bbc4f19 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java
@@ -674,6 +674,140 @@ public void editPrologContent() {
newDoc, "");
}
+ @Test
+ public void descentIntoRootElement() {
+ String oldText = "";
+ String newText = "";
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenElement oldRoot = (GreenElement) oldDoc.child(0);
+ GreenNode oldA = oldRoot.child(0);
+ GreenNode oldC = oldRoot.child(2);
+
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, 10, 4, 5, "test.xml", null);
+
+ assertEquals(newText.length(), newDoc.width());
+ GreenElement newRoot = (GreenElement) newDoc.child(0);
+ assertSame(oldA, newRoot.child(0), "Child before edit should be reused");
+ assertSame(oldC, newRoot.child(2), "Child after edit should be reused");
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
+ @Test
+ public void descentDeepNested() {
+ String oldText = "";
+ String newText = "";
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenElement oldRoot = (GreenElement) oldDoc.child(0);
+ GreenElement oldOuter = (GreenElement) oldRoot.child(0);
+ GreenNode oldA = oldOuter.child(0);
+ GreenNode oldC = oldOuter.child(2);
+
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, 17, 4, 5, "test.xml", null);
+
+ assertEquals(newText.length(), newDoc.width());
+ GreenElement newRoot = (GreenElement) newDoc.child(0);
+ GreenElement newOuter = (GreenElement) newRoot.child(0);
+ assertSame(oldA, newOuter.child(0), "Nested should be reused");
+ assertSame(oldC, newOuter.child(2), "Nested should be reused");
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
+ @Test
+ public void descentWithManyChildren() {
+ StringBuilder sb = new StringBuilder("");
+ for (int i = 0; i < 100; i++) {
+ sb.append("");
+ }
+ sb.append("");
+ String oldText = sb.toString();
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenElement oldRoot = (GreenElement) oldDoc.child(0);
+
+ int editOffset = oldRoot.childrenStartRel();
+ for (int i = 0; i < 50; i++) {
+ editOffset += oldRoot.child(i).width();
+ }
+ int oldChildWidth = oldRoot.child(50).width();
+ String replacement = "";
+
+ String newText = oldText.substring(0, editOffset) + replacement
+ + oldText.substring(editOffset + oldChildWidth);
+
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, editOffset, oldChildWidth, replacement.length(),
+ "test.xml", null);
+
+ assertEquals(newText.length(), newDoc.width());
+ GreenElement newRoot = (GreenElement) newDoc.child(0);
+ for (int i = 0; i < 50; i++) {
+ assertSame(oldRoot.child(i), newRoot.child(i),
+ "Prefix child " + i + " inside root should be reused");
+ }
+ for (int i = 51; i < 100; i++) {
+ assertSame(oldRoot.child(i), newRoot.child(i),
+ "Suffix child " + i + " inside root should be reused");
+ }
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
+ @Test
+ public void descentFallsBackOnAttributeEdit() {
+ String oldText = "";
+ String newText = "";
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, 13, 3, 3, "test.xml", null);
+ assertEquals(newText.length(), newDoc.width());
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
+ @Test
+ public void descentTextCoalescing() {
+ String oldText = "hello";
+ String newText = "helloX";
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, 11, 0, 1, "test.xml", null);
+ assertEquals(newText.length(), newDoc.width());
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
+ @Test
+ public void descentWithPrologAndDoctype() {
+ String oldText = "";
+ String newText = "";
+ GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null);
+ GreenNode oldProlog = oldDoc.child(0);
+ GreenNode oldDoctype = oldDoc.child(1);
+
+ GreenDocument newDoc = IncrementalParser.incrementalParse(
+ oldDoc, newText, 46, 4, 5, "test.xml", null);
+
+ assertEquals(newText.length(), newDoc.width());
+ assertSame(oldProlog, newDoc.child(0), "Prolog should be reused");
+ assertSame(oldDoctype, newDoc.child(1), "Doctype should be reused");
+
+ GreenElement oldRoot = (GreenElement) oldDoc.child(2);
+ GreenElement newRoot = (GreenElement) newDoc.child(2);
+ assertSame(oldRoot.child(0), newRoot.child(0), " inside root should be reused");
+ assertSame(oldRoot.child(2), newRoot.child(2), " inside root should be reused");
+ assertGreenTreesEqual(
+ GreenTreeBuilder.parse(newText, "test.xml", null),
+ newDoc, "");
+ }
+
@Test
public void middleEndLessThanMiddleStartFallsBack() {
// Large deletion: remove middle 2 nodes from 3, leaving prefix+suffix
From 54c28fc36949e989f6656de4985a215988b8575e Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 08:16:44 +0200
Subject: [PATCH 04/18] Add detailed documentation for incremental red-green
parser
Covers the full architecture: green tree (immutable, width-based nodes),
red tree (mutable DOMNode facade), GreenTreeBuilder, RedTreeBuilder,
incremental parser algorithm (prefix/suffix detection, recursive
descent,
text coalescing), LSP integration (ModelTextDocument, volatile fields,
edit flow), performance characteristics, and limitations.
Co-Authored-By: Claude Opus 4.6
---
docs/Incremental-Red-Green-Parser.md | 494 +++++++++++++++++++++++++++
1 file changed, 494 insertions(+)
create mode 100644 docs/Incremental-Red-Green-Parser.md
diff --git a/docs/Incremental-Red-Green-Parser.md b/docs/Incremental-Red-Green-Parser.md
new file mode 100644
index 000000000..e9fc89c87
--- /dev/null
+++ b/docs/Incremental-Red-Green-Parser.md
@@ -0,0 +1,494 @@
+# Incremental Red-Green Parser
+
+LemMinX uses a **Roslyn-style red-green tree** architecture for its XML DOM parser. This design enables **incremental reparsing**: when a user edits a document, only the affected region is reparsed, while unchanged subtrees are reused from the previous parse. This makes editing large XML files (100k+ lines) feel instantaneous.
+
+## Table of Contents
+
+- [Architecture Overview](#architecture-overview)
+- [Green Tree (Immutable Layer)](#green-tree-immutable-layer)
+- [Red Tree (Mutable Facade)](#red-tree-mutable-facade)
+- [GreenTreeBuilder](#greentreebuilder)
+- [RedTreeBuilder](#redtreebuilder)
+- [Incremental Parsing](#incremental-parsing)
+ - [Algorithm Overview](#algorithm-overview)
+ - [Prefix/Suffix Detection](#prefixsuffix-detection)
+ - [Recursive Descent](#recursive-descent)
+ - [Text Coalescing](#text-coalescing)
+ - [Fallback to Full Parse](#fallback-to-full-parse)
+- [Integration with LSP](#integration-with-lsp)
+ - [ModelTextDocument](#modeltextdocument)
+ - [Atomic Swap (volatile)](#atomic-swap-volatile)
+ - [Parse Lambda](#parse-lambda)
+- [Performance Characteristics](#performance-characteristics)
+- [Limitations](#limitations)
+- [Node Types](#node-types)
+
+---
+
+## Architecture Overview
+
+The parser pipeline has three stages:
+
+```
+ XML text
+ |
+ v
+ GreenTreeBuilder ──► GreenDocument (immutable, width-based)
+ |
+ v
+ RedTreeBuilder ──► DOMDocument (mutable, absolute offsets, parent pointers)
+ |
+ v
+ LSP features (completion, validation, hover, ...)
+```
+
+On subsequent edits, the `IncrementalParser` replaces the first stage:
+
+```
+ Old GreenDocument + edit info + new text
+ |
+ v
+ IncrementalParser ──► New GreenDocument (shares unchanged subtrees)
+ |
+ v
+ RedTreeBuilder ──► New DOMDocument
+```
+
+The key insight from the [Roslyn compiler](https://github.com/dotnet/roslyn) is to split the tree into two layers:
+
+| Layer | Offsets | Parent pointers | Mutability | Sharing |
+|-------|---------|-----------------|------------|---------|
+| **Green** (syntax) | Relative (widths) | No | Immutable | Yes - across versions |
+| **Red** (semantic) | Absolute | Yes | Mutable | No - rebuilt each time |
+
+Because green nodes store **widths** (relative offsets) rather than absolute positions, two structurally identical subtrees at different document positions use the exact same green node objects.
+
+---
+
+## Green Tree (Immutable Layer)
+
+All green nodes extend `GreenNode`:
+
+```
+GreenNode (abstract)
+├── width: int — total span in characters
+├── closed: boolean — whether this node was properly closed
+├── children(): GreenNode[]
+├── childrenStartRel(): int — offset from node start where children begin
+└── nodeType(): short
+```
+
+### Green Node Types
+
+| Class | DOM Node Type | Description |
+|-------|--------------|-------------|
+| `GreenDocument` | `DOCUMENT_NODE` | Root, width = text length |
+| `GreenElement` | `ELEMENT_NODE` | Tag, attributes, children |
+| `GreenText` | `TEXT_NODE` | Text content, tracks `whitespace` flag |
+| `GreenComment` | `COMMENT_NODE` | `` |
+| `GreenCDATA` | `CDATA_SECTION_NODE` | `` |
+| `GreenProcessingInstruction` | `PROCESSING_INSTRUCTION_NODE` | ``, prolog |
+| `GreenDocumentType` | `DOCUMENT_TYPE_NODE` | `` |
+| `GreenDTDElementDecl` | `DTD_ELEMENT_DECL_NODE` | `` |
+| `GreenDTDAttlistDecl` | `DTD_ATT_LIST_NODE` | `` |
+| `GreenDTDEntityDecl` | `ENTITY_NODE` | `` |
+| `GreenDTDNotationDecl` | `DTD_NOTATION_DECL` | `` |
+
+### GreenElement Fields
+
+All offsets are **relative to the element's own start** (offset 0 is the first `<`):
+
+```java
+tag: String // "div", "root", etc.
+selfClosed: boolean // true for
+startTagCloseRel: int // offset of '>' in start tag
+endTagOpenRel: int // offset of '<' in
+endTagCloseRel: int // offset of '>' in
+contentStartRel: int // offset where first child content starts
+attributes: GreenAttr[]
+children: GreenNode[]
+```
+
+Example for `` (width = 21):
+```
+startTagCloseRel = 5 (the '>' after "root")
+endTagOpenRel = 14 (the '<' in "")
+endTagCloseRel = 20 (the '>' in "")
+contentStartRel = 6 (start of first child)
+children = [GreenElement("a", w=4), GreenElement("b", w=4)]
+```
+
+### GreenElement.withNewChildren()
+
+Creates a new element node with different children, adjusting end-tag offsets by the width delta:
+
+```java
+public GreenElement withNewChildren(GreenNode[] newChildren, int widthDelta) {
+ return new GreenElement(
+ width() + widthDelta, closed(), tag, selfClosed,
+ startTagCloseRel,
+ endTagOpenRel != NULL_VALUE ? endTagOpenRel + widthDelta : NULL_VALUE,
+ endTagCloseRel != NULL_VALUE ? endTagCloseRel + widthDelta : NULL_VALUE,
+ contentStartRel, attributes, newChildren);
+}
+```
+
+The start tag is unchanged (same `startTagCloseRel`, same `attributes`), but the end tag shifts by `delta` because the content area grew or shrank.
+
+### GreenAttr
+
+Each attribute stores name/value offsets relative to its owning element:
+
+```java
+nameStartRel, nameEndRel // "class" in class="foo"
+delimiterRel // the '='
+valueStartRel, valueEndRel // "\"foo\"" (including quotes)
+```
+
+---
+
+## Red Tree (Mutable Facade)
+
+The **red tree** is the existing `DOMDocument` / `DOMNode` / `DOMElement` hierarchy that all LemMinX features depend on. It provides:
+
+- **Absolute offsets** (`getStart()`, `getEnd()`)
+- **Parent pointers** (`getParentNode()`)
+- **W3C DOM API** (`getChildNodes()`, `getAttributes()`, etc.)
+
+The red tree is built fresh from the green tree on every parse, but this is fast because `RedTreeBuilder` only walks the tree once, computing absolute offsets on the fly.
+
+---
+
+## GreenTreeBuilder
+
+`GreenTreeBuilder.parse(text, uri, monitor)` produces a `GreenDocument` from XML text.
+
+It uses the existing `XMLScanner` (char-by-char, no regex, no substring) and mirrors the token-handling logic of the original `DOMParser`, but builds `GreenNode` objects instead of `DOMNode` objects.
+
+### parseRange()
+
+```java
+GreenTreeBuilder.parseRange(text, uri, rangeStart, rangeEnd, monitor)
+```
+
+Parses a **sub-range** of the document text. The scanner starts at `rangeStart` and stops when it reaches `rangeEnd` with an empty stack. This is the building block for incremental parsing: only the affected range is re-scanned.
+
+### NodeBuilder
+
+Internally, `GreenTreeBuilder` uses `NodeBuilder` — a mutable scratch object that accumulates scanner tokens and converts them into an immutable `GreenNode` via `buildGreen()`. Each `NodeBuilder` tracks:
+
+- Tag name, attributes, children
+- Absolute offsets (converted to relative during `buildGreen()`)
+- DTD-specific fields (element decls, attlists, entities, notations)
+
+---
+
+## RedTreeBuilder
+
+`RedTreeBuilder.build(greenDoc, textDocument, resolverExtensionManager)` walks the green tree recursively, creating the corresponding red (`DOMNode`) tree.
+
+For each green node, it:
+1. Computes absolute offsets: `absStart + relativeOffset`
+2. Creates the corresponding `DOMNode` subclass
+3. Copies fields (tag, attributes, content offsets)
+4. Recursively processes children, advancing `childAbsStart` by each child's width
+
+Whitespace-only text nodes between elements are skipped when `ignoreWhitespaceContent` is true (the default), matching the original parser's behavior.
+
+---
+
+## Incremental Parsing
+
+The `IncrementalParser` is the heart of the performance optimization. Given an old `GreenDocument`, the new text, and edit coordinates, it produces a new `GreenDocument` that structurally shares unchanged subtrees with the old one.
+
+### Algorithm Overview
+
+```
+incrementalParse(oldDoc, newText, editStart, deleteLength, insertLength)
+ │
+ ├─ tryIncrementalOnChildren(oldDoc.children, ...)
+ │ │
+ │ ├─ Find prefix: children entirely before the edit
+ │ ├─ Find suffix: children entirely after the edit
+ │ │
+ │ ├─ If exactly 1 middle child is a GreenElement with children:
+ │ │ └─ tryDescentIntoElement() → recursive descent
+ │ │
+ │ ├─ If no sharing possible (prefix + suffix == 0):
+ │ │ └─ return null (fallback to full parse)
+ │ │
+ │ └─ parseRange the middle region
+ │ ├─ If last reparsed child is unclosed: extend to include suffix
+ │ └─ splice(prefix + middle + suffix) → coalesceAdjacentText()
+ │
+ └─ If result is null: GreenTreeBuilder.parse() (full reparse)
+```
+
+### Prefix/Suffix Detection
+
+The algorithm scans children left-to-right to find the **prefix** — consecutive children whose end offset falls before the edit start — and right-to-left for the **suffix** — children whose start offset falls after the edit end.
+
+```
+Document children: [PI] [Text] [Element-A] [Element-B] [Element-C]
+ ^^^ ^^^ ^^^^^^^^^
+ prefix (2) suffix (1)
+ ^^^^^^^^^^^^ ^^^^^^^^^^^^
+ middle (2) — must be reparsed
+```
+
+Prefix and suffix children are **reused as-is** from the old tree. Only the middle region is reparsed via `GreenTreeBuilder.parseRange()`.
+
+### Recursive Descent
+
+When exactly **one child** contains the edit and that child is a `GreenElement` with sub-children, the parser **descends into it** rather than reparsing the entire element.
+
+This is critical for the common XML pattern where a single root element wraps thousands of children:
+
+```xml
+ ← root element (1 child of document)
+ ...
+ ... ← edit is here
+ ...
+ ...
+ ...
+
+```
+
+Without recursive descent, the entire `` element (and all 24,863 products) would be reparsed. With descent, the algorithm enters ``, finds 24,862 products as prefix/suffix, and reparses only the one affected product.
+
+The descent algorithm in `tryDescentIntoElement()`:
+
+1. Check that the edit is entirely within the element's **children area** (not in the start tag or end tag). If the edit touches an attribute or tag name, descent is not safe.
+2. Recursively call `tryIncrementalOnChildren()` on the element's children.
+3. If successful, return `elem.withNewChildren(newChildren, delta)` — a new `GreenElement` with the same tag, attributes, and start tag, but different children and adjusted end-tag offsets.
+
+Descent can be **multi-level**: if the element's single middle child is itself an element with children, the algorithm descends again. This handles arbitrarily deep nesting.
+
+### Text Coalescing
+
+After splicing prefix + reparsed middle + suffix, adjacent `GreenText` nodes may appear at the boundaries. For example:
+
+```
+Old children: [...] [Text "hello world"] [...]
+ ^^^^^
+ edit here
+After splice: [Text "hello "] [Text "new"] [Text " world"]
+ ^^ prefix text ^^ middle ^^ suffix text
+```
+
+A full parse would produce a single `GreenText` for the combined content. To ensure the incremental result matches the full-parse structure, `coalesceAdjacentText()` merges adjacent `GreenText` nodes:
+
+```java
+[Text "hello "] [Text "new"] [Text " world"] → [Text "hello new world"]
+```
+
+The merged node's `whitespace` flag is `true` only if **all** merged nodes were whitespace.
+
+### Fallback to Full Parse
+
+The incremental parser falls back to a full reparse when:
+
+- **No structural sharing is possible**: `prefixCount + suffixCount == 0` at the document level
+- **The reparsed middle ends with an unclosed element**: this invalidates the suffix (the unclosed element would absorb suffix nodes). The parser first tries extending the reparse range to include the suffix. If that also fails, it falls back to full parse.
+- **The edit touches a tag name or attribute** of the single middle child: descent guard prevents corruption.
+
+---
+
+## Integration with LSP
+
+### ModelTextDocument
+
+`ModelTextDocument` extends `TextDocument` with three `volatile` fields for thread-safe incremental support:
+
+```java
+volatile DOMDocument model; // current parsed model
+volatile DOMDocument previousModel; // model before the last edit
+volatile EditInfo pendingEdit; // edit coordinates
+```
+
+The `update()` method is overridden to capture `EditInfo` **before** the text is changed (because offsets refer to the old text):
+
+```java
+@Override
+public void update(List changes) {
+ // Capture edit info BEFORE super.update() changes the text
+ if (changes != null && changes.size() == 1) {
+ Range range = changes.get(0).getRange();
+ if (range != null) {
+ int start = offsetAt(range.getStart());
+ pendingEdit = new EditInfo(start, deleteLength, insertLength);
+ }
+ } else {
+ pendingEdit = null; // multiple changes → can't incrementally parse
+ }
+ super.update(changes); // applies the text change
+}
+```
+
+### Atomic Swap (volatile)
+
+The `volatile` keyword on `model`, `previousModel`, and `pendingEdit` ensures that:
+
+- The parsing thread sees the latest `previousModel` and `pendingEdit` set by the LSP event thread
+- The LSP event thread sees the latest `model` set by the parsing thread
+
+The `cancelModel()` method atomically transitions the model state:
+
+```java
+private void cancelModel() {
+ if (model != null) {
+ previousModel = model; // save current model for incremental use
+ }
+ model = null; // mark as dirty
+}
+```
+
+The `DOMDocument` also stores its green tree via `volatile GreenDocument greenDocument`, so the incremental parser can access the previous green tree.
+
+### Parse Lambda
+
+In `XMLTextDocumentService`, the parse lambda checks for the incremental path:
+
+```java
+DOMDocument prev = mtd.getPreviousModel();
+ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit();
+if (prev != null && prev.getGreenDocument() != null && editInfo != null) {
+ return parser.parseIncremental(document,
+ prev.getGreenDocument(),
+ editInfo.getStartOffset(),
+ editInfo.getDeleteLength(),
+ editInfo.getInsertLength(),
+ resolverExtensionManager,
+ true /* ignoreWhitespaceContent */, cancelChecker);
+}
+// fallback: full parse
+return parser.parse(document, resolverExtensionManager, true, cancelChecker);
+```
+
+The incremental path is taken when all three conditions are met:
+1. A previous model exists (the document was parsed before)
+2. The previous model has a green tree
+3. A single-change edit info is available
+
+If any condition is missing (first parse, multiple simultaneous changes, full-document replacement), the standard full-parse path is taken.
+
+### Edit Flow
+
+The complete sequence for a `textDocument/didChange` notification:
+
+```
+1. setVersion(newVersion) → cancelModel() → previousModel = model; model = null
+2. update(changes) → captures EditInfo before super.update()
+ → super.update() modifies the text
+3. (later) getModel() → model is null → getSynchronizedModel()
+4. getSynchronizedModel() → parse lambda runs
+ a. Checks previousModel, pendingEdit, greenDocument
+ b. If available: IncrementalParser.incrementalParse()
+ c. Else: GreenTreeBuilder.parse() (full)
+ d. RedTreeBuilder.build() (in both cases)
+ e. model = result; previousModel = null; pendingEdit = null
+```
+
+---
+
+## Performance Characteristics
+
+### Time Complexity
+
+| Operation | Full Parse | Incremental Parse |
+|-----------|-----------|-------------------|
+| GreenTreeBuilder | O(n) | O(m) where m = middle range size |
+| RedTreeBuilder | O(n) | O(n) — always walks full tree |
+| IncrementalParser | — | O(k) where k = children count at each level |
+
+For a 600,000-line file with a single-character edit in one element:
+- **Full parse**: scans all 600,000 lines
+- **Incremental parse with descent**: scans only the ~25 lines of the affected element, plus O(log depth) work for descent
+
+### Memory
+
+Green nodes are shared between versions via structural sharing. For a single-character edit in a 24,863-element document, only the modified element and its ancestors are new allocations — the other 24,862 elements are the exact same Java objects.
+
+### Scanner Performance
+
+The `XMLScanner` and `GreenTreeBuilder` use only `charAt()` for character-by-character scanning. No `substring()`, no regex, no string allocation in the hot path. This is critical for handling multi-MB files without GC pressure.
+
+---
+
+## Limitations
+
+1. **The red tree is always fully rebuilt.** `RedTreeBuilder` walks the entire green tree on every parse. For very large files, this is the bottleneck rather than the scanner. A future optimization could cache and reuse red subtrees that map to unchanged green subtrees.
+
+2. **Multi-change edits bypass incremental parsing.** If `textDocument/didChange` contains more than one `TextDocumentContentChangeEvent`, the incremental path is skipped (full reparse). This is uncommon in practice — most editors send single-change events.
+
+3. **Full-document replacements bypass incremental parsing.** A `TextDocumentContentChangeEvent` without a `Range` replaces the entire document. This cannot be incrementally parsed.
+
+4. **Edits that touch element structure fall back to broader reparsing.** If an edit changes a tag name or removes a closing tag, the incremental parser correctly detects that the reparsed middle is unclosed and extends the reparse range or falls back entirely.
+
+5. **DTD internal subsets are not incrementally parsed.** The `GreenDocumentType` node is treated as atomic — any edit within a `` block reparses the entire doctype.
+
+---
+
+## Node Types
+
+### Class Diagram
+
+```
+GreenNode (abstract)
+├── GreenDocument
+├── GreenElement
+├── GreenText
+├── GreenComment
+├── GreenCDATA
+├── GreenProcessingInstruction
+├── GreenDocumentType
+│ └── (DTD nodes as children)
+├── GreenDTDDeclNode
+│ ├── GreenDTDElementDecl
+│ ├── GreenDTDAttlistDecl
+│ ├── GreenDTDEntityDecl
+│ └── GreenDTDNotationDecl
+├── GreenAttr (not a GreenNode — stored in GreenElement.attributes[])
+└── GreenDTDParam (not a GreenNode — stores DTD parameter offsets)
+```
+
+### File Locations
+
+```
+org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/
+├── dom/green/
+│ ├── GreenNode.java — abstract base, width/closed/children
+│ ├── GreenDocument.java — document root
+│ ├── GreenElement.java — XML element with tag, attrs, children
+│ ├── GreenText.java — text content
+│ ├── GreenComment.java — comment
+│ ├── GreenCDATA.java — CDATA section
+│ ├── GreenProcessingInstruction.java — PI and prolog
+│ ├── GreenDocumentType.java — DOCTYPE
+│ ├── GreenDTDDeclNode.java — base for DTD declarations
+│ ├── GreenDTDElementDecl.java —
+│ ├── GreenDTDAttlistDecl.java —
+│ ├── GreenDTDEntityDecl.java —
+│ ├── GreenDTDNotationDecl.java —
+│ ├── GreenAttr.java — attribute (name/value offsets)
+│ ├── GreenDTDParam.java — DTD parameter offsets
+│ ├── GreenTreeBuilder.java — scanner → green tree
+│ └── IncrementalParser.java — old green + edit → new green
+├── dom/
+│ ├── DOMParser.java — entry point (parse + parseIncremental)
+│ └── RedTreeBuilder.java — green tree → DOMDocument
+└── commons/
+ └── ModelTextDocument.java — volatile model + EditInfo capture
+```
+
+### Test Locations
+
+```
+org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/
+├── dom/green/
+│ ├── GreenTreeBuilderTest.java — 37 tests (green tree correctness)
+│ ├── RedTreeBuilderTest.java — 77 tests (green→red equivalence)
+│ └── IncrementalParserTest.java — 59 tests (incremental correctness)
+└── commons/
+ └── ModelTextDocumentTest.java — 6 tests (EditInfo, volatile state)
+```
From aa9bb76b2774d2eb35d8983fb9b5647b0e83f187 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 08:51:22 +0200
Subject: [PATCH 05/18] Reduce memory by extracting only GreenDocument from
previous model
Replace previousModel (full DOMDocument with entire red tree) with
previousIncrementalData that stores only the extracted GreenDocument.
The old red tree is now eligible for GC immediately on cancelModel()
instead of being held during the entire incremental parse.
For a 21 MB file, this frees ~20 MB of heap earlier. The
incrementalDataExtractor function is passed through ModelTextDocuments
to ModelTextDocument, keeping the generic API clean.
Co-Authored-By: Claude Opus 4.6
---
.../lemminx/XMLTextDocumentService.java | 9 +--
.../lemminx/commons/ModelTextDocument.java | 32 +++++++---
.../lemminx/commons/ModelTextDocuments.java | 10 +++-
.../commons/ModelTextDocumentTest.java | 60 ++++++++++++++++---
4 files changed, 91 insertions(+), 20 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
index b427b7584..957ffd10d 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
@@ -38,6 +38,7 @@
import org.eclipse.lemminx.commons.TextDocument;
import org.eclipse.lemminx.dom.DOMDocument;
import org.eclipse.lemminx.dom.DOMParser;
+import org.eclipse.lemminx.dom.green.GreenDocument;
import org.eclipse.lemminx.extensions.contentmodel.settings.XMLValidationRootSettings;
import org.eclipse.lemminx.services.DocumentSymbolsResult;
import org.eclipse.lemminx.services.SymbolInformationResult;
@@ -205,11 +206,11 @@ public XMLTextDocumentService(XMLLanguageServer xmlLanguageServer) {
if (document instanceof ModelTextDocument) {
@SuppressWarnings("unchecked")
ModelTextDocument mtd = (ModelTextDocument) document;
- DOMDocument prev = mtd.getPreviousModel();
+ Object prevData = mtd.getPreviousIncrementalData();
ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit();
- if (prev != null && prev.getGreenDocument() != null && editInfo != null) {
+ if (prevData instanceof GreenDocument && editInfo != null) {
return parser.parseIncremental(document,
- prev.getGreenDocument(),
+ (GreenDocument) prevData,
editInfo.getStartOffset(),
editInfo.getDeleteLength(),
editInfo.getInsertLength(),
@@ -218,7 +219,7 @@ public XMLTextDocumentService(XMLLanguageServer xmlLanguageServer) {
}
}
return parser.parse(document, getXMLLanguageService().getResolverExtensionManager(), true, cancelChecker);
- });
+ }, DOMDocument::getGreenDocument);
this.sharedSettings = new SharedSettings();
this.limitExceededWarner = null;
this.xmlValidatorDelayer = new ModelValidatorDelayer((document) -> {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
index 08c703df4..759663977 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
@@ -14,6 +14,7 @@
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.function.BiFunction;
+import java.util.function.Function;
import java.util.logging.Logger;
import org.eclipse.lsp4j.Range;
@@ -34,20 +35,34 @@ public class ModelTextDocument extends TextDocument {
private final BiFunction parse;
+ private final Function incrementalDataExtractor;
+
private volatile T model;
- private volatile T previousModel;
+ private volatile Object previousIncrementalData;
private volatile EditInfo pendingEdit;
public ModelTextDocument(TextDocumentItem document, BiFunction parse) {
+ this(document, parse, null);
+ }
+
+ public ModelTextDocument(TextDocumentItem document, BiFunction parse,
+ Function incrementalDataExtractor) {
super(document);
this.parse = parse;
+ this.incrementalDataExtractor = incrementalDataExtractor;
}
public ModelTextDocument(String text, String uri, BiFunction parse) {
+ this(text, uri, parse, null);
+ }
+
+ public ModelTextDocument(String text, String uri, BiFunction parse,
+ Function incrementalDataExtractor) {
super(text, uri);
this.parse = parse;
+ this.incrementalDataExtractor = incrementalDataExtractor;
}
/**
@@ -98,7 +113,7 @@ private synchronized T getSynchronizedModel() {
+ (System.currentTimeMillis() - start) + "ms");
throw e;
} finally {
- previousModel = null;
+ previousIncrementalData = null;
pendingEdit = null;
LOGGER.fine("End parse of model with version '" + version + "' in " + (System.currentTimeMillis() - start)
+ "ms");
@@ -148,18 +163,21 @@ public void update(List changes) {
*/
private void cancelModel() {
if (model != null) {
- previousModel = model;
+ if (incrementalDataExtractor != null) {
+ previousIncrementalData = incrementalDataExtractor.apply(model);
+ }
}
model = null;
}
/**
- * Returns the previous model (before the last edit) and null if not available.
+ * Returns the data extracted from the previous model for incremental parsing,
+ * or null if not available.
*
- * @return the previous model or null
+ * @return the incremental data or null
*/
- public T getPreviousModel() {
- return previousModel;
+ public Object getPreviousIncrementalData() {
+ return previousIncrementalData;
}
/**
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocuments.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocuments.java
index d3431fc58..cb2736af1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocuments.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocuments.java
@@ -32,13 +32,21 @@ public class ModelTextDocuments extends TextDocuments> {
private final BiFunction parse;
+ private final Function incrementalDataExtractor;
+
public ModelTextDocuments(BiFunction parse) {
+ this(parse, null);
+ }
+
+ public ModelTextDocuments(BiFunction parse,
+ Function incrementalDataExtractor) {
this.parse = parse;
+ this.incrementalDataExtractor = incrementalDataExtractor;
}
@Override
public ModelTextDocument createDocument(TextDocumentItem document) {
- ModelTextDocument doc = new ModelTextDocument(document, parse);
+ ModelTextDocument doc = new ModelTextDocument(document, parse, incrementalDataExtractor);
doc.setIncremental(isIncremental());
return doc;
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
index 521c72479..1873dc833 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java
@@ -45,14 +45,14 @@ public void editInfoCapturedBeforeTextUpdate() {
}
@Test
- public void previousModelPreservedOnCancel() {
+ public void previousIncrementalDataPreservedOnCancel() {
ModelTextDocument doc = createDoc("");
String firstModel = doc.getModel();
assertNotNull(firstModel);
doc.setText("changed");
- String prev = doc.getPreviousModel();
+ Object prev = doc.getPreviousIncrementalData();
assertNotNull(prev);
assertEquals(firstModel, prev);
}
@@ -70,7 +70,7 @@ public void pendingEditClearedAfterGetModel() {
doc.getModel();
assertNull(doc.getPendingEdit());
- assertNull(doc.getPreviousModel());
+ assertNull(doc.getPreviousIncrementalData());
}
@Test
@@ -97,25 +97,69 @@ public void fullDocumentChangeNullifyPendingEdit() {
}
@Test
- public void doubleCancelModelPreservesFirstPreviousModel() {
+ public void doubleCancelModelPreservesFirstIncrementalData() {
ModelTextDocument doc = createDoc("");
String firstModel = doc.getModel();
assertNotNull(firstModel);
doc.setText("changed1");
- String prevAfterFirst = doc.getPreviousModel();
+ Object prevAfterFirst = doc.getPreviousIncrementalData();
assertEquals(firstModel, prevAfterFirst);
// Second setText without getModel() in between — model is already null,
- // so cancelModel() does NOT overwrite previousModel
+ // so cancelModel() does NOT overwrite previousIncrementalData
doc.setText("changed2");
- String prevAfterSecond = doc.getPreviousModel();
+ Object prevAfterSecond = doc.getPreviousIncrementalData();
assertEquals(firstModel, prevAfterSecond);
}
+ @Test
+ public void noExtractorMeansNoPreviousIncrementalData() {
+ ModelTextDocument doc = new ModelTextDocument<>("", "test://test.xml",
+ (document, cancelChecker) -> document.getText());
+ doc.setIncremental(true);
+ String firstModel = doc.getModel();
+ assertNotNull(firstModel);
+
+ doc.setText("changed");
+
+ assertNull(doc.getPreviousIncrementalData());
+ }
+
+ @Test
+ public void customExtractorStoresTransformedData() {
+ ModelTextDocument doc = new ModelTextDocument<>("", "test://test.xml",
+ (document, cancelChecker) -> document.getText(),
+ s -> s.length());
+ doc.setIncremental(true);
+ String firstModel = doc.getModel();
+ assertNotNull(firstModel);
+
+ doc.setText("changed");
+
+ Object prev = doc.getPreviousIncrementalData();
+ assertNotNull(prev);
+ assertEquals(firstModel.length(), prev);
+ }
+
+ @Test
+ public void doubleCancelWithoutExtractorRemainsNull() {
+ ModelTextDocument doc = new ModelTextDocument<>("", "test://test.xml",
+ (document, cancelChecker) -> document.getText());
+ doc.setIncremental(true);
+ doc.getModel();
+
+ doc.setText("changed1");
+ assertNull(doc.getPreviousIncrementalData());
+
+ doc.setText("changed2");
+ assertNull(doc.getPreviousIncrementalData());
+ }
+
private ModelTextDocument createDoc(String text) {
ModelTextDocument doc = new ModelTextDocument<>(text, "test://test.xml",
- (document, cancelChecker) -> document.getText());
+ (document, cancelChecker) -> document.getText(),
+ s -> s);
doc.setIncremental(true);
return doc;
}
From 745a573378d3a20eaf94dcdc4986850196ff0765 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 14:05:08 +0200
Subject: [PATCH 06/18] Add lazy red tree building for incremental parses
When parsing incrementally, defer DOMNode children creation until
accessed via getChildren(), getFirstChild(), etc. Each element stores
its GreenNode and absolute offset, expanding only when a feature
navigates into it. This avoids creating all 50K+ red nodes upfront
when only a small path is accessed (e.g., hover at cursor position).
Co-Authored-By: Claude Opus 4.6
---
.../java/org/eclipse/lemminx/dom/DOMNode.java | 26 ++++++-
.../org/eclipse/lemminx/dom/DOMParser.java | 5 +-
.../eclipse/lemminx/dom/RedTreeBuilder.java | 40 +++++++---
.../lemminx/dom/green/RedTreeBuilderTest.java | 74 +++++++++++++++++++
4 files changed, 132 insertions(+), 13 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
index 4442b95de..eb922dd80 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
@@ -18,6 +18,7 @@
import java.util.Objects;
import java.util.function.Function;
+import org.eclipse.lemminx.dom.green.GreenNode;
import org.w3c.dom.DOMException;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
@@ -73,11 +74,14 @@ public abstract class DOMNode implements Node, DOMRange {
int end; // |
DOMNode parent;
-
+
// Cache the index in parent's children list to avoid O(n) indexOf() calls
// This is set to -1 when not cached, and updated when needed
int cachedIndexInParent = -1;
+ GreenNode lazyGreenNode;
+ int lazyAbsStart;
+
private static final NodeList EMPTY_CHILDREN = new NodeList() {
@Override
@@ -236,6 +240,7 @@ private String toString(int indent) {
result.append(getNodeName());
result.append(", closed: ");
result.append(isClosed());
+ ensureChildren();
if (children != null && children.size() > 0) {
result.append(", \n");
for (int i = 0; i < indent + 1; i++) {
@@ -520,12 +525,22 @@ public List getChildrenWithAttributeValue(String name, String value) {
return result;
}
+ private void ensureChildren() {
+ if (lazyGreenNode != null) {
+ GreenNode green = lazyGreenNode;
+ int absStart = lazyAbsStart;
+ lazyGreenNode = null;
+ RedTreeBuilder.expandLazy(this, green, absStart);
+ }
+ }
+
/**
* Returns the node children.
- *
+ *
* @return the node children.
*/
public List getChildren() {
+ ensureChildren();
if (children == null) {
return Collections.emptyList();
}
@@ -690,6 +705,7 @@ public DOMNode getParentNode() {
*/
@Override
public DOMNode getFirstChild() {
+ ensureChildren();
return this.children != null && children.size() > 0 ? this.children.get(0) : null;
}
@@ -700,6 +716,7 @@ public DOMNode getFirstChild() {
*/
@Override
public DOMNode getLastChild() {
+ ensureChildren();
return this.children != null && this.children.size() > 0 ? this.children.get(this.children.size() - 1) : null;
}
@@ -720,6 +737,7 @@ public NamedNodeMap getAttributes() {
*/
@Override
public NodeList getChildNodes() {
+ ensureChildren();
return children != null ? children : EMPTY_CHILDREN;
}
@@ -921,6 +939,7 @@ public String getTextContent() throws DOMException {
return null;
// concatenation of the textContent attribute value of every child node
default:
+ ensureChildren();
if (this.children != null && children.size() > 0) {
final StringBuilder builder = new StringBuilder();
for (DOMNode child : children) {
@@ -953,6 +972,9 @@ public Object getUserData(String arg0) {
*/
@Override
public boolean hasChildNodes() {
+ if (lazyGreenNode != null) {
+ return true;
+ }
return children != null && !children.isEmpty();
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
index f8e4372e1..274a0f8b3 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
@@ -74,7 +74,10 @@ public DOMDocument parseIncremental(TextDocument document,
String uri = document.getUri();
GreenDocument greenDoc = IncrementalParser.incrementalParse(
previousGreenDoc, text, editStart, deleteLength, insertLength, uri, monitor);
- return buildDocument(greenDoc, document, resolverExtensionManager, ignoreWhitespaceContent, monitor);
+ DOMDocument xmlDocument = RedTreeBuilder.buildLazy(greenDoc, document, resolverExtensionManager);
+ xmlDocument.setGreenDocument(greenDoc);
+ xmlDocument.setCancelChecker(monitor);
+ return xmlDocument;
}
private static DOMDocument buildDocument(GreenDocument greenDoc, TextDocument document,
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
index 63f8c7f98..e461382b1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
@@ -41,19 +41,30 @@
public final class RedTreeBuilder {
private final boolean ignoreWhitespaceContent;
+ private final boolean lazy;
- private RedTreeBuilder(boolean ignoreWhitespaceContent) {
+ private RedTreeBuilder(boolean ignoreWhitespaceContent, boolean lazy) {
this.ignoreWhitespaceContent = ignoreWhitespaceContent;
+ this.lazy = lazy;
}
public static DOMDocument build(GreenDocument greenDoc, TextDocument textDocument,
URIResolverExtensionManager resolverExtensionManager) {
- return new RedTreeBuilder(true).doBuild(greenDoc, textDocument, resolverExtensionManager);
+ return new RedTreeBuilder(true, false).doBuild(greenDoc, textDocument, resolverExtensionManager);
}
public static DOMDocument build(GreenDocument greenDoc, TextDocument textDocument,
URIResolverExtensionManager resolverExtensionManager, boolean ignoreWhitespaceContent) {
- return new RedTreeBuilder(ignoreWhitespaceContent).doBuild(greenDoc, textDocument, resolverExtensionManager);
+ return new RedTreeBuilder(ignoreWhitespaceContent, false).doBuild(greenDoc, textDocument, resolverExtensionManager);
+ }
+
+ public static DOMDocument buildLazy(GreenDocument greenDoc, TextDocument textDocument,
+ URIResolverExtensionManager resolverExtensionManager) {
+ return new RedTreeBuilder(true, true).doBuild(greenDoc, textDocument, resolverExtensionManager);
+ }
+
+ static void expandLazy(DOMNode node, GreenNode green, int absStart) {
+ new RedTreeBuilder(true, true).addChildren(node, green, absStart);
}
private DOMDocument doBuild(GreenDocument greenDoc, TextDocument textDocument,
@@ -63,6 +74,15 @@ private DOMDocument doBuild(GreenDocument greenDoc, TextDocument textDocument,
return domDoc;
}
+ private void addChildrenOrDefer(DOMNode node, GreenNode green, int absStart) {
+ if (lazy && green.children().length > 0) {
+ node.lazyGreenNode = green;
+ node.lazyAbsStart = absStart;
+ } else {
+ addChildren(node, green, absStart);
+ }
+ }
+
private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsStart) {
GreenNode[] children = greenParent.children();
if (children.length == 0) {
@@ -155,7 +175,7 @@ private DOMElement createElement(GreenElement green, int absStart, int absEnd) {
elem.setAttributeNode(attr);
}
- addChildren(elem, green, absStart);
+ addChildrenOrDefer(elem, green, absStart);
return elem;
}
@@ -244,7 +264,7 @@ private DOMDocumentType createDocumentType(GreenDocumentType green,
}
setDTDUnrecognized(dt, green, absStart);
- addChildren(dt, green, absStart);
+ addChildrenOrDefer(dt, green, absStart);
dt.end = absEnd;
return dt;
}
@@ -263,7 +283,7 @@ private DTDElementDecl createDTDElementDecl(GreenDTDElementDecl green,
absStart + green.content().endRel());
}
setDTDUnrecognized(decl, green, absStart);
- addChildren(decl, green, absStart);
+ addChildrenOrDefer(decl, green, absStart);
decl.end = absEnd;
return decl;
}
@@ -286,7 +306,7 @@ private DTDAttlistDecl createDTDAttlistDecl(GreenDTDAttlistDecl green,
absStart + green.attributeValue().endRel());
}
setDTDUnrecognized(decl, green, absStart);
- addChildren(decl, green, absStart);
+ addChildrenOrDefer(decl, green, absStart);
decl.end = absEnd;
return decl;
}
@@ -317,7 +337,7 @@ private DTDEntityDecl createDTDEntityDecl(GreenDTDEntityDecl green,
absStart + green.systemId().endRel());
}
setDTDUnrecognized(decl, green, absStart);
- addChildren(decl, green, absStart);
+ addChildrenOrDefer(decl, green, absStart);
decl.end = absEnd;
return decl;
}
@@ -340,7 +360,7 @@ private DTDNotationDecl createDTDNotationDecl(GreenDTDNotationDecl green,
absStart + green.systemId().endRel());
}
setDTDUnrecognized(decl, green, absStart);
- addChildren(decl, green, absStart);
+ addChildrenOrDefer(decl, green, absStart);
decl.end = absEnd;
return decl;
}
@@ -351,7 +371,7 @@ private DTDDeclNode createDTDDeclNode(GreenDTDDeclNode green,
decl.setClosed(green.closed());
setDTDDeclFields(decl, green, absStart);
setDTDUnrecognized(decl, green, absStart);
- addChildren(decl, green, absStart);
+ addChildrenOrDefer(decl, green, absStart);
decl.end = absEnd;
return decl;
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/RedTreeBuilderTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/RedTreeBuilderTest.java
index 866541598..7eff0efbd 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/RedTreeBuilderTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/RedTreeBuilderTest.java
@@ -534,6 +534,80 @@ public void multiplePIsInsideElement() {
assertRedTreeEquivalent("");
}
+ // --- Lazy building tests ---
+
+ @Test
+ public void lazyBuildProducesSameTree() {
+ String xml = "text";
+ assertLazyTreeEquivalent(xml);
+ }
+
+ @Test
+ public void lazyBuildDeferredUntilAccess() {
+ String xml = "";
+ TextDocument textDoc = new TextDocument(xml, "test://test.xml");
+ GreenDocument greenDoc = GreenTreeBuilder.parse(xml, "test://test.xml", null);
+ DOMDocument doc = RedTreeBuilder.buildLazy(greenDoc, textDoc, null);
+
+ DOMElement root = (DOMElement) doc.getFirstChild();
+ assertEquals("root", root.getTagName());
+
+ DOMElement a = (DOMElement) root.getFirstChild();
+ assertEquals("a", a.getTagName());
+ assertEquals(true, a.hasChildNodes());
+
+ DOMElement b = (DOMElement) a.getFirstChild();
+ assertEquals("b", b.getTagName());
+ }
+
+ @Test
+ public void lazyBuildComplexDocument() {
+ String xml = "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ " junit\n" +
+ " \n" +
+ " \n" +
+ "";
+ assertLazyTreeEquivalent(xml);
+ }
+
+ @Test
+ public void lazyBuildFindNodeAt() {
+ String xml = "textdeep";
+ TextDocument textDoc = new TextDocument(xml, "test://test.xml");
+ GreenDocument greenDoc = GreenTreeBuilder.parse(xml, "test://test.xml", null);
+ DOMDocument doc = RedTreeBuilder.buildLazy(greenDoc, textDoc, null);
+ doc.setGreenDocument(greenDoc);
+
+ int offset = xml.indexOf("deep") + 1;
+ DOMNode found = doc.findNodeAt(offset);
+ assertEquals(true, found.isText());
+ assertEquals("deep", ((DOMText) found).getData());
+ }
+
+ @Test
+ public void lazyBuildLargeDocument() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ for (int i = 0; i < 1000; i++) {
+ sb.append(" value ").append(i).append("\n");
+ }
+ sb.append("");
+ assertLazyTreeEquivalent(sb.toString());
+ }
+
+ private void assertLazyTreeEquivalent(String xml) {
+ TextDocument textDoc = new TextDocument(xml, "test://test.xml");
+
+ GreenDocument greenDoc = GreenTreeBuilder.parse(xml, "test://test.xml", null);
+ DOMDocument eager = RedTreeBuilder.build(greenDoc, textDoc, null);
+ DOMDocument lazy = RedTreeBuilder.buildLazy(greenDoc, textDoc, null);
+
+ assertNodesEqual(eager, lazy, xml);
+ }
+
private void assertRedTreeEquivalent(String xml) {
TextDocument textDoc = new TextDocument(xml, "test://test.xml");
From 190a3791df0ca0626e4172bf109d78a97492b749 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 15:06:34 +0200
Subject: [PATCH 07/18] Reduce memory footprint of red-green tree and DOM nodes
- Pack boolean fields into bit flags (widthAndFlags in GreenNode,
byte flags in DOMNode) saving ~12MB across ~1.5M green nodes
and ~8MB across red nodes
- Replace ArrayList with raw arrays in NodeBuilder and
GreenElementBuilder, reducing allocation from 578MB to 24MB
- Add ArrayLineTracker (flat int[]+byte[]) replacing TreeLineTracker
AVL tree, saving ~22MB (5 bytes/line vs 40 bytes/line)
- Cache GreenText whitespace nodes for widths 0-127, saving ~14MB
- Remove cached delimiter field from DOMCharacterData (computed on
demand)
- Add trimToSize compaction for XMLNodeList and XMLNamedNodeMap
- Reduce default capacity of XMLNodeList from 10 to 2
Co-Authored-By: Claude Opus 4.6
---
.../lemminx/commons/ArrayLineTracker.java | 365 ++++++++++++++++++
.../eclipse/lemminx/commons/TextDocument.java | 2 +-
.../eclipse/lemminx/dom/DOMCharacterData.java | 28 +-
.../org/eclipse/lemminx/dom/DOMElement.java | 7 +-
.../java/org/eclipse/lemminx/dom/DOMNode.java | 89 ++---
.../eclipse/lemminx/dom/RedTreeBuilder.java | 4 +-
.../eclipse/lemminx/dom/green/GreenCDATA.java | 2 +-
.../lemminx/dom/green/GreenComment.java | 8 +-
.../lemminx/dom/green/GreenDTDDeclNode.java | 2 +-
.../lemminx/dom/green/GreenDocument.java | 2 +-
.../lemminx/dom/green/GreenElement.java | 10 +-
.../dom/green/GreenElementBuilder.java | 31 +-
.../eclipse/lemminx/dom/green/GreenNode.java | 26 +-
.../dom/green/GreenProcessingInstruction.java | 2 +-
.../eclipse/lemminx/dom/green/GreenText.java | 22 +-
.../lemminx/dom/green/GreenTreeBuilder.java | 80 ++--
.../lemminx/dom/green/IncrementalParser.java | 2 +-
.../lemminx/commons/ArrayLineTrackerTest.java | 263 +++++++++++++
18 files changed, 794 insertions(+), 151 deletions(-)
create mode 100644 org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ArrayLineTracker.java
create mode 100644 org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ArrayLineTrackerTest.java
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ArrayLineTracker.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ArrayLineTracker.java
new file mode 100644
index 000000000..c2f2316f4
--- /dev/null
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ArrayLineTracker.java
@@ -0,0 +1,365 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Red Hat Inc. and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat Inc. - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.lemminx.commons;
+
+import org.eclipse.lsp4j.Position;
+
+/**
+ * Memory-efficient line tracker using flat arrays instead of an AVL tree.
+ *
+ *
Uses ~5 bytes per line (4-byte int offset + 1-byte delimiter type) vs
+ * ~40 bytes per line for the tree-based tracker. For a 621K-line file this
+ * saves ~22 MB of heap.
+ *
+ *
Query operations (offset-to-line, line-to-offset) are O(log n) via
+ * binary search. Update operations are O(n) for the array shift but use
+ * {@link System#arraycopy} which is extremely fast in practice.
+ */
+public class ArrayLineTracker implements ILineTracker {
+
+ private static final String[] DELIMITERS = { "\r", "\n", "\r\n" };
+ private static final String NO_DELIM = "";
+
+ private static final byte DELIM_NONE = 0;
+ private static final byte DELIM_LF = 1;
+ private static final byte DELIM_CR = 2;
+ private static final byte DELIM_CRLF = 3;
+
+ private int[] lineStarts;
+ private byte[] delimTypes;
+ private int lineCount;
+ private int textLength;
+
+ public ArrayLineTracker() {
+ lineStarts = new int[16];
+ delimTypes = new byte[16];
+ lineStarts[0] = 0;
+ delimTypes[0] = DELIM_NONE;
+ lineCount = 1;
+ textLength = 0;
+ }
+
+ @Override
+ public void set(String text) {
+ textLength = text != null ? text.length() : 0;
+ int count = 1;
+ if (text != null) {
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (ch == '\n') {
+ count++;
+ } else if (ch == '\r') {
+ count++;
+ if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+ i++;
+ }
+ }
+ }
+ }
+
+ lineStarts = new int[count];
+ delimTypes = new byte[count];
+ lineCount = count;
+
+ lineStarts[0] = 0;
+ int lineIndex = 0;
+ if (text != null) {
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (ch == '\n') {
+ delimTypes[lineIndex] = DELIM_LF;
+ lineIndex++;
+ lineStarts[lineIndex] = i + 1;
+ } else if (ch == '\r') {
+ if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+ delimTypes[lineIndex] = DELIM_CRLF;
+ lineIndex++;
+ lineStarts[lineIndex] = i + 2;
+ i++;
+ } else {
+ delimTypes[lineIndex] = DELIM_CR;
+ lineIndex++;
+ lineStarts[lineIndex] = i + 1;
+ }
+ }
+ }
+ }
+ delimTypes[lineIndex] = DELIM_NONE;
+ }
+
+ @Override
+ public void replace(int offset, int length, String text) throws BadLocationException {
+ int textLen = text != null ? text.length() : 0;
+ int delta = textLen - length;
+
+ int startLine = findLineByOffset(offset);
+ int endLine = (length == 0) ? startLine : findLineByOffset(offset + length);
+
+ byte endLineDelim = delimTypes[endLine];
+
+ int newDelimCount = 0;
+ int[] newStarts = null;
+ byte[] newDelims = null;
+
+ if (textLen > 0) {
+ int count = 0;
+ for (int i = 0; i < textLen; i++) {
+ char ch = text.charAt(i);
+ if (ch == '\n') {
+ count++;
+ } else if (ch == '\r') {
+ count++;
+ if (i + 1 < textLen && text.charAt(i + 1) == '\n') {
+ i++;
+ }
+ }
+ }
+ if (count > 0) {
+ newDelimCount = count;
+ newStarts = new int[count];
+ newDelims = new byte[count];
+ int idx = 0;
+ for (int i = 0; i < textLen; i++) {
+ char ch = text.charAt(i);
+ if (ch == '\n') {
+ newDelims[idx] = DELIM_LF;
+ newStarts[idx] = offset + i + 1;
+ idx++;
+ } else if (ch == '\r') {
+ if (i + 1 < textLen && text.charAt(i + 1) == '\n') {
+ newDelims[idx] = DELIM_CRLF;
+ newStarts[idx] = offset + i + 2;
+ idx++;
+ i++;
+ } else {
+ newDelims[idx] = DELIM_CR;
+ newStarts[idx] = offset + i + 1;
+ idx++;
+ }
+ }
+ }
+ }
+ }
+
+ int removedLines = endLine - startLine;
+ int linesDelta = newDelimCount - removedLines;
+ int newLineCount = lineCount + linesDelta;
+
+ ensureCapacity(newLineCount);
+
+ int tailSrc = endLine + 1;
+ int tailDst = startLine + 1 + newDelimCount;
+ int tailLen = lineCount - tailSrc;
+
+ if (tailLen > 0) {
+ System.arraycopy(lineStarts, tailSrc, lineStarts, tailDst, tailLen);
+ System.arraycopy(delimTypes, tailSrc, delimTypes, tailDst, tailLen);
+ if (delta != 0) {
+ for (int i = tailDst; i < tailDst + tailLen; i++) {
+ lineStarts[i] += delta;
+ }
+ }
+ }
+
+ if (newDelimCount > 0) {
+ for (int i = 0; i < newDelimCount; i++) {
+ lineStarts[startLine + 1 + i] = newStarts[i];
+ delimTypes[startLine + i] = newDelims[i];
+ }
+ }
+ delimTypes[startLine + newDelimCount] = endLineDelim;
+
+ lineCount = newLineCount;
+ textLength += delta;
+ }
+
+ @Override
+ public String getLineDelimiter(int line) throws BadLocationException {
+ if (line < 0 || line >= lineCount) {
+ throw new BadLocationException();
+ }
+ String delim = delimiterTypeToString(delimTypes[line]);
+ return NO_DELIM.equals(delim) ? null : delim;
+ }
+
+ @Override
+ public int computeNumberOfLines(String text) {
+ int count = 0;
+ if (text != null) {
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (ch == '\n') {
+ count++;
+ } else if (ch == '\r') {
+ count++;
+ if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
+ i++;
+ }
+ }
+ }
+ }
+ return count;
+ }
+
+ @Override
+ public int getNumberOfLines() {
+ return lineCount;
+ }
+
+ @Override
+ public int getNumberOfLines(int offset, int length) throws BadLocationException {
+ if (length == 0) {
+ return 1;
+ }
+ int startLine = findLineByOffset(offset);
+ int endLine = findLineByOffset(offset + length);
+ return endLine - startLine + 1;
+ }
+
+ @Override
+ public int getLineOffset(int line) throws BadLocationException {
+ if (line < 0 || line >= lineCount) {
+ throw new BadLocationException();
+ }
+ return lineStarts[line];
+ }
+
+ @Override
+ public int getLineLength(int line) throws BadLocationException {
+ if (line < 0 || line >= lineCount) {
+ throw new BadLocationException();
+ }
+ if (line + 1 < lineCount) {
+ return lineStarts[line + 1] - lineStarts[line];
+ }
+ return textLength - lineStarts[line];
+ }
+
+ @Override
+ public int getLineNumberOfOffset(int offset) throws BadLocationException {
+ return findLineByOffset(offset);
+ }
+
+ @Override
+ public Position getPositionAt(int offset) throws BadLocationException {
+ int line = findLineByOffset(offset);
+ int character = offset - lineStarts[line];
+ return new Position(line, character);
+ }
+
+ @Override
+ public int getOffsetAt(Position position) throws BadLocationException {
+ int line = position.getLine();
+ if (line < 0 || line >= lineCount) {
+ throw new BadLocationException("The line value, {" + line + "}, is out of bounds.");
+ }
+ int lineOffset = lineStarts[line];
+ int lineLength = pureLength(line);
+ int character = position.getCharacter();
+ int offset = lineOffset + character;
+ int endLineOffset = lineOffset + lineLength;
+ if (offset > endLineOffset) {
+ throw new BadLocationException(
+ "The character value, {" + character + "} of the line" + line + "}, is out of bounds.");
+ }
+ return offset;
+ }
+
+ @Override
+ public Line getLineInformationOfOffset(int offset) throws BadLocationException {
+ int line = findLineByOffset(offset);
+ return new Line(lineStarts[line], pureLength(line));
+ }
+
+ @Override
+ public Line getLineInformation(int line) throws BadLocationException {
+ try {
+ if (line < 0 || line >= lineCount) {
+ throw new BadLocationException();
+ }
+ return new Line(lineStarts[line], pureLength(line));
+ } catch (BadLocationException x) {
+ if (line > 0 && line == lineCount) {
+ int lastLine = line - 1;
+ int lastLineEnd = lineStarts[lastLine] + getLineLength(lastLine);
+ if (getLineLength(lastLine) > 0) {
+ return new Line(lastLineEnd, 0);
+ }
+ }
+ throw x;
+ }
+ }
+
+ private int pureLength(int line) {
+ int totalLength;
+ if (line + 1 < lineCount) {
+ totalLength = lineStarts[line + 1] - lineStarts[line];
+ } else {
+ totalLength = textLength - lineStarts[line];
+ }
+ return totalLength - delimLength(delimTypes[line]);
+ }
+
+ private int findLineByOffset(int offset) throws BadLocationException {
+ if (offset < 0 || offset > textLength) {
+ throw new BadLocationException();
+ }
+ int lo = 0, hi = lineCount - 1;
+ while (lo < hi) {
+ int mid = lo + (hi - lo + 1) / 2;
+ if (lineStarts[mid] <= offset) {
+ lo = mid;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ return lo;
+ }
+
+ private void ensureCapacity(int minCapacity) {
+ if (minCapacity > lineStarts.length) {
+ int newCapacity = Math.max(minCapacity, lineStarts.length + (lineStarts.length >> 1));
+ int[] newStarts = new int[newCapacity];
+ byte[] newDelims = new byte[newCapacity];
+ System.arraycopy(lineStarts, 0, newStarts, 0, lineCount);
+ System.arraycopy(delimTypes, 0, newDelims, 0, lineCount);
+ lineStarts = newStarts;
+ delimTypes = newDelims;
+ }
+ }
+
+ private static int delimLength(byte delimType) {
+ switch (delimType) {
+ case DELIM_LF:
+ case DELIM_CR:
+ return 1;
+ case DELIM_CRLF:
+ return 2;
+ default:
+ return 0;
+ }
+ }
+
+ private static String delimiterTypeToString(byte delimiterType) {
+ switch (delimiterType) {
+ case DELIM_LF:
+ return DELIMITERS[1];
+ case DELIM_CR:
+ return DELIMITERS[0];
+ case DELIM_CRLF:
+ return DELIMITERS[2];
+ default:
+ return NO_DELIM;
+ }
+ }
+}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
index e62a3db94..bc62642de 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
@@ -148,7 +148,7 @@ private synchronized ILineTracker createLineTracker() {
if (lineTracker != null) {
return lineTracker;
}
- ILineTracker lineTracker = isIncremental() ? new TreeLineTracker(new ListLineTracker()) : new ListLineTracker();
+ ILineTracker lineTracker = isIncremental() ? new ArrayLineTracker() : new ListLineTracker();
lineTracker.set(super.getText());
return lineTracker;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
index b3cc42d60..564e53ec2 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
@@ -26,10 +26,6 @@
*/
public abstract class DOMCharacterData extends DOMNode implements org.w3c.dom.CharacterData {
- private boolean isWhitespace;
-
- private String delimiter;
-
public DOMCharacterData(int start, int end) {
super(start, end);
}
@@ -39,16 +35,14 @@ public boolean hasMultiLine() {
}
public String getDelimiter() {
- if (delimiter != null) {
- return delimiter;
- }
try {
- delimiter = getOwnerDocument().getTextDocument().lineDelimiter(0);
- return delimiter;
+ String d = getOwnerDocument().getTextDocument().lineDelimiter(0);
+ if (d != null) {
+ return d;
+ }
} catch (BadLocationException e) {
- delimiter = lineSeparator();
- return delimiter;
}
+ return lineSeparator();
}
/**
@@ -150,20 +144,12 @@ public String getNodeValue() throws DOMException {
return getData();
}
- /**
- * @return the isWhitespace
- */
public boolean isWhitespace() {
- return isWhitespace;
+ return hasFlag(FLAG_WHITESPACE);
}
- /**
- * Set true if this node's data is all whitespace
- *
- * @param isWhitespace
- */
public void setWhitespace(boolean isWhitespace) {
- this.isWhitespace = isWhitespace;
+ setFlag(FLAG_WHITESPACE, isWhitespace);
}
/*
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
index f0351c635..6f02007be 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
@@ -33,7 +33,6 @@
public class DOMElement extends DOMNode implements org.w3c.dom.Element {
String tag;
- boolean selfClosed;
// DomElement.start == startTagOpenOffset
int startTagOpenOffset = NULL_VALUE; // |
@@ -235,7 +234,11 @@ public boolean isDocumentElement() {
}
public boolean isSelfClosed() {
- return selfClosed;
+ return hasFlag(FLAG_SELF_CLOSED);
+ }
+
+ void setSelfClosed(boolean selfClosed) {
+ setFlag(FLAG_SELF_CLOSED, selfClosed);
}
/**
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
index eb922dd80..aaecf4309 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
@@ -61,11 +61,10 @@ public abstract class DOMNode implements Node, DOMRange {
*/
public static final short DTD_DECL_NODE = 105;
- // Memory optimization: Use byte flags instead of multiple boolean fields
- // This saves 7 bytes per node (boolean with padding = 8 bytes, byte = 1 byte)
private byte flags = 0;
private static final byte FLAG_CLOSED = 0x01;
- // Reserved for future flags: 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80
+ static final byte FLAG_SELF_CLOSED = 0x02;
+ static final byte FLAG_WHITESPACE = 0x04;
private XMLNamedNodeMap attributeNodes;
private XMLNodeList children;
@@ -75,8 +74,6 @@ public abstract class DOMNode implements Node, DOMRange {
DOMNode parent;
- // Cache the index in parent's children list to avoid O(n) indexOf() calls
- // This is set to -1 when not cached, and updated when needed
int cachedIndexInParent = -1;
GreenNode lazyGreenNode;
@@ -98,13 +95,9 @@ public int getLength() {
static class XMLNodeList extends ArrayList implements NodeList {
private static final long serialVersionUID = 1L;
-
- // Pre-allocate capacity to reduce ArrayList resizing overhead
- // Most elements have 2-5 children, so start with capacity of 4
- private static final int INITIAL_CAPACITY = 4;
XMLNodeList() {
- super(INITIAL_CAPACITY);
+ super(2);
}
@Override
@@ -116,35 +109,6 @@ public int getLength() {
public DOMNode item(int index) {
return super.get(index);
}
-
- @Override
- public boolean add(T node) {
- boolean result = super.add(node);
- // Invalidate cached indices for all nodes after this one
- invalidateCachedIndices(size() - 1);
- return result;
- }
-
- @Override
- public void add(int index, T node) {
- super.add(index, node);
- // Invalidate cached indices for all nodes from this index onwards
- invalidateCachedIndices(index);
- }
-
- @Override
- public T remove(int index) {
- T removed = super.remove(index);
- // Invalidate cached indices for all nodes from this index onwards
- invalidateCachedIndices(index);
- return removed;
- }
-
- private void invalidateCachedIndices(int fromIndex) {
- for (int i = fromIndex; i < size(); i++) {
- get(i).cachedIndexInParent = -1;
- }
- }
}
@@ -152,6 +116,10 @@ static class XMLNamedNodeMap extends ArrayList implements
private static final long serialVersionUID = 1L;
+ XMLNamedNodeMap() {
+ super(4);
+ }
+
@Override
public int getLength() {
return super.size();
@@ -202,7 +170,18 @@ public T setNamedItemNS(org.w3c.dom.Node arg0) throws DOMException {
public DOMNode(int start, int end) {
this.start = start;
this.end = end;
- // flags is already initialized to 0, so FLAG_CLOSED is not set
+ }
+
+ protected final boolean hasFlag(byte flag) {
+ return (flags & flag) != 0;
+ }
+
+ protected final void setFlag(byte flag, boolean value) {
+ if (value) {
+ flags |= flag;
+ } else {
+ flags &= ~flag;
+ }
}
/**
@@ -557,11 +536,19 @@ public void addChild(DOMNode child) {
if (children == null) {
children = new XMLNodeList<>();
}
- // Cache the index when adding
child.cachedIndexInParent = children.size();
children.add(child);
}
+ void compactChildren() {
+ if (children != null) {
+ children.trimToSize();
+ }
+ if (attributeNodes != null) {
+ attributeNodes.trimToSize();
+ }
+ }
+
/**
* Returns node child at the given index.
*
@@ -573,19 +560,11 @@ public DOMNode getChild(int index) {
}
public boolean isClosed() {
- return (flags & FLAG_CLOSED) != 0;
+ return hasFlag(FLAG_CLOSED);
}
- /**
- * Sets the closed flag for this node.
- * Package-private to allow DOMParser to set it.
- */
void setClosed(boolean closed) {
- if (closed) {
- flags |= FLAG_CLOSED;
- } else {
- flags &= ~FLAG_CLOSED;
- }
+ setFlag(FLAG_CLOSED, closed);
}
public DOMElement getParentElement() {
@@ -808,15 +787,11 @@ public DOMNode getNextSibling() {
return null;
}
List children = parentNode.getChildren();
-
- // Use cached index if available to avoid O(n) indexOf() call
int currentIndex = cachedIndexInParent;
if (currentIndex == -1) {
- // Cache miss - compute and cache the index
currentIndex = children.indexOf(this);
cachedIndexInParent = currentIndex;
}
-
int nextIndex = currentIndex + 1;
return nextIndex < children.size() ? children.get(nextIndex) : null;
}
@@ -843,15 +818,11 @@ public DOMNode getPreviousSibling() {
return null;
}
List children = parentNode.getChildren();
-
- // Use cached index if available to avoid O(n) indexOf() call
int currentIndex = cachedIndexInParent;
if (currentIndex == -1) {
- // Cache miss - compute and cache the index
currentIndex = children.indexOf(this);
cachedIndexInParent = currentIndex;
}
-
int previousIndex = currentIndex - 1;
return previousIndex >= 0 ? children.get(previousIndex) : null;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
index e461382b1..73768da18 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
@@ -71,6 +71,7 @@ private DOMDocument doBuild(GreenDocument greenDoc, TextDocument textDocument,
URIResolverExtensionManager resolverExtensionManager) {
DOMDocument domDoc = new DOMDocument(textDocument, resolverExtensionManager);
addChildren(domDoc, greenDoc, 0);
+ domDoc.compactChildren();
return domDoc;
}
@@ -110,6 +111,7 @@ private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsSta
}
childAbsStart += greenChild.width();
}
+ parent.compactChildren();
}
private static boolean hasNonWhitespaceChild(GreenNode[] children) {
@@ -159,7 +161,7 @@ private DOMNode createRedNode(GreenNode green, int absStart) {
private DOMElement createElement(GreenElement green, int absStart, int absEnd) {
DOMElement elem = new DOMElement(absStart, absEnd);
elem.tag = green.tag();
- elem.selfClosed = green.selfClosed();
+ elem.setSelfClosed(green.selfClosed());
boolean isOrphanEndTag = green.endTagOpenRel() != GreenElement.NULL_VALUE
&& green.endTagOpenRel() == 0
&& green.startTagCloseRel() == GreenElement.NULL_VALUE;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenCDATA.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenCDATA.java
index 1048cba78..cfc654c48 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenCDATA.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenCDATA.java
@@ -22,7 +22,7 @@ public final class GreenCDATA extends GreenNode {
private final int endContentRel;
public GreenCDATA(int width, boolean closed, int startContentRel, int endContentRel) {
- super(width, closed);
+ super(width, closed ? CLOSED_FLAG : 0);
this.startContentRel = startContentRel;
this.endContentRel = endContentRel;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenComment.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenComment.java
index 070e30c21..8dafde6f0 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenComment.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenComment.java
@@ -18,14 +18,12 @@
*/
public final class GreenComment extends GreenNode {
- private final boolean commentSameLineEndTag;
private final int startContentRel;
private final int endContentRel;
public GreenComment(int width, boolean closed, boolean commentSameLineEndTag,
int startContentRel, int endContentRel) {
- super(width, closed);
- this.commentSameLineEndTag = commentSameLineEndTag;
+ super(width, (closed ? CLOSED_FLAG : 0) | (commentSameLineEndTag ? SUBCLASS_FLAG : 0));
this.startContentRel = startContentRel;
this.endContentRel = endContentRel;
}
@@ -36,7 +34,7 @@ public short nodeType() {
}
public boolean commentSameLineEndTag() {
- return commentSameLineEndTag;
+ return subclassFlag();
}
public int startContentRel() {
@@ -49,7 +47,7 @@ public int endContentRel() {
@Override
protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) {
- return new GreenComment(newWidth, closed(), commentSameLineEndTag,
+ return new GreenComment(newWidth, closed(), commentSameLineEndTag(),
startContentRel, endContentRel);
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
index 67ee849d1..ddf2df0ea 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java
@@ -34,7 +34,7 @@ public GreenDTDDeclNode(int width, boolean closed,
GreenDTDParam unrecognized, GreenDTDParam declType,
GreenDTDParam name, GreenDTDParam[] parameters,
GreenNode[] children) {
- super(width, closed);
+ super(width, closed ? CLOSED_FLAG : 0);
this.unrecognized = unrecognized;
this.declType = declType;
this.name = name;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocument.java
index 338a35b70..e351f33fb 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocument.java
@@ -24,7 +24,7 @@ public final class GreenDocument extends GreenNode {
private final GreenNode[] children;
public GreenDocument(int width, GreenNode[] children) {
- super(width, true);
+ super(width, CLOSED_FLAG);
this.children = children != null ? children : EMPTY_CHILDREN;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
index 17323eff4..9d69f07e6 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
@@ -25,7 +25,6 @@ public final class GreenElement extends GreenNode {
public static final int NULL_VALUE = -1;
private final String tag;
- private final boolean selfClosed;
private final int startTagCloseRel;
private final int endTagOpenRel;
private final int endTagCloseRel;
@@ -37,9 +36,8 @@ public GreenElement(int width, boolean closed, String tag, boolean selfClosed,
int startTagCloseRel, int endTagOpenRel, int endTagCloseRel,
int contentStartRel,
GreenAttr[] attributes, GreenNode[] children) {
- super(width, closed);
+ super(width, (closed ? CLOSED_FLAG : 0) | (selfClosed ? SUBCLASS_FLAG : 0));
this.tag = tag;
- this.selfClosed = selfClosed;
this.startTagCloseRel = startTagCloseRel;
this.endTagOpenRel = endTagOpenRel;
this.endTagCloseRel = endTagCloseRel;
@@ -58,7 +56,7 @@ public String tag() {
}
public boolean selfClosed() {
- return selfClosed;
+ return subclassFlag();
}
public int startTagCloseRel() {
@@ -103,7 +101,7 @@ public GreenNode[] children() {
public GreenElement withNewChildren(GreenNode[] newChildren, int widthDelta) {
return new GreenElement(
- width() + widthDelta, closed(), tag, selfClosed,
+ width() + widthDelta, closed(), tag, selfClosed(),
startTagCloseRel,
endTagOpenRel != NULL_VALUE ? endTagOpenRel + widthDelta : NULL_VALUE,
endTagCloseRel != NULL_VALUE ? endTagCloseRel + widthDelta : NULL_VALUE,
@@ -112,7 +110,7 @@ public GreenElement withNewChildren(GreenNode[] newChildren, int widthDelta) {
@Override
protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) {
- return new GreenElement(newWidth, closed(), tag, selfClosed,
+ return new GreenElement(newWidth, closed(), tag, selfClosed(),
startTagCloseRel, endTagOpenRel, endTagCloseRel,
contentStartRel, attributes, newChildren);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
index 2c0e51e22..3d27f64ff 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
@@ -11,8 +11,7 @@
*******************************************************************************/
package org.eclipse.lemminx.dom.green;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.Arrays;
/**
* Mutable builder for {@link GreenElement}.
@@ -31,8 +30,10 @@ public final class GreenElementBuilder {
private int startTagCloseOffset = GreenElement.NULL_VALUE;
private int endTagOpenOffset = GreenElement.NULL_VALUE;
private int endTagCloseOffset = GreenElement.NULL_VALUE;
- private List attributes;
- private List children;
+ private GreenAttr[] attributes;
+ private int attrCount;
+ private GreenNode[] children;
+ private int childCount;
public GreenElementBuilder(int nodeStart) {
this.nodeStart = nodeStart;
@@ -81,16 +82,24 @@ public void setEndTagCloseOffset(int offset) {
public void addAttribute(GreenAttr attr) {
if (attributes == null) {
- attributes = new ArrayList<>(4);
+ attributes = new GreenAttr[4];
+ attrCount = 0;
}
- attributes.add(attr);
+ if (attrCount == attributes.length) {
+ attributes = Arrays.copyOf(attributes, attributes.length * 2);
+ }
+ attributes[attrCount++] = attr;
}
public void addChild(GreenNode child) {
if (children == null) {
- children = new ArrayList<>(4);
+ children = new GreenNode[4];
+ childCount = 0;
+ }
+ if (childCount == children.length) {
+ children = Arrays.copyOf(children, children.length * 2);
}
- children.add(child);
+ children[childCount++] = child;
}
public GreenElement build() {
@@ -103,9 +112,11 @@ public GreenElement build() {
? endTagCloseOffset - nodeStart : GreenElement.NULL_VALUE;
GreenAttr[] attrs = attributes != null
- ? attributes.toArray(GreenNode.EMPTY_ATTRS) : null;
+ ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount))
+ : null;
GreenNode[] kids = children != null
- ? children.toArray(GreenNode.EMPTY_CHILDREN) : null;
+ ? (childCount == children.length ? children : Arrays.copyOf(children, childCount))
+ : null;
return new GreenElement(width, closed, tag, selfClosed,
stcRel, etoRel, etcRel, GreenElement.NULL_VALUE, attrs, kids);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
index aec6f8493..3ac420869 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java
@@ -27,22 +27,28 @@ public abstract class GreenNode {
public static final GreenNode[] EMPTY_CHILDREN = new GreenNode[0];
public static final GreenAttr[] EMPTY_ATTRS = new GreenAttr[0];
- private final int width;
- private final boolean closed;
+ static final int CLOSED_FLAG = 1 << 31;
+ static final int SUBCLASS_FLAG = 1 << 30;
+ private static final int WIDTH_MASK = 0x3FFFFFFF;
- protected GreenNode(int width, boolean closed) {
- this.width = width;
- this.closed = closed;
+ private final int widthAndFlags;
+
+ protected GreenNode(int width, int flags) {
+ this.widthAndFlags = width | flags;
}
public abstract short nodeType();
- public int width() {
- return width;
+ public final int width() {
+ return widthAndFlags & WIDTH_MASK;
+ }
+
+ public final boolean closed() {
+ return (widthAndFlags & CLOSED_FLAG) != 0;
}
- public boolean closed() {
- return closed;
+ protected final boolean subclassFlag() {
+ return (widthAndFlags & SUBCLASS_FLAG) != 0;
}
public GreenNode[] children() {
@@ -63,7 +69,7 @@ public GreenNode withReplacedChild(int index, GreenNode newChild) {
System.arraycopy(old, 0, copy, 0, old.length);
int deltaWidth = newChild.width() - old[index].width();
copy[index] = newChild;
- return replaceChildren(copy, width + deltaWidth);
+ return replaceChildren(copy, width() + deltaWidth);
}
public int childrenStartRel() {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java
index 07b23f7b1..422cde7a9 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java
@@ -32,7 +32,7 @@ public GreenProcessingInstruction(int width, boolean closed, boolean startTagClo
String target, boolean prolog, boolean processingInstruction,
int startContentRel, int endContentRel, int endTagOpenRel,
GreenAttr[] attributes) {
- super(width, closed);
+ super(width, closed ? CLOSED_FLAG : 0);
this.startTagClose = startTagClose;
this.target = target;
this.prolog = prolog;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenText.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenText.java
index 5608f3c9b..f6fabece2 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenText.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenText.java
@@ -18,11 +18,23 @@
*/
public final class GreenText extends GreenNode {
- private final boolean whitespace;
+ private static final int CACHE_SIZE = 128;
+ private static final GreenText[] WHITESPACE_CACHE = new GreenText[CACHE_SIZE];
+ static {
+ for (int i = 0; i < CACHE_SIZE; i++) {
+ WHITESPACE_CACHE[i] = new GreenText(i, true);
+ }
+ }
public GreenText(int width, boolean whitespace) {
- super(width, true);
- this.whitespace = whitespace;
+ super(width, CLOSED_FLAG | (whitespace ? SUBCLASS_FLAG : 0));
+ }
+
+ public static GreenText whitespace(int width) {
+ if (width >= 0 && width < CACHE_SIZE) {
+ return WHITESPACE_CACHE[width];
+ }
+ return new GreenText(width, true);
}
@Override
@@ -31,11 +43,11 @@ public short nodeType() {
}
public boolean whitespace() {
- return whitespace;
+ return subclassFlag();
}
@Override
protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) {
- return new GreenText(newWidth, whitespace);
+ return whitespace() ? whitespace(newWidth) : new GreenText(newWidth, false);
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
index efe3c37af..55600cd8a 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
@@ -13,6 +13,7 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Deque;
import java.util.List;
@@ -439,7 +440,7 @@ public static GreenDocument parseRange(String text, String uri,
break;
}
- GreenText textNode = new GreenText(end - start, isBlank);
+ GreenText textNode = isBlank ? GreenText.whitespace(end - start) : new GreenText(end - start, false);
addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, textNode, start);
break;
}
@@ -831,8 +832,8 @@ public static GreenDocument parseRange(String text, String uri,
}
private static int lastParamEnd(NodeBuilder nb) {
- if (nb.dtdParams != null && !nb.dtdParams.isEmpty()) {
- return nb.dtdParams.get(nb.dtdParams.size() - 1)[1];
+ if (nb.dtdParams != null && nb.paramCount > 0) {
+ return nb.dtdParams[nb.paramCount - 1][1];
}
return nb.nodeEnd;
}
@@ -879,7 +880,7 @@ private static void addChildToCurrentOrRoot(Deque stack,
stack.peek().addChild(child, childAbsStart);
} else {
if (childAbsStart > nextRootChildEnd[0]) {
- rootChildren.add(new GreenText(childAbsStart - nextRootChildEnd[0], true));
+ rootChildren.add(GreenText.whitespace(childAbsStart - nextRootChildEnd[0]));
}
rootChildren.add(child);
nextRootChildEnd[0] = childAbsStart + child.width();
@@ -905,7 +906,8 @@ static final class NodeBuilder {
int startTagCloseOffset = GreenElement.NULL_VALUE;
int endTagOpenOffset = GreenElement.NULL_VALUE;
int endTagCloseOffset = GreenElement.NULL_VALUE;
- List attributes;
+ GreenAttr[] attributes;
+ int attrCount;
// PI fields
boolean startTagClose;
@@ -933,10 +935,12 @@ static final class NodeBuilder {
int[] dtdPercentParam;
int[] dtdValueParam;
int[] dtdUnrecognizedParam;
- List dtdParams;
+ int[][] dtdParams;
+ int paramCount;
// Children
- List children;
+ GreenNode[] children;
+ int childCount;
int firstChildAbsStart = GreenElement.NULL_VALUE;
int nextChildAbsStart = GreenElement.NULL_VALUE;
@@ -948,29 +952,51 @@ static final class NodeBuilder {
void addChild(GreenNode child, int childAbsStart) {
if (children == null) {
- children = new ArrayList<>(4);
+ children = new GreenNode[4];
+ childCount = 0;
firstChildAbsStart = childAbsStart;
nextChildAbsStart = childAbsStart;
}
if (childAbsStart > nextChildAbsStart) {
- children.add(new GreenText(childAbsStart - nextChildAbsStart, true));
+ appendChild(GreenText.whitespace(childAbsStart - nextChildAbsStart));
}
- children.add(child);
+ appendChild(child);
nextChildAbsStart = childAbsStart + child.width();
}
+ private void appendChild(GreenNode child) {
+ if (childCount == children.length) {
+ GreenNode[] grown = new GreenNode[children.length * 2];
+ System.arraycopy(children, 0, grown, 0, childCount);
+ children = grown;
+ }
+ children[childCount++] = child;
+ }
+
void addAttribute(GreenAttr attr) {
if (attributes == null) {
- attributes = new ArrayList<>(4);
+ attributes = new GreenAttr[4];
+ attrCount = 0;
}
- attributes.add(attr);
+ if (attrCount == attributes.length) {
+ GreenAttr[] grown = new GreenAttr[attributes.length * 2];
+ System.arraycopy(attributes, 0, grown, 0, attrCount);
+ attributes = grown;
+ }
+ attributes[attrCount++] = attr;
}
void addParam(int start, int end) {
if (dtdParams == null) {
- dtdParams = new ArrayList<>(4);
+ dtdParams = new int[4][];
+ paramCount = 0;
+ }
+ if (paramCount == dtdParams.length) {
+ int[][] grown = new int[dtdParams.length * 2][];
+ System.arraycopy(dtdParams, 0, grown, 0, paramCount);
+ dtdParams = grown;
}
- dtdParams.add(new int[] { start, end });
+ dtdParams[paramCount++] = new int[] { start, end };
}
void addDeclType(int start, int end) {
@@ -978,20 +1004,20 @@ void addDeclType(int start, int end) {
}
int[] lastParam() {
- return dtdParams.get(dtdParams.size() - 1);
+ return dtdParams[paramCount - 1];
}
void updateLastParamEnd(int end) {
- if (dtdParams != null && !dtdParams.isEmpty()) {
- int[] last = dtdParams.get(dtdParams.size() - 1);
- last[1] = end;
+ if (dtdParams != null && paramCount > 0) {
+ dtdParams[paramCount - 1][1] = end;
}
}
GreenNode buildGreen() {
int width = nodeEnd - nodeStart;
GreenNode[] kids = children != null
- ? children.toArray(GreenNode.EMPTY_CHILDREN) : null;
+ ? (childCount == children.length ? children : Arrays.copyOf(children, childCount))
+ : null;
switch (kind) {
case ELEMENT:
@@ -1021,7 +1047,8 @@ GreenNode buildGreen() {
private GreenElement buildElement(int width, GreenNode[] kids) {
GreenAttr[] attrs = attributes != null
- ? attributes.toArray(GreenNode.EMPTY_ATTRS) : null;
+ ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount))
+ : null;
int contentStart = firstChildAbsStart != GreenElement.NULL_VALUE
? firstChildAbsStart - nodeStart
: (startTagCloseOffset != GreenElement.NULL_VALUE
@@ -1044,7 +1071,8 @@ private GreenCDATA buildCDATA(int width) {
private GreenProcessingInstruction buildPI(int width) {
GreenAttr[] attrs = attributes != null
- ? attributes.toArray(GreenNode.EMPTY_ATTRS) : null;
+ ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount))
+ : null;
return new GreenProcessingInstruction(width, closed, startTagClose,
target, prolog, processingInstruction,
rel(startContentOffset), rel(endContentOffset),
@@ -1076,7 +1104,7 @@ private GreenNode[] padChildrenForDocType(GreenNode[] kids) {
return kids;
}
GreenNode[] padded = new GreenNode[kids.length + 1];
- padded[0] = new GreenText(leadingGap, true);
+ padded[0] = GreenText.whitespace(leadingGap);
System.arraycopy(kids, 0, padded, 1, kids.length);
return padded;
}
@@ -1152,12 +1180,12 @@ private GreenDTDParam toParam(int[] offsets) {
}
private GreenDTDParam[] buildDTDParams() {
- if (dtdParams == null || dtdParams.isEmpty()) {
+ if (dtdParams == null || paramCount == 0) {
return null;
}
- GreenDTDParam[] result = new GreenDTDParam[dtdParams.size()];
- for (int i = 0; i < dtdParams.size(); i++) {
- int[] p = dtdParams.get(i);
+ GreenDTDParam[] result = new GreenDTDParam[paramCount];
+ for (int i = 0; i < paramCount; i++) {
+ int[] p = dtdParams[i];
result[i] = new GreenDTDParam(p[0] - nodeStart, p[1] - nodeStart);
}
return result;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
index 48e9cb08b..ec0d3dfc5 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
@@ -227,7 +227,7 @@ private static GreenNode[] coalesceAdjacentText(GreenNode[] nodes) {
i++;
}
result[j++] = (i - start == 1) ? nodes[start]
- : new GreenText(w, allWhitespace);
+ : allWhitespace ? GreenText.whitespace(w) : new GreenText(w, false);
} else {
result[j++] = nodes[i++];
}
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ArrayLineTrackerTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ArrayLineTrackerTest.java
new file mode 100644
index 000000000..495188ddd
--- /dev/null
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ArrayLineTrackerTest.java
@@ -0,0 +1,263 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Red Hat Inc. and others.
+ * All rights reserved. This program and the accompanying materials
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat Inc. - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.lemminx.commons;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.eclipse.lsp4j.Position;
+import org.junit.jupiter.api.Test;
+
+public class ArrayLineTrackerTest {
+
+ @Test
+ public void emptyText() throws BadLocationException {
+ ArrayLineTracker t = create("");
+ assertEquals(1, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(0, t.getLineLength(0));
+ assertNull(t.getLineDelimiter(0));
+ }
+
+ @Test
+ public void singleLine() throws BadLocationException {
+ ArrayLineTracker t = create("hello");
+ assertEquals(1, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(5, t.getLineLength(0));
+ assertNull(t.getLineDelimiter(0));
+
+ assertEquals(0, t.getLineNumberOfOffset(0));
+ assertEquals(0, t.getLineNumberOfOffset(4));
+ assertEquals(0, t.getLineNumberOfOffset(5));
+ }
+
+ @Test
+ public void twoLinesLF() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef");
+ assertEquals(2, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(4, t.getLineLength(0));
+ assertEquals("\n", t.getLineDelimiter(0));
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(3, t.getLineLength(1));
+ assertNull(t.getLineDelimiter(1));
+
+ assertEquals(0, t.getLineNumberOfOffset(0));
+ assertEquals(0, t.getLineNumberOfOffset(3));
+ assertEquals(1, t.getLineNumberOfOffset(4));
+ assertEquals(1, t.getLineNumberOfOffset(7));
+ }
+
+ @Test
+ public void twoLinesCRLF() throws BadLocationException {
+ ArrayLineTracker t = create("abc\r\ndef");
+ assertEquals(2, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(5, t.getLineLength(0));
+ assertEquals("\r\n", t.getLineDelimiter(0));
+ assertEquals(5, t.getLineOffset(1));
+ assertEquals(3, t.getLineLength(1));
+ }
+
+ @Test
+ public void trailingNewline() throws BadLocationException {
+ ArrayLineTracker t = create("abc\n");
+ assertEquals(2, t.getNumberOfLines());
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(0, t.getLineLength(1));
+ assertNull(t.getLineDelimiter(1));
+ }
+
+ @Test
+ public void positionAt() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ Position p = t.getPositionAt(0);
+ assertEquals(0, p.getLine());
+ assertEquals(0, p.getCharacter());
+
+ p = t.getPositionAt(5);
+ assertEquals(1, p.getLine());
+ assertEquals(1, p.getCharacter());
+
+ p = t.getPositionAt(11);
+ assertEquals(2, p.getLine());
+ assertEquals(3, p.getCharacter());
+ }
+
+ @Test
+ public void offsetAt() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ assertEquals(0, t.getOffsetAt(new Position(0, 0)));
+ assertEquals(3, t.getOffsetAt(new Position(0, 3)));
+ assertEquals(4, t.getOffsetAt(new Position(1, 0)));
+ assertEquals(8, t.getOffsetAt(new Position(2, 0)));
+ }
+
+ @Test
+ public void replaceInsertCharNoDelimiter() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef");
+ t.replace(1, 0, "X");
+ assertEquals(2, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(5, t.getLineLength(0));
+ assertEquals(5, t.getLineOffset(1));
+ assertEquals(3, t.getLineLength(1));
+ }
+
+ @Test
+ public void replaceInsertNewline() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef");
+ t.replace(5, 0, "\n");
+ assertEquals(3, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(4, t.getLineLength(0));
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(2, t.getLineLength(1));
+ assertEquals("\n", t.getLineDelimiter(1));
+ assertEquals(6, t.getLineOffset(2));
+ assertEquals(2, t.getLineLength(2));
+ assertNull(t.getLineDelimiter(2));
+ }
+
+ @Test
+ public void replaceDeleteNewline() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef");
+ t.replace(3, 1, "");
+ assertEquals(1, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(6, t.getLineLength(0));
+ assertNull(t.getLineDelimiter(0));
+ }
+
+ @Test
+ public void replaceDeleteMultipleLines() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi\njkl");
+ // Delete "ef\nghi" at offset 5, length 6 → "abc\nd\njkl"
+ t.replace(5, 6, "");
+ assertEquals(3, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(4, t.getLineLength(0));
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(2, t.getLineLength(1));
+ assertEquals(6, t.getLineOffset(2));
+ assertEquals(3, t.getLineLength(2));
+ }
+
+ @Test
+ public void replaceWithMultipleNewlines() throws BadLocationException {
+ ArrayLineTracker t = create("abcdef");
+ t.replace(2, 2, "X\nY\nZ");
+ assertEquals(3, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(4, t.getLineLength(0));
+ assertEquals("\n", t.getLineDelimiter(0));
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(2, t.getLineLength(1));
+ assertEquals("\n", t.getLineDelimiter(1));
+ assertEquals(6, t.getLineOffset(2));
+ assertEquals(3, t.getLineLength(2));
+ assertNull(t.getLineDelimiter(2));
+ }
+
+ @Test
+ public void replaceEntireContent() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef");
+ t.replace(0, 7, "x\ny\nz");
+ assertEquals(3, t.getNumberOfLines());
+ assertEquals(0, t.getLineOffset(0));
+ assertEquals(2, t.getLineLength(0));
+ assertEquals(2, t.getLineOffset(1));
+ assertEquals(2, t.getLineLength(1));
+ assertEquals(4, t.getLineOffset(2));
+ assertEquals(1, t.getLineLength(2));
+ }
+
+ @Test
+ public void computeNumberOfLines() {
+ ArrayLineTracker t = create("");
+ assertEquals(0, t.computeNumberOfLines(""));
+ assertEquals(0, t.computeNumberOfLines("abc"));
+ assertEquals(1, t.computeNumberOfLines("abc\n"));
+ assertEquals(2, t.computeNumberOfLines("abc\ndef\n"));
+ assertEquals(1, t.computeNumberOfLines("\r\n"));
+ assertEquals(2, t.computeNumberOfLines("\r\n\r\n"));
+ }
+
+ @Test
+ public void getNumberOfLinesRange() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ assertEquals(1, t.getNumberOfLines(0, 0));
+ assertEquals(1, t.getNumberOfLines(0, 3));
+ assertEquals(2, t.getNumberOfLines(0, 4));
+ assertEquals(3, t.getNumberOfLines(0, 11));
+ }
+
+ @Test
+ public void lineInformationOfOffset() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ Line line = t.getLineInformationOfOffset(0);
+ assertEquals(0, line.offset);
+ assertEquals(3, line.length);
+
+ line = t.getLineInformationOfOffset(5);
+ assertEquals(4, line.offset);
+ assertEquals(3, line.length);
+ }
+
+ @Test
+ public void lineInformation() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ Line line = t.getLineInformation(0);
+ assertEquals(0, line.offset);
+ assertEquals(3, line.length);
+
+ line = t.getLineInformation(1);
+ assertEquals(4, line.offset);
+ assertEquals(3, line.length);
+
+ line = t.getLineInformation(2);
+ assertEquals(8, line.offset);
+ assertEquals(3, line.length);
+ }
+
+ @Test
+ public void badOffsetThrows() {
+ ArrayLineTracker t = create("abc");
+ assertThrows(BadLocationException.class, () -> t.getLineNumberOfOffset(-1));
+ assertThrows(BadLocationException.class, () -> t.getLineNumberOfOffset(4));
+ assertThrows(BadLocationException.class, () -> t.getLineOffset(-1));
+ assertThrows(BadLocationException.class, () -> t.getLineOffset(2));
+ }
+
+ @Test
+ public void multipleSequentialReplacements() throws BadLocationException {
+ ArrayLineTracker t = create("abc\ndef\nghi");
+ // Insert "X" at offset 4 → "abc\nXdef\nghi"
+ t.replace(4, 0, "X");
+ assertEquals(3, t.getNumberOfLines());
+ assertEquals(4, t.getLineOffset(1));
+ // Insert "\n" at offset 5 → "abc\nX\ndef\nghi"
+ t.replace(5, 0, "\n");
+ assertEquals(4, t.getNumberOfLines());
+ assertEquals(4, t.getLineOffset(1));
+ assertEquals(6, t.getLineOffset(2));
+ assertEquals(10, t.getLineOffset(3));
+ }
+
+ private static ArrayLineTracker create(String text) {
+ ArrayLineTracker t = new ArrayLineTracker();
+ t.set(text);
+ return t;
+ }
+}
From ab58dc7e441af1bfb42c21753fc73d7bfb5fe0b4 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 15:35:01 +0200
Subject: [PATCH 08/18] Reduce TextDocument.update allocation with persistent
StringBuilder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use a persistent StringBuilder buffer for incremental text updates
instead of creating a new one on every edit. Lazy toString() caching
defers String materialization until getText() is actually called.
- Persistent textBuffer: eliminates ~2.7 GB of StringBuilder allocations
- Lazy cachedText: halves toString() calls (114 → 51 per 30s session)
- GC events reduced from 77 to 52 (-32%)
- TextDocument.update no longer the #1 allocator
Co-Authored-By: Claude Opus 4.6
---
.../eclipse/lemminx/commons/TextDocument.java | 48 ++++++++++++++-----
1 file changed, 37 insertions(+), 11 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
index bc62642de..9e37b847c 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
@@ -39,6 +39,10 @@ public class TextDocument extends TextDocumentItem {
private boolean incremental;
+ private StringBuilder textBuffer;
+
+ private String cachedText;
+
public TextDocument(TextDocumentItem document) {
this(document.getText(), document.getUri());
super.setVersion(document.getVersion());
@@ -50,6 +54,30 @@ public TextDocument(String text, String uri) {
super.setText(text);
}
+ @Override
+ public String getText() {
+ synchronized (lock) {
+ if (cachedText != null) {
+ return cachedText;
+ }
+ if (textBuffer != null) {
+ cachedText = textBuffer.toString();
+ super.setText(cachedText);
+ return cachedText;
+ }
+ }
+ return super.getText();
+ }
+
+ @Override
+ public void setText(String text) {
+ synchronized (lock) {
+ super.setText(text);
+ textBuffer = null;
+ cachedText = null;
+ }
+ }
+
public void setIncremental(boolean incremental) {
this.incremental = incremental;
// reset line tracker
@@ -74,7 +102,7 @@ public int offsetAt(Position position) throws BadLocationException {
public String lineText(int lineNumber) throws BadLocationException {
ILineTracker lineTracker = getLineTracker();
Line line = lineTracker.getLineInformation(lineNumber);
- String text = super.getText();
+ String text = getText();
return text.substring(line.offset, line.offset + line.length);
}
@@ -115,7 +143,7 @@ public Range getWordRangeAt(int textOffset, Pattern wordDefinition) {
Position pos = positionAt(textOffset);
ILineTracker lineTracker = getLineTracker();
Line line = lineTracker.getLineInformation(pos.getLine());
- String text = super.getText();
+ String text = getText();
String lineText = text.substring(line.offset, textOffset);
int position = lineText.length();
Matcher m = wordDefinition.matcher(lineText);
@@ -149,7 +177,7 @@ private synchronized ILineTracker createLineTracker() {
return lineTracker;
}
ILineTracker lineTracker = isIncremental() ? new ArrayLineTracker() : new ListLineTracker();
- lineTracker.set(super.getText());
+ lineTracker.set(getText());
return lineTracker;
}
@@ -168,10 +196,10 @@ public void update(List changes) {
try {
long start = System.currentTimeMillis();
synchronized (lock) {
- // Initialize buffer and line tracker from the current text document
- StringBuilder buffer = new StringBuilder(getText());
+ if (textBuffer == null) {
+ textBuffer = new StringBuilder(getText());
+ }
- // Loop for each changes and update the buffer
for (int i = 0; i < changes.size(); i++) {
TextDocumentContentChangeEvent changeEvent = changes.get(i);
@@ -182,17 +210,15 @@ public void update(List changes) {
Integer rangeLength = changeEvent.getRangeLength();
length = rangeLength != null ? rangeLength.intValue() : offsetAt(range.getEnd()) - offsetAt(range.getStart());
} else {
- // range is optional and if not given, the whole file content is replaced
- length = buffer.length();
+ length = textBuffer.length();
range = new Range(positionAt(0), positionAt(length));
}
String text = changeEvent.getText();
int startOffset = offsetAt(range.getStart());
- buffer.replace(startOffset, startOffset + length, text);
+ textBuffer.replace(startOffset, startOffset + length, text);
lineTracker.replace(startOffset, length, text);
}
- // Update the new text content from the updated buffer
- setText(buffer.toString());
+ cachedText = null;
}
LOGGER.fine("Text document content updated in " + (System.currentTimeMillis() - start) + "ms");
} catch (BadLocationException e) {
From 598b50d1bac79eb965c401a96f99ca64c744d911 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 21:13:47 +0200
Subject: [PATCH 09/18] Migrate getText() callers to CharSequence to eliminate
toString() copies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace getText() with getTextSequence() across 44 files so the parser,
formatter, completions, and extensions read the StringBuilder directly
via CharSequence — zero-copy during rapid typing. Only callers that need
String for external APIs (Xerces InputSource, DOMParser.parse(String))
retain getText(). Also replace regex-based #region detection in
XMLFoldings with char-by-char scanning.
JFR profiling confirms LemMinX allocations dropped out of the top
allocators entirely (0 GC events in 30s vs 77 before optimizations).
Co-Authored-By: Claude Opus 4.6
---
.../relaxng/pattern/CMRelaxNGDocument.java | 2 +-
.../eclipse/lemminx/commons/TextDocument.java | 13 +++++
.../lemminx/commons/TextEditUtils.java | 8 +--
.../java/org/eclipse/lemminx/dom/DOMAttr.java | 4 +-
.../eclipse/lemminx/dom/DOMCharacterData.java | 2 +-
.../org/eclipse/lemminx/dom/DOMDocument.java | 16 ++++--
.../eclipse/lemminx/dom/DOMDocumentType.java | 2 +-
.../org/eclipse/lemminx/dom/DOMElement.java | 4 +-
.../org/eclipse/lemminx/dom/DOMParser.java | 4 +-
.../java/org/eclipse/lemminx/dom/DOMText.java | 2 +-
.../eclipse/lemminx/dom/DTDElementDecl.java | 6 +--
.../lemminx/dom/green/GreenTreeBuilder.java | 6 +--
.../lemminx/dom/green/IncrementalParser.java | 6 +--
.../lemminx/dom/parser/MultiLineStream.java | 10 ++--
.../lemminx/dom/parser/XMLScanner.java | 18 +++----
.../ContentModelCompletionParticipant.java | 4 +-
.../participants/XMLSyntaxErrorCode.java | 6 +--
.../codeactions/CloseTagCodeAction.java | 10 ++--
.../DownloadDisabledResourceCodeAction.java | 2 +-
.../EntityNotDeclaredCodeAction.java | 2 +-
.../FixMissingSpaceCodeAction.java | 3 +-
...MLCloseTagInlineCompletionParticipant.java | 4 +-
.../FilePathCompletionParticipant.java | 2 +-
.../FilePathCompletionResult.java | 6 +--
.../minify/XMLMinifierDocument.java | 8 +--
.../references/search/SearchNode.java | 12 ++---
.../references/search/SearchNodeFactory.java | 4 +-
.../lemminx/services/XMLCompletions.java | 46 ++++++++---------
.../eclipse/lemminx/services/XMLFoldings.java | 49 +++++++++++++++----
.../lemminx/services/XMLFormatter.java | 4 +-
.../eclipse/lemminx/services/XMLHover.java | 2 +-
.../lemminx/services/XMLLanguageService.java | 2 +-
.../eclipse/lemminx/services/XMLMinifier.java | 4 +-
.../services/format/DOMCDATAFormatter.java | 2 +-
.../services/format/DOMCommentFormatter.java | 2 +-
.../services/format/DOMDocTypeFormatter.java | 6 +--
.../services/format/DOMElementFormatter.java | 4 +-
.../services/format/DOMTextFormatter.java | 4 +-
.../services/format/XMLFormatterDocument.java | 28 ++++++-----
.../format/XMLFormatterDocumentOld.java | 9 ++--
.../snippets/SnippetContextUtils.java | 4 +-
.../eclipse/lemminx/utils/StringUtils.java | 12 ++---
.../eclipse/lemminx/utils/TextEditUtils.java | 2 +-
.../lemminx/utils/XMLPositionUtility.java | 14 +++---
44 files changed, 207 insertions(+), 153 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/com/thaiopensource/relaxng/pattern/CMRelaxNGDocument.java b/org.eclipse.lemminx/src/main/java/com/thaiopensource/relaxng/pattern/CMRelaxNGDocument.java
index 7441050f2..c077453b8 100644
--- a/org.eclipse.lemminx/src/main/java/com/thaiopensource/relaxng/pattern/CMRelaxNGDocument.java
+++ b/org.eclipse.lemminx/src/main/java/com/thaiopensource/relaxng/pattern/CMRelaxNGDocument.java
@@ -317,6 +317,6 @@ DOMNode findNodeAt(Locator locator) {
private static String getTextContent(DOMElement element) {
int start = element.getStartTagCloseOffset() + 1;
int end = element.getEndTagOpenOffset();
- return element.getOwnerDocument().getText().substring(start, end);
+ return element.getOwnerDocument().getTextSequence().subSequence(start, end).toString();
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
index 9e37b847c..372a12485 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
@@ -54,6 +54,10 @@ public TextDocument(String text, String uri) {
super.setText(text);
}
+ /**
+ * Returns the text content as a {@link String}. Prefer
+ * {@link #getTextSequence()} which avoids costly string materialization.
+ */
@Override
public String getText() {
synchronized (lock) {
@@ -69,6 +73,15 @@ public String getText() {
return super.getText();
}
+ public CharSequence getTextSequence() {
+ synchronized (lock) {
+ if (textBuffer != null) {
+ return textBuffer;
+ }
+ }
+ return getText();
+ }
+
@Override
public void setText(String text) {
synchronized (lock) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextEditUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextEditUtils.java
index d21b703a1..ea61a98d8 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextEditUtils.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextEditUtils.java
@@ -64,7 +64,7 @@ public static List> toEitherTextEdits(List edits) throws BadLocationException {
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
Collections.sort(edits /* .map(getWellformedEdit) */, (a, b) -> {
int diff = a.getRange().getStart().getLine() - b.getRange().getStart().getLine();
if (diff == 0) {
@@ -155,7 +155,7 @@ public static String applyEdits(TextDocument document, List extends TextEdit>
* @return the offset of the first whitespace that's found in the given range
* [leftLimit,to] from the left of the to, and leftLimit otherwise.
*/
- public static int adjustOffsetWithLeftWhitespaces(int leftLimit, int to, String text) {
+ public static int adjustOffsetWithLeftWhitespaces(int leftLimit, int to, CharSequence text) {
if (to == 0) {
return -1;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMAttr.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMAttr.java
index 19c5419e4..1ba927f61 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMAttr.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMAttr.java
@@ -90,7 +90,7 @@ public String getName() {
// Memory optimization: Extract name from document instead of caching
if (nameStart != NULL_VALUE && nameEnd != NULL_VALUE) {
// Name is in the document, extract it
- return getOwnerDocument().getText().substring(nameStart, nameEnd);
+ return getOwnerDocument().getTextSequence().subSequence(nameStart, nameEnd).toString();
}
// Name was set programmatically or doesn't exist
return name;
@@ -228,7 +228,7 @@ public boolean hasDelimiter() {
public String getOriginalValue() {
// Memory optimization: Extract from document instead of caching
if (valueStart != NULL_VALUE && delimiter < valueStart) {
- return getOwnerDocument().getText().substring(valueStart, valueEnd);
+ return getOwnerDocument().getTextSequence().subSequence(valueStart, valueEnd).toString();
}
return value;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
index 564e53ec2..5ca564348 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMCharacterData.java
@@ -131,7 +131,7 @@ public int getEndContent() {
public String getData() {
// No caching - extract directly from document to save memory
// The document text is already in memory, so this is just a substring operation
- return getOwnerDocument().getText().substring(getStartContent(), getEndContent());
+ return getOwnerDocument().getTextSequence().subSequence(getStartContent(), getEndContent()).toString();
}
/*
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
index ad33b1fee..a1e069e73 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java
@@ -69,7 +69,7 @@ public class DOMDocument extends DOMNode implements Document {
private volatile GreenDocument greenDocument;
public DOMDocument(TextDocument textDocument, URIResolverExtensionManager resolverExtensionManager) {
- super(0, textDocument.getText().length());
+ super(0, textDocument.getTextSequence().length());
this.textDocument = textDocument;
this.resolverExtensionManager = resolverExtensionManager;
resetGrammar();
@@ -142,14 +142,22 @@ public String getNamespaceURI() {
}
/**
- * Returns the text content of the XML document.
- *
- * @return the text content of the XML document.
+ * Returns the text content as a {@link String}. Prefer
+ * {@link #getTextSequence()} which avoids costly string materialization.
*/
public String getText() {
return textDocument.getText();
}
+ /**
+ * Returns the text content as a {@link CharSequence}, avoiding the
+ * allocation of a full {@link String} copy when the document is backed
+ * by a {@link StringBuilder}.
+ */
+ public CharSequence getTextSequence() {
+ return textDocument.getTextSequence();
+ }
+
public TextDocument getTextDocument() {
return textDocument;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocumentType.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocumentType.java
index 25e0bf33c..0a53a3fa8 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocumentType.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocumentType.java
@@ -47,7 +47,7 @@ public DOMDocumentType getOwnerDocType() {
@Override
public String getTextContent() {
if (content == null) {
- content = getOwnerDocument().getText().substring(getStart(), getEnd());
+ content = getOwnerDocument().getTextSequence().subSequence(getStart(), getEnd()).toString();
}
return content;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
index 6f02007be..7da58aace 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
@@ -252,7 +252,7 @@ void setSelfClosed(boolean selfClosed) {
* position after the character you want to start at.
*/
public Integer endsWith(char c, int startOffset) {
- String text = this.getOwnerDocument().getText();
+ CharSequence text = this.getOwnerDocument().getTextSequence();
if (startOffset > text.length() || startOffset < 0) {
return null;
}
@@ -461,7 +461,7 @@ public boolean isOrphanEndTagOf(String tagName) {
* with an angle bracket
*/
public int getUnclosedStartTagCloseOffset() {
- String documentText = getOwnerDocument().getText();
+ CharSequence documentText = getOwnerDocument().getTextSequence();
int i = getStart() + 1;
for (; i < documentText.length() && documentText.charAt(i) != '/' && documentText.charAt(i) != '<'; i++) {
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
index 274a0f8b3..b0aaef526 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
@@ -60,7 +60,7 @@ public DOMDocument parse(TextDocument document, URIResolverExtensionManager reso
public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager,
boolean ignoreWhitespaceContent, CancelChecker monitor) {
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
String uri = document.getUri();
GreenDocument greenDoc = GreenTreeBuilder.parse(text, uri, monitor);
return buildDocument(greenDoc, document, resolverExtensionManager, ignoreWhitespaceContent, monitor);
@@ -70,7 +70,7 @@ public DOMDocument parseIncremental(TextDocument document,
GreenDocument previousGreenDoc, int editStart, int deleteLength, int insertLength,
URIResolverExtensionManager resolverExtensionManager,
boolean ignoreWhitespaceContent, CancelChecker monitor) {
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
String uri = document.getUri();
GreenDocument greenDoc = IncrementalParser.incrementalParse(
previousGreenDoc, text, editStart, deleteLength, insertLength, uri, monitor);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMText.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMText.java
index ba408bc89..618f1befe 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMText.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMText.java
@@ -62,7 +62,7 @@ public String getWholeText() {
*/
@Override
public boolean isElementContentWhitespace() {
- String text = getOwnerDocument().getOwnerDocument().getText();
+ CharSequence text = getOwnerDocument().getOwnerDocument().getTextSequence();
return StringUtils.isWhitespace(text, getStart(), getEnd());
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDElementDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDElementDecl.java
index a6f78d6d1..74df5eaa1 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDElementDecl.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDElementDecl.java
@@ -101,7 +101,7 @@ public DTDDeclParameter getParameterAt(int offset) {
return null;
}
// We are after the wordStart offset and ends at wordEnd
* matches the given searchName
*/
- private static boolean isMatchName(String searchWord, String text, int wordStart, int wordEnd) {
+ private static boolean isMatchName(String searchWord, CharSequence text, int wordStart, int wordEnd) {
int length = wordEnd - wordStart;
if (searchWord.length() != length) {
return false;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
index 55600cd8a..25f96d973 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
@@ -43,11 +43,11 @@ private GreenTreeBuilder() {
* @param monitor optional cancel checker
* @return the root green document node
*/
- public static GreenDocument parse(String text, String uri, CancelChecker monitor) {
+ public static GreenDocument parse(CharSequence text, String uri, CancelChecker monitor) {
return parseRange(text, uri, 0, text.length(), monitor);
}
- public static GreenDocument parseRange(String text, String uri,
+ public static GreenDocument parseRange(CharSequence text, String uri,
int rangeStart, int rangeEnd, CancelChecker monitor) {
boolean isDTD = DOMUtils.isDTD(uri);
Scanner scanner = XMLScanner.createScanner(text, rangeStart, isDTD);
@@ -863,7 +863,7 @@ private static boolean hasMatchingElement(Deque stack, String tag)
return false;
}
- private static boolean containsNewline(String text, int from, int to) {
+ private static boolean containsNewline(CharSequence text, int from, int to) {
for (int i = from; i < to; i++) {
char c = text.charAt(i);
if (c == '\n' || c == '\r') {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
index ec0d3dfc5..32c33de6c 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java
@@ -50,7 +50,7 @@ private IncrementalParser() {
* @return a new GreenDocument (never null)
*/
public static GreenDocument incrementalParse(GreenDocument oldDoc,
- String newText, int editStart, int deleteLength, int insertLength,
+ CharSequence newText, int editStart, int deleteLength, int insertLength,
String uri, CancelChecker monitor) {
int delta = insertLength - deleteLength;
int editEnd = editStart + deleteLength;
@@ -68,7 +68,7 @@ public static GreenDocument incrementalParse(GreenDocument oldDoc,
private static GreenNode[] tryIncrementalOnChildren(
GreenNode[] oldChildren, int childrenAbsStart, int childrenAreaWidth,
int editStart, int editEnd, int delta,
- String newText, String uri, CancelChecker monitor) {
+ CharSequence newText, String uri, CancelChecker monitor) {
if (oldChildren.length == 0) {
return null;
@@ -155,7 +155,7 @@ private static GreenNode[] tryIncrementalOnChildren(
private static GreenElement tryDescentIntoElement(
GreenElement elem, int elemAbsStart,
int editStart, int editEnd, int delta,
- String newText, String uri, CancelChecker monitor) {
+ CharSequence newText, String uri, CancelChecker monitor) {
int csr = elem.childrenStartRel();
int childrenAbsStart = elemAbsStart + csr;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/MultiLineStream.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/MultiLineStream.java
index 4af050409..eeb317026 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/MultiLineStream.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/MultiLineStream.java
@@ -43,12 +43,12 @@ public class MultiLineStream {
return ch == _WSP || ch == _TAB || ch == _NWL || ch == _LFD || ch == _CAR;
};
- private final String source;
+ private final CharSequence source;
private final int len;
private int position;
private final Map regexpCache;
- public MultiLineStream(String source, int position) {
+ public MultiLineStream(CharSequence source, int position) {
this.source = source;
this.len = source.length();
this.position = position;
@@ -59,7 +59,7 @@ public boolean eos() {
return this.len <= this.position;
}
- public String getSource() {
+ public CharSequence getSource() {
return this.source;
}
@@ -103,7 +103,7 @@ public int peekChar(int n) {
if (pos >= len) {
return -1;
}
- return this.source.codePointAt(pos);
+ return Character.codePointAt(this.source, pos);
}
/**
@@ -115,7 +115,7 @@ public int peekCharAtOffset(int offset) {
if (offset >= len || offset < 0) {
return -1;
}
- return this.source.codePointAt(offset);
+ return Character.codePointAt(this.source, offset);
}
public boolean advanceIfChar(int ch) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/XMLScanner.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/XMLScanner.java
index 377c42bcf..6afb42615 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/XMLScanner.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/parser/XMLScanner.java
@@ -139,7 +139,7 @@ public class XMLScanner implements Scanner {
boolean isInitialAttlistDeclCompleted = false;
private int nbBraceOpened;
- public XMLScanner(String input, int initialOffset, ScannerState initialState, boolean isDTDFile) {
+ public XMLScanner(CharSequence input, int initialOffset, ScannerState initialState, boolean isDTDFile) {
stream = new MultiLineStream(input, initialOffset);
state = initialState;
tokenOffset = 0;
@@ -1031,7 +1031,7 @@ public int getTokenEnd() {
@Override
public String getTokenText() {
- return stream.getSource().substring(tokenOffset, stream.pos());
+ return stream.getSource().subSequence(tokenOffset, stream.pos()).toString();
}
@Override
@@ -1050,31 +1050,31 @@ public String getTokenError() {
}
public String getTokenTextFromOffset(int offset) {
- return stream.getSource().substring(offset, stream.pos());
+ return stream.getSource().subSequence(offset, stream.pos()).toString();
}
- public static Scanner createScanner(String input) {
+ public static Scanner createScanner(CharSequence input) {
return createScanner(input, false);
}
- public static Scanner createScanner(String input, boolean isDTD) {
+ public static Scanner createScanner(CharSequence input, boolean isDTD) {
return createScanner(input, 0, isDTD);
}
- public static Scanner createScanner(String input, int initialOffset) {
+ public static Scanner createScanner(CharSequence input, int initialOffset) {
return createScanner(input, initialOffset, false);
}
- public static Scanner createScanner(String input, int initialOffset, boolean isDTDFile) {
+ public static Scanner createScanner(CharSequence input, int initialOffset, boolean isDTDFile) {
return createScanner(input, initialOffset,
isDTDFile ? ScannerState.DTDWithinContent : ScannerState.WithinContent, isDTDFile);
}
- public static Scanner createScanner(String input, int initialOffset, ScannerState initialState) {
+ public static Scanner createScanner(CharSequence input, int initialOffset, ScannerState initialState) {
return new XMLScanner(input, initialOffset, initialState, false);
}
- public static Scanner createScanner(String input, int initialOffset, ScannerState initialState, boolean isDTDFile) {
+ public static Scanner createScanner(CharSequence input, int initialOffset, ScannerState initialState, boolean isDTDFile) {
return new XMLScanner(input, initialOffset, initialState, isDTDFile);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/ContentModelCompletionParticipant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/ContentModelCompletionParticipant.java
index fcb689172..a21df0df2 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/ContentModelCompletionParticipant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/ContentModelCompletionParticipant.java
@@ -240,7 +240,7 @@ private static void addTagName(NodeList list, Set tags, ICompletionReque
CompletionItem item = new CompletionItem(tagName);
item.setKind(CompletionItemKind.Property);
item.setFilterText(request.getFilterForStartTagName(tagName));
- String xml = elt.getOwnerDocument().getText().substring(elt.getStart(), elt.getEnd());
+ String xml = elt.getOwnerDocument().getTextSequence().subSequence(elt.getStart(), elt.getEnd()).toString();
item.setTextEdit(Either.forLeft(new TextEdit(request.getReplaceRange(), xml)));
response.addCompletionItem(item);
tags.add(item.getLabel());
@@ -456,7 +456,7 @@ public void onXMLContent(ICompletionRequest request, ICompletionResponse respons
end = document.positionAt(endOffset);
}
int completionOffset = request.getOffset();
- String tokenStart = StringUtils.getWhitespaces(document.getText(), startOffset,
+ String tokenStart = StringUtils.getWhitespaces(document.getTextSequence(), startOffset,
completionOffset);
Range fullRange = new Range(start, end);
values.forEach(value -> {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/XMLSyntaxErrorCode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/XMLSyntaxErrorCode.java
index 13fd86b95..49ebc68b7 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/XMLSyntaxErrorCode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/XMLSyntaxErrorCode.java
@@ -191,7 +191,7 @@ public static Range toLSPRange(XMLLocator location, XMLSyntaxErrorCode code, Obj
*
* = text.length()) {
return text.length();
@@ -369,7 +369,7 @@ private static int removeLeftSpaces(final int initialOffset, String text) {
* @return the proper range from the given node to the given offset.
*/
private static Range getRangeFromStartNodeToOffset(DOMNode fromNode, int toOffset, DOMDocument document) {
- int endOffset = removeLeftSpaces(toOffset, document.getText());
+ int endOffset = removeLeftSpaces(toOffset, document.getTextSequence());
int startOffset = fromNode.getStart();
if (fromNode.isElement()) {
// The from node is a DOM element, adjust end and start offset
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/CloseTagCodeAction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/CloseTagCodeAction.java
index 7626fdb51..a304ca0bc 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/CloseTagCodeAction.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/CloseTagCodeAction.java
@@ -98,7 +98,7 @@ private void doCodeActionsForEndTagUnclosed(DOMElement element, DOMDocument docu
private void doCodeActionsForStartTagUnclosed(DOMElement element, DOMDocument document, Range diagnosticRange,
Diagnostic diagnostic, List codeActions) throws BadLocationException {
// Here start tag element is not closed with '>'.
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
int closeAngleBracketOffset = element.getUnclosedStartTagCloseOffset();
final Position closeAngleBracketPosition = document.positionAt(closeAngleBracketOffset);
if (!element.hasEndTag()) {
@@ -150,7 +150,7 @@ private void doCodeActionsForStartTagUnclosed(DOMElement element, DOMDocument do
private void doCodeActionsForStartTagClosed(DOMElement element, DOMDocument document, Range diagnosticRange,
Diagnostic diagnostic, List codeActions) throws BadLocationException {
// Here start tag element is closed with '>'.
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
if (!element.hasEndTag()) {
// The element has no an end tag
// ex :
@@ -261,10 +261,10 @@ private static CodeAction insertGreaterThanCharacterCodeAction(DOMDocument docum
*/
private static CodeAction removeTagCodeAction(DOMElement element, DOMDocument document, Diagnostic diagnostic)
throws BadLocationException {
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
Position startPosition = document.positionAt(element.getStart());
Position endPosition = document.positionAt(element.getEnd());
- String contentToRemove = text.substring(element.getStart(), element.getEnd());
+ String contentToRemove = text.subSequence(element.getStart(), element.getEnd()).toString();
CodeAction removeAction = CodeActionFactory.remove("Remove '" + contentToRemove + "'",
new Range(startPosition, endPosition), document.getTextDocument(), diagnostic);
return removeAction;
@@ -316,7 +316,7 @@ private static boolean hasElements(DOMElement element) {
return false;
}
- private static boolean isCharAt(String text, int offset, char ch) {
+ private static boolean isCharAt(CharSequence text, int offset, char ch) {
if (text.length() <= offset) {
return false;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/DownloadDisabledResourceCodeAction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/DownloadDisabledResourceCodeAction.java
index ae19c8d74..7de3ca24f 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/DownloadDisabledResourceCodeAction.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/DownloadDisabledResourceCodeAction.java
@@ -53,7 +53,7 @@ public void doCodeAction(ICodeActionRequest request, List codeAction
Range diagnosticRange = diagnostic.getRange();
int start = document.offsetAt(diagnosticRange.getStart());
int end = document.offsetAt(diagnosticRange.getEnd());
- String url = document.getText().substring(start, end);
+ String url = document.getTextSequence().subSequence(start, end).toString();
String title = MessageFormat.format(FORCE_DOWNLOAD_TITLE, url);
CodeAction codeAction = new CodeAction(title);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/EntityNotDeclaredCodeAction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/EntityNotDeclaredCodeAction.java
index 51c1bce87..7c1854009 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/EntityNotDeclaredCodeAction.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/EntityNotDeclaredCodeAction.java
@@ -225,7 +225,7 @@ private Position getEntityInsertPosition(DOMDocument document) throws BadLocatio
*/
private static String getEntityName(Diagnostic diagnostic, DOMDocument doc) throws BadLocationException {
Range range = diagnostic.getRange();
- String name = doc.getText().substring(doc.offsetAt(range.getStart()), doc.offsetAt(range.getEnd()));
+ String name = doc.getTextSequence().subSequence(doc.offsetAt(range.getStart()), doc.offsetAt(range.getEnd())).toString();
String removedAmpAndSemiColon = name.substring(1, name.length() - 1);
String message = DiagnosticUtils.getDiagnosticMessage(diagnostic);
if (!message.contains("\"" + removedAmpAndSemiColon + "\"")) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/FixMissingSpaceCodeAction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/FixMissingSpaceCodeAction.java
index 238efe0f0..6c951c772 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/FixMissingSpaceCodeAction.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/codeactions/FixMissingSpaceCodeAction.java
@@ -35,8 +35,7 @@ public void doCodeAction(ICodeActionRequest request, List codeAction
try {
int startOffset = document.offsetAt(diagnosticRange.getStart());
int endOffset = document.offsetAt(diagnosticRange.getEnd());
- String text = document.getText();
- String value = text.substring(startOffset, endOffset);
+ String value = document.getTextSequence().subSequence(startOffset, endOffset).toString();
codeActions.add(CodeActionFactory.insert("Add space after '" + value + "'", diagnosticRange.getEnd(), " ",
document.getTextDocument(), diagnostic));
} catch (BadLocationException | IndexOutOfBoundsException e) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/core/participants/inlinecompletion/XMLCloseTagInlineCompletionParticipant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/core/participants/inlinecompletion/XMLCloseTagInlineCompletionParticipant.java
index 9bbc68bff..aac8e530f 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/core/participants/inlinecompletion/XMLCloseTagInlineCompletionParticipant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/core/participants/inlinecompletion/XMLCloseTagInlineCompletionParticipant.java
@@ -49,9 +49,9 @@ public void onInlineCompletion(IInlineCompletionRequest request, IInlineCompleti
}
// Check if the cursor is at a position where we should suggest a closing tag
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
if (offset > 0 && offset <= text.length()) {
- int charBefore = text.codePointAt(offset - 1);
+ int charBefore = Character.codePointAt(text, offset - 1);
// Suggest closing tag after '>' or after content
if (charBefore == _RAN || Character.isLetterOrDigit(charBefore) || Character.isWhitespace(charBefore)) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionParticipant.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionParticipant.java
index 9aed9f3b6..e3d60ae0b 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionParticipant.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionParticipant.java
@@ -216,7 +216,7 @@ private static void addFileCompletionItems(DOMDocument xmlDocument, int startOff
// ex:
// base dir is equals for instance to C://path/to
Character separator = expression != null ? expression.getSeparator() : null;
- FilePathCompletionResult result = FilePathCompletionResult.create(xmlDocument.getText(),
+ FilePathCompletionResult result = FilePathCompletionResult.create(xmlDocument.getTextSequence(),
xmlDocument.getDocumentURI(), startOffset, endOffset, completionOffset, separator);
Path baseDir = result.getBaseDir();
if (baseDir == null) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionResult.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionResult.java
index 174e55b8b..ece5936b8 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionResult.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/filepath/participants/FilePathCompletionResult.java
@@ -88,7 +88,7 @@ public Path getBaseDir() {
* otherwise.
* @return the file path completion result.
*/
- public static FilePathCompletionResult create(String content, String fileUri, int startNodeOffset,
+ public static FilePathCompletionResult create(CharSequence content, String fileUri, int startNodeOffset,
int endNodeOffset, int completionOffset, Character separator) {
boolean isMultiFilePath = separator != null;
Predicate isStartValidChar = isStartValidCharForSimplePath;
@@ -118,10 +118,10 @@ public static FilePathCompletionResult create(String content, String fileUri, in
return new FilePathCompletionResult(startPathOffset, endPathOffset, baseDir);
}
- private static Path getBaseDir(String content, String fileUri, int start, int end) {
+ private static Path getBaseDir(CharSequence content, String fileUri, int start, int end) {
if (end > start) {
// ex :
- String basePath = content.substring(start, end);
+ String basePath = content.subSequence(start, end).toString();
if (!hasPathBeginning(basePath)) {
// Try to returns the absolute path
// Ex basePath=
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/minify/XMLMinifierDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/minify/XMLMinifierDocument.java
index b10bd2d65..5444ecdfe 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/minify/XMLMinifierDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/minify/XMLMinifierDocument.java
@@ -81,7 +81,7 @@ public List extends TextEdit> minify() throws BadLocationException {
public List extends TextEdit> minify(DOMDocument document, int start, int end) {
// Pre-allocate list capacity based on document size
- int estimatedCapacity = Math.min(textDocument.getText().length() / 100, 10000);
+ int estimatedCapacity = Math.min(textDocument.getTextSequence().length() / 100, 10000);
List edits = new ArrayList<>(estimatedCapacity);
// Get initial document region
@@ -302,7 +302,7 @@ private void minifyAttributes(List edits, DOMElement element) {
if (afterElementName < beforeFirstAttr) {
int length = beforeFirstAttr - afterElementName;
- if (StringUtils.isWhitespace(textDocument.getText(), afterElementName, beforeFirstAttr) && length > 1) {
+ if (StringUtils.isWhitespace(textDocument.getTextSequence(), afterElementName, beforeFirstAttr) && length > 1) {
// Replace multiple spaces with single space
try {
Range range = new Range(textDocument.positionAt(afterElementName),
@@ -325,7 +325,7 @@ private void minifyAttributes(List edits, DOMElement element) {
if (afterAttr < beforeNextAttr) {
int length = beforeNextAttr - afterAttr;
- if (StringUtils.isWhitespace(textDocument.getText(), afterAttr, beforeNextAttr) && length > 1) {
+ if (StringUtils.isWhitespace(textDocument.getTextSequence(), afterAttr, beforeNextAttr) && length > 1) {
// Replace multiple spaces with single space
try {
Range range = new Range(textDocument.positionAt(afterAttr),
@@ -383,7 +383,7 @@ private void removeWhitespace(List edits, int start, int end) {
if (start >= end) {
return;
}
- if (StringUtils.isWhitespace(textDocument.getText(), start, end)) {
+ if (StringUtils.isWhitespace(textDocument.getTextSequence(), start, end)) {
// Only whitespace, remove it
removeContent(edits, start, end);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNode.java
index 6b16adb4d..d9a0e988c 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNode.java
@@ -99,7 +99,7 @@ public String getValue(String forcedPrefix) {
if (forcedPrefix != null) {
value.append(forcedPrefix);
}
- String text = getOwnerDocument().getText();
+ CharSequence text = getOwnerDocument().getTextSequence();
for (int i = getStart(); i < getEnd(); i++) {
value.append(text.charAt(i));
}
@@ -122,7 +122,7 @@ public String getPrefix() {
public boolean matchesValue(SearchNode searchNode) {
int fromStart = getStart();
int fromEnd = getEnd();
- String fromText = getOwnerDocument().getText();
+ CharSequence fromText = getOwnerDocument().getTextSequence();
if (direction == Direction.FROM) {
int adjust = adjustWithPrefix(this);
if (adjust == -1) {
@@ -132,7 +132,7 @@ public boolean matchesValue(SearchNode searchNode) {
}
int toStart = searchNode.getStart();
int toEnd = searchNode.getEnd();
- String toText = searchNode.getOwnerDocument().getText();
+ CharSequence toText = searchNode.getOwnerDocument().getTextSequence();
if (direction == Direction.TO) {
int adjust = adjustWithPrefix(searchNode);
if (adjust == -1) {
@@ -270,7 +270,7 @@ private boolean isValidPrefix() {
if (prefix.length() > (end - start)) {
return false;
}
- String text = node.getOwnerDocument().getText();
+ CharSequence text = node.getOwnerDocument().getTextSequence();
for (int i = 0; i < prefix.length(); i++) {
if (text.charAt(start + i) != prefix.charAt(i)) {
return false;
@@ -282,8 +282,8 @@ private boolean isValidPrefix() {
@Override
public String toString() {
StringBuilder result = new StringBuilder();
- String text = node.getOwnerDocument().getText();
- result.append(text.substring(start, end));
+ CharSequence text = node.getOwnerDocument().getTextSequence();
+ result.append(text.subSequence(start, end));
result.append(direction == Direction.FROM ? " -->" : " <--");
return result.toString();
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNodeFactory.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNodeFactory.java
index d2fe34340..ad1a1550f 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNodeFactory.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/references/search/SearchNodeFactory.java
@@ -67,7 +67,7 @@ public static List findSearchNodes(DOMNode node, String prefix, bool
}
if (multiple) {
- String text = node.getOwnerDocument().getText();
+ CharSequence text = node.getOwnerDocument().getTextSequence();
List searchNodes = new ArrayList<>();
int itemStart = -1;
for (int j = startNode; j < endNode; j++) {
@@ -124,7 +124,7 @@ public static SearchNode getSearchNodeAt(DOMNode node, int offset, String prefix
return null;
}
if (multiple) {
- String text = node.getOwnerDocument().getText();
+ CharSequence text = node.getOwnerDocument().getTextSequence();
if (offset != startNode) {
int left = StringUtils.findStartWord(text, offset, startNode, NAME_PREDICATE);
if (left != -1) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java
index f8ddb20bf..38c53650c 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLCompletions.java
@@ -95,11 +95,11 @@ public CompletionList doComplete(DOMDocument xmlDocument, Position position, Sha
return completionResponse;
}
- String text = xmlDocument.getText();
+ CharSequence text = xmlDocument.getTextSequence();
int offset = completionRequest.getOffset();
DOMNode node = completionRequest.getNode();
try {
- if (text.isEmpty()) {
+ if (text.length() == 0) {
// When XML document is empty, try to collect root element (from file
// association)
collectInsideContent(completionRequest, completionResponse, cancelChecker);
@@ -233,7 +233,7 @@ public CompletionList doComplete(DOMDocument xmlDocument, Position position, Sha
case StartTagSelfClose:
if (offset <= scanner.getTokenEnd()) {
if (currentTag != null && currentTag.length() > 0
- && xmlDocument.getText().charAt(offset - 1) == '>') { // if the actual character
+ && xmlDocument.getTextSequence().charAt(offset - 1) == '>') { // if the actual character
// typed
// was
// '>'
@@ -358,7 +358,7 @@ public CompletionItem resolveCompletionItem(CompletionItem unresolved, DOMDocume
*/
private void collectSnippetSuggestions(CompletionRequest completionRequest, CompletionResponse completionResponse) {
DOMDocument document = completionRequest.getXMLDocument();
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
int endExpr = completionRequest.getOffset();
// compute the from for search expression according to the node
int fromSearchExpr = getExprLimitStart(completionRequest.getNode(), endExpr);
@@ -407,7 +407,7 @@ private void collectSnippetSuggestions(CompletionRequest completionRequest, Comp
}
}
- private static Integer getSuffixIndex(String text, String suffix, final int initOffset) {
+ private static Integer getSuffixIndex(CharSequence text, String suffix, final int initOffset) {
int offset = initOffset;
char ch = text.charAt(offset);
// Try to search the first character which matches the suffix
@@ -491,7 +491,7 @@ private static int getExprLimitStart(DOMNode currentNode, int offset) {
return element.getStartTagCloseOffset() + 1;
}
- private static int getExprStart(String value, int from, int to) {
+ private static int getExprStart(CharSequence value, int from, int to) {
if (to == 0) {
return to;
}
@@ -556,8 +556,8 @@ public AutoCloseTagResponse doTagComplete(DOMDocument xmlDocument, Position posi
if (offset <= 0) {
return null;
}
- char c = xmlDocument.getText().charAt(offset - 1);
- char cBefore = xmlDocument.getText().charAt(offset - 2);
+ char c = xmlDocument.getTextSequence().charAt(offset - 1);
+ char cBefore = xmlDocument.getTextSequence().charAt(offset - 2);
String snippet = null;
if (XMLPositionUtility.isInAttributeValue(xmlDocument, position)) {
return null;
@@ -598,7 +598,7 @@ public AutoCloseTagResponse doTagComplete(DOMDocument xmlDocument, Position posi
return null;
}
}
- String text = xmlDocument.getText();
+ CharSequence text = xmlDocument.getTextSequence();
// After the slash is a close bracket
boolean closeBracketAfterSlash = offset < text.length() ? text.charAt(offset) == '>' : false;
@@ -684,7 +684,7 @@ private void collectOpenTagSuggestions(boolean hasOpenBracket, Range replaceRang
CompletionRequest completionRequest, CompletionResponse completionResponse, CancelChecker cancelChecker) {
try {
DOMDocument document = completionRequest.getXMLDocument();
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
int tagNameEnd = document.offsetAt(replaceRange.getEnd());
int newOffset = getOffsetFollowedBy(text, tagNameEnd, ScannerState.WithinEndTag, TokenType.EndTagClose);
if (newOffset != -1) {
@@ -731,7 +731,7 @@ private void collectCloseTagSuggestions(int afterOpenBracket, boolean inOpenTag,
CompletionRequest completionRequest, CompletionResponse completionResponse, CancelChecker cancelChecker) {
try {
Range range = getReplaceRange(afterOpenBracket, tagNameEnd, completionRequest);
- String text = completionRequest.getXMLDocument().getText();
+ CharSequence text = completionRequest.getXMLDocument().getTextSequence();
boolean hasCloseTag = isFollowedBy(text, tagNameEnd, ScannerState.WithinEndTag, TokenType.EndTagClose);
collectCloseTagSuggestions(range, false, !hasCloseTag, inOpenTag, completionRequest, completionResponse);
} catch (BadLocationException e) {
@@ -742,7 +742,7 @@ private void collectCloseTagSuggestions(int afterOpenBracket, boolean inOpenTag,
private void collectCloseTagSuggestions(Range range, boolean openEndTag, boolean closeEndTag, boolean inOpenTag,
CompletionRequest completionRequest, CompletionResponse completionResponse) {
try {
- String text = completionRequest.getXMLDocument().getText();
+ CharSequence text = completionRequest.getXMLDocument().getTextSequence();
DOMNode curr = completionRequest.getNode();
if (inOpenTag) {
curr = curr.getParentNode(); // don't suggest the own tag, it's not yet open
@@ -887,7 +887,7 @@ private void collectAttributeNameSuggestions(int nameStart, CompletionRequest co
private void collectAttributeNameSuggestions(int nameStart, int nameEnd, CompletionRequest completionRequest,
CompletionResponse completionResponse, CancelChecker cancelChecker) {
int replaceEnd = completionRequest.getOffset();
- String text = completionRequest.getXMLDocument().getText();
+ CharSequence text = completionRequest.getXMLDocument().getTextSequence();
while (replaceEnd < nameEnd && text.charAt(replaceEnd) != '<' && text.charAt(replaceEnd) != '?') { // < is a
// valid
// attribute
@@ -928,7 +928,7 @@ private void collectAttributeValueSuggestions(int valueStart, int valueEnd, Comp
boolean addQuotes = false;
String valuePrefix;
int offset = completionRequest.getOffset();
- String text = completionRequest.getXMLDocument().getText();
+ CharSequence text = completionRequest.getXMLDocument().getTextSequence();
// Adjusts range to handle if quotations for the value exist
if (offset > valueStart && offset <= valueEnd && StringUtils.isQuote(text.charAt(valueStart))) {
@@ -940,13 +940,13 @@ private void collectAttributeValueSuggestions(int valueStart, int valueEnd, Comp
valueContentEnd--;
}
valuePrefix = offset >= valueContentStart && offset <= valueContentEnd
- ? text.substring(valueContentStart, offset)
+ ? text.subSequence(valueContentStart, offset).toString()
: "";
valueStart = valueContentStart;
valueEnd = valueContentEnd;
addQuotes = false;
} else {
- valuePrefix = text.substring(valueStart, offset);
+ valuePrefix = text.subSequence(valueStart, offset).toString();
addQuotes = true;
}
@@ -992,11 +992,11 @@ private void collectAttributeValueSuggestions(int valueStart, int valueEnd, Comp
private void collectDTDSystemIdSuggestions(int valueStart, int valueEnd, CompletionRequest completionRequest,
CompletionResponse completionResponse, CancelChecker cancelChecker) {
int offset = completionRequest.getOffset();
- String text = completionRequest.getXMLDocument().getText();
+ CharSequence text = completionRequest.getXMLDocument().getTextSequence();
int valueContentStart = valueStart + 1;
int valueContentEnd = valueEnd - 1;
String valuePrefix = offset >= valueContentStart && offset <= valueContentEnd
- ? text.substring(valueContentStart, offset)
+ ? text.subSequence(valueContentStart, offset).toString()
: "";
Collection completionParticipants = getCompletionParticipants();
@@ -1046,7 +1046,7 @@ private Collection getCompletionParticipants() {
return extensionsRegistry.getCompletionParticipants();
}
- private static boolean isFollowedBy(String s, int offset, ScannerState intialState, TokenType expectedToken) {
+ private static boolean isFollowedBy(CharSequence s, int offset, ScannerState intialState, TokenType expectedToken) {
return getOffsetFollowedBy(s, offset, intialState, expectedToken) != -1;
}
@@ -1060,7 +1060,7 @@ private static boolean isFollowedBy(String s, int offset, ScannerState intialSta
* @param expectedToken
* @return
*/
- public static int getOffsetFollowedBy(String s, int offset, ScannerState intialState, TokenType expectedToken) {
+ public static int getOffsetFollowedBy(CharSequence s, int offset, ScannerState intialState, TokenType expectedToken) {
Scanner scanner = XMLScanner.createScanner(s, offset, intialState);
TokenType token = scanner.scan();
while (token == TokenType.Whitespace) {
@@ -1079,19 +1079,19 @@ private static Range getReplaceRange(int replaceStart, int replaceEnd, ICompleti
return XMLPositionUtility.createRange(replaceStart, replaceEnd, document);
}
- private static String getLineIndent(int offset, String text) {
+ private static String getLineIndent(int offset, CharSequence text) {
int start = offset;
while (start > 0) {
char ch = text.charAt(start - 1);
if ("\n\r".indexOf(ch) >= 0) {
- return text.substring(start, offset);
+ return text.subSequence(start, offset).toString();
}
if (!isWhitespace(ch)) {
return null;
}
start--;
}
- return text.substring(0, offset);
+ return text.subSequence(0, offset).toString();
}
private boolean isEmptyElement(String tag) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFoldings.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFoldings.java
index 65291bcfe..dfc738bc2 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFoldings.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFoldings.java
@@ -21,8 +21,6 @@
import java.util.Map.Entry;
import java.util.logging.Level;
import java.util.logging.Logger;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
import org.eclipse.lemminx.commons.BadLocationException;
import org.eclipse.lemminx.commons.TextDocument;
@@ -43,7 +41,6 @@ class XMLFoldings {
private static Logger LOGGER = Logger.getLogger(XMLFoldings.class.getName());
private final XMLExtensionsRegistry extensionsRegistry;
- private static final Pattern REGION_PATTERN = Pattern.compile("\\s*#(region\\b)|(endregion\\b)");
public XMLFoldings(XMLExtensionsRegistry extensionsRegistry) {
this.extensionsRegistry = extensionsRegistry;
@@ -63,10 +60,11 @@ public TagInfo(int startLine, String tagName) {
public List getFoldingRanges(TextDocument document, XMLFoldingSettings context,
CancelChecker cancelChecker) {
- Scanner scanner = XMLScanner.createScanner(document.getText());
+ CharSequence text = document.getTextSequence();
+ Scanner scanner = XMLScanner.createScanner(text);
TokenType token = scanner.scan();
// Pre-allocate capacity based on document size (estimate: 1 folding per 500 chars)
- int estimatedCapacity = Math.min(document.getText().length() / 500, 1000);
+ int estimatedCapacity = Math.min(text.length() / 500, 1000);
List ranges = new ArrayList<>(estimatedCapacity);
// Pre-allocate stack capacity (estimate: max nesting depth of 50)
@@ -124,10 +122,9 @@ public List getFoldingRanges(TextDocument document, XMLFoldingSett
}
case Comment: {
int startLine = document.positionAt(scanner.getTokenOffset()).getLine();
- String text = scanner.getTokenText();
- Matcher m = REGION_PATTERN.matcher(text);
- if (m.find()) {
- if ("#region".equals(m.group().trim())) { // start pattern match
+ int regionKind = findRegionMarker(text, scanner.getTokenOffset(), scanner.getTokenOffset() + scanner.getTokenLength());
+ if (regionKind != 0) {
+ if (regionKind == 1) { // #region
stack.add(new TagInfo(startLine, "")); // empty tagName marks region
} else {
int i = stack.size() - 1;
@@ -191,6 +188,40 @@ public List getFoldingRanges(TextDocument document, XMLFoldingSett
return ranges;
}
+ private static final char[] REGION = "#region".toCharArray();
+ private static final char[] ENDREGION = "#endregion".toCharArray();
+
+ /**
+ * @return 1 for #region, 2 for #endregion, 0 if neither.
+ */
+ private static int findRegionMarker(CharSequence text, int from, int to) {
+ int i = from;
+ while (i < to && Character.isWhitespace(text.charAt(i))) {
+ i++;
+ }
+ if (i < to && text.charAt(i) == '#') {
+ if (matches(text, i, to, REGION)) {
+ return 1;
+ }
+ if (matches(text, i, to, ENDREGION)) {
+ return 2;
+ }
+ }
+ return 0;
+ }
+
+ private static boolean matches(CharSequence text, int offset, int limit, char[] word) {
+ if (offset + word.length > limit) {
+ return false;
+ }
+ for (int i = 0; i < word.length; i++) {
+ if (text.charAt(offset + i) != word[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private static boolean isIncludeClosingTagInFold(XMLFoldingSettings settings) {
return settings.isIncludeClosingTagInFold();
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFormatter.java
index c9851e085..b1ecbc317 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLFormatter.java
@@ -89,7 +89,7 @@ public List extends TextEdit> format(DOMDocument xmlDocument, Range range, Sha
private boolean shouldMergeEdits(List extends TextEdit> edits, DOMDocument xmlDocument) {
// Merge if there are many edits (> 1000) or if the document is large (> 100KB)
int editCount = edits != null ? edits.size() : 0;
- int documentSize = xmlDocument.getTextDocument().getText().length();
+ int documentSize = xmlDocument.getTextDocument().getTextSequence().length();
return editCount > 1000 || documentSize > 100_000;
}
@@ -103,7 +103,7 @@ private boolean shouldMergeEdits(List extends TextEdit> edits, DOMDocument xml
private Range getFullDocumentRange(DOMDocument xmlDocument) throws BadLocationException {
TextDocument textDocument = xmlDocument.getTextDocument();
Position start = new Position(0, 0);
- Position end = textDocument.positionAt(textDocument.getText().length());
+ Position end = textDocument.positionAt(textDocument.getTextSequence().length());
return new Range(start, end);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLHover.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLHover.java
index acacdb2a4..fdc606c42 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLHover.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLHover.java
@@ -129,7 +129,7 @@ private Hover getTagHover(HoverRequest hoverRequest, Range tagRange, boolean ope
}
private Range getTagNameRange(TokenType tokenType, int startOffset, int offset, DOMDocument document) {
- Scanner scanner = XMLScanner.createScanner(document.getText(), startOffset);
+ Scanner scanner = XMLScanner.createScanner(document.getTextSequence(), startOffset);
TokenType token = scanner.scan();
while (token != TokenType.EOS
&& (scanner.getTokenEnd() < offset || scanner.getTokenEnd() == offset && token != tokenType)) {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLLanguageService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLLanguageService.java
index ea71c25f7..92d5e185d 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLLanguageService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLLanguageService.java
@@ -337,7 +337,7 @@ public AutoCloseTagResponse doAutoClose(DOMDocument xmlDocument, Position positi
XMLCompletionSettings completionSettings, CancelChecker cancelChecker) {
try {
int offset = xmlDocument.offsetAt(position);
- String text = xmlDocument.getText();
+ CharSequence text = xmlDocument.getTextSequence();
if (offset > 0) {
char c = text.charAt(offset - 1);
if (c == '>' || c == '/') {
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLMinifier.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLMinifier.java
index af1ba8571..5b82954d4 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLMinifier.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/XMLMinifier.java
@@ -75,7 +75,7 @@ public List extends TextEdit> minify(DOMDocument xmlDocument, Range range, Sha
private boolean shouldMergeEdits(List extends TextEdit> edits, DOMDocument xmlDocument) {
// Merge if there are many edits (> 1000) or if the document is large (> 100KB)
int editCount = edits != null ? edits.size() : 0;
- int documentSize = xmlDocument.getTextDocument().getText().length();
+ int documentSize = xmlDocument.getTextDocument().getTextSequence().length();
return editCount > 1000 || documentSize > 100_000;
}
@@ -89,7 +89,7 @@ private boolean shouldMergeEdits(List extends TextEdit> edits, DOMDocument xml
private Range getFullDocumentRange(DOMDocument xmlDocument) throws BadLocationException {
TextDocument textDocument = xmlDocument.getTextDocument();
Position start = new Position(0, 0);
- Position end = textDocument.positionAt(textDocument.getText().length());
+ Position end = textDocument.positionAt(textDocument.getTextSequence().length());
return new Range(start, end);
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCDATAFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCDATAFormatter.java
index 2f05f8dd8..2d63718a4 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCDATAFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCDATAFormatter.java
@@ -28,7 +28,7 @@ public DOMCDATAFormatter(XMLFormatterDocument formatterDocument) {
public void formatCDATASection(DOMCDATASection cDATANode, XMLFormattingConstraints parentConstraints,
List edits) {
- String text = formatterDocument.getText();
+ CharSequence text = formatterDocument.getTextSequence();
int start = cDATANode.getStart();
int leftWhitespaceOffset = start > 0 ? start - 1 : 0;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCommentFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCommentFormatter.java
index 4492a2651..74287cdf8 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCommentFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMCommentFormatter.java
@@ -35,7 +35,7 @@ public void formatComment(DOMComment commentNode, XMLFormattingConstraints paren
return;
}
- String text = formatterDocument.getText();
+ CharSequence text = formatterDocument.getTextSequence();
int availableLineWidth = parentConstraints.getAvailableLineWidth();
int start = commentNode.getStart();
int leftWhitespaceOffset = start > 0 ? start - 1 : 0;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMDocTypeFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMDocTypeFormatter.java
index 1b292e6b6..259c11669 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMDocTypeFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMDocTypeFormatter.java
@@ -136,7 +136,7 @@ private void formatDTDNodeDecl(DTDDeclNode nodeDecl, XMLFormattingConstraints pa
int nodeDeclStart = nodeDecl.getStart();
int indentLevel = parentConstraints.getIndentLevel();
int preservedNewLines = getPreservedNewlines();
- int currentNewLineCount = XMLFormatterDocument.getExistingNewLineCount(formatterDocument.getText(),
+ int currentNewLineCount = XMLFormatterDocument.getExistingNewLineCount(formatterDocument.getTextSequence(),
nodeDeclStart, formatterDocument.getLineDelimiter());
if (currentNewLineCount > preservedNewLines) {
// Reduce to number of new lines to the new line number specified by
@@ -287,8 +287,8 @@ private static int getDocTypeIdEnd(DOMDocumentType docType) {
private void replaceQuoteWithPreferred(DTDDeclNode nodeDecl, DTDDeclParameter parameter, List edits) {
int paramStart = parameter.getStart();
int paramEnd = parameter.getEnd();
- if (StringUtils.isQuote(nodeDecl.getOwnerDocument().getText().charAt(paramStart))
- && StringUtils.isQuote(nodeDecl.getOwnerDocument().getText().charAt(paramEnd - 1))) {
+ if (StringUtils.isQuote(nodeDecl.getOwnerDocument().getTextSequence().charAt(paramStart))
+ && StringUtils.isQuote(nodeDecl.getOwnerDocument().getTextSequence().charAt(paramEnd - 1))) {
if (getEnforceQuoteStyle() == EnforceQuoteStyle.preferred) {
formatterDocument.replaceQuoteWithPreferred(paramStart, paramStart + 1, edits);
formatterDocument.replaceQuoteWithPreferred(paramEnd - 1, paramEnd, edits);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMElementFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMElementFormatter.java
index ba6e58e0d..8904eaf30 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMElementFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMElementFormatter.java
@@ -105,7 +105,7 @@ private int formatStartTagElement(DOMElement element, XMLFormattingConstraints p
// after formatting: \n example text
int parentStartCloseOffset = element.getParentElement() != null ? element.getParentElement().getStartTagCloseOffset() + 1 : 0;
if ((parentStartCloseOffset != startTagOpenOffset
- && StringUtils.isWhitespace(formatterDocument.getText(), parentStartCloseOffset,
+ && StringUtils.isWhitespace(formatterDocument.getTextSequence(), parentStartCloseOffset,
startTagOpenOffset))) {
replaceLeftSpacesWithIndentationPreservedNewLines(parentStartCloseOffset, startTagOpenOffset,
indentLevel, edits);
@@ -321,7 +321,7 @@ private int formatEndTagElement(DOMElement element, XMLFormattingConstraints par
DOMNode lastChild = element.getLastChild();
if (lastChild != null
&& (lastChild.isElement() || lastChild.isComment())
- && Character.isWhitespace(formatterDocument.getText().charAt(endTagOpenOffset - 1))) {
+ && Character.isWhitespace(formatterDocument.getTextSequence().charAt(endTagOpenOffset - 1))) {
replaceLeftSpacesWithIndentationPreservedNewLines(startTagCloseOffset, endTagOpenOffset,
indentLevel, edits);
width += indentLevel * getTabSize();
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMTextFormatter.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMTextFormatter.java
index 9dec08ad0..b2f084bcb 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMTextFormatter.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/DOMTextFormatter.java
@@ -38,7 +38,7 @@ public void formatText(DOMText textNode, XMLFormattingConstraints parentConstrai
// Don't format the spacing in text for case of preserve empty content setting
FormatElementCategory formatElementCategory = parentConstraints.getFormatElementCategory();
if (formatElementCategory == FormatElementCategory.PreserveSpace && isTrimTrailingWhitespace()) {
- String text = formatterDocument.getText();
+ CharSequence text = formatterDocument.getTextSequence();
int i = text.length() - 1;
char curr = text.charAt(i);
boolean removeSpaces = true;
@@ -68,7 +68,7 @@ public void formatText(DOMText textNode, XMLFormattingConstraints parentConstrai
} else if (formatElementCategory == FormatElementCategory.PreserveSpace) {
return;
}
- String text = formatterDocument.getText();
+ CharSequence text = formatterDocument.getTextSequence();
int availableLineWidth = parentConstraints.getAvailableLineWidth();
int indentLevel = parentConstraints.getIndentLevel();
boolean isMixedContent = formatElementCategory == FormatElementCategory.MixedContent;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocument.java
index f5163ddf8..e84c2bf37 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocument.java
@@ -145,7 +145,7 @@ public List extends TextEdit> format() throws BadLocationException {
public List extends TextEdit> format(DOMDocument document, int start, int end) {
// Pre-allocate list capacity based on document size to reduce reallocations
// Estimate: 1 edit per 100 characters for typical XML formatting
- int estimatedCapacity = Math.min(textDocument.getText().length() / 100, 10000);
+ int estimatedCapacity = Math.min(textDocument.getTextSequence().length() / 100, 10000);
List edits = new ArrayList<>(estimatedCapacity);
// get initial document region
@@ -179,7 +179,7 @@ public List extends TextEdit> format(DOMDocument document, int start, int end)
}
boolean insertFinalNewline = isInsertFinalNewline();
- String xml = textDocument.getText();
+ CharSequence xml = textDocument.getTextSequence();
int endDocument = xml.length() - 1;
if (isTrimFinalNewlines() && (end == -1 || endDocument < end)) {
trimFinalNewlines(insertFinalNewline, edits);
@@ -417,7 +417,7 @@ void replaceQuoteWithPreferred(int from, int to, List edits) {
}
public int adjustOffsetWithLeftWhitespaces(int leftLimit, int to) {
- return TextEditUtils.adjustOffsetWithLeftWhitespaces(leftLimit, to, textDocument.getText());
+ return TextEditUtils.adjustOffsetWithLeftWhitespaces(leftLimit, to, textDocument.getTextSequence());
}
public int replaceLeftSpacesWithIndentation(int indentLevel, int leftLimit, int to, boolean addLineSeparator,
@@ -457,7 +457,7 @@ public void replaceLeftSpacesWithIndentationPreservedNewLines(int spaceStart, in
int indentLevel, List edits) {
int preservedNewLines = getFormattingSettings().getPreservedNewlines();
int currentNewLineCount = XMLFormatterDocument.getExistingNewLineCount(
- textDocument.getText(), spaceEnd, lineDelimiter);
+ textDocument.getTextSequence(), spaceEnd, lineDelimiter);
if (currentNewLineCount > preservedNewLines) {
replaceLeftSpacesWithIndentationWithMultiNewLines(indentLevel, spaceStart,
spaceEnd, preservedNewLines + 1, edits);
@@ -469,7 +469,7 @@ public void replaceLeftSpacesWithIndentationPreservedNewLines(int spaceStart, in
}
boolean hasLineBreak(int from, int to) {
- String text = textDocument.getText();
+ CharSequence text = textDocument.getTextSequence();
for (int i = from; i < to; i++) {
char c = text.charAt(i);
if (isLineSeparator(c)) {
@@ -480,7 +480,7 @@ boolean hasLineBreak(int from, int to) {
}
public int getNormalizedLength(int from, int to) {
- String text = textDocument.getText();
+ CharSequence text = textDocument.getTextSequence();
int contentOffset = 0;
for (int i = from; i < to; i++) {
if (Character.isWhitespace(text.charAt(i)) && !Character.isWhitespace(text.charAt(i + 1))) {
@@ -495,7 +495,7 @@ public int getNormalizedLength(int from, int to) {
public int getOffsetWithPreserveLineBreaks(int from, int to, int tabSize, boolean isInsertSpaces) {
int initialTo = to;
- String text = textDocument.getText();
+ CharSequence text = textDocument.getTextSequence();
for (int i = to; i > from; i--) {
if (text.charAt(i) == '\t') {
to -= tabSize;
@@ -530,7 +530,7 @@ public int getOffsetWithPreserveLineBreaks(int from, int to, int tabSize, boolea
// ------- Utilities method
int updateLineWidthWithLastLine(DOMNode child, int availableLineWidth) {
- String text = textDocument.getText();
+ CharSequence text = textDocument.getTextSequence();
int lineWidth = availableLineWidth;
int end = child.getEnd();
// Check if next char after the end of the DOM node is a new line feed.
@@ -557,7 +557,7 @@ private static boolean isLineSeparator(char c) {
}
public int getLineBreakOffset(int startAttr, int start) {
- String text = textDocument.getText();
+ CharSequence text = textDocument.getTextSequence();
for (int i = startAttr; i < start; i++) {
char c = text.charAt(i);
if (isLineSeparator(c)) {
@@ -736,7 +736,7 @@ private String getIndentSpacesWithOffsetSpaces(int spaceCount, boolean addLineSe
}
private void trimFinalNewlines(boolean insertFinalNewline, List edits) {
- String xml = textDocument.getText();
+ CharSequence xml = textDocument.getTextSequence();
int end = xml.length() - 1;
int i = end;
while (i >= 0 && isLineSeparator(xml.charAt(i))) {
@@ -774,7 +774,7 @@ private void trimFinalNewlines(boolean insertFinalNewline, List edits)
* @return the number of new lines in the whitespaces to the left of the given
* offset.
*/
- public static int getExistingNewLineCount(String text, int offset, String delimiter) {
+ public static int getExistingNewLineCount(CharSequence text, int offset, String delimiter) {
boolean delimiterHasTwoCharacters = delimiter.length() == 2;
int newLineCounter = 0;
for (int i = offset; i > 1; i--) {
@@ -789,7 +789,7 @@ public static int getExistingNewLineCount(String text, int offset, String delimi
return newLineCounter;
}
if (delimiterHasTwoCharacters) {
- c = text.substring(i - 2, i);
+ c = text.subSequence(i - 2, i).toString();
if (delimiter.equals(c)) {
newLineCounter++;
i--; // skip the second char of the delimiter
@@ -848,6 +848,10 @@ String getLineDelimiter() {
return lineDelimiter;
}
+ CharSequence getTextSequence() {
+ return textDocument.getTextSequence();
+ }
+
String getText() {
return textDocument.getText();
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocumentOld.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocumentOld.java
index 58d6483dc..96d5f2c49 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocumentOld.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/format/XMLFormatterDocumentOld.java
@@ -115,8 +115,7 @@ private void setupRangeFormatting(Range range) throws BadLocationException {
this.startOffset = this.textDocument.offsetAt(startPosition);
this.endOffset = this.textDocument.offsetAt(endPosition);
- String fullText = this.textDocument.getText();
- String rangeText = fullText.substring(this.startOffset, this.endOffset);
+ String rangeText = this.textDocument.getTextSequence().subSequence(this.startOffset, this.endOffset).toString();
withinDTDContent = this.fullDomDocument.isWithinInternalDTD(startOffset);
String uri = this.textDocument.getUri();
@@ -127,7 +126,7 @@ private void setupRangeFormatting(Range range) throws BadLocationException {
if (containsTextWithinStartTag()) {
adjustOffsetToStartTag();
- rangeText = fullText.substring(this.startOffset, this.endOffset);
+ rangeText = this.textDocument.getTextSequence().subSequence(this.startOffset, this.endOffset).toString();
this.rangeDomDocument = DOMParser.getInstance().parse(rangeText, uri, null, false);
}
@@ -167,7 +166,7 @@ private void adjustOffsetToStartTag() throws BadLocationException {
private void setupFullFormatting(Range range) throws BadLocationException {
this.startOffset = 0;
- this.endOffset = textDocument.getText().length();
+ this.endOffset = textDocument.getTextSequence().length();
this.rangeDomDocument = this.fullDomDocument;
Position startPosition = textDocument.positionAt(startOffset);
@@ -754,7 +753,7 @@ private List extends TextEdit> getFormatTextEdit() throws BadLocationException
List edits = new ArrayList<>();
// check if format range reaches the end of the document
- if (this.endOffset == this.textDocument.getText().length()) {
+ if (this.endOffset == this.textDocument.getTextSequence().length()) {
if (this.sharedSettings.getFormattingSettings().isTrimFinalNewlines()) {
this.xmlBuilder.trimFinalNewlines();
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/SnippetContextUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/SnippetContextUtils.java
index 3c10c93a3..28e1ad463 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/SnippetContextUtils.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/snippets/SnippetContextUtils.java
@@ -58,7 +58,7 @@ public static boolean canAcceptExpression(ICompletionRequest request) {
if (element.isInInsideStartEndTag(offset)) {
// |
// |
- String text = request.getXMLDocument().getText();
+ CharSequence text = request.getXMLDocument().getTextSequence();
if (text.charAt(offset - 1) == '/') {
// | -> should be ignore
return false;
@@ -78,7 +78,7 @@ public static boolean canAcceptExpression(ICompletionRequest request) {
if (!element.hasEndTag()) {
// |
// |
- String text = request.getXMLDocument().getText();
+ CharSequence text = request.getXMLDocument().getTextSequence();
if (text.charAt(node.getEnd() - 1) == '/') {
// -> should be ignore
return false;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java
index 9147ada1a..992b83f15 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/StringUtils.java
@@ -46,7 +46,7 @@ public static boolean isWhitespace(String value, int index) {
return isWhitespace(value, index, value.length());
}
- public static boolean isWhitespace(String value, int index, int end) {
+ public static boolean isWhitespace(CharSequence value, int index, int end) {
if (value == null) {
return false;
}
@@ -134,7 +134,7 @@ public static String getStartWhitespaces(String lineText) {
* @param text the text
* @return the whitespaces from the given range start/end of the given text.
*/
- public static String getWhitespaces(String text, int start, int end) {
+ public static String getWhitespaces(CharSequence text, int start, int end) {
StringBuilder whitespaces = new StringBuilder();
for (int i = start; i < end; i++) {
char c = text.charAt(i);
@@ -421,7 +421,7 @@ public static String getString(Object obj) {
* @return the start word offset from the left of the given offset
* and -1 if no word.
*/
- public static int findStartWord(String text, int offset, Predicate isValidChar) {
+ public static int findStartWord(CharSequence text, int offset, Predicate isValidChar) {
return findStartWord(text, offset, 0, isValidChar);
}
@@ -437,7 +437,7 @@ public static int findStartWord(String text, int offset, Predicate is
* @return the start word offset from the left of the given offset
* to the given min and -1 if no word.
*/
- public static int findStartWord(String text, int offset, int min, Predicate isValidChar) {
+ public static int findStartWord(CharSequence text, int offset, int min, Predicate isValidChar) {
if (offset < 0 || offset >= text.length()) {
return -1;
}
@@ -460,7 +460,7 @@ public static int findStartWord(String text, int offset, int min, Predicateoffset
* and -1 if no word.
*/
- public static int findEndWord(String text, int offset, Predicate isValidChar) {
+ public static int findEndWord(CharSequence text, int offset, Predicate isValidChar) {
return findEndWord(text, offset, text.length(), isValidChar);
}
@@ -475,7 +475,7 @@ public static int findEndWord(String text, int offset, Predicate isVa
* @return the start word offset from the right of the given offset
* and -1 if no word.
*/
- public static int findEndWord(String text, int offset, int max, Predicate isValidChar) {
+ public static int findEndWord(CharSequence text, int offset, int max, Predicate isValidChar) {
if (offset < 0 || offset >= text.length() || !isValidChar.test(text.charAt(offset))) {
return -1;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/TextEditUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/TextEditUtils.java
index 3f9779596..f577f85ac 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/TextEditUtils.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/TextEditUtils.java
@@ -61,7 +61,7 @@ public static String applyEdits(TextDocument document, List extends TextEdit>
* @deprecated Use {@link org.eclipse.lemminx.commons.TextEditUtils#adjustOffsetWithLeftWhitespaces(int, int, String)}
*/
@Deprecated
- public static int adjustOffsetWithLeftWhitespaces(int leftLimit, int to, String text) {
+ public static int adjustOffsetWithLeftWhitespaces(int leftLimit, int to, CharSequence text) {
return org.eclipse.lemminx.commons.TextEditUtils.adjustOffsetWithLeftWhitespaces(leftLimit, to, text);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/XMLPositionUtility.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/XMLPositionUtility.java
index 58c280ac0..900bf7e1c 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/XMLPositionUtility.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/XMLPositionUtility.java
@@ -294,7 +294,7 @@ private static int adjustOffsetForAttribute(int offset, DOMDocument document) {
// ->
// ->
// Remove spaces
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
char c = text.charAt(offset);
if (c == '>') {
offset--;
@@ -635,7 +635,7 @@ public static EntityReferenceRange selectEntityReference(int offset, DOMDocument
*/
public static EntityReferenceRange selectEntityReference(int offset, DOMDocument document,
boolean endsWithSemicolon) {
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
// Search '&' or '%' character on the left of the offset
int entityReferenceStart = getEntityReferenceStartOffset(text, offset);
if (entityReferenceStart == -1) {
@@ -649,7 +649,7 @@ public static EntityReferenceRange selectEntityReference(int offset, DOMDocument
}
entityReferenceEnd = offset;
}
- String name = endsWithSemicolon ? document.getText().substring(entityReferenceStart + 1, entityReferenceEnd - 1)
+ String name = endsWithSemicolon ? text.subSequence(entityReferenceStart + 1, entityReferenceEnd - 1).toString()
: null;
return new EntityReferenceRange(name, createRange(entityReferenceStart, entityReferenceEnd, document));
}
@@ -663,7 +663,7 @@ public static EntityReferenceRange selectEntityReference(int offset, DOMDocument
* @return the start offset of the entity reference (ex : &am|p;) from the left
* of the given offset and -1 if no entity reference.
*/
- public static int getEntityReferenceStartOffset(String text, int offset) {
+ public static int getEntityReferenceStartOffset(CharSequence text, int offset) {
// adjust offset to get the left character of the offset
offset--;
if (offset < 0) {
@@ -700,7 +700,7 @@ public static int getEntityReferenceStartOffset(String text, int offset) {
* @return the end offset of the entity reference (ex : &am|p;) from the right
* of the given offset and -1 if no entity reference.
*/
- public static int getEntityReferenceEndOffset(String text, int offset) {
+ public static int getEntityReferenceEndOffset(CharSequence text, int offset) {
int endEntityOffset = StringUtils.findEndWord(text, offset, ENTITY_NAME_PREDICATE);
if (endEntityOffset == -1) {
return -1;
@@ -721,7 +721,7 @@ public static Range selectFirstNonWhitespaceText(int offset, DOMDocument documen
DOMCharacterData data = (DOMCharacterData) node;
int start = data.getStartContent();
Integer end = null;
- String text = document.getText();
+ CharSequence text = document.getTextSequence();
for (int i = start; i < data.getEndContent(); i++) {
char c = text.charAt(i);
if (end == null) {
@@ -1142,7 +1142,7 @@ public static boolean isBeforeOrEqual(Position pos1, Position pos2) {
public static Range getTagNameRange(TokenType tokenType, int startOffset, DOMDocument xmlDocument) {
- Scanner scanner = XMLScanner.createScanner(xmlDocument.getText(), startOffset);
+ Scanner scanner = XMLScanner.createScanner(xmlDocument.getTextSequence(), startOffset);
TokenType token = scanner.scan();
while (token != TokenType.EOS && token != tokenType) {
From 4ef57712a28e532d20e0fbfea60fee0d482a5776 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sat, 5 Sep 2026 22:46:33 +0200
Subject: [PATCH 10/18] Reduce DOM memory: tag interning, DOMNode[] children,
GreenElement ref
- Intern tag strings in GreenTreeBuilder to deduplicate ~1.7M Strings
- Replace XMLNodeList (ArrayList) with DOMNode[] arrays
- Store GreenElement reference in DOMElement instead of caching
tag/startTagCloseOffset/endTagOpenOffset/endTagCloseOffset fields
- Pre-allocate children array in RedTreeBuilder.addChildren()
- Make lazyGreenNode volatile for thread-safe double-checked locking
- Reduces DOM memory from ~700MB to ~260MB for 30MB XML files
Co-Authored-By: Claude Opus 4.6
---
.../org/eclipse/lemminx/dom/DOMElement.java | 43 ++++++-----
.../java/org/eclipse/lemminx/dom/DOMNode.java | 71 +++++++++----------
.../eclipse/lemminx/dom/RedTreeBuilder.java | 27 ++++---
.../lemminx/dom/green/GreenTreeBuilder.java | 7 +-
.../eclipse/lemminx/dom/DOMParserTest.java | 11 ++-
5 files changed, 89 insertions(+), 70 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
index 7da58aace..eae23a2ec 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMElement.java
@@ -21,6 +21,7 @@
import java.util.List;
import java.util.Objects;
+import org.eclipse.lemminx.dom.green.GreenElement;
import org.eclipse.lemminx.utils.StringUtils;
import org.w3c.dom.DOMException;
import org.w3c.dom.NodeList;
@@ -32,15 +33,9 @@
*/
public class DOMElement extends DOMNode implements org.w3c.dom.Element {
- String tag;
+ GreenElement greenElement;
- // DomElement.start == startTagOpenOffset
int startTagOpenOffset = NULL_VALUE; // |
- int startTagCloseOffset = NULL_VALUE; //
-
- int endTagOpenOffset = NULL_VALUE; // |
- int endTagCloseOffset = NULL_VALUE;//
- // DomElement.end = | , is always scanner.getTokenEnd()
public DOMElement(int start, int end) {
super(start, end);
@@ -73,7 +68,7 @@ public String getNodeName() {
*/
@Override
public String getTagName() {
- return tag;
+ return greenElement != null ? greenElement.tag() : null;
}
/**
@@ -84,7 +79,7 @@ public String getTagName() {
* or '').
*/
public boolean hasTagName() {
- return tag != null;
+ return greenElement != null && greenElement.tag() != null;
}
/*
@@ -280,15 +275,16 @@ public Integer endsWith(char c, int startOffset) {
* otherwise.
*/
public boolean isSameTag(String tag) {
- return Objects.equals(this.tag, tag);
+ return Objects.equals(getTagName(), tag);
}
public boolean isInStartTag(int offset) {
- if (startTagOpenOffset == NULL_VALUE || startTagCloseOffset == NULL_VALUE) {
+ int stcOffset = getStartTagCloseOffset();
+ if (startTagOpenOffset == NULL_VALUE || stcOffset == NULL_VALUE) {
// case <|
return true;
}
- if (offset > startTagOpenOffset && offset <= startTagCloseOffset) {
+ if (offset > startTagOpenOffset && offset <= stcOffset) {
// case
return true;
}
@@ -300,11 +296,12 @@ public boolean isInEndTag(int offset) {
}
public boolean isInEndTag(int offset, boolean afterBackSlash) {
- if (endTagOpenOffset == NULL_VALUE) {
+ int etoOffset = getEndTagOpenOffset();
+ if (etoOffset == NULL_VALUE) {
// case >|
return false;
}
- if (offset > endTagOpenOffset + (afterBackSlash ? 1 : 0) && offset < getEnd()) {
+ if (offset > etoOffset + (afterBackSlash ? 1 : 0) && offset < getEnd()) {
// case
return true;
}
@@ -312,7 +309,7 @@ public boolean isInEndTag(int offset, boolean afterBackSlash) {
}
public boolean isInInsideStartEndTag(int offset) {
- return offset > startTagCloseOffset && offset <= endTagOpenOffset;
+ return offset > getStartTagCloseOffset() && offset <= getEndTagOpenOffset();
}
/**
@@ -334,7 +331,9 @@ public int getStartTagOpenOffset() {
* doesn't exist.
*/
public int getStartTagCloseOffset() {
- return startTagCloseOffset;
+ if (greenElement == null) return NULL_VALUE;
+ int rel = greenElement.startTagCloseRel();
+ return rel != GreenElement.NULL_VALUE ? start + rel : NULL_VALUE;
}
/**
@@ -345,7 +344,9 @@ public int getStartTagCloseOffset() {
* exist.
*/
public int getEndTagOpenOffset() {
- return endTagOpenOffset;
+ if (greenElement == null) return NULL_VALUE;
+ int rel = greenElement.endTagOpenRel();
+ return rel != GreenElement.NULL_VALUE ? start + rel : NULL_VALUE;
}
/**
@@ -356,7 +357,9 @@ public int getEndTagOpenOffset() {
* exist.
*/
public int getEndTagCloseOffset() {
- return endTagCloseOffset;
+ if (greenElement == null) return NULL_VALUE;
+ int rel = greenElement.endTagCloseRel();
+ return rel != GreenElement.NULL_VALUE ? start + rel : NULL_VALUE;
}
/**
@@ -627,7 +630,9 @@ public boolean isEmpty() {
* otherwise.
*/
public DOMText findTextAt(int offset) {
- if (offset > startTagCloseOffset && startTagCloseOffset == endTagOpenOffset - 1) {
+ int stcOffset = getStartTagCloseOffset();
+ int etoOffset = getEndTagOpenOffset();
+ if (offset > stcOffset && stcOffset == etoOffset - 1) {
// |
// In this case, DOM text doesn't exists, create an empty DOM text
DOMText text = new DOMText(offset, offset);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
index aaecf4309..8230ed962 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
@@ -13,6 +13,7 @@
package org.eclipse.lemminx.dom;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -67,7 +68,7 @@ public abstract class DOMNode implements Node, DOMRange {
static final byte FLAG_WHITESPACE = 0x04;
private XMLNamedNodeMap attributeNodes;
- private XMLNodeList children;
+ DOMNode[] children;
final int start; // |
int end; // |
@@ -76,7 +77,7 @@ public abstract class DOMNode implements Node, DOMRange {
int cachedIndexInParent = -1;
- GreenNode lazyGreenNode;
+ volatile GreenNode lazyGreenNode;
int lazyAbsStart;
private static final NodeList EMPTY_CHILDREN = new NodeList() {
@@ -92,24 +93,15 @@ public int getLength() {
}
};
- static class XMLNodeList extends ArrayList implements NodeList {
-
- private static final long serialVersionUID = 1L;
-
- XMLNodeList() {
- super(2);
- }
-
+ private static final class ArrayNodeList implements NodeList {
+ private final DOMNode[] nodes;
+ ArrayNodeList(DOMNode[] nodes) { this.nodes = nodes; }
@Override
- public int getLength() {
- return super.size();
- }
-
+ public int getLength() { return nodes.length; }
@Override
- public DOMNode item(int index) {
- return super.get(index);
+ public Node item(int index) {
+ return index >= 0 && index < nodes.length ? nodes[index] : null;
}
-
}
static class XMLNamedNodeMap extends ArrayList implements NamedNodeMap {
@@ -220,17 +212,17 @@ private String toString(int indent) {
result.append(", closed: ");
result.append(isClosed());
ensureChildren();
- if (children != null && children.size() > 0) {
+ if (children != null && children.length > 0) {
result.append(", \n");
for (int i = 0; i < indent + 1; i++) {
result.append("\t");
}
result.append("children:[");
- for (int i = 0; i < children.size(); i++) {
- DOMNode node = children.get(i);
+ for (int i = 0; i < children.length; i++) {
+ DOMNode node = children[i];
result.append("\n");
result.append(node.toString(indent + 2));
- if (i < children.size() - 1) {
+ if (i < children.length - 1) {
result.append(",");
}
}
@@ -506,10 +498,14 @@ public List getChildrenWithAttributeValue(String name, String value) {
private void ensureChildren() {
if (lazyGreenNode != null) {
- GreenNode green = lazyGreenNode;
- int absStart = lazyAbsStart;
- lazyGreenNode = null;
- RedTreeBuilder.expandLazy(this, green, absStart);
+ synchronized (this) {
+ if (lazyGreenNode != null) {
+ GreenNode green = lazyGreenNode;
+ int absStart = lazyAbsStart;
+ lazyGreenNode = null;
+ RedTreeBuilder.expandLazy(this, green, absStart);
+ }
+ }
}
}
@@ -520,10 +516,10 @@ private void ensureChildren() {
*/
public List getChildren() {
ensureChildren();
- if (children == null) {
+ if (children == null || children.length == 0) {
return Collections.emptyList();
}
- return children;
+ return Arrays.asList(children);
}
/**
@@ -534,16 +530,15 @@ public List getChildren() {
public void addChild(DOMNode child) {
child.parent = this;
if (children == null) {
- children = new XMLNodeList<>();
+ children = new DOMNode[] { child };
+ } else {
+ children = Arrays.copyOf(children, children.length + 1);
+ children[children.length - 1] = child;
}
- child.cachedIndexInParent = children.size();
- children.add(child);
+ child.cachedIndexInParent = children.length - 1;
}
void compactChildren() {
- if (children != null) {
- children.trimToSize();
- }
if (attributeNodes != null) {
attributeNodes.trimToSize();
}
@@ -685,7 +680,7 @@ public DOMNode getParentNode() {
@Override
public DOMNode getFirstChild() {
ensureChildren();
- return this.children != null && children.size() > 0 ? this.children.get(0) : null;
+ return children != null && children.length > 0 ? children[0] : null;
}
/*
@@ -696,7 +691,7 @@ public DOMNode getFirstChild() {
@Override
public DOMNode getLastChild() {
ensureChildren();
- return this.children != null && this.children.size() > 0 ? this.children.get(this.children.size() - 1) : null;
+ return children != null && children.length > 0 ? children[children.length - 1] : null;
}
/*
@@ -717,7 +712,7 @@ public NamedNodeMap getAttributes() {
@Override
public NodeList getChildNodes() {
ensureChildren();
- return children != null ? children : EMPTY_CHILDREN;
+ return children != null && children.length > 0 ? new ArrayNodeList(children) : EMPTY_CHILDREN;
}
/*
@@ -911,7 +906,7 @@ public String getTextContent() throws DOMException {
// concatenation of the textContent attribute value of every child node
default:
ensureChildren();
- if (this.children != null && children.size() > 0) {
+ if (this.children != null && children.length > 0) {
final StringBuilder builder = new StringBuilder();
for (DOMNode child : children) {
short nodeType = child.getNodeType();
@@ -946,7 +941,7 @@ public boolean hasChildNodes() {
if (lazyGreenNode != null) {
return true;
}
- return children != null && !children.isEmpty();
+ return children != null && children.length > 0;
}
@Override
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
index 73768da18..1fc51cbb7 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
@@ -11,6 +11,8 @@
*******************************************************************************/
package org.eclipse.lemminx.dom;
+import java.util.Arrays;
+
import org.eclipse.lemminx.commons.TextDocument;
import org.eclipse.lemminx.dom.green.GreenAttr;
import org.eclipse.lemminx.dom.green.GreenCDATA;
@@ -85,17 +87,21 @@ private void addChildrenOrDefer(DOMNode node, GreenNode green, int absStart) {
}
private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsStart) {
- GreenNode[] children = greenParent.children();
- if (children.length == 0) {
+ GreenNode[] greenChildren = greenParent.children();
+ if (greenChildren.length == 0) {
+ parent.compactChildren();
return;
}
int childrenStartRel = greenParent.childrenStartRel();
int childAbsStart = parentAbsStart + childrenStartRel;
boolean skipWhitespace = ignoreWhitespaceContent
- && (hasNonWhitespaceChild(children) || greenParent instanceof GreenDocumentType);
+ && (hasNonWhitespaceChild(greenChildren) || greenParent instanceof GreenDocumentType);
+
+ DOMNode[] redChildren = new DOMNode[greenChildren.length];
+ int idx = 0;
- for (GreenNode greenChild : children) {
+ for (GreenNode greenChild : greenChildren) {
if (skipWhitespace && isWhitespaceText(greenChild)) {
childAbsStart += greenChild.width();
continue;
@@ -106,11 +112,17 @@ private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsSta
((DTDAttlistDecl) parent).addAdditionalAttDecl((DTDAttlistDecl) redChild);
redChild.parent = parent;
} else {
- parent.addChild(redChild);
+ redChild.parent = parent;
+ redChild.cachedIndexInParent = idx;
+ redChildren[idx++] = redChild;
}
}
childAbsStart += greenChild.width();
}
+
+ if (idx > 0) {
+ parent.children = (idx == redChildren.length) ? redChildren : Arrays.copyOf(redChildren, idx);
+ }
parent.compactChildren();
}
@@ -160,15 +172,12 @@ private DOMNode createRedNode(GreenNode green, int absStart) {
private DOMElement createElement(GreenElement green, int absStart, int absEnd) {
DOMElement elem = new DOMElement(absStart, absEnd);
- elem.tag = green.tag();
+ elem.greenElement = green;
elem.setSelfClosed(green.selfClosed());
boolean isOrphanEndTag = green.endTagOpenRel() != GreenElement.NULL_VALUE
&& green.endTagOpenRel() == 0
&& green.startTagCloseRel() == GreenElement.NULL_VALUE;
elem.startTagOpenOffset = isOrphanEndTag ? DOMNode.NULL_VALUE : absStart;
- elem.startTagCloseOffset = abs(green.startTagCloseRel(), absStart);
- elem.endTagOpenOffset = abs(green.endTagOpenRel(), absStart);
- elem.endTagCloseOffset = abs(green.endTagCloseRel(), absStart);
elem.setClosed(green.closed());
GreenAttr[] greenAttrs = green.attributes();
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
index 25f96d973..0e82bd254 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
@@ -15,7 +15,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.eclipse.lemminx.dom.DOMNode;
import org.eclipse.lemminx.dom.parser.Scanner;
@@ -61,6 +63,7 @@ public static GreenDocument parseRange(CharSequence text, String uri,
stack.push(dtdRoot);
}
+ Map tagIntern = new HashMap<>();
GreenAttrBuilder currentAttr = null;
int endTagOpenOffset = -1;
boolean previousTokenWasEndTagOpen = false;
@@ -168,7 +171,7 @@ public static GreenDocument parseRange(CharSequence text, String uri,
case StartTag: {
if (!stack.isEmpty()) {
NodeBuilder top = stack.peek();
- top.tag = scanner.getTokenText();
+ top.tag = tagIntern.computeIfAbsent(scanner.getTokenText(), k -> k);
top.nodeEnd = scanner.getTokenEnd();
}
break;
@@ -201,7 +204,7 @@ public static GreenDocument parseRange(CharSequence text, String uri,
}
case EndTag: {
- String closeTag = scanner.getTokenText();
+ String closeTag = tagIntern.computeIfAbsent(scanner.getTokenText(), k -> k);
if (hasMatchingElement(stack, closeTag)) {
while (!stack.isEmpty()) {
NodeBuilder top = stack.peek();
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
index ceb7a9b8f..38f1b1f38 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
@@ -26,6 +26,7 @@
import javax.xml.parsers.DocumentBuilderFactory;
import org.eclipse.lemminx.dom.DOMDocumentType.DocumentTypeKind;
+import org.eclipse.lemminx.dom.green.GreenElement;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.w3c.dom.Document;
@@ -1217,8 +1218,14 @@ private static DOMNode createNode(short nodeType, int start, int end) {
private static void setRestOfNode(DOMNode n, String tag, Integer endTagStart, boolean closed) {
if (n.isElement()) {
- ((DOMElement) n).tag = tag;
- ((DOMElement) n).endTagOpenOffset = endTagStart != null ? endTagStart : DOMNode.NULL_VALUE;
+ DOMElement elem = (DOMElement) n;
+ int width = n.end - n.start;
+ int endTagOpenRel = endTagStart != null ? endTagStart - n.start : GreenElement.NULL_VALUE;
+ elem.greenElement = new GreenElement(width, closed, tag, false,
+ GreenElement.NULL_VALUE, endTagOpenRel, GreenElement.NULL_VALUE,
+ GreenElement.NULL_VALUE, null, null);
+ boolean isOrphanEndTag = endTagOpenRel == 0;
+ elem.startTagOpenOffset = isOrphanEndTag ? DOMNode.NULL_VALUE : n.start;
} else if (n instanceof DOMProcessingInstruction) {
((DOMProcessingInstruction) n).target = tag;
((DOMProcessingInstruction) n).endTagOpenOffset = endTagStart != null ? endTagStart : DOMNode.NULL_VALUE;
From 08ea7731d996c6db86c598e0be6b2b29f8b73521 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sun, 6 Sep 2026 00:48:43 +0200
Subject: [PATCH 11/18] Release StringBuilder after text materialization to
avoid double buffering
When getText() materializes the cached String from the StringBuilder,
null out textBuffer to free the duplicate byte[] backing array.
Reduces byte[] memory from ~66MB to ~24MB for large XML files.
Co-Authored-By: Claude Opus 4.6
---
.../src/main/java/org/eclipse/lemminx/commons/TextDocument.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
index 372a12485..b0cc57424 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TextDocument.java
@@ -66,6 +66,7 @@ public String getText() {
}
if (textBuffer != null) {
cachedText = textBuffer.toString();
+ textBuffer = null;
super.setText(cachedText);
return cachedText;
}
From 158a598ddf3a3576fd1d5c1d9caf759633e150b7 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sun, 6 Sep 2026 01:50:21 +0200
Subject: [PATCH 12/18] Reduce memory spikes: lazy initial parse, compact
GreenElement, early GC
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Use buildLazy for initial parse (not just incremental), so red tree
nodes are materialized on demand instead of all at once
- Remove unused contentStartRel field from GreenElement (48→40 bytes)
- Clear previousIncrementalData right after incremental parse to allow
earlier GC of old green tree (~58MB freed sooner)
- Fix DTDAttlistDecl.getInternalChildren() and hasChildNodes() to
properly trigger lazy expansion
- Fall back to eager build when ignoreWhitespaceContent=false (tests)
Co-Authored-By: Claude Opus 4.6
---
.../eclipse/lemminx/XMLTextDocumentService.java | 4 +++-
.../eclipse/lemminx/commons/ModelTextDocument.java | 8 ++++++++
.../main/java/org/eclipse/lemminx/dom/DOMNode.java | 4 +---
.../java/org/eclipse/lemminx/dom/DOMParser.java | 5 +++--
.../org/eclipse/lemminx/dom/DTDAttlistDecl.java | 1 +
.../org/eclipse/lemminx/dom/RedTreeBuilder.java | 5 +++++
.../eclipse/lemminx/dom/green/GreenElement.java | 14 ++------------
.../lemminx/dom/green/GreenElementBuilder.java | 2 +-
.../lemminx/dom/green/GreenTreeBuilder.java | 11 +++--------
.../org/eclipse/lemminx/dom/DOMParserTest.java | 2 +-
10 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
index 957ffd10d..0d3816371 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java
@@ -209,13 +209,15 @@ public XMLTextDocumentService(XMLLanguageServer xmlLanguageServer) {
Object prevData = mtd.getPreviousIncrementalData();
ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit();
if (prevData instanceof GreenDocument && editInfo != null) {
- return parser.parseIncremental(document,
+ DOMDocument result = parser.parseIncremental(document,
(GreenDocument) prevData,
editInfo.getStartOffset(),
editInfo.getDeleteLength(),
editInfo.getInsertLength(),
getXMLLanguageService().getResolverExtensionManager(),
true, cancelChecker);
+ mtd.clearPreviousIncrementalData();
+ return result;
}
}
return parser.parse(document, getXMLLanguageService().getResolverExtensionManager(), true, cancelChecker);
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
index 759663977..6c91dbc3b 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
@@ -180,6 +180,14 @@ public Object getPreviousIncrementalData() {
return previousIncrementalData;
}
+ /**
+ * Clears the previous incremental data to allow early GC of the old
+ * green tree after incremental parsing completes.
+ */
+ public void clearPreviousIncrementalData() {
+ previousIncrementalData = null;
+ }
+
/**
* Returns the pending edit info (offset, delete/insert lengths) for
* the most recent single-change edit, or null if not available.
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
index 8230ed962..d74802814 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMNode.java
@@ -938,9 +938,7 @@ public Object getUserData(String arg0) {
*/
@Override
public boolean hasChildNodes() {
- if (lazyGreenNode != null) {
- return true;
- }
+ ensureChildren();
return children != null && children.length > 0;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
index b0aaef526..facc76555 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMParser.java
@@ -83,8 +83,9 @@ public DOMDocument parseIncremental(TextDocument document,
private static DOMDocument buildDocument(GreenDocument greenDoc, TextDocument document,
URIResolverExtensionManager resolverExtensionManager,
boolean ignoreWhitespaceContent, CancelChecker monitor) {
- DOMDocument xmlDocument = RedTreeBuilder.build(greenDoc, document,
- resolverExtensionManager, ignoreWhitespaceContent);
+ DOMDocument xmlDocument = ignoreWhitespaceContent
+ ? RedTreeBuilder.buildLazy(greenDoc, document, resolverExtensionManager)
+ : RedTreeBuilder.build(greenDoc, document, resolverExtensionManager, ignoreWhitespaceContent);
xmlDocument.setGreenDocument(greenDoc);
xmlDocument.setCancelChecker(monitor);
return xmlDocument;
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDAttlistDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDAttlistDecl.java
index 7daf7d386..cc95f57d8 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDAttlistDecl.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDAttlistDecl.java
@@ -113,6 +113,7 @@ void addAdditionalAttDecl(DTDAttlistDecl child) {
}
public List getInternalChildren() {
+ getChildren();
return internalChildren;
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
index 1fc51cbb7..5fbb83598 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java
@@ -65,6 +65,11 @@ public static DOMDocument buildLazy(GreenDocument greenDoc, TextDocument textDoc
return new RedTreeBuilder(true, true).doBuild(greenDoc, textDocument, resolverExtensionManager);
}
+ public static DOMDocument buildLazy(GreenDocument greenDoc, TextDocument textDocument,
+ URIResolverExtensionManager resolverExtensionManager, boolean ignoreWhitespaceContent) {
+ return new RedTreeBuilder(ignoreWhitespaceContent, true).doBuild(greenDoc, textDocument, resolverExtensionManager);
+ }
+
static void expandLazy(DOMNode node, GreenNode green, int absStart) {
new RedTreeBuilder(true, true).addChildren(node, green, absStart);
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
index 9d69f07e6..988195026 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java
@@ -28,20 +28,17 @@ public final class GreenElement extends GreenNode {
private final int startTagCloseRel;
private final int endTagOpenRel;
private final int endTagCloseRel;
- private final int contentStartRel;
private final GreenAttr[] attributes;
private final GreenNode[] children;
public GreenElement(int width, boolean closed, String tag, boolean selfClosed,
int startTagCloseRel, int endTagOpenRel, int endTagCloseRel,
- int contentStartRel,
GreenAttr[] attributes, GreenNode[] children) {
super(width, (closed ? CLOSED_FLAG : 0) | (selfClosed ? SUBCLASS_FLAG : 0));
this.tag = tag;
this.startTagCloseRel = startTagCloseRel;
this.endTagOpenRel = endTagOpenRel;
this.endTagCloseRel = endTagCloseRel;
- this.contentStartRel = contentStartRel;
this.attributes = attributes != null ? attributes : EMPTY_ATTRS;
this.children = children != null ? children : EMPTY_CHILDREN;
}
@@ -71,10 +68,6 @@ public int endTagCloseRel() {
return endTagCloseRel;
}
- public int contentStartRel() {
- return contentStartRel;
- }
-
public GreenAttr[] attributes() {
return attributes;
}
@@ -85,9 +78,6 @@ public int attributeCount() {
@Override
public int childrenStartRel() {
- if (contentStartRel != NULL_VALUE) {
- return contentStartRel;
- }
if (startTagCloseRel != NULL_VALUE) {
return startTagCloseRel + 1;
}
@@ -105,13 +95,13 @@ public GreenElement withNewChildren(GreenNode[] newChildren, int widthDelta) {
startTagCloseRel,
endTagOpenRel != NULL_VALUE ? endTagOpenRel + widthDelta : NULL_VALUE,
endTagCloseRel != NULL_VALUE ? endTagCloseRel + widthDelta : NULL_VALUE,
- contentStartRel, attributes, newChildren);
+ attributes, newChildren);
}
@Override
protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) {
return new GreenElement(newWidth, closed(), tag, selfClosed(),
startTagCloseRel, endTagOpenRel, endTagCloseRel,
- contentStartRel, attributes, newChildren);
+ attributes, newChildren);
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
index 3d27f64ff..537535043 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java
@@ -119,6 +119,6 @@ public GreenElement build() {
: null;
return new GreenElement(width, closed, tag, selfClosed,
- stcRel, etoRel, etcRel, GreenElement.NULL_VALUE, attrs, kids);
+ stcRel, etoRel, etcRel, attrs, kids);
}
}
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
index 0e82bd254..f6b4f6e4e 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java
@@ -120,7 +120,7 @@ public static GreenDocument parseRange(CharSequence text, String uri,
GreenElement fakeEndTag = new GreenElement(
2, false, null, false,
GreenElement.NULL_VALUE, 0, GreenElement.NULL_VALUE,
- GreenElement.NULL_VALUE, null, null);
+ null, null);
addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, fakeEndTag, endTagOpenOffset);
}
}
@@ -809,7 +809,7 @@ public static GreenDocument parseRange(CharSequence text, String uri,
GreenElement fakeEndTag = new GreenElement(
2, false, null, false,
GreenElement.NULL_VALUE, 0, GreenElement.NULL_VALUE,
- GreenElement.NULL_VALUE, null, null);
+ null, null);
addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, fakeEndTag, endTagOpenOffset);
}
@@ -1052,14 +1052,9 @@ private GreenElement buildElement(int width, GreenNode[] kids) {
GreenAttr[] attrs = attributes != null
? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount))
: null;
- int contentStart = firstChildAbsStart != GreenElement.NULL_VALUE
- ? firstChildAbsStart - nodeStart
- : (startTagCloseOffset != GreenElement.NULL_VALUE
- ? startTagCloseOffset + 1 - nodeStart
- : GreenElement.NULL_VALUE);
return new GreenElement(width, closed, tag, selfClosed,
rel(startTagCloseOffset), rel(endTagOpenOffset),
- rel(endTagCloseOffset), contentStart, attrs, kids);
+ rel(endTagCloseOffset), attrs, kids);
}
private GreenComment buildComment(int width) {
diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
index 38f1b1f38..bf8830ff1 100644
--- a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
+++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMParserTest.java
@@ -1223,7 +1223,7 @@ private static void setRestOfNode(DOMNode n, String tag, Integer endTagStart, bo
int endTagOpenRel = endTagStart != null ? endTagStart - n.start : GreenElement.NULL_VALUE;
elem.greenElement = new GreenElement(width, closed, tag, false,
GreenElement.NULL_VALUE, endTagOpenRel, GreenElement.NULL_VALUE,
- GreenElement.NULL_VALUE, null, null);
+ null, null);
boolean isOrphanEndTag = endTagOpenRel == 0;
elem.startTagOpenOffset = isOrphanEndTag ? DOMNode.NULL_VALUE : n.start;
} else if (n instanceof DOMProcessingInstruction) {
From 1cca6f0c7ccaf15b92155613798054a3b4a357c6 Mon Sep 17 00:00:00 2001
From: azerr
Date: Sun, 6 Sep 2026 11:31:12 +0200
Subject: [PATCH 13/18] Fix incremental parser corruption and reduce memory
spikes
- Merge rapid edits into a single dirty range against the original green
tree instead of overwriting pendingEdit (which caused coordinate
mismatch and DOM corruption after rapid typing)
- Reduce validator thread pool from 2 to 1 to limit concurrent tree
retention during reparse
- Fix GreenElement.childrenStartRel() for unclosed start tags
- Invalidate cached model in update() via cancelModel()
Co-Authored-By: Claude Opus 4.6
---
.../lemminx/commons/ModelTextDocument.java | 65 +++++++--
.../commons/ModelValidatorDelayer.java | 2 +-
.../lemminx/dom/green/GreenElement.java | 12 +-
.../commons/ModelTextDocumentTest.java | 127 ++++++++++++++++++
4 files changed, 192 insertions(+), 14 deletions(-)
diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
index 6c91dbc3b..107485bec 100644
--- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
+++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelTextDocument.java
@@ -147,15 +147,22 @@ public void update(List changes) {
int delLen = rangeLength != null ? rangeLength.intValue()
: offsetAt(range.getEnd()) - start;
int insLen = change.getText() != null ? change.getText().length() : 0;
- pendingEdit = new EditInfo(start, delLen, insLen);
+ if (pendingEdit != null) {
+ pendingEdit.merge(start, delLen, insLen);
+ } else {
+ pendingEdit = new EditInfo(start, delLen, insLen);
+ }
} catch (BadLocationException e) {
pendingEdit = null;
+ previousIncrementalData = null;
}
}
} else {
pendingEdit = null;
+ previousIncrementalData = null;
}
super.update(changes);
+ cancelModel();
}
/**
@@ -199,30 +206,64 @@ public EditInfo getPendingEdit() {
}
/**
- * Information about a single text edit: where it started in the old text,
- * how many characters were deleted, and how many were inserted.
+ * Tracks the cumulative dirty range across multiple edits, always expressed
+ * relative to the original text (T0) that matches previousIncrementalData.
+ *
+ *