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) +``` 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/XMLTextDocumentService.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLTextDocumentService.java index 8827a1bf9..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 @@ -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; @@ -202,8 +203,25 @@ 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; + Object prevData = mtd.getPreviousIncrementalData(); + ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit(); + if (prevData instanceof GreenDocument && editInfo != null) { + 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); - }); + }, 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/ArrayLineTracker.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ArrayLineTracker.java new file mode 100644 index 000000000..4878dce8a --- /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(CharSequence 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/CharSequenceReader.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/CharSequenceReader.java new file mode 100644 index 000000000..23ea5ec3b --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/CharSequenceReader.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 Angelo ZERR. + * 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: + * Angelo Zerr - initial API and implementation + */ +package org.eclipse.lemminx.commons; + +import java.io.Reader; + +/** + * A {@link Reader} that reads from a {@link CharSequence} without + * materializing it to a {@link String}. This avoids a costly copy + * when the backing sequence is a {@link StringBuilder}. + */ +public final class CharSequenceReader extends Reader { + + private final CharSequence cs; + private int pos; + private final int length; + + public CharSequenceReader(CharSequence cs) { + this.cs = cs; + this.length = cs.length(); + } + + @Override + public int read(char[] cbuf, int off, int len) { + int remaining = length - pos; + if (remaining <= 0) { + return -1; + } + int n = Math.min(len, remaining); + for (int i = 0; i < n; i++) { + cbuf[off + i] = cs.charAt(pos + i); + } + pos += n; + return n; + } + + @Override + public void close() { + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ILineTracker.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ILineTracker.java index 455334bee..581e58ce2 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ILineTracker.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ILineTracker.java @@ -142,7 +142,7 @@ public interface ILineTracker { * * @param text the new tracked text */ - void set(String text); + void set(CharSequence text); Position getPositionAt(int position) throws BadLocationException; diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ListLineTracker.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ListLineTracker.java index fe7639011..ac46d39d1 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ListLineTracker.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ListLineTracker.java @@ -343,7 +343,7 @@ public final String getLineDelimiter(int line) throws BadLocationException { * @param offset the offset in the given text * @return the information of the first found delimiter or null */ - protected DelimiterInfo nextDelimiterInfo(String text, int offset) { + protected DelimiterInfo nextDelimiterInfo(CharSequence text, int offset) { char ch; int length = text.length(); for (int i = offset; i < length; i++) { @@ -389,7 +389,7 @@ protected DelimiterInfo nextDelimiterInfo(String text, int offset) { * @param offset the offset of all newly created lines * @return the number of newly created lines */ - private int createLines(String text, int insertPosition, int offset) { + private int createLines(CharSequence text, int insertPosition, int offset) { int count = 0; int start = 0; @@ -432,7 +432,7 @@ public final void replace(int position, int length, String text) throws BadLocat } @Override - public final void set(String text) { + public final void set(CharSequence text) { fLines.clear(); if (text != null) { fTextLength = text.length(); 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..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 @@ -1,121 +1,270 @@ -/******************************************************************************* -* 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.function.Function; +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 final Function incrementalDataExtractor; + + private volatile T model; + + 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; + } + + /** + * 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 { + previousIncrementalData = 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; + 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(); + } + + /** + * Mark the model as dirty + */ + private void cancelModel() { + if (model != null) { + if (incrementalDataExtractor != null) { + previousIncrementalData = incrementalDataExtractor.apply(model); + } + } + model = null; + } + + /** + * Returns the data extracted from the previous model for incremental parsing, + * or null if not available. + * + * @return the incremental data or null + */ + 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. + * + * @return the edit info or null + */ + public EditInfo getPendingEdit() { + return pendingEdit; + } + + /** + * Tracks the cumulative dirty range across multiple edits, always expressed + * relative to the original text (T0) that matches previousIncrementalData. + * + *

When multiple edits arrive before the model is rebuilt, each new edit + * is merged into this range so the incremental parser receives correct + * coordinates against the original green tree.

+ */ + public static final class EditInfo { + private int startInOld; + private int endInOld; + private int totalDelta; + + public EditInfo(int startOffset, int deleteLength, int insertLength) { + this.startInOld = startOffset; + this.endInOld = startOffset + deleteLength; + this.totalDelta = insertLength - deleteLength; + } + + /** + * Merge a new edit (in current-text coordinates) into this dirty range. + */ + public void merge(int s2, int d2, int i2) { + int dirtyEndCurrent = endInOld + totalDelta; + + int s2Old; + if (s2 <= startInOld) { + s2Old = s2; + } else if (s2 >= dirtyEndCurrent) { + s2Old = s2 - totalDelta; + } else { + s2Old = startInOld; + } + + int s2End = s2 + d2; + int s2EndOld; + if (s2End <= startInOld) { + s2EndOld = s2End; + } else if (s2End >= dirtyEndCurrent) { + s2EndOld = s2End - totalDelta; + } else { + s2EndOld = endInOld; + } + + startInOld = Math.min(startInOld, s2Old); + endInOld = Math.max(endInOld, s2EndOld); + totalDelta += (i2 - d2); + } + + public int getStartOffset() { + return startInOld; + } + + public int getDeleteLength() { + return endInOld - startInOld; + } + + public int getInsertLength() { + return (endInOld - startInOld) + totalDelta; + } + } + +} 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/main/java/org/eclipse/lemminx/commons/ModelValidatorDelayer.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelValidatorDelayer.java index 53d4cb748..f1c70d31a 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelValidatorDelayer.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/ModelValidatorDelayer.java @@ -39,7 +39,7 @@ public class ModelValidatorDelayer { private final long validationDelayMs; public ModelValidatorDelayer(Consumer> validator) { - this(Executors.newScheduledThreadPool(2), validator, DEFAULT_VALIDATION_DELAY_MS); + this(Executors.newScheduledThreadPool(1), validator, DEFAULT_VALIDATION_DELAY_MS); } public ModelValidatorDelayer(ScheduledExecutorService executorService, Consumer> validator, 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..140de50e1 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,46 @@ 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. + * + * @deprecated Use {@link #getTextSequence()} instead to avoid allocating + * a full String copy of the document text. + */ + @Deprecated + @Override + public String getText() { + synchronized (lock) { + if (cachedText != null) { + return cachedText; + } + if (textBuffer != null) { + cachedText = textBuffer.toString(); + return cachedText; + } + } + return super.getText(); + } + + public CharSequence getTextSequence() { + synchronized (lock) { + if (textBuffer != null) { + return textBuffer; + } + } + return 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 +118,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 +159,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); @@ -148,8 +192,8 @@ private synchronized ILineTracker createLineTracker() { if (lineTracker != null) { return lineTracker; } - ILineTracker lineTracker = isIncremental() ? new TreeLineTracker(new ListLineTracker()) : new ListLineTracker(); - lineTracker.set(super.getText()); + ILineTracker lineTracker = isIncremental() ? new ArrayLineTracker() : new ListLineTracker(); + lineTracker.set(getTextSequence()); return lineTracker; } @@ -168,10 +212,15 @@ 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) { + String currentText = getText(); + int len = currentText.length(); + textBuffer = new StringBuilder(len + Math.max(1024, len >> 3)); + textBuffer.append(currentText); + cachedText = null; + super.setText(""); + } - // Loop for each changes and update the buffer for (int i = 0; i < changes.size(); i++) { TextDocumentContentChangeEvent changeEvent = changes.get(i); @@ -182,17 +231,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) { 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 * @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/commons/TreeLineTracker.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TreeLineTracker.java index 86a7cccdb..b3141cf70 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TreeLineTracker.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/commons/TreeLineTracker.java @@ -1416,10 +1416,10 @@ public final Line getLineInformation(int line) throws BadLocationException { } @Override - public final void set(String text) { + public final void set(CharSequence text) { fRoot = new Node(0, NO_DELIM); try { - replace(0, 0, text); + replace(0, 0, text.toString()); } catch (BadLocationException x) { throw new InternalError(); } 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..224ebf794 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 @@ -32,32 +32,23 @@ public class DOMAttr extends DOMNode implements org.w3c.dom.Attr { // For 546K attributes, this eliminates 1,092,576 objects (AttrName + AttrValue) // Saves ~35 MB of heap memory - private String name; // Only cached if set programmatically (rare) - - // Attribute name offsets (instead of AttrName object) - private int nameStart = NULL_VALUE; - private int nameEnd = NULL_VALUE; + private String name; private int delimiter; - // Attribute value offsets (instead of AttrValue object) - private String value; // Only cached if set programmatically (rare) + private String value; private int valueStart = NULL_VALUE; private int valueEnd = NULL_VALUE; - private final DOMNode ownerElement; - public DOMAttr(String name, DOMNode ownerElement) { this(name, NULL_VALUE, NULL_VALUE, ownerElement); } - public DOMAttr(String name, int start, int end, DOMNode ownerElement) { - super(NULL_VALUE, NULL_VALUE); + public DOMAttr(String name, int nameStart, int nameEnd, DOMNode ownerElement) { + super(nameStart, nameEnd); this.name = name; this.delimiter = NULL_VALUE; - this.nameStart = start; - this.nameEnd = end; - this.ownerElement = ownerElement; + this.parent = ownerElement; } /* @@ -87,12 +78,9 @@ public String getNodeName() { */ @Override 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); + if (start != NULL_VALUE && end != NULL_VALUE) { + return getOwnerDocument().getTextSequence().subSequence(start, end).toString(); } - // Name was set programmatically or doesn't exist return name; } @@ -117,12 +105,12 @@ public String getLocalName() { * @see org.w3c.dom.Attr#getOwnerElement() */ public DOMElement getOwnerElement() { - return ownerElement.isElement() ? (DOMElement) ownerElement : null; + return parent != null && parent.isElement() ? (DOMElement) parent : null; } @Override public DOMDocument getOwnerDocument() { - return ownerElement != null ? ownerElement.getOwnerDocument() : null; + return parent != null ? parent.getOwnerDocument() : null; } /* @@ -188,19 +176,18 @@ public void setValue(String value) throws DOMException { } public DOMRange getNodeAttrName() { - // Return a DOMRange implementation for the name - if (nameStart == NULL_VALUE) { + if (start == NULL_VALUE) { return null; } return new DOMRange() { @Override public int getStart() { - return nameStart; + return start; } @Override public int getEnd() { - return nameEnd; + return end; } @Override @@ -228,7 +215,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; } @@ -294,12 +281,11 @@ public String getPrefix() { */ @Override public String getNamespaceURI() { - if (ownerElement == null || ownerElement.getNodeType() != Node.ELEMENT_NODE) { + if (parent == null || parent.getNodeType() != Node.ELEMENT_NODE) { return null; } String prefix = getPrefix(); - // Try to get xmlns attribute from the element - return ((DOMElement) ownerElement).getNamespaceURI(prefix); + return ((DOMElement) parent).getNamespaceURI(prefix); } /** @@ -399,23 +385,15 @@ public boolean isIncluded(int offset) { return DOMNode.isIncluded(getStart(), getEnd(), offset); } - @Override - public int getStart() { - return nameStart; - } - @Override public int getEnd() { if (valueStart != NULL_VALUE) { - // return valueEnd; } if (hasDelimiter()) { - // return delimiter + 1; } - // - return nameEnd; + return end; } @Override 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..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 @@ -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(); } /** @@ -137,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(); } /* @@ -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/DOMDocument.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMDocument.java index 28d785af3..2d3b5539d 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,14 +66,26 @@ public class DOMDocument extends DOMNode implements Document { private String schemaPrefix; private CancelChecker cancelChecker; private String externalGrammarFromNamespaceURI; + private volatile GreenDocument greenDocument; + DOMNode[] children; public DOMDocument(TextDocument textDocument, URIResolverExtensionManager resolverExtensionManager) { - super(0, textDocument.getText().length()); + super(0, textDocument.getTextSequence().length()); this.textDocument = textDocument; this.resolverExtensionManager = resolverExtensionManager; resetGrammar(); } + @Override + DOMNode[] getChildrenArray() { + return children; + } + + @Override + void setChildrenArray(DOMNode[] c) { + this.children = c; + } + public void setCancelChecker(CancelChecker cancelChecker) { this.cancelChecker = cancelChecker; } @@ -81,6 +94,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(); } @@ -132,14 +153,26 @@ 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. + * + * @deprecated Use {@link #getTextSequence()} instead to avoid allocating + * a full String copy of the document text. */ + @Deprecated 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; } @@ -892,7 +925,7 @@ public Range getTrimmedRange(Range range) { } public Range getTrimmedRange(int start, int end) { - String text = getText(); + CharSequence text = getTextSequence(); char c = text.charAt(start); while (Character.isWhitespace(c)) { start++; 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..35706f0c8 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 @@ -12,9 +12,12 @@ */ package org.eclipse.lemminx.dom; +import java.util.ArrayList; import java.util.List; +import org.w3c.dom.DOMException; import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; /** * A doctype node. @@ -26,7 +29,7 @@ public static enum DocumentTypeKind { PUBLIC, SYSTEM, INVALID } - private XMLNamedNodeMap entitiesNodes; + private List entitiesList; DTDDeclParameter kind; // SYSTEM || PUBLIC DTDDeclParameter publicId; @@ -47,7 +50,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; } @@ -104,16 +107,32 @@ public short getNodeType() { */ @Override public NamedNodeMap getEntities() { - if (entitiesNodes == null) { - entitiesNodes = new XMLNamedNodeMap<>(); + if (entitiesList == null) { + entitiesList = new ArrayList<>(); List children = super.getChildren(); for (DOMNode child : children) { if (child.getNodeType() == DOMNode.ENTITY_NODE) { - entitiesNodes.add((DTDEntityDecl) child); + entitiesList.add((DTDEntityDecl) child); } } } - return entitiesNodes; + return new EntityNamedNodeMap(entitiesList); + } + + private static final class EntityNamedNodeMap implements NamedNodeMap { + private final List entities; + EntityNamedNodeMap(List entities) { this.entities = entities; } + @Override public int getLength() { return entities.size(); } + @Override public Node item(int index) { return index >= 0 && index < entities.size() ? entities.get(index) : null; } + @Override public Node getNamedItem(String name) { + for (DTDEntityDecl e : entities) { if (name.equals(e.getNodeName())) return e; } + return null; + } + @Override public Node getNamedItemNS(String ns, String local) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node removeNamedItem(String n) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node removeNamedItemNS(String ns, String local) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node setNamedItem(Node n) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node setNamedItemNS(Node n) throws DOMException { throw new UnsupportedOperationException(); } } /* 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..e8560d06a 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 @@ -16,13 +16,17 @@ import static org.eclipse.lemminx.dom.DOMAttr.XMLNS_NO_DEFAULT_ATTR; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; +import org.eclipse.lemminx.dom.green.GreenElement; +import org.eclipse.lemminx.dom.green.GreenNode; import org.eclipse.lemminx.utils.StringUtils; import org.w3c.dom.DOMException; +import org.w3c.dom.NamedNodeMap; import org.w3c.dom.NodeList; import org.w3c.dom.TypeInfo; @@ -32,21 +36,80 @@ */ public class DOMElement extends DOMNode implements org.w3c.dom.Element { - String tag; - boolean selfClosed; + GreenElement greenElement; + DOMAttr[] attributeNodes; + DOMNode[] children; - // 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() + private volatile GreenNode lazyGreenNode; public DOMElement(int start, int end) { super(start, end); } + @Override + DOMNode[] getChildrenArray() { + return children; + } + + @Override + void setChildrenArray(DOMNode[] c) { + this.children = c; + } + + @Override + void ensureChildren() { + if (lazyGreenNode != null) { + synchronized (this) { + if (lazyGreenNode != null) { + GreenNode green = lazyGreenNode; + lazyGreenNode = null; + RedTreeBuilder.expandLazy(this, green, start); + } + } + } + } + + @Override + void setLazy(GreenNode green, int absStart) { + this.lazyGreenNode = green; + } + + @Override + public boolean hasAttributes() { + return attributeNodes != null && attributeNodes.length > 0; + } + + @Override + public void setAttributeNode(DOMAttr attr) { + if (attributeNodes == null) { + attributeNodes = new DOMAttr[] { attr }; + } else { + attributeNodes = Arrays.copyOf(attributeNodes, attributeNodes.length + 1); + attributeNodes[attributeNodes.length - 1] = attr; + } + } + + @Override + public List getAttributeNodes() { + return attributeNodes != null ? Arrays.asList(attributeNodes) : null; + } + + @Override + public DOMAttr getAttributeAtIndex(int index) { + if (!hasAttributes()) { + return null; + } + if (index < 0 || index >= attributeNodes.length) { + return null; + } + return attributeNodes[index]; + } + + @Override + public NamedNodeMap getAttributes() { + return attributeNodes != null ? new AttrNamedNodeMap(attributeNodes) : null; + } + /* * (non-Javadoc) * @@ -74,7 +137,7 @@ public String getNodeName() { */ @Override public String getTagName() { - return tag; + return greenElement != null ? greenElement.tag() : null; } /** @@ -85,7 +148,7 @@ public String getTagName() { * or ' text.length() || startOffset < 0) { return null; } @@ -277,15 +344,17 @@ 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 stoOffset = getStartTagOpenOffset(); + int stcOffset = getStartTagCloseOffset(); + if (stoOffset == NULL_VALUE || stcOffset == NULL_VALUE) { // case <| return true; } - if (offset > startTagOpenOffset && offset <= startTagCloseOffset) { + if (offset > stoOffset && offset <= stcOffset) { // case return true; } @@ -297,11 +366,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; } @@ -309,7 +379,7 @@ public boolean isInEndTag(int offset, boolean afterBackSlash) { } public boolean isInInsideStartEndTag(int offset) { - return offset > startTagCloseOffset && offset <= endTagOpenOffset; + return offset > getStartTagCloseOffset() && offset <= getEndTagOpenOffset(); } /** @@ -320,7 +390,15 @@ public boolean isInInsideStartEndTag(int offset) { * doesn't exist. */ public int getStartTagOpenOffset() { - return startTagOpenOffset; + if (greenElement == null) { + return NULL_VALUE; + } + int eto = greenElement.endTagOpenRel(); + if (eto != GreenElement.NULL_VALUE && eto == 0 + && greenElement.startTagCloseRel() == GreenElement.NULL_VALUE) { + return NULL_VALUE; + } + return start; } /** @@ -331,7 +409,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; } /** @@ -342,7 +422,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; } /** @@ -353,7 +435,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; } /** @@ -458,7 +542,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++) { } @@ -624,7 +708,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 4442b95de..ff4fa25f2 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,11 +13,13 @@ 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; -import java.util.function.Function; +import java.util.function.Predicate; +import org.eclipse.lemminx.dom.green.GreenNode; import org.w3c.dom.DOMException; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; @@ -60,22 +62,16 @@ 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 - - private XMLNamedNodeMap attributeNodes; - private XMLNodeList children; + static final byte FLAG_SELF_CLOSED = 0x02; + static final byte FLAG_WHITESPACE = 0x04; final int start; // | 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; private static final NodeList EMPTY_CHILDREN = new NodeList() { @@ -91,114 +87,48 @@ 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); - } - + 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); - } - - @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; - } + public Node item(int index) { + return index >= 0 && index < nodes.length ? nodes[index] : null; } - } - static class XMLNamedNodeMap extends ArrayList implements NamedNodeMap { - - private static final long serialVersionUID = 1L; - - @Override - public int getLength() { - return super.size(); - } - - @Override - public T getNamedItem(String name) { - for (T node : this) { - if (name.equals(node.getNodeName())) { - return node; - } - } + static final class AttrNamedNodeMap implements NamedNodeMap { + private final DOMAttr[] attrs; + AttrNamedNodeMap(DOMAttr[] attrs) { this.attrs = attrs; } + @Override public int getLength() { return attrs.length; } + @Override public Node item(int index) { return index >= 0 && index < attrs.length ? attrs[index] : null; } + @Override public Node getNamedItem(String name) { + for (DOMAttr a : attrs) { if (name.equals(a.getNodeName())) return a; } return null; } - - @Override - public T getNamedItemNS(String name, String arg1) throws DOMException { - throw new UnsupportedOperationException(); - } - - @Override - public T item(int index) { - return super.get(index); - } - - @Override - public T removeNamedItem(String arg0) throws DOMException { - throw new UnsupportedOperationException(); - } - - @Override - public T removeNamedItemNS(String arg0, String arg1) throws DOMException { - throw new UnsupportedOperationException(); - } - - @Override - public T setNamedItem(org.w3c.dom.Node arg0) throws DOMException { - throw new UnsupportedOperationException(); - } - - @Override - public T setNamedItemNS(org.w3c.dom.Node arg0) throws DOMException { - throw new UnsupportedOperationException(); - } - + @Override public Node getNamedItemNS(String ns, String local) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node removeNamedItem(String n) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node removeNamedItemNS(String ns, String local) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node setNamedItem(Node n) throws DOMException { throw new UnsupportedOperationException(); } + @Override public Node setNamedItemNS(Node n) throws DOMException { throw new UnsupportedOperationException(); } } 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; + } } /** @@ -236,17 +166,19 @@ private String toString(int indent) { result.append(getNodeName()); result.append(", closed: "); result.append(isClosed()); - if (children != null && children.size() > 0) { + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + if (arr != null && arr.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 < arr.length; i++) { + DOMNode node = arr[i]; result.append("\n"); result.append(node.toString(indent + 2)); - if (i < children.size() - 1) { + if (i < arr.length - 1) { result.append(","); } } @@ -381,14 +313,14 @@ public static DOMNode findNodeOrAttrAt(DOMDocument document, int offset) { * @returns the least x for which p(x) is true or array.length if no element * full fills the given function. */ - private static int findFirst(List array, Function p) { + private static int findFirst(List array, Predicate p) { int low = 0, high = array.size(); if (high == 0) { - return 0; // no children + return 0; } while (low < high) { - int mid = (int) Math.floor((low + high) / 2); - if (p.apply(array.get(mid))) { + int mid = (low + high) >>> 1; + if (p.test(array.get(mid))) { high = mid; } else { low = mid + 1; @@ -407,6 +339,9 @@ public DOMAttr getAttributeNode(String name) { * If there is no namespace, set prefix to null. */ public DOMAttr getAttributeNode(String prefix, String suffix) { + if (!hasAttributes()) { + return null; + } StringBuilder sb = new StringBuilder(); if (prefix != null) { sb.append(prefix); @@ -414,10 +349,7 @@ public DOMAttr getAttributeNode(String prefix, String suffix) { } sb.append(suffix); String name = sb.toString(); - if (!hasAttributes()) { - return null; - } - for (DOMAttr attr : attributeNodes) { + for (DOMAttr attr : getAttributeNodes()) { if (name.equals(attr.getName())) { return attr; } @@ -455,14 +387,7 @@ public String getAttribute(String name) { * @return */ public DOMAttr getAttributeAtIndex(int index) { - if (!hasAttributes()) { - return null; - } - - if (index > attributeNodes.getLength() - 1) { - return null; - } - return attributeNodes.get(index); + return null; } public boolean hasAttribute(String name) { @@ -476,7 +401,7 @@ public boolean hasAttribute(String name) { */ @Override public boolean hasAttributes() { - return attributeNodes != null && attributeNodes.size() != 0; + return false; } public void setAttribute(String name, String value) { @@ -489,14 +414,10 @@ public void setAttribute(String name, String value) { } public void setAttributeNode(DOMAttr attr) { - if (attributeNodes == null) { - attributeNodes = new XMLNamedNodeMap<>(); - } - attributeNodes.add(attr); } public List getAttributeNodes() { - return attributeNodes; + return null; } /** @@ -520,16 +441,31 @@ public List getChildrenWithAttributeValue(String name, String value) { return result; } + DOMNode[] getChildrenArray() { + return null; + } + + void setChildrenArray(DOMNode[] c) { + } + + void ensureChildren() { + } + + void setLazy(GreenNode green, int absStart) { + } + /** * Returns the node children. - * + * * @return the node children. */ public List getChildren() { - if (children == null) { + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + if (arr == null || arr.length == 0) { return Collections.emptyList(); } - return children; + return Arrays.asList(arr); } /** @@ -539,12 +475,18 @@ public List getChildren() { */ public void addChild(DOMNode child) { child.parent = this; - if (children == null) { - children = new XMLNodeList<>(); + DOMNode[] arr = getChildrenArray(); + if (arr == null) { + arr = new DOMNode[] { child }; + } else { + arr = Arrays.copyOf(arr, arr.length + 1); + arr[arr.length - 1] = child; } - // Cache the index when adding - child.cachedIndexInParent = children.size(); - children.add(child); + setChildrenArray(arr); + child.cachedIndexInParent = arr.length - 1; + } + + void compactChildren() { } /** @@ -558,19 +500,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() { @@ -690,7 +624,9 @@ public DOMNode getParentNode() { */ @Override public DOMNode getFirstChild() { - return this.children != null && children.size() > 0 ? this.children.get(0) : null; + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + return arr != null && arr.length > 0 ? arr[0] : null; } /* @@ -700,7 +636,9 @@ public DOMNode getFirstChild() { */ @Override public DOMNode getLastChild() { - return this.children != null && this.children.size() > 0 ? this.children.get(this.children.size() - 1) : null; + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + return arr != null && arr.length > 0 ? arr[arr.length - 1] : null; } /* @@ -710,7 +648,7 @@ public DOMNode getLastChild() { */ @Override public NamedNodeMap getAttributes() { - return attributeNodes; + return null; } /* @@ -720,7 +658,9 @@ public NamedNodeMap getAttributes() { */ @Override public NodeList getChildNodes() { - return children != null ? children : EMPTY_CHILDREN; + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + return arr != null && arr.length > 0 ? new ArrayNodeList(arr) : EMPTY_CHILDREN; } /* @@ -790,15 +730,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; } @@ -825,15 +761,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; } @@ -921,9 +853,11 @@ public String getTextContent() throws DOMException { return null; // concatenation of the textContent attribute value of every child node default: - if (this.children != null && children.size() > 0) { + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + if (arr != null && arr.length > 0) { final StringBuilder builder = new StringBuilder(); - for (DOMNode child : children) { + for (DOMNode child : arr) { short nodeType = child.getNodeType(); if (nodeType == Node.COMMENT_NODE || nodeType == Node.PROCESSING_INSTRUCTION_NODE) { // excluding COMMENT_NODE and PROCESSING_INSTRUCTION_NODE nodes. @@ -953,7 +887,9 @@ public Object getUserData(String arg0) { */ @Override public boolean hasChildNodes() { - return children != null && !children.isEmpty(); + ensureChildren(); + DOMNode[] arr = getChildrenArray(); + return arr != null && arr.length > 0; } @Override 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 a88dfb52c..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 @@ -1,701 +1,93 @@ -/** - * Copyright (c) 2018 Angelo ZERR. - * 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 - * - * Initial code from https://github.com/Microsoft/vscode-html-languageservice - * Initial copyright Copyright (C) Microsoft Corporation. All rights reserved. - * Initial license: MIT - * - * Contributors: - * - Microsoft Corporation: Initial code, written in TypeScript, licensed under MIT license - * - Angelo Zerr - translation and adaptation to Java - */ -package org.eclipse.lemminx.dom; - -import java.util.logging.Level; -import java.util.logging.Logger; - -import org.eclipse.lemminx.commons.BadLocationException; -import org.eclipse.lemminx.commons.TextDocument; -import org.eclipse.lemminx.dom.parser.Scanner; -import org.eclipse.lemminx.dom.parser.TokenType; -import org.eclipse.lemminx.dom.parser.XMLScanner; -import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager; -import org.eclipse.lemminx.utils.DOMUtils; -import org.eclipse.lsp4j.jsonrpc.CancelChecker; - -/** - * Tolerant XML parser. - * - */ -public class DOMParser { - - private static final Logger LOGGER = Logger.getLogger(DOMParser.class.getName()); - - private static final DOMParser INSTANCE = new DOMParser(); - - public static DOMParser getInstance() { - return INSTANCE; - } - - private DOMParser() { - - } - - public DOMDocument parse(String text, String uri, URIResolverExtensionManager resolverExtensionManager) { - return parse(new TextDocument(text, uri), resolverExtensionManager); - } - - public DOMDocument parse(String text, String uri, URIResolverExtensionManager resolverExtensionManager, - boolean ignoreWhitespaceContent) { - return parse(new TextDocument(text, uri), resolverExtensionManager, ignoreWhitespaceContent); - } - - public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager) { - return parse(document, resolverExtensionManager, true); - } - - public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager, - boolean ignoreWhitespaceContent) { - return parse(document, resolverExtensionManager, ignoreWhitespaceContent, null); - } - - public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager, - boolean ignoreWhitespaceContent, CancelChecker monitor) { - boolean isDTD = DOMUtils.isDTD(document.getUri()); - boolean inDTDInternalSubset = false; - String text = document.getText(); - Scanner scanner = XMLScanner.createScanner(text, 0, isDTD); - DOMDocument xmlDocument = new DOMDocument(document, resolverExtensionManager); - xmlDocument.setCancelChecker(monitor); - - DOMNode curr = isDTD ? new DOMDocumentType(0, text.length()) : xmlDocument; - if (isDTD) { - xmlDocument.addChild(curr); - - // This DOMDocumentType object is hidden, and just represents the DTD file - // nothing should affect it's closed status - curr.setClosed(true); - } - DOMNode lastClosed = curr; - DOMAttr attr = null; - int endTagOpenOffset = -1; - DOMNode tempWhitespaceContent = null; - boolean isInitialDeclaration = true; // A declaration can have multiple internal declarations - boolean previousTokenWasEndTagOpen = false; - TokenType token = scanner.scan(); - while (token != TokenType.EOS) { - if (monitor != null) { - monitor.checkCanceled(); - } - if (tempWhitespaceContent != null && token != TokenType.EndTagOpen) { - tempWhitespaceContent = null; - } - if (previousTokenWasEndTagOpen) { - previousTokenWasEndTagOpen = false; - boolean linkToEmptyStartTag = false; - if (token == TokenType.EndTagClose) { - // ex : - String closeTag = null; - - /** - * Try to find a start element with no tag : <> - */ - while (!(curr.isElement() && ((DOMElement) curr).isSameTag(closeTag)) && curr.parent != null) { - curr.end = endTagOpenOffset; - curr = curr.parent; - } - if (curr != xmlDocument) { - linkToEmptyStartTag = true; - curr.setClosed(true); - if (curr.isElement()) { - ((DOMElement) curr).endTagOpenOffset = endTagOpenOffset; - } else if (curr.isProcessingInstruction() || curr.isProlog()) { - ((DOMProcessingInstruction) curr).endTagOpenOffset = endTagOpenOffset; - } - curr.end = scanner.getTokenEnd(); - } - } - if (token != TokenType.EndTag && !linkToEmptyStartTag) { - // The excepted token is not an EndTag, create a fake end tag element - DOMElement element = xmlDocument.createElement(endTagOpenOffset, endTagOpenOffset + 2); - element.endTagOpenOffset = endTagOpenOffset; - curr.addChild(element); - } - } - switch (token) { - case StartTagOpen: { - if (!curr.isClosed() && curr.parent != null) { - // The next node's parent (curr) is not closed at this point - // so the node's parent (curr) will have its end position updated - // to a newer end position. - curr.end = scanner.getTokenOffset(); - } - if ((curr.isClosed()) || curr.isDoctype()) { - // The next node being considered is a child of 'curr' - // and if 'curr' is already closed then 'curr' was not updated properly. - // Or if we get a Doctype node then we know it was not closed and 'curr' - // wasn't updated properly. - curr = curr.parent; - inDTDInternalSubset = false; // In case it was previously in the internal subset - } - DOMElement child = xmlDocument.createElement(scanner.getTokenOffset(), scanner.getTokenEnd()); - child.startTagOpenOffset = scanner.getTokenOffset(); - curr.addChild(child); - curr = child; - break; - } - - case StartTag: { - DOMElement element = (DOMElement) curr; - element.tag = scanner.getTokenText(); - curr.end = scanner.getTokenEnd(); - break; - } - - case StartTagClose: - if (curr.isElement()) { - DOMElement element = (DOMElement) curr; - curr.end = scanner.getTokenEnd(); // might be later set to end tag position - element.startTagCloseOffset = scanner.getTokenOffset(); - - // never enters isEmptyElement() is always false - if (element.hasTagName() && isEmptyElement(element.getTagName()) && curr.parent != null) { - curr.setClosed(true); - curr = curr.parent; - } - } else if (curr.isProcessingInstruction() || curr.isProlog()) { - DOMProcessingInstruction element = (DOMProcessingInstruction) curr; - curr.end = scanner.getTokenEnd(); // might be later set to end tag position - element.startTagClose = true; - if (element.getTarget() != null && isEmptyElement(element.getTarget()) && curr.parent != null) { - curr.setClosed(true); - curr = curr.parent; - } - } - curr.end = scanner.getTokenEnd(); - break; - - case EndTagOpen: - if (tempWhitespaceContent != null) { - curr.addChild(tempWhitespaceContent); - tempWhitespaceContent = null; - } - endTagOpenOffset = scanner.getTokenOffset(); - curr.end = scanner.getTokenOffset(); - previousTokenWasEndTagOpen = true; - break; - - case EndTag: - // end tag (ex: ) - String closeTag = scanner.getTokenText(); - DOMNode current = curr; - - /** - * eg:
will set a,b,c end position to the start of | - */ - while (!(curr.isElement() && ((DOMElement) curr).isSameTag(closeTag)) && curr.parent != null) { - curr.end = endTagOpenOffset; - curr = curr.parent; - } - if (curr != xmlDocument) { - curr.setClosed(true); - if (curr.isElement()) { - ((DOMElement) curr).endTagOpenOffset = endTagOpenOffset; - } else if (curr.isProcessingInstruction() || curr.isProlog()) { - ((DOMProcessingInstruction) curr).endTagOpenOffset = endTagOpenOffset; - } - curr.end = scanner.getTokenEnd(); - } else { - // element open tag not found (ex: ) add a fake element which only has an - // end tag (no start tag). - DOMElement element = xmlDocument.createElement(scanner.getTokenOffset() - 2, - scanner.getTokenEnd()); - element.endTagOpenOffset = endTagOpenOffset; - element.tag = closeTag; - current.addChild(element); - curr = element; - } - break; - - case StartTagSelfClose: - if (curr.parent != null) { - curr.setClosed(true); - ((DOMElement) curr).selfClosed = true; - curr.end = scanner.getTokenEnd(); - lastClosed = curr; - curr = curr.parent; - } - break; - - case EndTagClose: - if (curr.parent != null) { - curr.end = scanner.getTokenEnd(); - lastClosed = curr; - if (lastClosed.isElement()) { - ((DOMElement) curr).endTagCloseOffset = scanner.getTokenOffset(); - } - if (curr.isDoctype()) { - curr.setClosed(true); - } - curr = curr.parent; - - } - break; - - case AttributeName: { - attr = new DOMAttr(null, scanner.getTokenOffset(), - scanner.getTokenEnd(), curr); - curr.setAttributeNode(attr); - curr.end = scanner.getTokenEnd(); - break; - } - - case DelimiterAssign: { - if (attr != null) { - // Sets the value to the '=' position in case there is no AttributeValue - attr.setDelimiter(scanner.getTokenOffset()); - } - break; - } - - case AttributeValue: { - if (curr.hasAttributes() && attr != null) { - attr.setValue(null, scanner.getTokenOffset(), scanner.getTokenEnd()); - } - attr = null; - curr.end = scanner.getTokenEnd(); - break; - } - - case CDATATagOpen: { - DOMCDATASection cdataNode = xmlDocument.createCDataSection(scanner.getTokenOffset(), text.length()); - curr.addChild(cdataNode); - curr = cdataNode; - break; - } - - case CDATAContent: { - DOMCDATASection cdataNode = (DOMCDATASection) curr; - cdataNode.startContent = scanner.getTokenOffset(); - cdataNode.endContent = scanner.getTokenEnd(); - curr.end = scanner.getTokenEnd(); - break; - } - - case CDATATagClose: { - curr.end = scanner.getTokenEnd(); - curr.setClosed(true); - curr = curr.parent; - break; - } - - case StartPrologOrPI: { - DOMProcessingInstruction prologOrPINode = xmlDocument - .createProcessingInstruction(scanner.getTokenOffset(), text.length()); - curr.addChild(prologOrPINode); - curr = prologOrPINode; - break; - } - - case PIName: { - DOMProcessingInstruction processingInstruction = ((DOMProcessingInstruction) curr); - processingInstruction.target = scanner.getTokenText(); - processingInstruction.processingInstruction = true; - break; - } - - case PrologName: { - DOMProcessingInstruction processingInstruction = ((DOMProcessingInstruction) curr); - processingInstruction.target = scanner.getTokenText(); - processingInstruction.prolog = true; - break; - } - - case PIContent: { - DOMProcessingInstruction processingInstruction = (DOMProcessingInstruction) curr; - processingInstruction.startContent = scanner.getTokenOffset(); - processingInstruction.endContent = scanner.getTokenEnd(); - break; - } - - case PIEnd: - case PrologEnd: { - curr.end = scanner.getTokenEnd(); - curr.setClosed(true); - curr = curr.parent; - break; - } - - case StartCommentTag: { - // Incase the tag before the comment tag (curr) was not properly closed - // curr should be set to the root node. - if (xmlDocument.isDTD() || inDTDInternalSubset) { - while (!curr.isDoctype()) { - curr = curr.parent; - } - } else if ((curr.isClosed())) { - curr = curr.parent; - } - DOMComment comment = xmlDocument.createComment(scanner.getTokenOffset(), text.length()); - curr.addChild(comment); - curr = comment; - try { - int endLine = document.positionAt(lastClosed.end).getLine(); - int startLine = document.positionAt(curr.start).getLine(); - if (endLine == startLine && lastClosed.end <= curr.start) { - comment.commentSameLineEndTag = true; - } - } catch (BadLocationException e) { - LOGGER.log(Level.SEVERE, "XMLParser StartCommentTag bad offset in document", e); - } - break; - } - - case Comment: { - DOMComment comment = (DOMComment) curr; - comment.startContent = scanner.getTokenOffset(); - comment.endContent = scanner.getTokenEnd(); - break; - } - - case EndCommentTag: { - curr.end = scanner.getTokenEnd(); - curr.setClosed(true); - curr = curr.parent; - break; - } - - case Content: { - boolean currIsDeclNode = curr instanceof DTDDeclNode; - if (currIsDeclNode) { - curr.end = scanner.getTokenOffset() - 1; - while (!curr.isDoctype()) { - curr = curr.getParentNode(); - } - } - int start = scanner.getTokenOffset(); - int end = scanner.getTokenEnd(); - DOMText textNode = xmlDocument.createText(start, end); - textNode.setClosed(true); - - if (scanner.isTokenTextBlank()) { - if (ignoreWhitespaceContent) { - if (curr.hasChildNodes()) { - break; - } - - tempWhitespaceContent = textNode; - break; - - } else if (!currIsDeclNode) { - textNode.setWhitespace(true); - } else { - break; - } - - } - - curr.addChild(textNode); - break; - } - - // DTD - - case DTDStartDoctypeTag: { - DOMDocumentType doctype = xmlDocument.createDocumentType(scanner.getTokenOffset(), text.length()); - curr.addChild(doctype); - doctype.parent = curr; - curr = doctype; - break; - } - - case DTDDoctypeName: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDDocTypeKindPUBLIC: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setKind(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDDocTypeKindSYSTEM: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setKind(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDDoctypePublicId: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setPublicId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDDoctypeSystemId: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setSystemId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDStartInternalSubset: { - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setStartInternalSubset(scanner.getTokenOffset()); - inDTDInternalSubset = true; - break; - } - - case DTDEndInternalSubset: { - while (!curr.isDoctype()) { - curr.end = scanner.getTokenOffset() - 1; - curr = curr.getParentNode(); - } - inDTDInternalSubset = false; - DOMDocumentType doctype = (DOMDocumentType) curr; - doctype.setEndInternalSubset(scanner.getTokenEnd()); - break; - } - - case DTDStartElement: { - // If previous 'curr' was an unclosed DTD Declaration - while (!curr.isDoctype()) { - curr.end = scanner.getTokenOffset(); - curr = curr.getParentNode(); - } - - DTDElementDecl child = new DTDElementDecl(scanner.getTokenOffset(), text.length()); - curr.addChild(child); - curr = child; - break; - } - - case DTDElementDeclName: { - DTDElementDecl element = (DTDElementDecl) curr; - element.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDElementCategory: { - DTDElementDecl element = (DTDElementDecl) curr; - element.setCategory(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDStartElementContent: { - DTDElementDecl element = (DTDElementDecl) curr; - element.setContent(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDElementContent: { - DTDElementDecl element = (DTDElementDecl) curr; - element.updateLastParameterEnd(scanner.getTokenEnd()); - break; - } - - case DTDEndElementContent: { - DTDElementDecl element = (DTDElementDecl) curr; - element.updateLastParameterEnd(scanner.getTokenEnd()); - break; - } - - case DTDStartAttlist: { - while (!curr.isDoctype()) { // If previous DTD Decl was unclosed - curr.end = scanner.getTokenOffset(); - curr = curr.getParentNode(); - } - DTDAttlistDecl child = new DTDAttlistDecl(scanner.getTokenOffset(), text.length()); - - isInitialDeclaration = true; - curr.addChild(child); - curr = child; - break; - } - - case DTDAttlistElementName: { - DTDAttlistDecl attribute = (DTDAttlistDecl) curr; - attribute.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDAttlistAttributeName: { - DTDAttlistDecl attribute = (DTDAttlistDecl) curr; - if (isInitialDeclaration == false) { - // All additional declarations are created as new DTDAttlistDecl's - DTDAttlistDecl child = new DTDAttlistDecl(attribute.getStart(), attribute.getEnd()); - attribute.addAdditionalAttDecl(child); - child.parent = attribute; - - attribute = child; - curr = child; - } - attribute.setAttributeName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDAttlistAttributeType: { - DTDAttlistDecl attribute = (DTDAttlistDecl) curr; - attribute.setAttributeType(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDAttlistAttributeValue: { - DTDAttlistDecl attribute = (DTDAttlistDecl) curr; - attribute.setAttributeValue(scanner.getTokenOffset(), scanner.getTokenEnd()); - - if (attribute.parent.isDTDAttListDecl()) { // Is not the root/main ATTLIST node - curr = attribute.parent; - } else { - isInitialDeclaration = false; - } - break; - } - - case DTDStartEntity: { - while (!curr.isDoctype()) { // If previous DTD Decl was unclosed - curr.end = scanner.getTokenOffset(); - curr = curr.getParentNode(); - } - DTDEntityDecl child = new DTDEntityDecl(scanner.getTokenOffset(), text.length()); - curr.addChild(child); - curr = child; - break; - } - - case DTDEntityPercent: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setPercent(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEntityName: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEntityValue: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setValue(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEntityKindPUBLIC: - case DTDEntityKindSYSTEM: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setKind(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEntityPublicId: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setPublicId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEntitySystemId: { - DTDEntityDecl entity = (DTDEntityDecl) curr; - entity.setSystemId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDStartNotation: { - while (!curr.isDoctype()) { // If previous DTD Decl was unclosed - curr.end = scanner.getTokenOffset(); - curr = curr.getParentNode(); - } - DTDNotationDecl child = new DTDNotationDecl(scanner.getTokenOffset(), text.length()); - curr.addChild(child); - curr = child; - isInitialDeclaration = true; - break; - } - - case DTDNotationName: { - DTDNotationDecl notation = (DTDNotationDecl) curr; - notation.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDNotationKindPUBLIC: { - DTDNotationDecl notation = (DTDNotationDecl) curr; - notation.setKind(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDNotationKindSYSTEM: { - DTDNotationDecl notation = (DTDNotationDecl) curr; - notation.setKind(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDNotationPublicId: { - DTDNotationDecl notation = (DTDNotationDecl) curr; - notation.setPublicId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDNotationSystemId: { - DTDNotationDecl notation = (DTDNotationDecl) curr; - notation.setSystemId(scanner.getTokenOffset(), scanner.getTokenEnd()); - break; - } - - case DTDEndTag: { - if ((curr.isDTDElementDecl() || curr.isDTDAttListDecl() || curr.isDTDEntityDecl() - || curr.isDTDNotationDecl())) { - while (curr.parent != null && !curr.parent.isDoctype()) { - curr = curr.parent; - } - curr.end = scanner.getTokenEnd(); - curr.setClosed(true); - curr = curr.parent; - } - break; - } - - case DTDEndDoctypeTag: { - ((DOMDocumentType) curr).end = scanner.getTokenEnd(); - curr.setClosed(true); - curr = curr.parent; - break; - } - - case DTDUnrecognizedParameters: { - DTDDeclNode node = (DTDDeclNode) curr; - node.setUnrecognized(scanner.getTokenOffset(), ((XMLScanner) scanner).getLastNonWhitespaceOffset()); - break; - } - - default: - } - token = scanner.scan(); - } - if (previousTokenWasEndTagOpen) { - previousTokenWasEndTagOpen = false; - if (token != TokenType.EndTag) { - // The excepted token is not an EndTag, create a fake end tag element - DOMElement element = xmlDocument.createElement(endTagOpenOffset, endTagOpenOffset + 2); - element.endTagOpenOffset = endTagOpenOffset; - curr.addChild(element); - } - } - while (curr.parent != null) { - curr.end = text.length(); - curr = curr.parent; - } - return xmlDocument; - } - - private static boolean isEmptyElement(String tag) { - return false; - } - -} +/** + * Copyright (c) 2018 Angelo ZERR. + * 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 + * + * Initial code from https://github.com/Microsoft/vscode-html-languageservice + * Initial copyright Copyright (C) Microsoft Corporation. All rights reserved. + * Initial license: MIT + * + * Contributors: + * - Microsoft Corporation: Initial code, written in TypeScript, licensed under MIT license + * - Angelo Zerr - translation and adaptation to Java + */ +package org.eclipse.lemminx.dom; + +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; + +/** + * Tolerant XML parser. + * + */ +public class DOMParser { + + private static final DOMParser INSTANCE = new DOMParser(); + + public static DOMParser getInstance() { + return INSTANCE; + } + + private DOMParser() { + + } + + public DOMDocument parse(String text, String uri, URIResolverExtensionManager resolverExtensionManager) { + return parse(new TextDocument(text, uri), resolverExtensionManager); + } + + public DOMDocument parse(String text, String uri, URIResolverExtensionManager resolverExtensionManager, + boolean ignoreWhitespaceContent) { + return parse(new TextDocument(text, uri), resolverExtensionManager, ignoreWhitespaceContent); + } + + public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager) { + return parse(document, resolverExtensionManager, true); + } + + public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager, + boolean ignoreWhitespaceContent) { + return parse(document, resolverExtensionManager, ignoreWhitespaceContent, null); + } + + public DOMDocument parse(TextDocument document, URIResolverExtensionManager resolverExtensionManager, + boolean ignoreWhitespaceContent, CancelChecker monitor) { + CharSequence text = document.getTextSequence(); + 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) { + CharSequence text = document.getTextSequence(); + String uri = document.getUri(); + GreenDocument greenDoc = IncrementalParser.incrementalParse( + previousGreenDoc, text, editStart, deleteLength, insertLength, uri, monitor); + DOMDocument xmlDocument = RedTreeBuilder.buildLazy(greenDoc, document, resolverExtensionManager); + xmlDocument.setGreenDocument(greenDoc); + xmlDocument.setCancelChecker(monitor); + return xmlDocument; + } + + private static DOMDocument buildDocument(GreenDocument greenDoc, TextDocument document, + URIResolverExtensionManager resolverExtensionManager, + boolean ignoreWhitespaceContent, CancelChecker monitor) { + 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/DOMProcessingInstruction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMProcessingInstruction.java index 1463e8809..34d3fb822 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMProcessingInstruction.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DOMProcessingInstruction.java @@ -12,7 +12,11 @@ */ package org.eclipse.lemminx.dom; +import java.util.Arrays; +import java.util.List; + import org.w3c.dom.DOMException; +import org.w3c.dom.NamedNodeMap; /** * A processing instruction node. @@ -20,6 +24,7 @@ */ public class DOMProcessingInstruction extends DOMCharacterData implements org.w3c.dom.ProcessingInstruction { + DOMAttr[] attributeNodes; boolean startTagClose; String target; boolean prolog = false; @@ -32,6 +37,42 @@ public DOMProcessingInstruction(int start, int end) { super(start, end); } + @Override + public boolean hasAttributes() { + return attributeNodes != null && attributeNodes.length > 0; + } + + @Override + public void setAttributeNode(DOMAttr attr) { + if (attributeNodes == null) { + attributeNodes = new DOMAttr[] { attr }; + } else { + attributeNodes = Arrays.copyOf(attributeNodes, attributeNodes.length + 1); + attributeNodes[attributeNodes.length - 1] = attr; + } + } + + @Override + public List getAttributeNodes() { + return attributeNodes != null ? Arrays.asList(attributeNodes) : null; + } + + @Override + public DOMAttr getAttributeAtIndex(int index) { + if (!hasAttributes()) { + return null; + } + if (index < 0 || index >= attributeNodes.length) { + return null; + } + return attributeNodes[index]; + } + + @Override + public NamedNodeMap getAttributes() { + return attributeNodes != null ? new AttrNamedNodeMap(attributeNodes) : null; + } + public boolean isProlog() { return prolog; } 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/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/DTDDeclNode.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDDeclNode.java index 18dbde395..7ffe27db5 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDDeclNode.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/DTDDeclNode.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.List; +import org.eclipse.lemminx.dom.green.GreenNode; import org.w3c.dom.Node; /** @@ -30,9 +31,12 @@ public class DTDDeclNode extends DOMNode { * ELEMENT, ATTLIST, ENTITY, or NOTATION */ - public DTDDeclParameter unrecognized; // holds all content after parsing goes wrong in a DTD declaration (ENTITY, - // ATTLIST, ...). - public DTDDeclParameter declType; // represents the actual name of the decl eg: ENTITY, ATTLIST, ... + public DTDDeclParameter unrecognized; + public DTDDeclParameter declType; + + DOMNode[] children; + + private volatile GreenNode lazyGreenNode; private List parameters; private DTDDeclParameter name; @@ -41,6 +45,34 @@ public DTDDeclNode(int start, int end) { super(start, end); } + @Override + DOMNode[] getChildrenArray() { + return children; + } + + @Override + void setChildrenArray(DOMNode[] c) { + this.children = c; + } + + @Override + void ensureChildren() { + if (lazyGreenNode != null) { + synchronized (this) { + if (lazyGreenNode != null) { + GreenNode green = lazyGreenNode; + lazyGreenNode = null; + RedTreeBuilder.expandLazy(this, green, start); + } + } + } + } + + @Override + void setLazy(GreenNode green, int absStart) { + this.lazyGreenNode = green; + } + public String getName() { DTDDeclParameter name = getNameParameter(); return name != null ? name.getParameter() : null; 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/RedTreeBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java new file mode 100644 index 000000000..e9f64b325 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/RedTreeBuilder.java @@ -0,0 +1,420 @@ +/******************************************************************************* + * 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; + +import java.util.Arrays; + +import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lemminx.dom.green.GreenAttr; +import org.eclipse.lemminx.dom.green.GreenCDATA; +import org.eclipse.lemminx.dom.green.GreenComment; +import org.eclipse.lemminx.dom.green.GreenDTDAttlistDecl; +import org.eclipse.lemminx.dom.green.GreenDTDDeclNode; +import org.eclipse.lemminx.dom.green.GreenDTDElementDecl; +import org.eclipse.lemminx.dom.green.GreenDTDEntityDecl; +import org.eclipse.lemminx.dom.green.GreenDTDNotationDecl; +import org.eclipse.lemminx.dom.green.GreenDocument; +import org.eclipse.lemminx.dom.green.GreenDocumentType; +import org.eclipse.lemminx.dom.green.GreenElement; +import org.eclipse.lemminx.dom.green.GreenNode; +import org.eclipse.lemminx.dom.green.GreenProcessingInstruction; +import org.eclipse.lemminx.dom.green.GreenText; +import org.eclipse.lemminx.uriresolver.URIResolverExtensionManager; +import org.w3c.dom.Node; + +/** + * Builds a red tree ({@link DOMDocument}) from an immutable green tree + * ({@link GreenDocument}). + * + *

The red tree provides absolute offsets, parent pointers, and the full + * {@link org.w3c.dom.Node} API that existing LemMinX features expect. + * The green tree is preserved for structural sharing during incremental + * reparsing.

+ */ +public final class RedTreeBuilder { + + private final boolean ignoreWhitespaceContent; + private final boolean lazy; + + 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, false).doBuild(greenDoc, textDocument, resolverExtensionManager); + } + + public static DOMDocument build(GreenDocument greenDoc, TextDocument textDocument, + URIResolverExtensionManager resolverExtensionManager, boolean ignoreWhitespaceContent) { + 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); + } + + 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); + } + + private DOMDocument doBuild(GreenDocument greenDoc, TextDocument textDocument, + URIResolverExtensionManager resolverExtensionManager) { + DOMDocument domDoc = new DOMDocument(textDocument, resolverExtensionManager); + addChildren(domDoc, greenDoc, 0); + domDoc.compactChildren(); + return domDoc; + } + + private void addChildrenOrDefer(DOMNode node, GreenNode green, int absStart) { + if (lazy && green.childCount() > 0) { + node.setLazy(green, absStart); + } else { + addChildren(node, green, absStart); + } + } + + private void addChildren(DOMNode parent, GreenNode greenParent, int parentAbsStart) { + int cc = greenParent.childCount(); + if (cc == 0) { + parent.compactChildren(); + return; + } + + int childrenStartRel = greenParent.childrenStartRel(); + int childAbsStart = parentAbsStart + childrenStartRel; + boolean skipWhitespace = ignoreWhitespaceContent + && (hasNonWhitespaceChild(greenParent, cc) || greenParent instanceof GreenDocumentType); + + DOMNode[] redChildren = new DOMNode[cc]; + int idx = 0; + + for (int i = 0; i < cc; i++) { + GreenNode greenChild = greenParent.child(i); + if (skipWhitespace && isWhitespaceText(greenChild)) { + childAbsStart += greenChild.width(); + continue; + } + DOMNode redChild = createRedNode(greenChild, childAbsStart); + if (redChild != null) { + if (parent instanceof DTDAttlistDecl && redChild instanceof DTDAttlistDecl) { + ((DTDAttlistDecl) parent).addAdditionalAttDecl((DTDAttlistDecl) redChild); + redChild.parent = parent; + } else { + redChild.parent = parent; + redChild.cachedIndexInParent = idx; + redChildren[idx++] = redChild; + } + } + childAbsStart += greenChild.width(); + } + + if (idx > 0) { + parent.setChildrenArray((idx == redChildren.length) ? redChildren : Arrays.copyOf(redChildren, idx)); + } + parent.compactChildren(); + } + + private static boolean hasNonWhitespaceChild(GreenNode parent, int cc) { + for (int i = 0; i < cc; i++) { + if (!isWhitespaceText(parent.child(i))) { + return true; + } + } + return false; + } + + private static boolean isWhitespaceText(GreenNode node) { + return node instanceof GreenText && ((GreenText) node).whitespace(); + } + + private DOMNode createRedNode(GreenNode green, int absStart) { + int absEnd = absStart + green.width(); + + switch (green.nodeType()) { + case Node.ELEMENT_NODE: + return createElement((GreenElement) green, absStart, absEnd); + case Node.TEXT_NODE: + return createText((GreenText) green, absStart, absEnd); + case Node.COMMENT_NODE: + return createComment((GreenComment) green, absStart, absEnd); + case Node.CDATA_SECTION_NODE: + return createCDATA((GreenCDATA) green, absStart, absEnd); + case Node.PROCESSING_INSTRUCTION_NODE: + return createPI((GreenProcessingInstruction) green, absStart, absEnd); + case Node.DOCUMENT_TYPE_NODE: + return createDocumentType((GreenDocumentType) green, absStart, absEnd); + case DOMNode.DTD_ELEMENT_DECL_NODE: + return createDTDElementDecl((GreenDTDElementDecl) green, absStart, absEnd); + case DOMNode.DTD_ATT_LIST_NODE: + return createDTDAttlistDecl((GreenDTDAttlistDecl) green, absStart, absEnd); + case Node.ENTITY_NODE: + return createDTDEntityDecl((GreenDTDEntityDecl) green, absStart, absEnd); + case DOMNode.DTD_NOTATION_DECL: + return createDTDNotationDecl((GreenDTDNotationDecl) green, absStart, absEnd); + case DOMNode.DTD_DECL_NODE: + return createDTDDeclNode((GreenDTDDeclNode) green, absStart, absEnd); + default: + return null; + } + } + + private DOMElement createElement(GreenElement green, int absStart, int absEnd) { + DOMElement elem = new DOMElement(absStart, absEnd); + elem.greenElement = green; + elem.setSelfClosed(green.selfClosed()); + elem.setClosed(green.closed()); + + GreenAttr[] greenAttrs = green.attributes(); + if (greenAttrs.length > 0) { + DOMAttr[] attrs = new DOMAttr[greenAttrs.length]; + for (int i = 0; i < greenAttrs.length; i++) { + attrs[i] = createAttr(greenAttrs[i], absStart, elem); + } + elem.attributeNodes = attrs; + } + + addChildrenOrDefer(elem, green, absStart); + return elem; + } + + private static DOMAttr createAttr(GreenAttr ga, int elemAbsStart, DOMNode owner) { + int nameStart = elemAbsStart + ga.nameStartRel(); + int nameEnd = elemAbsStart + ga.nameEndRel(); + DOMAttr attr = new DOMAttr(null, nameStart, nameEnd, owner); + if (ga.delimiterRel() != GreenAttr.NULL_VALUE) { + attr.setDelimiter(elemAbsStart + ga.delimiterRel()); + } + if (ga.valueStartRel() != GreenAttr.NULL_VALUE) { + attr.setValue(null, elemAbsStart + ga.valueStartRel(), elemAbsStart + ga.valueEndRel()); + } + return attr; + } + + private static DOMText createText(GreenText green, int absStart, int absEnd) { + DOMText text = new DOMText(absStart, absEnd); + text.setWhitespace(green.whitespace()); + text.setClosed(true); + return text; + } + + private static DOMComment createComment(GreenComment green, int absStart, int absEnd) { + DOMComment comment = new DOMComment(absStart, absEnd); + comment.startContent = abs(green.startContentRel(), absStart); + comment.endContent = abs(green.endContentRel(), absStart); + comment.commentSameLineEndTag = green.commentSameLineEndTag(); + comment.setClosed(green.closed()); + return comment; + } + + private static DOMCDATASection createCDATA(GreenCDATA green, int absStart, int absEnd) { + DOMCDATASection cdata = new DOMCDATASection(absStart, absEnd); + cdata.startContent = abs(green.startContentRel(), absStart); + cdata.endContent = abs(green.endContentRel(), absStart); + cdata.setClosed(green.closed()); + return cdata; + } + + private static DOMProcessingInstruction createPI(GreenProcessingInstruction green, + int absStart, int absEnd) { + DOMProcessingInstruction pi = new DOMProcessingInstruction(absStart, absEnd); + pi.target = green.target(); + pi.prolog = green.prolog(); + pi.processingInstruction = green.processingInstruction(); + pi.startTagClose = green.startTagClose(); + if (green.startContentRel() != GreenElement.NULL_VALUE) { + pi.startContent = absStart + green.startContentRel(); + } + if (green.endContentRel() != GreenElement.NULL_VALUE) { + pi.endContent = absStart + green.endContentRel(); + } + pi.endTagOpenOffset = abs(green.endTagOpenRel(), absStart); + pi.setClosed(green.closed()); + + GreenAttr[] greenAttrs = green.attributes(); + if (greenAttrs.length > 0) { + DOMAttr[] attrs = new DOMAttr[greenAttrs.length]; + for (int i = 0; i < greenAttrs.length; i++) { + attrs[i] = createAttr(greenAttrs[i], absStart, pi); + } + pi.attributeNodes = attrs; + } + return pi; + } + + private DOMDocumentType createDocumentType(GreenDocumentType green, + int absStart, int absEnd) { + DOMDocumentType dt = new DOMDocumentType(absStart, absEnd); + dt.setClosed(green.closed()); + setDTDDeclFields(dt, green, absStart); + + if (green.kind() != null) { + dt.setKind(absStart + green.kind().startRel(), + absStart + green.kind().endRel()); + } + if (green.publicId() != null) { + dt.setPublicId(absStart + green.publicId().startRel(), + absStart + green.publicId().endRel()); + } + if (green.systemId() != null) { + dt.setSystemId(absStart + green.systemId().startRel(), + absStart + green.systemId().endRel()); + } + if (green.internalSubset() != null) { + dt.setStartInternalSubset(absStart + green.internalSubset().startRel()); + dt.setEndInternalSubset(absStart + green.internalSubset().endRel()); + } + + setDTDUnrecognized(dt, green, absStart); + addChildrenOrDefer(dt, green, absStart); + dt.end = absEnd; + return dt; + } + + private DTDElementDecl createDTDElementDecl(GreenDTDElementDecl green, + int absStart, int absEnd) { + DTDElementDecl decl = new DTDElementDecl(absStart, absEnd); + decl.setClosed(green.closed()); + setDTDDeclFields(decl, green, absStart); + if (green.category() != null) { + decl.setCategory(absStart + green.category().startRel(), + absStart + green.category().endRel()); + } + if (green.content() != null) { + decl.setContent(absStart + green.content().startRel(), + absStart + green.content().endRel()); + } + setDTDUnrecognized(decl, green, absStart); + addChildrenOrDefer(decl, green, absStart); + decl.end = absEnd; + return decl; + } + + private DTDAttlistDecl createDTDAttlistDecl(GreenDTDAttlistDecl green, + int absStart, int absEnd) { + DTDAttlistDecl decl = new DTDAttlistDecl(absStart, absEnd); + decl.setClosed(green.closed()); + setDTDDeclFields(decl, green, absStart); + if (green.attributeName() != null) { + decl.setAttributeName(absStart + green.attributeName().startRel(), + absStart + green.attributeName().endRel()); + } + if (green.attributeType() != null) { + decl.setAttributeType(absStart + green.attributeType().startRel(), + absStart + green.attributeType().endRel()); + } + if (green.attributeValue() != null) { + decl.setAttributeValue(absStart + green.attributeValue().startRel(), + absStart + green.attributeValue().endRel()); + } + setDTDUnrecognized(decl, green, absStart); + addChildrenOrDefer(decl, green, absStart); + decl.end = absEnd; + return decl; + } + + private DTDEntityDecl createDTDEntityDecl(GreenDTDEntityDecl green, + int absStart, int absEnd) { + DTDEntityDecl decl = new DTDEntityDecl(absStart, absEnd); + decl.setClosed(green.closed()); + if (green.percent() != null) { + decl.setPercent(absStart + green.percent().startRel(), + absStart + green.percent().endRel()); + } + setDTDDeclFields(decl, green, absStart); + if (green.value() != null) { + decl.setValue(absStart + green.value().startRel(), + absStart + green.value().endRel()); + } + if (green.kind() != null) { + decl.setKind(absStart + green.kind().startRel(), + absStart + green.kind().endRel()); + } + if (green.publicId() != null) { + decl.setPublicId(absStart + green.publicId().startRel(), + absStart + green.publicId().endRel()); + } + if (green.systemId() != null) { + decl.setSystemId(absStart + green.systemId().startRel(), + absStart + green.systemId().endRel()); + } + setDTDUnrecognized(decl, green, absStart); + addChildrenOrDefer(decl, green, absStart); + decl.end = absEnd; + return decl; + } + + private DTDNotationDecl createDTDNotationDecl(GreenDTDNotationDecl green, + int absStart, int absEnd) { + DTDNotationDecl decl = new DTDNotationDecl(absStart, absEnd); + decl.setClosed(green.closed()); + setDTDDeclFields(decl, green, absStart); + if (green.kind() != null) { + decl.setKind(absStart + green.kind().startRel(), + absStart + green.kind().endRel()); + } + if (green.publicId() != null) { + decl.setPublicId(absStart + green.publicId().startRel(), + absStart + green.publicId().endRel()); + } + if (green.systemId() != null) { + decl.setSystemId(absStart + green.systemId().startRel(), + absStart + green.systemId().endRel()); + } + setDTDUnrecognized(decl, green, absStart); + addChildrenOrDefer(decl, green, absStart); + decl.end = absEnd; + return decl; + } + + private DTDDeclNode createDTDDeclNode(GreenDTDDeclNode green, + int absStart, int absEnd) { + DTDDeclNode decl = new DTDDeclNode(absStart, absEnd); + decl.setClosed(green.closed()); + setDTDDeclFields(decl, green, absStart); + setDTDUnrecognized(decl, green, absStart); + addChildrenOrDefer(decl, green, absStart); + decl.end = absEnd; + return decl; + } + + private static void setDTDDeclFields(DTDDeclNode decl, GreenDTDDeclNode green, + int absStart) { + if (green.declType() != null) { + decl.setDeclType(absStart + green.declType().startRel(), + absStart + green.declType().endRel()); + } + if (green.name() != null) { + decl.setName(absStart + green.name().startRel(), + absStart + green.name().endRel()); + } + } + + private static void setDTDUnrecognized(DTDDeclNode decl, GreenDTDDeclNode green, + int absStart) { + if (green.unrecognized() != null) { + decl.setUnrecognized(absStart + green.unrecognized().startRel(), + absStart + green.unrecognized().endRel()); + } + } + + 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/GreenAttr.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenAttr.java new file mode 100644 index 000000000..2fc51d09e --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenAttr.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * 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; + +/** + * Immutable green node for an XML attribute. + * + *

Offsets are relative to the owning element's start position. + * The red wrapper adds the element's absolute offset when converting.

+ */ +public final class GreenAttr { + + public static final int NULL_VALUE = -1; + + private final int nameStartRel; + private final int nameEndRel; + private final int delimiterRel; + private final int valueStartRel; + private final int valueEndRel; + + public GreenAttr(int nameStartRel, int nameEndRel, int delimiterRel, + int valueStartRel, int valueEndRel) { + this.nameStartRel = nameStartRel; + this.nameEndRel = nameEndRel; + this.delimiterRel = delimiterRel; + this.valueStartRel = valueStartRel; + this.valueEndRel = valueEndRel; + } + + public int nameStartRel() { + return nameStartRel; + } + + public int nameEndRel() { + return nameEndRel; + } + + public int delimiterRel() { + return delimiterRel; + } + + public int valueStartRel() { + return valueStartRel; + } + + public int valueEndRel() { + return valueEndRel; + } + + public int width() { + if (valueEndRel != NULL_VALUE) { + return valueEndRel - nameStartRel; + } + if (delimiterRel != NULL_VALUE) { + return delimiterRel + 1 - nameStartRel; + } + return nameEndRel - nameStartRel; + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenAttrBuilder.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenAttrBuilder.java new file mode 100644 index 000000000..b60022e33 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenAttrBuilder.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * 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; + +/** + * Mutable builder for {@link GreenAttr}. + * + *

Offsets are collected as absolute positions during scanning; the builder + * converts them to element-relative offsets at {@link #build(int)} time.

+ */ +public final class GreenAttrBuilder { + + private int nameStart = GreenAttr.NULL_VALUE; + private int nameEnd = GreenAttr.NULL_VALUE; + private int delimiter = GreenAttr.NULL_VALUE; + private int valueStart = GreenAttr.NULL_VALUE; + private int valueEnd = GreenAttr.NULL_VALUE; + + public void setName(int start, int end) { + this.nameStart = start; + this.nameEnd = end; + } + + public void setDelimiter(int offset) { + this.delimiter = offset; + } + + public void setValue(int start, int end) { + this.valueStart = start; + this.valueEnd = end; + } + + /** + * Builds an immutable {@link GreenAttr} with offsets relative to the given + * element start position. + * + * @param elementStart the absolute start offset of the owning element + * @return immutable attribute + */ + public GreenAttr build(int elementStart) { + return new GreenAttr( + nameStart - elementStart, + nameEnd - elementStart, + delimiter != GreenAttr.NULL_VALUE ? delimiter - elementStart : GreenAttr.NULL_VALUE, + valueStart != GreenAttr.NULL_VALUE ? valueStart - elementStart : GreenAttr.NULL_VALUE, + valueEnd != GreenAttr.NULL_VALUE ? valueEnd - elementStart : GreenAttr.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 new file mode 100644 index 000000000..cfc654c48 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenCDATA.java @@ -0,0 +1,47 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for a CDATA section ({@code }). + */ +public final class GreenCDATA extends GreenNode { + + private final int startContentRel; + private final int endContentRel; + + public GreenCDATA(int width, boolean closed, int startContentRel, int endContentRel) { + super(width, closed ? CLOSED_FLAG : 0); + this.startContentRel = startContentRel; + this.endContentRel = endContentRel; + } + + @Override + public short nodeType() { + return Node.CDATA_SECTION_NODE; + } + + public int startContentRel() { + return startContentRel; + } + + public int endContentRel() { + return endContentRel; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenCDATA(newWidth, closed(), startContentRel, 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 new file mode 100644 index 000000000..8dafde6f0 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenComment.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for an XML comment ({@code }). + */ +public final class GreenComment extends GreenNode { + + private final int startContentRel; + private final int endContentRel; + + public GreenComment(int width, boolean closed, boolean commentSameLineEndTag, + int startContentRel, int endContentRel) { + super(width, (closed ? CLOSED_FLAG : 0) | (commentSameLineEndTag ? SUBCLASS_FLAG : 0)); + this.startContentRel = startContentRel; + this.endContentRel = endContentRel; + } + + @Override + public short nodeType() { + return Node.COMMENT_NODE; + } + + public boolean commentSameLineEndTag() { + return subclassFlag(); + } + + public int startContentRel() { + return startContentRel; + } + + public int endContentRel() { + return endContentRel; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenComment(newWidth, closed(), commentSameLineEndTag(), + startContentRel, endContentRel); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDAttlistDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDAttlistDecl.java new file mode 100644 index 000000000..cd920d28a --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDAttlistDecl.java @@ -0,0 +1,67 @@ +/******************************************************************************* + * 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.lemminx.dom.DOMNode; + +/** + * Immutable green node for a DTD ATTLIST declaration ({@code }). + */ +public final class GreenDTDAttlistDecl extends GreenDTDDeclNode { + + private final GreenDTDParam attributeName; + private final GreenDTDParam attributeType; + private final GreenDTDParam attributeValue; + private final GreenDTDAttlistDecl[] internalDecls; + + public GreenDTDAttlistDecl(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children, + GreenDTDParam attributeName, GreenDTDParam attributeType, + GreenDTDParam attributeValue, + GreenDTDAttlistDecl[] internalDecls) { + super(width, closed, unrecognized, declType, name, parameters, children); + this.attributeName = attributeName; + this.attributeType = attributeType; + this.attributeValue = attributeValue; + this.internalDecls = internalDecls; + } + + @Override + public short nodeType() { + return DOMNode.DTD_ATT_LIST_NODE; + } + + public GreenDTDParam attributeName() { + return attributeName; + } + + public GreenDTDParam attributeType() { + return attributeType; + } + + public GreenDTDParam attributeValue() { + return attributeValue; + } + + public GreenDTDAttlistDecl[] internalDecls() { + return internalDecls; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDTDAttlistDecl(newWidth, closed(), unrecognized(), declType(), + name(), parameters(), newChildren, + attributeName, attributeType, attributeValue, internalDecls); + } +} 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 new file mode 100644 index 000000000..ddf2df0ea --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDDeclNode.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * 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.lemminx.dom.DOMNode; + +/** + * Immutable green node for a generic DTD declaration. + * + *

Serves as base data for {@link GreenDocumentType}, + * {@link GreenDTDAttlistDecl}, {@link GreenDTDElementDecl}, + * {@link GreenDTDEntityDecl}, and {@link GreenDTDNotationDecl}.

+ */ +public class GreenDTDDeclNode extends GreenNode { + + public static final GreenDTDParam[] EMPTY_PARAMS = new GreenDTDParam[0]; + + private final GreenDTDParam unrecognized; + private final GreenDTDParam declType; + private final GreenDTDParam name; + private final GreenDTDParam[] parameters; + private final GreenNode[] children; + + public GreenDTDDeclNode(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children) { + super(width, closed ? CLOSED_FLAG : 0); + this.unrecognized = unrecognized; + this.declType = declType; + this.name = name; + this.parameters = parameters != null ? parameters : EMPTY_PARAMS; + this.children = children != null ? children : EMPTY_CHILDREN; + } + + @Override + public short nodeType() { + return DOMNode.DTD_DECL_NODE; + } + + public GreenDTDParam unrecognized() { + return unrecognized; + } + + public GreenDTDParam declType() { + return declType; + } + + public GreenDTDParam name() { + return name; + } + + 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; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDTDDeclNode(newWidth, closed(), unrecognized, declType, + name, parameters, newChildren); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDElementDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDElementDecl.java new file mode 100644 index 000000000..d164c6bbd --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDElementDecl.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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.lemminx.dom.DOMNode; + +/** + * Immutable green node for a DTD ELEMENT declaration ({@code }). + */ +public final class GreenDTDElementDecl extends GreenDTDDeclNode { + + private final GreenDTDParam category; + private final GreenDTDParam content; + + public GreenDTDElementDecl(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children, + GreenDTDParam category, GreenDTDParam content) { + super(width, closed, unrecognized, declType, name, parameters, children); + this.category = category; + this.content = content; + } + + @Override + public short nodeType() { + return DOMNode.DTD_ELEMENT_DECL_NODE; + } + + public GreenDTDParam category() { + return category; + } + + public GreenDTDParam content() { + return content; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDTDElementDecl(newWidth, closed(), unrecognized(), declType(), + name(), parameters(), newChildren, + category, content); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDEntityDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDEntityDecl.java new file mode 100644 index 000000000..2bab3e709 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDEntityDecl.java @@ -0,0 +1,73 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for a DTD ENTITY declaration ({@code }). + */ +public final class GreenDTDEntityDecl extends GreenDTDDeclNode { + + private final GreenDTDParam percent; + private final GreenDTDParam value; + private final GreenDTDParam kind; + private final GreenDTDParam publicId; + private final GreenDTDParam systemId; + + public GreenDTDEntityDecl(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children, + GreenDTDParam percent, GreenDTDParam value, + GreenDTDParam kind, GreenDTDParam publicId, + GreenDTDParam systemId) { + super(width, closed, unrecognized, declType, name, parameters, children); + this.percent = percent; + this.value = value; + this.kind = kind; + this.publicId = publicId; + this.systemId = systemId; + } + + @Override + public short nodeType() { + return Node.ENTITY_NODE; + } + + public GreenDTDParam percent() { + return percent; + } + + public GreenDTDParam value() { + return value; + } + + public GreenDTDParam kind() { + return kind; + } + + public GreenDTDParam publicId() { + return publicId; + } + + public GreenDTDParam systemId() { + return systemId; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDTDEntityDecl(newWidth, closed(), unrecognized(), declType(), + name(), parameters(), newChildren, + percent, value, kind, publicId, systemId); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDNotationDecl.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDNotationDecl.java new file mode 100644 index 000000000..4fce98334 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDNotationDecl.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * 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.lemminx.dom.DOMNode; + +/** + * Immutable green node for a DTD NOTATION declaration ({@code }). + */ +public final class GreenDTDNotationDecl extends GreenDTDDeclNode { + + private final GreenDTDParam kind; + private final GreenDTDParam publicId; + private final GreenDTDParam systemId; + + public GreenDTDNotationDecl(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children, + GreenDTDParam kind, GreenDTDParam publicId, + GreenDTDParam systemId) { + super(width, closed, unrecognized, declType, name, parameters, children); + this.kind = kind; + this.publicId = publicId; + this.systemId = systemId; + } + + @Override + public short nodeType() { + return DOMNode.DTD_NOTATION_DECL; + } + + public GreenDTDParam kind() { + return kind; + } + + public GreenDTDParam publicId() { + return publicId; + } + + public GreenDTDParam systemId() { + return systemId; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDTDNotationDecl(newWidth, closed(), unrecognized(), declType(), + name(), parameters(), newChildren, + kind, publicId, systemId); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDParam.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDParam.java new file mode 100644 index 000000000..1517044e8 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDTDParam.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * 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; + +/** + * Immutable representation of a DTD declaration parameter's position. + * + *

Offsets are relative to the owning DTD declaration node's start.

+ */ +public final class GreenDTDParam { + + private final int startRel; + private final int endRel; + + public GreenDTDParam(int startRel, int endRel) { + this.startRel = startRel; + this.endRel = endRel; + } + + public int startRel() { + return startRel; + } + + public int endRel() { + return endRel; + } + + public int width() { + return endRel - startRel; + } +} 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 new file mode 100644 index 000000000..e351f33fb --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocument.java @@ -0,0 +1,45 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for the document root. + * + *

The document root's width equals the full text length. Its children are + * the top-level nodes (prolog, doctype, root element, comments, PIs).

+ */ +public final class GreenDocument extends GreenNode { + + private final GreenNode[] children; + + public GreenDocument(int width, GreenNode[] children) { + super(width, CLOSED_FLAG); + this.children = children != null ? children : EMPTY_CHILDREN; + } + + @Override + public short nodeType() { + return Node.DOCUMENT_NODE; + } + + @Override + public GreenNode[] children() { + return children; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenDocument(newWidth, newChildren); + } +} 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 new file mode 100644 index 000000000..bb9deeb01 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenDocumentType.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for a DOCTYPE declaration ({@code }). + */ +public final class GreenDocumentType extends GreenDTDDeclNode { + + private final GreenDTDParam kind; + private final GreenDTDParam publicId; + private final GreenDTDParam systemId; + private final GreenDTDParam internalSubset; + + public GreenDocumentType(int width, boolean closed, + GreenDTDParam unrecognized, GreenDTDParam declType, + GreenDTDParam name, GreenDTDParam[] parameters, + GreenNode[] children, + GreenDTDParam kind, GreenDTDParam publicId, + GreenDTDParam systemId, GreenDTDParam internalSubset) { + super(width, closed, unrecognized, declType, name, parameters, children); + this.kind = kind; + this.publicId = publicId; + this.systemId = systemId; + this.internalSubset = internalSubset; + } + + @Override + public short nodeType() { + return Node.DOCUMENT_TYPE_NODE; + } + + public GreenDTDParam kind() { + return kind; + } + + public GreenDTDParam publicId() { + return publicId; + } + + public GreenDTDParam systemId() { + return systemId; + } + + 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(), + name(), parameters(), newChildren, + kind, publicId, systemId, internalSubset); + } +} 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 new file mode 100644 index 000000000..d28680826 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElement.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for an XML element. + * + *

All offsets are relative to the node's own start (i.e., relative + * offset 0 is the first character of the element). The red wrapper adds the + * absolute document offset when queried.

+ */ +public final class GreenElement extends GreenNode { + + public static final int NULL_VALUE = -1; + + private final String tag; + private final int startTagCloseRel; + private final int endTagOpenRel; + private final GreenAttr[] attributes; + private final Object childrenData; + + public GreenElement(int width, boolean closed, String tag, boolean selfClosed, + int startTagCloseRel, int endTagOpenRel, boolean endTagHasClose, + GreenAttr[] attributes, GreenNode[] children) { + super(width, (closed ? CLOSED_FLAG : 0) | (selfClosed ? SUBCLASS_FLAG : 0) + | (endTagHasClose ? EXTRA_FLAG : 0)); + this.tag = tag; + this.startTagCloseRel = startTagCloseRel; + this.endTagOpenRel = endTagOpenRel; + this.attributes = attributes != null ? attributes : EMPTY_ATTRS; + if (children == null || children.length == 0) { + this.childrenData = null; + } else if (children.length == 1) { + this.childrenData = children[0]; + } else { + this.childrenData = children; + } + } + + @Override + public short nodeType() { + return Node.ELEMENT_NODE; + } + + public String tag() { + return tag; + } + + public boolean selfClosed() { + return subclassFlag(); + } + + public int startTagCloseRel() { + return startTagCloseRel; + } + + public int endTagOpenRel() { + return endTagOpenRel; + } + + public int endTagCloseRel() { + if (!extraFlag()) { + return NULL_VALUE; + } + return width() - 1; + } + + public GreenAttr[] attributes() { + return attributes; + } + + public int attributeCount() { + return attributes.length; + } + + @Override + public int childrenStartRel() { + if (startTagCloseRel != NULL_VALUE) { + return startTagCloseRel + 1; + } + int cc = childCount(); + if (cc == 0) { + return 0; + } + int childrenWidth = 0; + for (int i = 0; i < cc; i++) { + childrenWidth += child(i).width(); + } + if (endTagOpenRel != NULL_VALUE) { + return endTagOpenRel - childrenWidth; + } + return width() - childrenWidth; + } + + @Override + public GreenNode[] children() { + if (childrenData == null) return EMPTY_CHILDREN; + if (childrenData instanceof GreenNode[]) return (GreenNode[]) childrenData; + return new GreenNode[] { (GreenNode) childrenData }; + } + + @Override + public int childCount() { + if (childrenData == null) return 0; + if (childrenData instanceof GreenNode[]) return ((GreenNode[]) childrenData).length; + return 1; + } + + @Override + public GreenNode child(int index) { + if (childrenData instanceof GreenNode[]) return ((GreenNode[]) childrenData)[index]; + if (index == 0 && childrenData != null) return (GreenNode) childrenData; + throw new IndexOutOfBoundsException(index); + } + + public GreenElement withNewChildren(GreenNode[] newChildren, int widthDelta) { + return new GreenElement( + width() + widthDelta, closed(), tag, selfClosed(), + startTagCloseRel, + endTagOpenRel != NULL_VALUE ? endTagOpenRel + widthDelta : NULL_VALUE, + extraFlag(), + attributes, newChildren); + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenElement(newWidth, closed(), tag, selfClosed(), + startTagCloseRel, endTagOpenRel, extraFlag(), + 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 new file mode 100644 index 000000000..d8b66be43 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenElementBuilder.java @@ -0,0 +1,122 @@ +/******************************************************************************* + * 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 java.util.Arrays; + +/** + * Mutable builder for {@link GreenElement}. + * + *

Used during parsing: the scanner populates fields across multiple + * tokens, then {@link #build()} freezes the result into an immutable + * {@link GreenElement}.

+ */ +public final class GreenElementBuilder { + + private final int nodeStart; + private int nodeEnd; + private String tag; + private boolean selfClosed; + private boolean closed; + private int startTagCloseOffset = GreenElement.NULL_VALUE; + private int endTagOpenOffset = GreenElement.NULL_VALUE; + private boolean endTagHasClose; + private GreenAttr[] attributes; + private int attrCount; + private GreenNode[] children; + private int childCount; + + public GreenElementBuilder(int nodeStart) { + this.nodeStart = nodeStart; + this.nodeEnd = nodeStart; + } + + public int nodeStart() { + return nodeStart; + } + + public int nodeEnd() { + return nodeEnd; + } + + public void setNodeEnd(int nodeEnd) { + this.nodeEnd = nodeEnd; + } + + public String tag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public void setSelfClosed(boolean selfClosed) { + this.selfClosed = selfClosed; + } + + public void setClosed(boolean closed) { + this.closed = closed; + } + + public void setStartTagCloseOffset(int offset) { + this.startTagCloseOffset = offset; + } + + public void setEndTagOpenOffset(int offset) { + this.endTagOpenOffset = offset; + } + + public void setEndTagHasClose(boolean endTagHasClose) { + this.endTagHasClose = endTagHasClose; + } + + public void addAttribute(GreenAttr attr) { + if (attributes == null) { + attributes = new GreenAttr[4]; + attrCount = 0; + } + if (attrCount == attributes.length) { + attributes = Arrays.copyOf(attributes, attributes.length * 2); + } + attributes[attrCount++] = attr; + } + + public void addChild(GreenNode child) { + if (children == null) { + children = new GreenNode[4]; + childCount = 0; + } + if (childCount == children.length) { + children = Arrays.copyOf(children, children.length * 2); + } + children[childCount++] = child; + } + + public GreenElement build() { + int width = nodeEnd - nodeStart; + int stcRel = startTagCloseOffset != GreenElement.NULL_VALUE + ? startTagCloseOffset - nodeStart : GreenElement.NULL_VALUE; + int etoRel = endTagOpenOffset != GreenElement.NULL_VALUE + ? endTagOpenOffset - nodeStart : GreenElement.NULL_VALUE; + + GreenAttr[] attrs = attributes != null + ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount)) + : null; + GreenNode[] kids = children != null + ? (childCount == children.length ? children : Arrays.copyOf(children, childCount)) + : null; + + return new GreenElement(width, closed, tag, selfClosed, + stcRel, etoRel, endTagHasClose, 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 new file mode 100644 index 000000000..43ce1d7b6 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenNode.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * 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; + +/** + * Immutable green node — the backbone of the red-green tree. + * + *

Green nodes store width (character span), not absolute offsets. + * They carry no parent pointer and are safe to share across document versions. + * The mutable red wrapper (the existing {@code DOMNode} hierarchy) computes + * absolute positions lazily from the green tree.

+ * + *

All subclasses must be immutable: every field is {@code final} and every + * collection is an unmodifiable snapshot taken at construction time.

+ */ +public abstract class GreenNode { + + public static final GreenNode[] EMPTY_CHILDREN = new GreenNode[0]; + public static final GreenAttr[] EMPTY_ATTRS = new GreenAttr[0]; + + static final int CLOSED_FLAG = 1 << 31; + static final int SUBCLASS_FLAG = 1 << 30; + static final int EXTRA_FLAG = 1 << 29; + private static final int WIDTH_MASK = 0x1FFFFFFF; + + private final int widthAndFlags; + + protected GreenNode(int width, int flags) { + this.widthAndFlags = width | flags; + } + + public abstract short nodeType(); + + public final int width() { + return widthAndFlags & WIDTH_MASK; + } + + public final boolean closed() { + return (widthAndFlags & CLOSED_FLAG) != 0; + } + + protected final boolean subclassFlag() { + return (widthAndFlags & SUBCLASS_FLAG) != 0; + } + + protected final boolean extraFlag() { + return (widthAndFlags & EXTRA_FLAG) != 0; + } + + public GreenNode[] children() { + return EMPTY_CHILDREN; + } + + public int childCount() { + return children().length; + } + + public GreenNode child(int index) { + return children()[index]; + } + + public GreenNode withReplacedChild(int index, GreenNode newChild) { + GreenNode[] old = children(); + GreenNode[] copy = new GreenNode[old.length]; + System.arraycopy(old, 0, copy, 0, old.length); + int deltaWidth = newChild.width() - old[index].width(); + copy[index] = 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/GreenProcessingInstruction.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java new file mode 100644 index 000000000..422cde7a9 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenProcessingInstruction.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for a processing instruction ({@code }) + * or XML prolog ({@code }). + */ +public final class GreenProcessingInstruction extends GreenNode { + + private final boolean startTagClose; + private final String target; + private final boolean prolog; + private final boolean processingInstruction; + private final int startContentRel; + private final int endContentRel; + private final int endTagOpenRel; + private final GreenAttr[] attributes; + + public GreenProcessingInstruction(int width, boolean closed, boolean startTagClose, + String target, boolean prolog, boolean processingInstruction, + int startContentRel, int endContentRel, int endTagOpenRel, + GreenAttr[] attributes) { + super(width, closed ? CLOSED_FLAG : 0); + this.startTagClose = startTagClose; + this.target = target; + this.prolog = prolog; + this.processingInstruction = processingInstruction; + this.startContentRel = startContentRel; + this.endContentRel = endContentRel; + this.endTagOpenRel = endTagOpenRel; + this.attributes = attributes != null ? attributes : EMPTY_ATTRS; + } + + @Override + public short nodeType() { + return Node.PROCESSING_INSTRUCTION_NODE; + } + + public boolean startTagClose() { + return startTagClose; + } + + public String target() { + return target; + } + + public boolean prolog() { + return prolog; + } + + public boolean processingInstruction() { + return processingInstruction; + } + + public int startContentRel() { + return startContentRel; + } + + public int endContentRel() { + return endContentRel; + } + + public int endTagOpenRel() { + return endTagOpenRel; + } + + public GreenAttr[] attributes() { + return attributes; + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + return new GreenProcessingInstruction(newWidth, closed(), startTagClose, + target, prolog, processingInstruction, + startContentRel, endContentRel, endTagOpenRel, attributes); + } +} 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 new file mode 100644 index 000000000..f6fabece2 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenText.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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.w3c.dom.Node; + +/** + * Immutable green node for a text content node. + */ +public final class GreenText extends GreenNode { + + 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, 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 + public short nodeType() { + return Node.TEXT_NODE; + } + + public boolean whitespace() { + return subclassFlag(); + } + + @Override + protected GreenNode replaceChildren(GreenNode[] newChildren, int newWidth) { + 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 new file mode 100644 index 000000000..79450119f --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/GreenTreeBuilder.java @@ -0,0 +1,1191 @@ +/******************************************************************************* + * 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 java.util.ArrayDeque; +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; +import org.eclipse.lemminx.dom.parser.TokenType; +import org.eclipse.lemminx.dom.parser.XMLScanner; +import org.eclipse.lemminx.utils.DOMUtils; +import org.eclipse.lsp4j.jsonrpc.CancelChecker; + +/** + * Builds an immutable {@link GreenDocument} from XML text using the scanner. + * + *

This mirrors the logic of {@code DOMParser} but produces green nodes + * (width-based, immutable, no parent pointers) instead of red DOM nodes.

+ */ +public final class GreenTreeBuilder { + + private GreenTreeBuilder() { + } + + /** + * Parses the given XML text and returns an immutable green tree. + * + * @param text the XML text + * @param uri the document URI + * @param monitor optional cancel checker + * @return the root green document node + */ + public static GreenDocument parse(CharSequence text, String uri, CancelChecker monitor) { + return parseRange(text, uri, 0, text.length(), monitor); + } + + 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); + + Deque stack = new ArrayDeque<>(); + List rootChildren = new ArrayList<>(); + + if (isDTD) { + NodeBuilder dtdRoot = new NodeBuilder(NodeKind.DOCUMENT_TYPE, rangeStart); + dtdRoot.closed = true; + stack.push(dtdRoot); + } + + Map tagIntern = new HashMap<>(); + GreenAttrBuilder currentAttr = null; + int endTagOpenOffset = -1; + boolean previousTokenWasEndTagOpen = false; + boolean isInitialDeclaration = true; + boolean inDTDInternalSubset = false; + GreenText tempWhitespaceContent = null; + int tempWhitespaceStart = -1; + 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(); + } + + if (tempWhitespaceContent != null && token != TokenType.EndTagOpen) { + tempWhitespaceContent = null; + } + + if (previousTokenWasEndTagOpen) { + previousTokenWasEndTagOpen = false; + boolean linkToEmptyStartTag = false; + + if (token == TokenType.EndTagClose) { + // + while (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.kind == NodeKind.ELEMENT && top.tag == null) { + break; + } + if (stack.size() == 1 && !isDTD) { + break; + } + top.nodeEnd = endTagOpenOffset; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + linkToEmptyStartTag = true; + top.closed = true; + top.endTagOpenOffset = endTagOpenOffset; + top.endTagHasClose = true; + top.nodeEnd = scanner.getTokenEnd(); + } + } + + if (token != TokenType.EndTag && !linkToEmptyStartTag) { + GreenElement fakeEndTag = new GreenElement( + 2, false, null, false, + GreenElement.NULL_VALUE, 0, false, + null, null); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, fakeEndTag, endTagOpenOffset); + } + } + + if (currentAttr != null + && token != TokenType.DelimiterAssign + && token != TokenType.AttributeValue + && token != TokenType.Whitespace) { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addAttribute(currentAttr.build(top.nodeStart)); + } + currentAttr = null; + } + + switch (token) { + case StartTagOpen: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (!top.closed) { + top.nodeEnd = scanner.getTokenOffset(); + } + if (isDTD) { + if (top.closed && top.kind != NodeKind.DOCUMENT_TYPE) { + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } else if (isDTDDeclKind(top.kind)) { + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + } else if (top.closed || top.kind == NodeKind.DOCUMENT_TYPE) { + if (top.kind == NodeKind.DOCUMENT_TYPE) { + inDTDInternalSubset = false; + } + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + } + NodeBuilder element = new NodeBuilder(NodeKind.ELEMENT, scanner.getTokenOffset()); + element.startTagOpenOffset = scanner.getTokenOffset(); + stack.push(element); + break; + } + + case StartTag: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.tag = tagIntern.computeIfAbsent(scanner.getTokenText(), k -> k); + top.nodeEnd = scanner.getTokenEnd(); + } + break; + } + + case StartTagClose: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + if (top.kind == NodeKind.ELEMENT) { + top.startTagCloseOffset = scanner.getTokenOffset(); + } else if (top.kind == NodeKind.PROCESSING_INSTRUCTION) { + top.startTagClose = true; + } + } + break; + } + + case EndTagOpen: { + if (tempWhitespaceContent != null) { + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, tempWhitespaceContent, tempWhitespaceStart); + tempWhitespaceContent = null; + } + endTagOpenOffset = scanner.getTokenOffset(); + if (!stack.isEmpty() && !stack.peek().closed) { + stack.peek().nodeEnd = scanner.getTokenOffset(); + } + previousTokenWasEndTagOpen = true; + break; + } + + case EndTag: { + String closeTag = tagIntern.computeIfAbsent(scanner.getTokenText(), k -> k); + if (hasMatchingElement(stack, closeTag)) { + while (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.kind == NodeKind.ELEMENT && closeTag.equals(top.tag)) { + break; + } + if (stack.size() == 1 && !isDTD) { + break; + } + top.nodeEnd = endTagOpenOffset; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + } + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.kind == NodeKind.ELEMENT && closeTag.equals(top.tag)) { + top.closed = true; + top.endTagOpenOffset = endTagOpenOffset; + top.nodeEnd = scanner.getTokenEnd(); + } else { + NodeBuilder orphan = new NodeBuilder(NodeKind.ELEMENT, + scanner.getTokenOffset() - 2); + orphan.endTagOpenOffset = endTagOpenOffset; + orphan.tag = closeTag; + orphan.nodeEnd = scanner.getTokenEnd(); + stack.push(orphan); + } + } else { + NodeBuilder orphan = new NodeBuilder(NodeKind.ELEMENT, + scanner.getTokenOffset() - 2); + orphan.endTagOpenOffset = endTagOpenOffset; + orphan.tag = closeTag; + orphan.nodeEnd = scanner.getTokenEnd(); + stack.push(orphan); + } + break; + } + + case StartTagSelfClose: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.closed = true; + top.selfClosed = true; + top.nodeEnd = scanner.getTokenEnd(); + lastClosed = top; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case EndTagClose: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + top.endTagHasClose = true; + if (top.kind == NodeKind.DOCUMENT_TYPE) { + top.closed = true; + } + lastClosed = top; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case AttributeName: { + currentAttr = new GreenAttrBuilder(); + currentAttr.setName(scanner.getTokenOffset(), scanner.getTokenEnd()); + if (!stack.isEmpty()) { + stack.peek().nodeEnd = scanner.getTokenEnd(); + } + break; + } + + case DelimiterAssign: { + if (currentAttr != null) { + currentAttr.setDelimiter(scanner.getTokenOffset()); + } + break; + } + + case AttributeValue: { + if (currentAttr != null && !stack.isEmpty()) { + NodeBuilder top = stack.peek(); + currentAttr.setValue(scanner.getTokenOffset(), scanner.getTokenEnd()); + top.addAttribute(currentAttr.build(top.nodeStart)); + } + currentAttr = null; + if (!stack.isEmpty()) { + stack.peek().nodeEnd = scanner.getTokenEnd(); + } + break; + } + + case CDATATagOpen: { + NodeBuilder cdata = new NodeBuilder(NodeKind.CDATA, scanner.getTokenOffset()); + stack.push(cdata); + break; + } + + case CDATAContent: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.startContentOffset = scanner.getTokenOffset(); + top.endContentOffset = scanner.getTokenEnd(); + top.nodeEnd = scanner.getTokenEnd(); + } + break; + } + + case CDATATagClose: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + top.closed = true; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case StartPrologOrPI: { + NodeBuilder pi = new NodeBuilder(NodeKind.PROCESSING_INSTRUCTION, + scanner.getTokenOffset()); + stack.push(pi); + break; + } + + case PIName: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.target = scanner.getTokenText(); + top.processingInstruction = true; + } + break; + } + + case PrologName: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.target = scanner.getTokenText(); + top.prolog = true; + } + break; + } + + case PIContent: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.startContentOffset = scanner.getTokenOffset(); + top.endContentOffset = scanner.getTokenEnd(); + } + break; + } + + case PIEnd: + case PrologEnd: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + top.closed = true; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case StartCommentTag: { + if (isDTD || inDTDInternalSubset) { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + } else if (!stack.isEmpty() && stack.peek().closed) { + NodeBuilder top = stack.peek(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder comment = new NodeBuilder(NodeKind.COMMENT, scanner.getTokenOffset()); + if (lastClosed != null && lastClosed.nodeEnd <= scanner.getTokenOffset()) { + comment.commentSameLineEndTag = !containsNewline(text, + lastClosed.nodeEnd, scanner.getTokenOffset()); + } + stack.push(comment); + break; + } + + case Comment: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.startContentOffset = scanner.getTokenOffset(); + top.endContentOffset = scanner.getTokenEnd(); + } + break; + } + + case EndCommentTag: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + top.closed = true; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case Content: { + boolean currIsDeclNode = !stack.isEmpty() + && isDTDDeclKind(stack.peek().kind); + if (currIsDeclNode) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset() - 1; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + int start = scanner.getTokenOffset(); + int end = scanner.getTokenEnd(); + boolean isBlank = scanner.isTokenTextBlank(); + + if (isBlank && currIsDeclNode) { + break; + } + + GreenText textNode = isBlank ? GreenText.whitespace(end - start) : new GreenText(end - start, false); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, textNode, start); + break; + } + + // DTD tokens + case DTDStartDoctypeTag: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder doctype = new NodeBuilder(NodeKind.DOCUMENT_TYPE, + scanner.getTokenOffset()); + stack.push(doctype); + break; + } + + case DTDDoctypeName: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdNameParam = stack.peek().lastParam(); + } + break; + } + + case DTDDocTypeKindPUBLIC: + case DTDDocTypeKindSYSTEM: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdKindParam = stack.peek().lastParam(); + } + break; + } + + case DTDDoctypePublicId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdPublicIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDDoctypeSystemId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdSystemIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDStartInternalSubset: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addParam(scanner.getTokenOffset(), scanner.getTokenOffset() + 1); + top.dtdInternalSubsetParam = top.lastParam(); + inDTDInternalSubset = true; + } + break; + } + + case DTDEndInternalSubset: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset() - 1; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + inDTDInternalSubset = false; + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.dtdInternalSubsetParam != null) { + top.dtdInternalSubsetParam = new int[] { + top.dtdInternalSubsetParam[0], scanner.getTokenEnd() }; + } + } + break; + } + + case DTDStartElement: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder decl = new NodeBuilder(NodeKind.DTD_ELEMENT_DECL, + scanner.getTokenOffset()); + decl.addDeclType(scanner.getTokenOffset() + 2, scanner.getTokenOffset() + 9); + stack.push(decl); + break; + } + + case DTDElementDeclName: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdNameParam = stack.peek().lastParam(); + } + break; + } + + case DTDElementCategory: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdCategoryParam = stack.peek().lastParam(); + } + break; + } + + case DTDStartElementContent: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdContentParam = stack.peek().lastParam(); + } + break; + } + + case DTDElementContent: + case DTDEndElementContent: { + if (!stack.isEmpty()) { + stack.peek().updateLastParamEnd(scanner.getTokenEnd()); + } + break; + } + + case DTDStartAttlist: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder decl = new NodeBuilder(NodeKind.DTD_ATTLIST_DECL, + scanner.getTokenOffset()); + decl.addDeclType(scanner.getTokenOffset() + 2, scanner.getTokenOffset() + 9); + isInitialDeclaration = true; + stack.push(decl); + break; + } + + case DTDAttlistElementName: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdNameParam = stack.peek().lastParam(); + } + break; + } + + case DTDAttlistAttributeName: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.kind == NodeKind.DTD_ATTLIST_DECL && top.dtdAttributeNameParam != null) { + int internalStart = lastParamEnd(top); + if (stack.size() >= 2) { + NodeBuilder below = peekSecond(stack); + if (below != null && below.kind == NodeKind.DTD_ATTLIST_DECL) { + top.nodeEnd = internalStart; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + } + NodeBuilder internalAttlist = new NodeBuilder(NodeKind.DTD_ATTLIST_DECL, + internalStart); + stack.push(internalAttlist); + top = internalAttlist; + } + top.addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + top.dtdAttributeNameParam = top.lastParam(); + } + break; + } + + case DTDAttlistAttributeType: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + top.dtdAttributeTypeParam = top.lastParam(); + } + break; + } + + case DTDAttlistAttributeValue: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + top.dtdAttributeValueParam = top.lastParam(); + } + break; + } + + case DTDStartEntity: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder decl = new NodeBuilder(NodeKind.DTD_ENTITY_DECL, + scanner.getTokenOffset()); + decl.addDeclType(scanner.getTokenOffset() + 2, scanner.getTokenOffset() + 8); + stack.push(decl); + break; + } + + case DTDEntityPercent: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdPercentParam = stack.peek().lastParam(); + } + break; + } + + case DTDEntityName: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdNameParam = stack.peek().lastParam(); + } + break; + } + + case DTDEntityValue: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdValueParam = stack.peek().lastParam(); + } + break; + } + + case DTDEntityKindPUBLIC: + case DTDEntityKindSYSTEM: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdKindParam = stack.peek().lastParam(); + } + break; + } + + case DTDEntityPublicId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdPublicIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDEntitySystemId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdSystemIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDStartNotation: { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenOffset(); + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + NodeBuilder decl = new NodeBuilder(NodeKind.DTD_NOTATION_DECL, + scanner.getTokenOffset()); + decl.addDeclType(scanner.getTokenOffset() + 2, scanner.getTokenOffset() + 10); + isInitialDeclaration = true; + stack.push(decl); + break; + } + + case DTDNotationName: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdNameParam = stack.peek().lastParam(); + } + break; + } + + case DTDNotationKindPUBLIC: + case DTDNotationKindSYSTEM: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdKindParam = stack.peek().lastParam(); + } + break; + } + + case DTDNotationPublicId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdPublicIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDNotationSystemId: { + if (!stack.isEmpty()) { + stack.peek().addParam(scanner.getTokenOffset(), scanner.getTokenEnd()); + stack.peek().dtdSystemIdParam = stack.peek().lastParam(); + } + break; + } + + case DTDEndTag: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + if (top.kind == NodeKind.DTD_ELEMENT_DECL + || top.kind == NodeKind.DTD_ATTLIST_DECL + || top.kind == NodeKind.DTD_ENTITY_DECL + || top.kind == NodeKind.DTD_NOTATION_DECL) { + while (!stack.isEmpty() && stack.peek().kind != NodeKind.DOCUMENT_TYPE) { + NodeBuilder nb = stack.peek(); + nb.nodeEnd = scanner.getTokenEnd(); + nb.closed = true; + GreenNode built = nb.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, nb.nodeStart); + } + } + } + break; + } + + case DTDEndDoctypeTag: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = scanner.getTokenEnd(); + top.closed = true; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + break; + } + + case DTDUnrecognizedParameters: { + if (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addParam(scanner.getTokenOffset(), + ((XMLScanner) scanner).getLastNonWhitespaceOffset()); + top.dtdUnrecognizedParam = top.lastParam(); + } + break; + } + + default: + if (!stack.isEmpty() && stack.peek().closed + && stack.peek().kind != NodeKind.ELEMENT) { + stack.peek().nodeEnd = scanner.getTokenEnd(); + } + break; + } + + token = scanner.scan(); + } + + if (previousTokenWasEndTagOpen) { + GreenElement fakeEndTag = new GreenElement( + 2, false, null, false, + GreenElement.NULL_VALUE, 0, false, + null, null); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, fakeEndTag, endTagOpenOffset); + } + + // Flush pending attribute before closing remaining nodes + if (currentAttr != null && !stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.addAttribute(currentAttr.build(top.nodeStart)); + currentAttr = null; + } + + // Flush remaining open nodes + while (!stack.isEmpty()) { + NodeBuilder top = stack.peek(); + top.nodeEnd = rangeEnd; + GreenNode built = top.buildGreen(); + stack.pop(); + addChildToCurrentOrRoot(stack, rootChildren, nextRootChildEnd, built, top.nodeStart); + } + + int docWidth = rangeEnd - rangeStart; + return new GreenDocument(docWidth, + rootChildren.toArray(GreenNode.EMPTY_CHILDREN)); + } + + private static int lastParamEnd(NodeBuilder nb) { + if (nb.dtdParams != null && nb.paramCount > 0) { + return nb.dtdParams[nb.paramCount - 1][1]; + } + return nb.nodeEnd; + } + + private static NodeBuilder peekSecond(Deque stack) { + java.util.Iterator it = stack.iterator(); + if (it.hasNext()) { + it.next(); + } + return it.hasNext() ? it.next() : null; + } + + private static boolean isDTDDeclKind(NodeKind kind) { + return kind == NodeKind.DTD_ENTITY_DECL + || kind == NodeKind.DTD_ELEMENT_DECL + || kind == NodeKind.DTD_ATTLIST_DECL + || kind == NodeKind.DTD_NOTATION_DECL + || kind == NodeKind.DTD_DECL; + } + + private static boolean hasMatchingElement(Deque stack, String tag) { + for (NodeBuilder nb : stack) { + if (nb.kind == NodeKind.ELEMENT && tag.equals(nb.tag)) { + return true; + } + } + return false; + } + + 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') { + return true; + } + } + return false; + } + + private static void addChildToCurrentOrRoot(Deque stack, + List rootChildren, int[] nextRootChildEnd, + GreenNode child, int childAbsStart) { + if (!stack.isEmpty()) { + stack.peek().addChild(child, childAbsStart); + } else { + if (childAbsStart > nextRootChildEnd[0]) { + rootChildren.add(GreenText.whitespace(childAbsStart - nextRootChildEnd[0])); + } + rootChildren.add(child); + nextRootChildEnd[0] = childAbsStart + child.width(); + } + } + + enum NodeKind { + ELEMENT, COMMENT, TEXT, CDATA, PROCESSING_INSTRUCTION, + DOCUMENT_TYPE, DTD_DECL, + DTD_ELEMENT_DECL, DTD_ATTLIST_DECL, DTD_ENTITY_DECL, DTD_NOTATION_DECL + } + + static final class NodeBuilder { + final NodeKind kind; + final int nodeStart; + int nodeEnd; + boolean closed; + + // Element fields + String tag; + boolean selfClosed; + int startTagOpenOffset = GreenElement.NULL_VALUE; + int startTagCloseOffset = GreenElement.NULL_VALUE; + int endTagOpenOffset = GreenElement.NULL_VALUE; + boolean endTagHasClose; + GreenAttr[] attributes; + int attrCount; + + // PI fields + boolean startTagClose; + String target; + boolean prolog; + boolean processingInstruction; + + // Content offsets (comment, CDATA, PI) + int startContentOffset = GreenElement.NULL_VALUE; + int endContentOffset = GreenElement.NULL_VALUE; + boolean commentSameLineEndTag; + + // DTD fields + int[] dtdDeclType; + int[] dtdNameParam; + int[] dtdKindParam; + int[] dtdPublicIdParam; + int[] dtdSystemIdParam; + int[] dtdInternalSubsetParam; + int[] dtdCategoryParam; + int[] dtdContentParam; + int[] dtdAttributeNameParam; + int[] dtdAttributeTypeParam; + int[] dtdAttributeValueParam; + int[] dtdPercentParam; + int[] dtdValueParam; + int[] dtdUnrecognizedParam; + int[][] dtdParams; + int paramCount; + + // Children + GreenNode[] children; + int childCount; + int firstChildAbsStart = GreenElement.NULL_VALUE; + int nextChildAbsStart = GreenElement.NULL_VALUE; + + NodeBuilder(NodeKind kind, int nodeStart) { + this.kind = kind; + this.nodeStart = nodeStart; + this.nodeEnd = nodeStart; + } + + void addChild(GreenNode child, int childAbsStart) { + if (children == null) { + children = new GreenNode[4]; + childCount = 0; + firstChildAbsStart = childAbsStart; + nextChildAbsStart = childAbsStart; + } + if (childAbsStart > nextChildAbsStart) { + appendChild(GreenText.whitespace(childAbsStart - nextChildAbsStart)); + } + 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 GreenAttr[4]; + attrCount = 0; + } + 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 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[paramCount++] = new int[] { start, end }; + } + + void addDeclType(int start, int end) { + dtdDeclType = new int[] { start, end }; + } + + int[] lastParam() { + return dtdParams[paramCount - 1]; + } + + void updateLastParamEnd(int end) { + if (dtdParams != null && paramCount > 0) { + dtdParams[paramCount - 1][1] = end; + } + } + + GreenNode buildGreen() { + int width = nodeEnd - nodeStart; + GreenNode[] kids = children != null + ? (childCount == children.length ? children : Arrays.copyOf(children, childCount)) + : null; + + switch (kind) { + case ELEMENT: + return buildElement(width, kids); + case COMMENT: + return buildComment(width); + case CDATA: + return buildCDATA(width); + case PROCESSING_INSTRUCTION: + return buildPI(width); + case DOCUMENT_TYPE: + return buildDocumentType(width, kids); + case DTD_ELEMENT_DECL: + return buildDTDElementDecl(width, kids); + case DTD_ATTLIST_DECL: + return buildDTDAttlistDecl(width, kids); + case DTD_ENTITY_DECL: + return buildDTDEntityDecl(width, kids); + case DTD_NOTATION_DECL: + return buildDTDNotationDecl(width, kids); + case DTD_DECL: + return buildDTDDeclNode(width, kids); + default: + return new GreenText(width, false); + } + } + + private GreenElement buildElement(int width, GreenNode[] kids) { + GreenAttr[] attrs = attributes != null + ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount)) + : null; + return new GreenElement(width, closed, tag, selfClosed, + rel(startTagCloseOffset), rel(endTagOpenOffset), + endTagHasClose, attrs, kids); + } + + private GreenComment buildComment(int width) { + return new GreenComment(width, closed, commentSameLineEndTag, + rel(startContentOffset), rel(endContentOffset)); + } + + private GreenCDATA buildCDATA(int width) { + return new GreenCDATA(width, closed, + rel(startContentOffset), rel(endContentOffset)); + } + + private GreenProcessingInstruction buildPI(int width) { + GreenAttr[] attrs = attributes != null + ? (attrCount == attributes.length ? attributes : Arrays.copyOf(attributes, attrCount)) + : null; + return new GreenProcessingInstruction(width, closed, startTagClose, + target, prolog, processingInstruction, + rel(startContentOffset), rel(endContentOffset), + rel(endTagOpenOffset), attrs); + } + + private GreenDocumentType buildDocumentType(int width, GreenNode[] kids) { + kids = padChildrenForDocType(kids); + return new GreenDocumentType(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids, + toParam(dtdKindParam), + toParam(dtdPublicIdParam), + toParam(dtdSystemIdParam), + toParam(dtdInternalSubsetParam)); + } + + private GreenNode[] padChildrenForDocType(GreenNode[] kids) { + if (dtdInternalSubsetParam == null || kids == null || kids.length == 0 + || firstChildAbsStart == GreenElement.NULL_VALUE) { + return kids; + } + int contentStart = dtdInternalSubsetParam[0] + 1; + int leadingGap = firstChildAbsStart - contentStart; + if (leadingGap <= 0) { + return kids; + } + GreenNode[] padded = new GreenNode[kids.length + 1]; + padded[0] = GreenText.whitespace(leadingGap); + System.arraycopy(kids, 0, padded, 1, kids.length); + return padded; + } + + private GreenDTDElementDecl buildDTDElementDecl(int width, GreenNode[] kids) { + return new GreenDTDElementDecl(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids, + toParam(dtdCategoryParam), + toParam(dtdContentParam)); + } + + private GreenDTDAttlistDecl buildDTDAttlistDecl(int width, GreenNode[] kids) { + return new GreenDTDAttlistDecl(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids, + toParam(dtdAttributeNameParam), + toParam(dtdAttributeTypeParam), + toParam(dtdAttributeValueParam), + null); + } + + private GreenDTDEntityDecl buildDTDEntityDecl(int width, GreenNode[] kids) { + return new GreenDTDEntityDecl(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids, + toParam(dtdPercentParam), + toParam(dtdValueParam), + toParam(dtdKindParam), + toParam(dtdPublicIdParam), + toParam(dtdSystemIdParam)); + } + + private GreenDTDNotationDecl buildDTDNotationDecl(int width, GreenNode[] kids) { + return new GreenDTDNotationDecl(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids, + toParam(dtdKindParam), + toParam(dtdPublicIdParam), + toParam(dtdSystemIdParam)); + } + + private GreenDTDDeclNode buildDTDDeclNode(int width, GreenNode[] kids) { + return new GreenDTDDeclNode(width, closed, + toParam(dtdUnrecognizedParam), + toParam(dtdDeclType), + toParam(dtdNameParam), + buildDTDParams(), + kids); + } + + private int rel(int absOffset) { + return absOffset != GreenElement.NULL_VALUE ? absOffset - nodeStart : GreenElement.NULL_VALUE; + } + + private GreenDTDParam toParam(int[] offsets) { + if (offsets == null) { + return null; + } + return new GreenDTDParam(offsets[0] - nodeStart, offsets[1] - nodeStart); + } + + private GreenDTDParam[] buildDTDParams() { + if (dtdParams == null || paramCount == 0) { + return null; + } + 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 new file mode 100644 index 000000000..d39f30b3a --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/dom/green/IncrementalParser.java @@ -0,0 +1,261 @@ +/******************************************************************************* + * 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 children that are entirely before or after the edit + * (prefix/suffix), and reparses only the affected middle range using + * {@link GreenTreeBuilder#parseRange}.

+ * + *

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 { + + 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, + CharSequence newText, int editStart, int deleteLength, int insertLength, + String uri, CancelChecker 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 new GreenDocument(newText.length(), result); + } + return GreenTreeBuilder.parse(newText, uri, monitor); + } + + private static GreenNode[] tryIncrementalOnChildren( + GreenNode[] oldChildren, int childrenAbsStart, int childrenAreaWidth, + int editStart, int editEnd, int delta, + CharSequence newText, String uri, CancelChecker monitor) { + + if (oldChildren.length == 0) { + return null; + } + + // Find prefix: children entirely before the edit + int prefixCount = 0; + int prefixWidth = 0; + for (int i = 0; i < oldChildren.length; i++) { + int childEnd = childrenAbsStart + prefixWidth + oldChildren[i].width(); + if (childEnd <= editStart) { + prefixCount++; + prefixWidth += oldChildren[i].width(); + } else { + break; + } + } + + // Find suffix: children entirely after the edit in old text + int suffixCount = 0; + int suffixWidth = 0; + int scanPos = childrenAbsStart + childrenAreaWidth; + 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; + } + } + + // 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 GreenText) { + int insertEnd = editEnd + delta; + if (!containsLessThan(newText, editStart, insertEnd)) { + GreenNode newText2 = new GreenText(middleChild.width() + delta, false); + return splice(oldChildren, prefixCount, suffixCount, + new GreenNode[] { newText2 }); + } + } else 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; + } + + // 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; + } + + GreenDocument middleDoc = GreenTreeBuilder.parseRange( + 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, extendedEnd, monitor); + middleChildren = middleDoc.children(); + suffixCount = 0; + } + + return splice(oldChildren, prefixCount, suffixCount, middleChildren); + } + + private static GreenElement tryDescentIntoElement( + GreenElement elem, int elemAbsStart, + int editStart, int editEnd, int delta, + CharSequence 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, + 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] + : allWhitespace ? GreenText.whitespace(w) : new GreenText(w, false); + } else { + result[j++] = nodes[i++]; + } + } + return result; + } + + private static boolean containsLessThan(CharSequence text, int from, int to) { + for (int i = from; i < to; i++) { + if (text.charAt(i) == '<') { + return true; + } + } + return false; + } + + 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/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..8ef465c1f 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 @@ -411,6 +411,16 @@ private static DOMElement findChildTag(String tagName, DOMElement element) { return null; } + private static int indexOf(CharSequence cs, char ch, int from) { + int len = cs.length(); + for (int i = from; i < len; i++) { + if (cs.charAt(i) == ch) { + return i; + } + } + return -1; + } + public static void registerCodeActionParticipants(Map codeActions, SharedSettings sharedSettings) { codeActions.put(ETagUnterminated.getCode(), new ETagUnterminatedCodeAction()); 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/contentmodel/participants/diagnostics/MultipleContentHandler.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/diagnostics/MultipleContentHandler.java index 617893842..f188f624a 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/diagnostics/MultipleContentHandler.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/contentmodel/participants/diagnostics/MultipleContentHandler.java @@ -11,9 +11,6 @@ *******************************************************************************/ package org.eclipse.lemminx.extensions.contentmodel.participants.diagnostics; -import java.util.LinkedHashSet; -import java.util.Set; - import org.xml.sax.Attributes; import org.xml.sax.ContentHandler; import org.xml.sax.Locator; @@ -21,99 +18,116 @@ /** * Multiple SAX {@link ContentHandler}. - * + * * @author Angelo ZERR * */ public class MultipleContentHandler implements ContentHandler { - private final Set handlers; + private ContentHandler[] handlers = new ContentHandler[0]; public MultipleContentHandler() { - this.handlers = new LinkedHashSet<>(); } @Override public void setDocumentLocator(Locator locator) { - for (ContentHandler contentHandler : handlers) { - contentHandler.setDocumentLocator(locator); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].setDocumentLocator(locator); } } @Override public void startDocument() throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.startDocument(); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].startDocument(); } } @Override public void endDocument() throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.endDocument(); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].endDocument(); } } @Override public void startPrefixMapping(String prefix, String uri) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.startPrefixMapping(prefix, uri); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].startPrefixMapping(prefix, uri); } } @Override public void endPrefixMapping(String prefix) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.endPrefixMapping(prefix); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].endPrefixMapping(prefix); } } @Override public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.startElement(uri, localName, qName, atts); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].startElement(uri, localName, qName, atts); } } @Override public void endElement(String uri, String localName, String qName) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.endElement(uri, localName, qName); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].endElement(uri, localName, qName); } } @Override public void characters(char[] ch, int start, int length) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.characters(ch, start, length); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].characters(ch, start, length); } } @Override public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.ignorableWhitespace(ch, start, length); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].ignorableWhitespace(ch, start, length); } } @Override public void processingInstruction(String target, String data) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.processingInstruction(target, data); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].processingInstruction(target, data); } } @Override public void skippedEntity(String name) throws SAXException { - for (ContentHandler contentHandler : handlers) { - contentHandler.skippedEntity(name); + ContentHandler[] h = handlers; + for (int i = 0; i < h.length; i++) { + h[i].skippedEntity(name); } } public void addContentHandler(ContentHandler contentHandler) { - if (!handlers.contains(contentHandler)) { - handlers.add(contentHandler); + ContentHandler[] old = handlers; + for (int i = 0; i < old.length; i++) { + if (old[i] == contentHandler) { + return; + } } + ContentHandler[] newHandlers = new ContentHandler[old.length + 1]; + System.arraycopy(old, 0, newHandlers, 0, old.length); + newHandlers[old.length] = contentHandler; + handlers = newHandlers; } } 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/dtd/participants/diagnostics/DTDValidator.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/dtd/participants/diagnostics/DTDValidator.java index 278e6b09a..064927bac 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/dtd/participants/diagnostics/DTDValidator.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/dtd/participants/diagnostics/DTDValidator.java @@ -14,8 +14,9 @@ import java.io.IOException; import java.io.Reader; -import java.io.StringReader; import java.util.HashMap; + +import org.eclipse.lemminx.commons.CharSequenceReader; import java.util.List; import java.util.concurrent.CancellationException; import java.util.logging.Level; @@ -52,10 +53,10 @@ public static void doDiagnostics(DOMDocument document, XMLEntityResolver entityR validationSettings); XMLDTDLoader loader = new LSPXML11DTDProcessor(entityManager, reporterForXML, entityResolver); - String content = document.getText(); + CharSequence content = document.getTextSequence(); String uri = document.getDocumentURI(); - Reader inputStream = new StringReader(content); + Reader inputStream = new CharSequenceReader(content); XMLInputSource source = new XMLInputSource(null, uri, uri, inputStream, null); loader.loadGrammar(source); } catch (IOException | CancellationException exception) { 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 minify() throws BadLocationException { public List 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/extensions/xsd/participants/diagnostics/XSDValidator.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/xsd/participants/diagnostics/XSDValidator.java index 7090ea8f3..8406a8a10 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/xsd/participants/diagnostics/XSDValidator.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/extensions/xsd/participants/diagnostics/XSDValidator.java @@ -14,8 +14,9 @@ import java.io.IOException; import java.io.Reader; -import java.io.StringReader; import java.util.HashMap; + +import org.eclipse.lemminx.commons.CharSequenceReader; import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; @@ -101,9 +102,9 @@ public static void doDiagnostics(DOMDocument document, XMLEntityResolver entityR grammarPreparser.setEntityResolver(entityResolver); } - String content = document.getText(); + CharSequence content = document.getTextSequence(); String uri = document.getDocumentURI(); - Reader inputStream = new StringReader(content); + Reader inputStream = new CharSequenceReader(content); XMLInputSource source = new XMLInputSource(null, uri, uri, inputStream, null); grammarPreparser.getLoader(XMLGrammarDescription.XML_SCHEMA); 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 format(DOMDocument xmlDocument, Range range, Sha private boolean shouldMergeEdits(List 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 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 minify(DOMDocument xmlDocument, Range range, Sha private boolean shouldMergeEdits(List 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 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/extensions/completion/AbstractElementCompletionItem.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/extensions/completion/AbstractElementCompletionItem.java index 8ae30a9b4..b605c7e86 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/extensions/completion/AbstractElementCompletionItem.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/services/extensions/completion/AbstractElementCompletionItem.java @@ -231,6 +231,12 @@ private static boolean isTextNode(DOMNode node, int offset) { } DOMElement element = (DOMElement) node; if (element.getEnd() <= offset) { + // When the cursor is at the exact end of an incomplete start tag + // (e.g., | return true; } @@ -292,6 +298,12 @@ private static List getAdditionalTextEdits(DOMNode node, int offset, S DOMElement element = (DOMElement) node; int endElementOffset = node.getEnd() - 1; endElement = element.hasEndTag() ? element : element.getOrphanEndElement(endElementOffset, tagName, true); + if (endElement == null && !element.hasEndTag()) { + // The orphan end tag might be a sibling rather than a child + // (e.g., where is a sibling of ). + // Search using the cursor offset which reaches beyond the element. + endElement = element.getOrphanEndElement(offset, tagName, true); + } } if (endElement != null && !tagName.equals(endElement.getTagName())) { Range range = XMLPositionUtility.selectEndTagName(endElement); 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 acbfaa8b2..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; @@ -37,12 +37,16 @@ public void formatCDATASection(DOMCDATASection cDATANode, XMLFormattingConstrain } if (isJoinCDATALines()) { + int cDATAStartContent = cDATANode.getStartContent(); + int cDATAEndContent = cDATANode.getEndContent(); + if (cDATAStartContent < 0 || cDATAEndContent < 0) { + // Empty CDATA section (e.g. ), nothing to format + return; + } int availableLineWidth = parentConstraints.getAvailableLineWidth(); int spaceStart = -1; int spaceEnd = -1; int contentEnd = -1; - int cDATAStartContent = cDATANode.getStartContent(); - int cDATAEndContent = cDATANode.getEndContent(); for (int i = cDATAStartContent; i <= cDATAEndContent; i++) { char c = text.charAt(i); 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..22f94cc15 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 format() throws BadLocationException { public List 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 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,8 +848,8 @@ String getLineDelimiter() { return lineDelimiter; } - String getText() { - return textDocument.getText(); + CharSequence getTextSequence() { + return textDocument.getTextSequence(); } public int getLineAtOffset(int offset) { 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 2bc44b5f7..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); @@ -658,6 +657,9 @@ private EmptyElements getEmptyElements(DOMElement element) { private static boolean formatDTD(DOMDocumentType doctype, int level, int end, XMLBuilder xmlBuilder) { DOMNode previous = null; for (DOMNode node : doctype.getChildren()) { + if (node.isText() && ((DOMText) node).isWhitespace()) { + continue; + } if (previous != null) { xmlBuilder.linefeed(); } @@ -751,7 +753,7 @@ private List 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()) { // | // should be ignore return false; diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/DOMUtils.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/DOMUtils.java index 80df91a8f..0a9f146f3 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/DOMUtils.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/utils/DOMUtils.java @@ -11,12 +11,12 @@ *******************************************************************************/ package org.eclipse.lemminx.utils; -import java.io.StringReader; import java.net.URL; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParserFactory; +import org.eclipse.lemminx.commons.CharSequenceReader; import org.eclipse.lemminx.dom.DOMDocument; import org.eclipse.lemminx.dom.DOMElement; import org.eclipse.lemminx.dom.DOMNode; @@ -281,10 +281,10 @@ public static boolean isDOMElement(DOMNode node, String tagName) { } public static InputSource createInputSource(DOMDocument document) { - String content = document.getText(); + CharSequence content = document.getTextSequence(); String uri = document.getDocumentURI(); InputSource inputSource = new InputSource(); - inputSource.setCharacterStream(new StringReader(content)); + inputSource.setCharacterStream(new CharSequenceReader(content)); inputSource.setSystemId(uri); return inputSource; } 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 * @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) { 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; + } +} 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..869e2b423 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/commons/ModelTextDocumentTest.java @@ -0,0 +1,300 @@ +/******************************************************************************* + * 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 previousIncrementalDataPreservedOnCancel() { + ModelTextDocument doc = createDoc(""); + String firstModel = doc.getModel(); + assertNotNull(firstModel); + + doc.setText("changed"); + + Object prev = doc.getPreviousIncrementalData(); + 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.getPreviousIncrementalData()); + } + + @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 doubleCancelModelPreservesFirstIncrementalData() { + ModelTextDocument doc = createDoc(""); + String firstModel = doc.getModel(); + assertNotNull(firstModel); + + doc.setText("changed1"); + Object prevAfterFirst = doc.getPreviousIncrementalData(); + assertEquals(firstModel, prevAfterFirst); + + // Second setText without getModel() in between — model is already null, + // so cancelModel() does NOT overwrite previousIncrementalData + doc.setText("changed2"); + 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()); + } + + // --- Edit merge tests --- + + @Test + public void mergeEditsAfterDirtyRegion() { + // T0 = "AABBCCDD", edit1 replaces "BB" with "YYY", edit2 replaces second "C" with "Z" + ModelTextDocument doc = createDoc("AABBCCDD"); + doc.getModel(); + + // Edit 1: replace "BB" (pos 2, len 2) with "YYY" → "AAYYYCCDD" + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 2, 0, 4, "YYY"))); + + // Edit 2: replace second "C" (pos 6 in T1, len 1) with "Z" → "AAYYYCZDD" + doc.setVersion(2); + doc.update(Collections.singletonList(createChange(0, 6, 0, 7, "Z"))); + + ModelTextDocument.EditInfo edit = doc.getPendingEdit(); + assertNotNull(edit); + // Merged against T0: dirty region is [2, 6), so delete "BBCC" (4 chars), insert "YYYCZ" (5 chars) + assertEquals(2, edit.getStartOffset()); + assertEquals(4, edit.getDeleteLength()); + assertEquals(5, edit.getInsertLength()); + } + + @Test + public void mergeEditsBeforeDirtyRegion() { + // T0 = "AABBCCDD", edit1 at pos 4, edit2 at pos 1 + ModelTextDocument doc = createDoc("AABBCCDD"); + doc.getModel(); + + // Edit 1: replace "CC" (pos 4, len 2) with "YYY" → "AABBYYYТDD" + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 4, 0, 6, "YYY"))); + + // Edit 2: delete "A" at pos 1 (len 1) → "ABBYYYТDD" + doc.setVersion(2); + doc.update(Collections.singletonList(createChange(0, 1, 0, 2, ""))); + + ModelTextDocument.EditInfo edit = doc.getPendingEdit(); + assertNotNull(edit); + // Merged against T0: dirty region is [1, 6), delete "ABBCC" (5 chars), insert "BBYYY" (5 chars) + assertEquals(1, edit.getStartOffset()); + assertEquals(5, edit.getDeleteLength()); + assertEquals(5, edit.getInsertLength()); + } + + @Test + public void mergeEditsWithinDirtyRegion() { + // T0 = "AABBCCDD", edit1 replaces "BB" with "YYY", edit2 within dirty region + ModelTextDocument doc = createDoc("AABBCCDD"); + doc.getModel(); + + // Edit 1: replace "BB" (pos 2, len 2) with "YYY" → "AAYYYCCDD" + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 2, 0, 4, "YYY"))); + + // Edit 2: replace "Y" at pos 3 (within dirty) with "ZZ" → "AAYZZYYCCDD"... wait + // T1 = "AAYYYCCDD", replace pos 3 len 1 with "ZZ" → "AAYZZYCCDD" + doc.setVersion(2); + doc.update(Collections.singletonList(createChange(0, 3, 0, 4, "ZZ"))); + + ModelTextDocument.EditInfo edit = doc.getPendingEdit(); + assertNotNull(edit); + // Merged against T0: dirty region is [2, 4), delete "BB" (2 chars), insert "YZZY" (4 chars) + assertEquals(2, edit.getStartOffset()); + assertEquals(2, edit.getDeleteLength()); + assertEquals(4, edit.getInsertLength()); + } + + @Test + public void mergeThreeConsecutiveEdits() { + // Simulate rapid typing: insert 'a', 'b', 'c' at consecutive positions + ModelTextDocument doc = createDoc(""); + doc.getModel(); + + // Edit 1: insert "a" at pos 6 → "a" + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 6, 0, 6, "a"))); + + // Edit 2: insert "b" at pos 7 → "ab" + doc.setVersion(2); + doc.update(Collections.singletonList(createChange(0, 7, 0, 7, "b"))); + + // Edit 3: insert "c" at pos 8 → "abc" + doc.setVersion(3); + doc.update(Collections.singletonList(createChange(0, 8, 0, 8, "c"))); + + ModelTextDocument.EditInfo edit = doc.getPendingEdit(); + assertNotNull(edit); + // Merged against T0: at pos 6, delete 0, insert 3 + assertEquals(6, edit.getStartOffset()); + assertEquals(0, edit.getDeleteLength()); + assertEquals(3, edit.getInsertLength()); + } + + @Test + public void singleEditNoMerge() { + ModelTextDocument doc = createDoc("text"); + doc.getModel(); + + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 6, 0, 10, "newtext"))); + + ModelTextDocument.EditInfo edit = doc.getPendingEdit(); + assertNotNull(edit); + assertEquals(6, edit.getStartOffset()); + assertEquals(4, edit.getDeleteLength()); + assertEquals(7, edit.getInsertLength()); + } + + @Test + public void mergeEditsPreviousIncrementalDataPreserved() { + ModelTextDocument doc = createDoc("AABBCCDD"); + String firstModel = doc.getModel(); + + doc.setVersion(1); + doc.update(Collections.singletonList(createChange(0, 2, 0, 4, "YYY"))); + + doc.setVersion(2); + doc.update(Collections.singletonList(createChange(0, 6, 0, 7, "Z"))); + + // previousIncrementalData should still be from the original model (T0) + Object prev = doc.getPreviousIncrementalData(); + assertNotNull(prev); + assertEquals(firstModel, prev); + } + + private ModelTextDocument createDoc(String text) { + ModelTextDocument doc = new ModelTextDocument<>(text, "test://test.xml", + (document, cancelChecker) -> document.getText(), + s -> s); + 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/DOMMemoryOptimizationsTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMMemoryOptimizationsTest.java new file mode 100644 index 000000000..13dafa641 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/DOMMemoryOptimizationsTest.java @@ -0,0 +1,380 @@ +/******************************************************************************* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.eclipse.lemminx.commons.TextDocument; +import org.junit.jupiter.api.Test; +import org.w3c.dom.NamedNodeMap; + +/** + * Tests for memory optimizations: DOMAttr field consolidation, + * DOMAttr[] attribute storage, lazy children deferred loading, + * and findFirst binary search. + */ +public class DOMMemoryOptimizationsTest { + + // --- DOMAttr field consolidation tests --- + + @Test + public void attrStartEndFromDocument() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("attr"); + assertNotNull(attr); + assertEquals(6, attr.getStart()); + assertEquals(18, attr.getEnd()); + } + + @Test + public void attrNameExtractedFromDocument() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("foo"); + assertNotNull(attr); + assertEquals("foo", attr.getName()); + } + + @Test + public void attrValueExtractedFromDocument() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("key"); + assertNotNull(attr); + assertEquals("val", attr.getValue()); + assertEquals("\"val\"", attr.getOriginalValue()); + } + + @Test + public void attrOwnerElementFromParent() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("attr"); + assertNotNull(attr); + assertEquals(root, attr.getOwnerElement()); + } + + @Test + public void attrOwnerDocumentFromParent() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("attr"); + assertNotNull(attr); + assertEquals(doc, attr.getOwnerDocument()); + } + + @Test + public void attrNodeAttrNameRange() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("myattr"); + assertNotNull(attr); + DOMRange nameRange = attr.getNodeAttrName(); + assertNotNull(nameRange); + assertEquals(6, nameRange.getStart()); + assertEquals(12, nameRange.getEnd()); + } + + @Test + public void attrWithoutValue() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("disabled"); + assertNotNull(attr); + assertEquals("disabled", attr.getName()); + assertNull(attr.getValue()); + assertEquals(6, attr.getStart()); + assertEquals(14, attr.getEnd()); + } + + @Test + public void attrWithDelimiterNoValue() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("attr"); + assertNotNull(attr); + assertTrue(attr.hasDelimiter()); + assertEquals(10, attr.getDelimiterOffset()); + } + + @Test + public void programmaticAttr() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + root.setAttribute("dynamic", "val"); + DOMAttr attr = root.getAttributeNode("dynamic"); + assertNotNull(attr); + assertEquals("dynamic", attr.getName()); + } + + @Test + public void attrNamespaceURI() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr nsAttr = root.getAttributeNode("ns:attr"); + assertNotNull(nsAttr); + assertEquals("ns", nsAttr.getPrefix()); + assertEquals("attr", nsAttr.getLocalName()); + } + + @Test + public void attrIsXmlns() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + DOMAttr attr = root.getAttributeNode("xmlns"); + assertNotNull(attr); + assertTrue(attr.isXmlns()); + assertTrue(attr.isDefaultXmlns()); + } + + // --- DOMAttr[] attribute storage tests --- + + @Test + public void attributeArrayMultipleAttrs() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertTrue(root.hasAttributes()); + List attrs = root.getAttributeNodes(); + assertEquals(3, attrs.size()); + assertEquals("a", attrs.get(0).getName()); + assertEquals("b", attrs.get(1).getName()); + assertEquals("c", attrs.get(2).getName()); + } + + @Test + public void attributeArrayGetByIndex() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertEquals("x", root.getAttributeAtIndex(0).getName()); + assertEquals("y", root.getAttributeAtIndex(1).getName()); + assertNull(root.getAttributeAtIndex(2)); + assertNull(root.getAttributeAtIndex(-1)); + } + + @Test + public void attributeArrayNamedNodeMap() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + NamedNodeMap map = root.getAttributes(); + assertNotNull(map); + assertEquals(2, map.getLength()); + assertNotNull(map.getNamedItem("id")); + assertNotNull(map.getNamedItem("class")); + assertNull(map.getNamedItem("nonexistent")); + assertEquals("id", map.item(0).getNodeName()); + } + + @Test + public void noAttributesReturnsNull() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertFalse(root.hasAttributes()); + assertNull(root.getAttributeNodes()); + assertNull(root.getAttributes()); + } + + @Test + public void attributeGetValue() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertEquals("hello world", root.getAttribute("key")); + assertNull(root.getAttribute("missing")); + } + + // --- Lazy children deferred loading tests --- + + @Test + public void lazyElementChildrenExpanded() { + DOMDocument doc = parseLazy(""); + DOMElement root = doc.getDocumentElement(); + assertTrue(root.hasChildNodes()); + List children = root.getChildren(); + assertEquals(2, children.size()); + assertTrue(children.get(0).isElement()); + assertEquals("child", ((DOMElement) children.get(0)).getTagName()); + } + + @Test + public void lazyNestedElements() { + DOMDocument doc = parseLazy(""); + DOMElement a = doc.getDocumentElement(); + assertEquals("a", a.getTagName()); + DOMElement b = (DOMElement) a.getFirstChild(); + assertEquals("b", b.getTagName()); + DOMElement c = (DOMElement) b.getFirstChild(); + assertEquals("c", c.getTagName()); + } + + @Test + public void lazyFindNodeAt() { + DOMDocument doc = parseLazy(""); + DOMNode node = doc.findNodeAt(7); + assertTrue(node.isElement()); + assertEquals("inner", ((DOMElement) node).getTagName()); + } + + @Test + public void lazyAttributesAccessible() { + DOMDocument doc = parseLazy(""); + DOMElement root = doc.getDocumentElement(); + DOMElement elem = (DOMElement) root.getFirstChild(); + assertTrue(elem.hasAttributes()); + assertEquals("val", elem.getAttribute("key")); + } + + @Test + public void lazySingleChildExpanded() { + DOMDocument doc = parseLazy(""); + DOMElement root = doc.getDocumentElement(); + assertTrue(root.hasChildNodes()); + assertEquals(1, root.getChildren().size()); + DOMElement only = (DOMElement) root.getFirstChild(); + assertEquals("only", only.getTagName()); + } + + @Test + public void lazySingleTextChildExpanded() { + DOMDocument doc = parseLazy("content"); + DOMElement root = doc.getDocumentElement(); + assertTrue(root.hasChildNodes()); + DOMNode text = root.getFirstChild(); + assertTrue(text.isText()); + } + + @Test + public void lazyEmptyElementNoChildren() { + DOMDocument doc = parseLazy(""); + DOMElement root = doc.getDocumentElement(); + DOMElement empty = (DOMElement) root.getFirstChild(); + assertFalse(empty.hasChildNodes()); + assertEquals(0, empty.getChildren().size()); + } + + @Test + public void findNodeAtSingleChild() { + DOMDocument doc = parseLazy(""); + DOMNode node = doc.findNodeAt(7); + assertTrue(node.isElement()); + assertEquals("child", ((DOMElement) node).getTagName()); + } + + // --- findFirst binary search tests --- + + @Test + public void findNodeBeforeInChildren() { + DOMDocument doc = parse(""); + DOMNode found = doc.findNodeBefore(12); + assertTrue(found.isElement()); + assertEquals("b", ((DOMElement) found).getTagName()); + } + + @Test + public void findNodeAtOffset() { + DOMDocument doc = parse(""); + DOMNode found = doc.findNodeAt(15); + assertTrue(found.isElement()); + assertEquals("second", ((DOMElement) found).getTagName()); + } + + @Test + public void findNodeBeforeAtDocStart() { + DOMDocument doc = parse(""); + DOMNode found = doc.findNodeBefore(1); + assertTrue(found.isElement()); + assertEquals("root", ((DOMElement) found).getTagName()); + } + + // --- attributeNodes field scoping tests --- + + @Test + public void textNodeHasNoAttributes() { + DOMDocument doc = parse("hello"); + DOMNode text = doc.getDocumentElement().getFirstChild(); + assertTrue(text.isText()); + assertFalse(text.hasAttributes()); + assertNull(text.getAttributeNodes()); + assertNull(text.getAttributes()); + assertNull(text.getAttributeAtIndex(0)); + } + + @Test + public void prologHasAttributes() { + DOMDocument doc = parse(""); + DOMNode prolog = doc.getFirstChild(); + assertTrue(prolog.isProlog()); + assertTrue(prolog.hasAttributes()); + assertEquals("1.0", prolog.getAttribute("version")); + assertEquals("UTF-8", prolog.getAttribute("encoding")); + } + + @Test + public void setAttributeOnElement() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertFalse(root.hasAttributes()); + root.setAttribute("key", "val"); + assertTrue(root.hasAttributes()); + assertEquals("val", root.getAttribute("key")); + } + + @Test + public void textNodeHasNoChildrenArray() { + DOMDocument doc = parse("hello"); + DOMNode text = doc.getDocumentElement().getFirstChild(); + assertTrue(text.isText()); + assertNull(text.getChildrenArray()); + assertFalse(text.hasChildNodes()); + } + + @Test + public void commentNodeHasNoChildrenArray() { + DOMDocument doc = parse(""); + DOMNode comment = doc.getDocumentElement().getFirstChild(); + assertTrue(comment.isComment()); + assertNull(comment.getChildrenArray()); + assertFalse(comment.hasChildNodes()); + } + + @Test + public void elementHasChildrenArray() { + DOMDocument doc = parse(""); + DOMElement root = doc.getDocumentElement(); + assertTrue(root.hasChildNodes()); + assertNotNull(root.getChildrenArray()); + assertEquals(1, root.getChildren().size()); + } + + @Test + public void documentHasChildrenArray() { + DOMDocument doc = parse(""); + assertNotNull(doc.getChildrenArray()); + assertTrue(doc.hasChildNodes()); + } + + // --- Helpers --- + + private DOMDocument parse(String xml) { + return DOMParser.getInstance().parse(xml, "test://test.xml", null, true); + } + + private DOMDocument parseLazy(String xml) { + TextDocument textDoc = new TextDocument(xml, "test://test.xml"); + return DOMParser.getInstance().parse(textDoc, null, true, null); + } +} 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..80354b4e6 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,12 @@ 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, closed && endTagOpenRel != GreenElement.NULL_VALUE, + null, null); } else if (n instanceof DOMProcessingInstruction) { ((DOMProcessingInstruction) n).target = tag; ((DOMProcessingInstruction) n).endTagOpenOffset = endTagStart != null ? endTagStart : DOMNode.NULL_VALUE; diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenRedConsistencyTest.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenRedConsistencyTest.java new file mode 100644 index 000000000..3475bfe12 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenRedConsistencyTest.java @@ -0,0 +1,235 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMElement; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.dom.DOMParser; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Node; + +/** + * Validates that the green tree builder produces a structure consistent + * with the existing DOMParser. + */ +public class GreenRedConsistencyTest { + + @Test + public void simpleDocument() { + assertConsistent(""); + } + + @Test + public void nestedDocument() { + assertConsistent("text"); + } + + @Test + public void multipleChildren() { + assertConsistent(""); + } + + @Test + public void attributes() { + assertConsistent(""); + } + + @Test + public void mixedContent() { + assertConsistent("

hello world end

"); + } + + @Test + public void commentAndPI() { + assertConsistent(""); + } + + @Test + public void selfClosing() { + assertConsistent("

"); + } + + @Test + public void unclosedTag() { + assertConsistent("
"); + } + + @Test + public void orphanEndTag() { + assertConsistent(""); + } + + @Test + public void cdataSection() { + assertConsistent(""); + } + + @Test + public void doctypeWithInternalSubset() { + assertConsistent( + "\n" + + " \n" + + "]>\n"); + } + + @Test + public void doctypeWithEntities() { + assertConsistent( + "\n" + + " \n" + + "]>\n"); + } + + @Test + public void orphanEndTagNoMatch() { + assertConsistent(""); + } + + @Test + public void multipleOrphanEndTags() { + assertConsistent(""); + } + + @Test + public void commentBetweenElements() { + assertConsistent(""); + } + + @Test + public void whitespaceOnlyContent() { + assertConsistent(" "); + } + + @Test + public void emptyDocument() { + assertConsistent(""); + } + + @Test + public void textOnly() { + assertConsistent("plain text"); + } + + @Test + public void unclosedTags() { + assertConsistent(""); + } + + @Test + public void windowsCRLF() { + assertConsistent("\r\n \r\n"); + } + + @Test + public void largeDocument() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + for (int i = 0; i < 100; i++) { + sb.append(" value ").append(i).append("\n"); + } + sb.append(""); + assertConsistent(sb.toString()); + } + + private void assertConsistent(String xml) { + TextDocument textDoc = new TextDocument(xml, "test://test.xml"); + DOMDocument domDoc = DOMParser.getInstance().parse(textDoc, null); + + GreenDocument greenDoc = GreenTreeBuilder.parse(xml, "test://test.xml", null); + + assertEquals(domDoc.getEnd(), greenDoc.width(), + "Document width mismatch"); + + assertNodeConsistent(domDoc, greenDoc, 0, xml); + } + + private void assertNodeConsistent(DOMNode domNode, GreenNode greenNode, + int greenAbsStart, String xml) { + assertEquals(domNode.getNodeType(), greenNode.nodeType(), + "Node type mismatch at offset " + greenAbsStart); + + int domWidth = domNode.getEnd() - domNode.getStart(); + assertEquals(domWidth, greenNode.width(), + "Width mismatch for " + domNode.getNodeName() + + " at offset " + greenAbsStart + + " (DOM: " + domNode.getStart() + "-" + domNode.getEnd() + + ", green width: " + greenNode.width() + ")"); + + if (domNode.isElement() && greenNode instanceof GreenElement) { + DOMElement domElem = (DOMElement) domNode; + GreenElement greenElem = (GreenElement) greenNode; + assertEquals(domElem.getTagName(), greenElem.tag(), + "Tag name mismatch at offset " + greenAbsStart); + assertEquals(domElem.isSelfClosed(), greenElem.selfClosed(), + "Self-closed mismatch for " + domElem.getTagName()); + } + + List domChildren = domNode.getChildren(); + List filteredGreenChildren = filterGreenChildren(greenNode.children(), domChildren); + assertEquals(domChildren.size(), filteredGreenChildren.size(), + "Child count mismatch for " + domNode.getNodeName() + + " at offset " + greenAbsStart); + + for (int i = 0; i < domChildren.size(); i++) { + DOMNode domChild = domChildren.get(i); + GreenNode greenChild = filteredGreenChildren.get(i); + + int expectedChildStart = domChild.getStart(); + assertNodeConsistent(domChild, greenChild, expectedChildStart, xml); + } + } + + private List filterGreenChildren(GreenNode[] greenChildren, + List domChildren) { + boolean hasNonWhitespace = false; + for (GreenNode child : greenChildren) { + if (!(child instanceof GreenText && ((GreenText) child).whitespace())) { + hasNonWhitespace = true; + break; + } + } + if (!hasNonWhitespace) { + List result = new ArrayList<>(greenChildren.length); + for (GreenNode child : greenChildren) { + result.add(child); + } + return result; + } + List result = new ArrayList<>(domChildren.size()); + for (GreenNode child : greenChildren) { + if (child instanceof GreenText && ((GreenText) child).whitespace()) { + continue; + } + result.add(child); + } + return result; + } + + private int computeChildrenStartOffset(DOMNode domNode, int greenAbsStart) { + if (domNode.isElement()) { + DOMElement elem = (DOMElement) domNode; + if (elem.getStartTagCloseOffset() != DOMNode.NULL_VALUE) { + return elem.getStartTagCloseOffset() + 1; + } + } + return greenAbsStart; + } +} 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 new file mode 100644 index 000000000..b7ff2cb57 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/GreenTreeBuilderTest.java @@ -0,0 +1,584 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link GreenTreeBuilder}. + */ +public class GreenTreeBuilderTest { + + @Test + public void simpleElement() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + assertEquals(xml.length(), doc.width()); + assertEquals(1, doc.childCount()); + + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("root", root.tag()); + assertEquals(xml.length(), root.width()); + assertTrue(root.closed()); + } + + @Test + public void selfClosingElement() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + assertEquals(xml.length(), doc.width()); + assertEquals(1, doc.childCount()); + + GreenElement br = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("br", br.tag()); + assertTrue(br.selfClosed()); + assertTrue(br.closed()); + assertEquals(xml.length(), br.width()); + } + + @Test + public void nestedElements() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + assertEquals(1, doc.childCount()); + GreenElement a = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("a", a.tag()); + assertEquals(xml.length(), a.width()); + + assertEquals(1, a.childCount()); + GreenElement b = assertInstanceOf(GreenElement.class, a.child(0)); + assertEquals("b", b.tag()); + + assertEquals(1, b.childCount()); + GreenElement c = assertInstanceOf(GreenElement.class, b.child(0)); + assertEquals("c", c.tag()); + assertTrue(c.selfClosed()); + } + + @Test + public void elementWithAttributes() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + GreenElement div = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("div", div.tag()); + assertEquals(2, div.attributeCount()); + + GreenAttr cls = div.attributes()[0]; + assertNotNull(cls); + assertTrue(cls.nameStartRel() >= 0); + + GreenAttr id = div.attributes()[1]; + assertNotNull(id); + } + + @Test + public void textContent() { + String xml = "

hello

"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + GreenElement p = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("p", p.tag()); + assertEquals(1, p.childCount()); + + GreenText text = assertInstanceOf(GreenText.class, p.child(0)); + assertEquals(5, text.width()); + } + + @Test + public void comment() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, root.childCount()); + + GreenComment comment = assertInstanceOf(GreenComment.class, root.child(0)); + assertTrue(comment.closed()); + assertTrue(comment.width() > 0); + } + + @Test + public void cdataSection() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, root.childCount()); + + GreenCDATA cdata = assertInstanceOf(GreenCDATA.class, root.child(0)); + assertTrue(cdata.closed()); + } + + @Test + public void processingInstruction() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + assertEquals(2, doc.childCount()); + GreenProcessingInstruction pi = assertInstanceOf( + GreenProcessingInstruction.class, doc.child(0)); + assertEquals("xml", pi.target()); + assertTrue(pi.prolog()); + assertTrue(pi.closed()); + + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(1)); + assertEquals("root", root.tag()); + } + + @Test + public void widthSumsToDocumentLength() { + String xml = "\n\n \n \n"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + assertTotalWidthConsistent(doc); + } + + @Test + public void replaceChildStructuralSharing() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + GreenNode originalA = root.child(0); + GreenNode originalC = root.child(2); + + GreenText replacement = new GreenText(10, false); + GreenNode newRoot = root.withReplacedChild(1, replacement); + + GreenElement newRootElement = assertInstanceOf(GreenElement.class, newRoot); + assertTrue(newRootElement.child(0) == originalA, "unchanged child should be same object"); + assertTrue(newRootElement.child(2) == originalC, "unchanged child should be same object"); + assertInstanceOf(GreenText.class, newRootElement.child(1)); + } + + @Test + public void orphanEndTag() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + assertEquals(1, doc.childCount()); + GreenElement elem = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("meta", elem.tag()); + assertNotNull(elem.endTagOpenRel()); + } + + @Test + public void unclosedElement() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + GreenElement a = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals("a", a.tag()); + assertFalse(a.closed()); + } + + @Test + public void emptyDocument() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(0, doc.width()); + assertEquals(0, doc.childCount()); + } + + @Test + public void textOnlyDocument() { + String xml = "just some text"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + assertEquals(1, doc.childCount()); + GreenText text = assertInstanceOf(GreenText.class, doc.child(0)); + assertEquals(xml.length(), text.width()); + } + + @Test + public void attributeWithoutValue() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement input = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, input.attributeCount()); + GreenAttr attr = input.attributes()[0]; + assertEquals(GreenAttr.NULL_VALUE, attr.delimiterRel()); + assertEquals(GreenAttr.NULL_VALUE, attr.valueStartRel()); + } + + @Test + public void attributeWithDelimiterNoValue() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement input = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, input.attributeCount()); + GreenAttr attr = input.attributes()[0]; + assertTrue(attr.delimiterRel() != GreenAttr.NULL_VALUE); + assertEquals(GreenAttr.NULL_VALUE, attr.valueStartRel()); + } + + @Test + public void multipleAttributes() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement div = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(3, div.attributeCount()); + } + + @Test + public void commentContent() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenComment comment = assertInstanceOf(GreenComment.class, doc.child(0)); + assertTrue(comment.closed()); + assertEquals(4, comment.startContentRel()); + } + + @Test + public void unclosedComment() { + String xml = "\n\n \n"); + } + + @Test + public void widthInvariantMalformed() { + assertWidthInvariant("
"); + } + + @Test + public void widthInvariantOrphanEndTags() { + assertWidthInvariant(""); + } + + @Test + public void widthInvariantDoctype() { + assertWidthInvariant("\n \n]>\n"); + } + + private void assertWidthInvariant(String xml) { + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + assertChildrenWidthSum(doc); + } + + private void assertChildrenWidthSum(GreenNode node) { + GreenNode[] children = node.children(); + if (children.length == 0) { + return; + } + int sum = 0; + for (GreenNode child : children) { + assertTrue(child.width() > 0, "Child width must be positive"); + sum += child.width(); + assertChildrenWidthSum(child); + } + } + + @Test + public void largeDocumentPerformance() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + int count = 10000; + for (int i = 0; i < count; i++) { + sb.append(" value").append(i).append("\n"); + } + sb.append(""); + String xml = sb.toString(); + + long start = System.nanoTime(); + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + long elapsed = System.nanoTime() - start; + + assertEquals(xml.length(), doc.width()); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + // count items + (count + 1) interleaved whitespace text nodes + assertEquals(2 * count + 1, root.childCount()); + + // Parse of 10K elements should complete in under 2 seconds + assertTrue(elapsed < 2_000_000_000L, + "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 = "

startboldmiditalicend

"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + GreenElement p = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(5, p.childCount()); + assertInstanceOf(GreenText.class, p.child(0)); + assertInstanceOf(GreenElement.class, p.child(1)); + assertInstanceOf(GreenText.class, p.child(2)); + assertInstanceOf(GreenElement.class, p.child(3)); + assertInstanceOf(GreenText.class, p.child(4)); + } + + @Test + public void namespaceDeclarations() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, root.attributeCount()); + assertEquals(1, root.childCount()); + GreenElement child = assertInstanceOf(GreenElement.class, root.child(0)); + assertEquals("ns:child", child.tag()); + } + + @Test + public void multipleProcessingInstructions() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + assertEquals(4, doc.childCount()); + GreenProcessingInstruction pi0 = assertInstanceOf(GreenProcessingInstruction.class, doc.child(0)); + assertTrue(pi0.prolog()); + GreenProcessingInstruction pi1 = assertInstanceOf(GreenProcessingInstruction.class, doc.child(1)); + assertEquals("pi1", pi1.target()); + assertFalse(pi1.prolog()); + GreenProcessingInstruction pi2 = assertInstanceOf(GreenProcessingInstruction.class, doc.child(2)); + assertEquals("pi2", pi2.target()); + assertInstanceOf(GreenElement.class, doc.child(3)); + } + + @Test + public void doctypePublicSystem() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + assertEquals(xml.length(), doc.width()); + GreenDocumentType dt = assertInstanceOf(GreenDocumentType.class, doc.child(0)); + assertTrue(dt.closed()); + assertChildrenWidthSum(doc); + } + + @Test + public void parseRangeUnclosedElement() { + String text = ""; + GreenDocument rangeDoc = GreenTreeBuilder.parseRange(text, "test.xml", 4, 11, null); + assertEquals(7, rangeDoc.width()); + GreenElement b = assertInstanceOf(GreenElement.class, rangeDoc.child(0)); + assertEquals("b", b.tag()); + assertFalse(b.closed()); + } + + @Test + public void withReplacedChildWidthAdjustment() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + int originalWidth = root.width(); + + GreenText wider = new GreenText(20, false); + GreenNode newRoot = root.withReplacedChild(1, wider); + assertEquals(originalWidth - 4 + 20, newRoot.width()); + + GreenText narrower = new GreenText(1, false); + GreenNode newRoot2 = root.withReplacedChild(1, narrower); + assertEquals(originalWidth - 4 + 1, newRoot2.width()); + } + + @Test + public void withReplacedChildFirst() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + GreenNode origB = root.child(1); + GreenNode origC = root.child(2); + + GreenText replacement = new GreenText(5, false); + GreenElement newRoot = assertInstanceOf(GreenElement.class, root.withReplacedChild(0, replacement)); + assertInstanceOf(GreenText.class, newRoot.child(0)); + assertTrue(origB == newRoot.child(1)); + assertTrue(origC == newRoot.child(2)); + } + + @Test + public void withReplacedChildLast() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + GreenNode origA = root.child(0); + GreenNode origB = root.child(1); + + GreenText replacement = new GreenText(5, false); + GreenElement newRoot = assertInstanceOf(GreenElement.class, root.withReplacedChild(2, replacement)); + assertTrue(origA == newRoot.child(0)); + assertTrue(origB == newRoot.child(1)); + assertInstanceOf(GreenText.class, newRoot.child(2)); + } + + // --- Compact children storage tests --- + + @Test + public void compactChildrenZero() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement empty = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(0, empty.childCount()); + assertEquals(0, empty.children().length); + assertThrows(IndexOutOfBoundsException.class, () -> empty.child(0)); + } + + @Test + public void compactChildrenOne() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement parent = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, parent.childCount()); + GreenNode only = parent.child(0); + assertInstanceOf(GreenElement.class, only); + assertEquals("only", ((GreenElement) only).tag()); + + GreenNode[] arr = parent.children(); + assertEquals(1, arr.length); + assertEquals(only.width(), arr[0].width()); + + assertThrows(IndexOutOfBoundsException.class, () -> parent.child(1)); + assertThrows(IndexOutOfBoundsException.class, () -> parent.child(-1)); + } + + @Test + public void compactChildrenTwo() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement parent = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(2, parent.childCount()); + assertEquals("a", ((GreenElement) parent.child(0)).tag()); + assertEquals("b", ((GreenElement) parent.child(1)).tag()); + assertEquals(2, parent.children().length); + } + + @Test + public void compactChildrenSingleText() { + String xml = "

text

"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement p = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, p.childCount()); + assertInstanceOf(GreenText.class, p.child(0)); + assertEquals(4, p.child(0).width()); + } + + @Test + public void withReplacedChildOnSingleChild() { + String xml = ""; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement parent = assertInstanceOf(GreenElement.class, doc.child(0)); + assertEquals(1, parent.childCount()); + + GreenText replacement = new GreenText(10, false); + GreenNode newParent = parent.withReplacedChild(0, replacement); + GreenElement newElem = assertInstanceOf(GreenElement.class, newParent); + assertEquals(1, newElem.childCount()); + assertInstanceOf(GreenText.class, newElem.child(0)); + assertEquals(10, newElem.child(0).width()); + } + + @Test + public void withNewChildrenCompactsCorrectly() { + String xml = "
"; + GreenDocument doc = GreenTreeBuilder.parse(xml, "test.xml", null); + GreenElement root = assertInstanceOf(GreenElement.class, doc.child(0)); + + GreenNode[] singleChild = new GreenNode[] { new GreenText(5, false) }; + GreenElement withOne = root.withNewChildren(singleChild, + 5 - (root.child(0).width() + root.child(1).width())); + assertEquals(1, withOne.childCount()); + assertInstanceOf(GreenText.class, withOne.child(0)); + + GreenElement withNone = root.withNewChildren(new GreenNode[0], + -(root.child(0).width() + root.child(1).width())); + assertEquals(0, withNone.childCount()); + } + + private void assertTotalWidthConsistent(GreenNode node) { + GreenNode[] kids = node.children(); + if (kids.length > 0) { + for (GreenNode child : kids) { + assertTrue(child.width() > 0 || child.width() == 0, + "child width must be non-negative"); + assertTotalWidthConsistent(child); + } + } + } +} 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 new file mode 100644 index 000000000..92bbc4f19 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/IncrementalParserTest.java @@ -0,0 +1,892 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMParser; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link IncrementalParser}. + */ +public class IncrementalParserTest { + + @Test + public void insertTextInElement() { + String oldText = "helloworld"; + String newText = "helloXworld"; + // Insert 'X' at offset 14 (after "hello", before "") + assertIncrementalParse(oldText, newText, 14, 0, 1); + } + + @Test + public void deleteTextInElement() { + String oldText = "helloworld"; + String newText = "hellworld"; + // Delete 'o' at offset 13 + assertIncrementalParse(oldText, newText, 13, 1, 0); + } + + @Test + public void replaceTextInElement() { + String oldText = "helloworld"; + String newText = "hiworld"; + // Replace "hello" (offset 9, length 5) with "hi" (length 2) + assertIncrementalParse(oldText, newText, 9, 5, 2); + } + + @Test + public void insertInNestedElement() { + String oldText = "text"; + String newText = "textX"; + // Insert 'X' at offset 23 (after "text") + assertIncrementalParse(oldText, newText, 23, 0, 1); + } + + @Test + public void insertAttributeValue() { + String oldText = "text"; + String newText = "text"; + // Replace "old" with "new" at offset 13 + assertIncrementalParse(oldText, newText, 13, 3, 3); + } + + @Test + public void insertInLargeDocument() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + for (int i = 0; i < 100; i++) { + sb.append(" value").append(i).append("\n"); + } + sb.append(""); + String oldText = sb.toString(); + + // Find where item 50 is and modify its text + int idx = oldText.indexOf(">value50<"); + String newText = oldText.substring(0, idx + 1) + "MODIFIED50" + oldText.substring(idx + 8); + // Replace "value50" (7 chars) with "MODIFIED50" (10 chars) at offset idx+1 + assertIncrementalParse(oldText, newText, idx + 1, 7, 10); + } + + @Test + public void structuralSharingTopLevel() { + // Structural sharing works at document level: prolog and comment + // are reused when editing inside the root element + String oldText = "text"; + String newText = "textX"; + + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenNode oldProlog = oldDoc.child(0); + GreenNode oldComment = oldDoc.child(1); + + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 46, 0, 1, "test.xml", null); + + assertEquals(newText.length(), newDoc.width()); + assertSame(oldProlog, newDoc.child(0), + "Prolog should be structurally shared (before edit)"); + assertSame(oldComment, newDoc.child(1), + "Comment should be structurally shared (before edit)"); + assertNotSame(oldDoc.child(2), newDoc.child(2), + "Root element should be new (contains the edit)"); + } + + @Test + public void fallbackOnTagNameChange() { + String oldText = "text"; + String newText = "text"; + // Change tag name — should fall back to full reparse but still produce correct result + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 7, 1, 1, "test.xml", null); + assertNotNull(newDoc); + assertEquals(newText.length(), newDoc.width()); + } + + @Test + public void fallbackOnEmptyOldDoc() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 0, 7, "test.xml", null); + assertNotNull(newDoc); + assertEquals(newText.length(), newDoc.width()); + } + + @Test + public void fallbackOnMultiNodeInsert() { + String oldText = "text"; + String newText = "text"; + // Insert '' after — creates new sibling, should fall back + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 17, 0, 4, "test.xml", null); + assertNotNull(newDoc); + assertEquals(newText.length(), newDoc.width()); + } + + @Test + public void resultMatchesFullParse() { + String oldText = "helloworld"; + String newText = "hello thereworld"; + // Insert " there" at offset 14 + assertIncrementalMatchesFullParse(oldText, newText, 14, 0, 6); + } + + @Test + public void resultMatchesFullParseOnDelete() { + String oldText = "some text"; + String newText = "text"; + // Delete "some " at offset 26 + assertIncrementalMatchesFullParse(oldText, newText, 26, 5, 0); + } + + @Test + public void resultMatchesFullParseNestedEdit() { + String oldText = "deep"; + String newText = "deeper"; + assertIncrementalMatchesFullParse(oldText, newText, 12, 1, 3); + } + + @Test + public void insertNewChildElement() { + String oldText = "text"; + String newText = "text"; + // Insert '' inside at offset 13 + assertIncrementalMatchesFullParse(oldText, newText, 13, 0, 4); + } + + @Test + public void editInDocWithProlog() { + String oldText = "\ntext"; + String newText = "\ntextX"; + assertIncrementalMatchesFullParse(oldText, newText, 34, 0, 1); + } + + @Test + public void editInDocWithComment() { + String oldText = "text"; + String newText = "textX"; + assertIncrementalMatchesFullParse(oldText, newText, 28, 0, 1); + } + + @Test + public void multipleTopLevelElementsEditMiddle() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 5, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(2), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void multipleTopLevelElementsEditFirst() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 4, 5, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(1), newDoc.child(1)); + assertSame(oldDoc.child(2), newDoc.child(2)); + } + + @Test + public void multipleTopLevelElementsEditLast() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 8, 4, 5, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(1)); + } + + @Test + public void insertElementBetween() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 0, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void deleteElementFromMiddle() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 0, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(2), newDoc.child(1)); + } + + @Test + public void appendElementAtEnd() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 8, 0, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(1)); + } + + @Test + public void prependElementAtStart() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 0, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(1)); + assertSame(oldDoc.child(1), newDoc.child(2)); + } + + @Test + public void unclosedElementInvalidatesSuffix() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 3, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void largeDocumentPrefixSuffixReuse() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 200; i++) { + sb.append(""); + } + String oldText = sb.toString(); + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + + int editOffset = 0; + for (int i = 0; i < 100; i++) { + editOffset += oldDoc.child(i).width(); + } + int oldChildWidth = oldDoc.child(100).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()); + for (int i = 0; i < 100; i++) { + assertSame(oldDoc.child(i), newDoc.child(i), + "Prefix child " + i + " should be reused"); + } + for (int i = 101; i < 200; i++) { + assertSame(oldDoc.child(i), newDoc.child(i), + "Suffix child " + i + " should be reused"); + } + } + + @Test + public void editWithWhitespaceGaps() { + String oldText = "\n\n\n"; + String newText = "\n\n\n"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 5, 4, 5, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void singleRootElementFallsBack() { + String oldText = "text"; + String newText = "textX"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 10, 0, 1, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void parseRangeSingleElement() { + String text = ""; + GreenDocument rangeDoc = GreenTreeBuilder.parseRange( + text, "test.xml", 4, 8, null); + assertEquals(4, rangeDoc.width()); + assertEquals(1, rangeDoc.childCount()); + GreenElement b = (GreenElement) rangeDoc.child(0); + assertEquals("b", b.tag()); + } + + @Test + public void parseRangeMultipleElements() { + String text = ""; + GreenDocument rangeDoc = GreenTreeBuilder.parseRange( + text, "test.xml", 4, 12, null); + assertEquals(8, rangeDoc.width()); + assertEquals(2, rangeDoc.childCount()); + } + + @Test + public void parseRangeFullDocument() { + String text = ""; + GreenDocument rangeDoc = GreenTreeBuilder.parseRange( + text, "test.xml", 0, text.length(), null); + GreenDocument fullDoc = GreenTreeBuilder.parse(text, "test.xml", null); + assertGreenTreesEqual(fullDoc, rangeDoc, ""); + } + + @Test + public void editPreservesDocWidth() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 16, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + } + + @Test + public void replaceAtPositionZero() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 4, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(1), newDoc.child(1)); + assertSame(oldDoc.child(2), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void deleteAtPositionZero() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 4, 0, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(1), newDoc.child(0)); + assertSame(oldDoc.child(2), newDoc.child(1)); + } + + @Test + public void deleteLastElement() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 8, 4, 0, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(1)); + } + + @Test + public void editSpanningTwoNodes() { + String oldText = ""; + String newText = ""; + // Replace entirely + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editWithDoctype() { + String oldText = "text"; + String newText = "textX"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenNode oldProlog = oldDoc.child(0); + GreenNode oldDoctype = oldDoc.child(1); + + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 45, 0, 1, "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"); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editInsertMalformedXml() { + String oldText = ""; + String newText = ""; + // Delete "/>" from — creates malformed XML + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 6, 2, 0, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editInCDATASection() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 19, 0, 1, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editInComment() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 15, 0, 1, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void replaceEntireDocument() { + String oldText = "old"; + String newText = "content"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, oldText.length(), newText.length(), "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editWithProcessingInstruction() { + String oldText = "text"; + String newText = "textX"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 43, 0, 1, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(1)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editSelfClosingToOpen() { + String oldText = ""; + String newText = "text"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 4, 11, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(2), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void whitespaceOnlyDocument() { + String oldText = " \n "; + String newText = " \n "; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 6, 0, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void sequentialIncrementalEdits() { + String text1 = ""; + String text2 = ""; + String text3 = ""; + + GreenDocument doc1 = GreenTreeBuilder.parse(text1, "test.xml", null); + GreenDocument doc2 = IncrementalParser.incrementalParse( + doc1, text2, 4, 4, 5, "test.xml", null); + GreenDocument doc3 = IncrementalParser.incrementalParse( + doc2, text3, 4, 5, 6, "test.xml", null); + + assertEquals(text3.length(), doc3.width()); + assertSame(doc1.child(0), doc3.child(0)); + assertSame(doc1.child(2), doc3.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(text3, "test.xml", null), + doc3, ""); + } + + @Test + public void editConvertOpenToSelfClosing() { + String oldText = "text"; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 11, 4, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(2), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void deleteAllContent() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, 12, 0, "test.xml", null); + assertEquals(0, newDoc.width()); + assertEquals(0, newDoc.childCount()); + } + + @Test + public void insertAtExactNodeBoundary() { + String oldText = ""; + String newText = "X"; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 0, 1, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void deleteSpanningThreeNodes() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 12, 0, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(4), newDoc.child(1)); + } + + @Test + public void veryLargeInsert() { + String oldText = ""; + StringBuilder sb = new StringBuilder(""); + for (int i = 0; i < 50; i++) { + sb.append(""); + } + sb.append(""); + String newText = sb.toString(); + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 4, 0, newText.length() - 8, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(0), newDoc.child(0)); + assertSame(oldDoc.child(1), newDoc.child(newDoc.childCount() - 1)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void editInDocWithNamespaces() { + String oldText = "text"; + String newText = "textX"; + assertIncrementalMatchesFullParse(oldText, newText, 50, 0, 1); + } + + @Test + public void editPrologContent() { + String oldText = ""; + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 15, 3, 3, "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertSame(oldDoc.child(1), newDoc.child(1)); + assertSame(oldDoc.child(2), newDoc.child(2)); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + 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 + // that overlap in the new shorter text + String oldText = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + // Delete everything between ... and ... AND shrink content + String newText = ""; + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, 0, oldText.length(), newText.length(), "test.xml", null); + assertEquals(newText.length(), newDoc.width()); + assertGreenTreesEqual( + GreenTreeBuilder.parse(newText, "test.xml", null), + newDoc, ""); + } + + @Test + public void parseIncrementalIntegration() { + String oldText = "text"; + String newText = "textX"; + + TextDocument textDoc = new TextDocument(newText, "test://test.xml"); + GreenDocument oldGreen = GreenTreeBuilder.parse(oldText, "test://test.xml", null); + + DOMDocument incremental = DOMParser.getInstance().parseIncremental( + textDoc, oldGreen, 11, 0, 1, null, true, null); + DOMDocument full = DOMParser.getInstance().parse( + new TextDocument(newText, "test://test.xml"), null, true, null); + + assertNotNull(incremental); + assertNotNull(incremental.getGreenDocument()); + assertEquals(full.getChildren().size(), incremental.getChildren().size()); + assertEquals(newText.length(), incremental.getEnd()); + } + + private void assertIncrementalParse(String oldText, String newText, + int editStart, int deleteLength, int insertLength) { + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + GreenDocument newDoc = IncrementalParser.incrementalParse( + oldDoc, newText, editStart, deleteLength, insertLength, "test.xml", null); + + assertNotNull(newDoc, "Incremental parse should produce a result"); + assertEquals(newText.length(), newDoc.width(), + "New document width should match new text length"); + } + + private void assertIncrementalMatchesFullParse(String oldText, String newText, + int editStart, int deleteLength, int insertLength) { + GreenDocument oldDoc = GreenTreeBuilder.parse(oldText, "test.xml", null); + + GreenDocument incremental = IncrementalParser.incrementalParse( + oldDoc, newText, editStart, deleteLength, insertLength, "test.xml", null); + + GreenDocument fullParse = GreenTreeBuilder.parse(newText, "test.xml", null); + + assertGreenTreesEqual(fullParse, incremental, ""); + } + + private void assertGreenTreesEqual(GreenNode expected, GreenNode actual, String path) { + assertEquals(expected.nodeType(), actual.nodeType(), + path + ": nodeType mismatch"); + assertEquals(expected.width(), actual.width(), + path + ": width mismatch"); + assertEquals(expected.closed(), actual.closed(), + path + ": closed mismatch"); + + if (expected instanceof GreenElement && actual instanceof GreenElement) { + assertEquals(((GreenElement) expected).tag(), ((GreenElement) actual).tag(), + path + ": tag mismatch"); + } + + GreenNode[] expectedChildren = expected.children(); + GreenNode[] actualChildren = actual.children(); + assertEquals(expectedChildren.length, actualChildren.length, + path + ": child count mismatch"); + + for (int i = 0; i < expectedChildren.length; i++) { + assertGreenTreesEqual(expectedChildren[i], actualChildren[i], + path + "/child[" + i + "]"); + } + } +} 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 new file mode 100644 index 000000000..7eff0efbd --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/dom/green/RedTreeBuilderTest.java @@ -0,0 +1,714 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.eclipse.lemminx.commons.TextDocument; +import org.eclipse.lemminx.dom.DOMAttr; +import org.eclipse.lemminx.dom.DOMCDATASection; +import org.eclipse.lemminx.dom.DOMComment; +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMElement; +import org.eclipse.lemminx.dom.DOMNode; +import org.eclipse.lemminx.dom.DOMParser; +import org.eclipse.lemminx.dom.DOMProcessingInstruction; +import org.eclipse.lemminx.dom.DOMText; +import org.eclipse.lemminx.dom.RedTreeBuilder; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link RedTreeBuilder} produces a DOMDocument identical to + * the one built by {@link DOMParser}. + */ +public class RedTreeBuilderTest { + + @Test + public void simpleElement() { + assertRedTreeEquivalent(""); + } + + @Test + public void selfClosing() { + assertRedTreeEquivalent("
"); + } + + @Test + public void nestedElements() { + assertRedTreeEquivalent("
"); + } + + @Test + public void elementWithAttributes() { + assertRedTreeEquivalent("
"); + } + + @Test + public void textContent() { + assertRedTreeEquivalent("

hello world

"); + } + + @Test + public void mixedContent() { + assertRedTreeEquivalent("

hello world end

"); + } + + @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"); + } + + @Test + public void deeplyNestedTenLevels() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10; i++) { + sb.append(""); + } + sb.append("deep"); + for (int i = 9; i >= 0; i--) { + sb.append(""); + } + assertRedTreeEquivalent(sb.toString()); + } + + @Test + public void piInsideElement() { + assertRedTreeEquivalent(""); + } + + @Test + 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"); + + 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"); + } +} diff --git a/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/performance/MemoryBenchmark.java b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/performance/MemoryBenchmark.java new file mode 100644 index 000000000..559e74bc1 --- /dev/null +++ b/org.eclipse.lemminx/src/test/java/org/eclipse/lemminx/performance/MemoryBenchmark.java @@ -0,0 +1,174 @@ +package org.eclipse.lemminx.performance; + +import static org.eclipse.lemminx.utils.IOUtils.convertStreamToString; + +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.util.Collections; + +import org.eclipse.lemminx.commons.ModelTextDocument; +import org.eclipse.lemminx.dom.DOMDocument; +import org.eclipse.lemminx.dom.DOMParser; +import org.eclipse.lemminx.dom.green.GreenDocument; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; + +public class MemoryBenchmark { + + public static void main(String[] args) throws Exception { + String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0]; + System.out.println("PID: " + pid); + System.out.println("Waiting 5s before start (attach profiler now)..."); + Thread.sleep(5000); + + InputStream in = MemoryBenchmark.class.getResourceAsStream("/xml/content.xml"); + String text = convertStreamToString(in); + System.out.println("File size: " + text.length() + " chars"); + + ModelTextDocument doc = new ModelTextDocument<>(text, "file:///content.xml", + (document, cancelChecker) -> { + if (document instanceof ModelTextDocument) { + @SuppressWarnings("unchecked") + ModelTextDocument mtd = (ModelTextDocument) document; + Object prevData = mtd.getPreviousIncrementalData(); + ModelTextDocument.EditInfo editInfo = mtd.getPendingEdit(); + if (prevData instanceof GreenDocument && editInfo != null) { + return DOMParser.getInstance().parseIncremental(document, + (GreenDocument) prevData, + editInfo.getStartOffset(), + editInfo.getDeleteLength(), + editInfo.getInsertLength(), + null, true, cancelChecker); + } + } + return DOMParser.getInstance().parse(document, null, true, cancelChecker); + }, + DOMDocument::getGreenDocument); + doc.setIncremental(true); + + // Phase 1: initial parse + System.out.println("\n=== Phase 1: Initial parse ==="); + long start = System.currentTimeMillis(); + DOMDocument model = doc.getModel(); + long parseTime = System.currentTimeMillis() - start; + System.out.println("Parse time: " + parseTime + " ms"); + System.out.println("Elements: " + countElements(model)); + System.gc(); + Thread.sleep(1000); + System.out.println(">> SNAPSHOT 1: after initial parse. Waiting 60s..."); + Thread.sleep(60000); + + // Phase 2: single edit + reparse + System.out.println("\n=== Phase 2: Single edit (insert 'X' at pos 100) ==="); + int version = 1; + doc.setVersion(version); + doc.update(Collections.singletonList( + new TextDocumentContentChangeEvent( + new Range(new Position(0, 100), new Position(0, 100)), "X"))); + start = System.currentTimeMillis(); + model = doc.getModel(); + parseTime = System.currentTimeMillis() - start; + System.out.println("Incremental parse time: " + parseTime + " ms"); + System.gc(); + Thread.sleep(1000); + System.out.println(">> SNAPSHOT 2: after 1 incremental edit. Press Enter to continue..."); + Thread.sleep(15000); + + // Phase 3: rapid edits (simulate typing) + System.out.println("\n=== Phase 3: 10 rapid edits (simulate fast typing) ==="); + for (int i = 0; i < 10; i++) { + version++; + doc.setVersion(version); + doc.update(Collections.singletonList( + new TextDocumentContentChangeEvent( + new Range(new Position(0, 100 + i), new Position(0, 100 + i)), "Y"))); + } + start = System.currentTimeMillis(); + model = doc.getModel(); + parseTime = System.currentTimeMillis() - start; + System.out.println("Parse time after 10 merged edits: " + parseTime + " ms"); + System.gc(); + Thread.sleep(1000); + System.out.println(">> SNAPSHOT 3: after 10 rapid edits. Press Enter to continue..."); + Thread.sleep(15000); + + // Phase 4: many edit cycles + System.out.println("\n=== Phase 4: 50 edit-parse cycles ==="); + long totalParseTime = 0; + for (int i = 0; i < 50; i++) { + version++; + doc.setVersion(version); + doc.update(Collections.singletonList( + new TextDocumentContentChangeEvent( + new Range(new Position(0, 100), new Position(0, 100)), "Z"))); + start = System.currentTimeMillis(); + model = doc.getModel(); + totalParseTime += System.currentTimeMillis() - start; + } + System.out.println("Avg incremental parse time: " + (totalParseTime / 50) + " ms"); + System.gc(); + Thread.sleep(1000); + System.out.println(">> SNAPSHOT 4: after 50 edit-parse cycles. Press Enter to continue..."); + Thread.sleep(15000); + + // Phase 5: steady state + System.out.println("\n=== Phase 5: Steady state (walk the tree) ==="); + start = System.currentTimeMillis(); + int nodeCount = walkTree(model); + System.out.println("Walked " + nodeCount + " nodes in " + (System.currentTimeMillis() - start) + " ms"); + System.gc(); + Thread.sleep(1000); + System.out.println(">> SNAPSHOT 5: after tree walk. Waiting 120s..."); + Thread.sleep(120000); + + System.out.println("Done."); + } + + private static int countElements(DOMDocument doc) { + int count = 0; + for (int i = 0; i < doc.getChildren().size(); i++) { + count += countNodes(doc.getChildren().get(i)); + } + return count; + } + + private static int countNodes(org.eclipse.lemminx.dom.DOMNode node) { + int count = 1; + if (node.hasChildNodes()) { + for (int i = 0; i < node.getChildren().size(); i++) { + count += countNodes(node.getChildren().get(i)); + } + } + return count; + } + + private static int walkTree(DOMDocument doc) { + int count = 0; + for (int i = 0; i < doc.getChildren().size(); i++) { + count += walkNode(doc.getChildren().get(i)); + } + return count; + } + + private static int walkNode(org.eclipse.lemminx.dom.DOMNode node) { + int count = 1; + node.getStart(); + node.getEnd(); + if (node.isElement()) { + org.eclipse.lemminx.dom.DOMElement elem = (org.eclipse.lemminx.dom.DOMElement) node; + elem.getTagName(); + elem.getStartTagCloseOffset(); + elem.getEndTagOpenOffset(); + if (elem.hasAttributes()) { + elem.getAttributeNodes(); + } + } + if (node.hasChildNodes()) { + for (int i = 0; i < node.getChildren().size(); i++) { + count += walkNode(node.getChildren().get(i)); + } + } + return count; + } +}