/** * @import {SyntaxNode} from '@lezer/common' * @import {ViewUpdate} from '@codemirror/view' * @import {Text} from '@codemirror/state' * @import {Diagnostic} from '@codemirror/lint' */ /** * @typedef {Diagnostic & {line?: number, column?: number}} ExtendedDiagnostic */ /** * @typedef {Object.} ReferenceMap * A recursive map structure that can contain references to other parts of itself. * Used for resolving deduplicated autocomplete data with "#x" and "#Mx" references. * Values can be strings, numbers, booleans, arrays, or other ReferenceMap objects. */ import { EditorView, ViewPlugin } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; /** * Links CodeMirror's syntax tree nodes representing XML elements with their corresponding DOM * elements parsed by DOMParser by traversing both trees in lockstep and storing cross-references * in two Maps. Processing instructions, comments, and text nodes between elements are skipped * transparently on both sides. * * Algorithm: walks both trees with a pair of sibling cursors; at each element pair it validates * the tag names match and recurses into their first element child. Per-element memory is O(1) — * no intermediate arrays of children are materialised. * * @param {EditorView} view The CodeMirror EditorView instance. * @param {SyntaxNode} syntaxNode The root syntax node of the CodeMirror XML editor's syntax tree. * @param {Element|Document} domNode The (root) DOM element parsed by DOMParser. * @throws {Error} If the root nodes are not Document/DOCUMENT_NODE, if a root element cannot be * found on either side, if tag names mismatch, or if the two trees have a different number of * element children at any level. * @returns {{syntaxToDom: Map, domToSyntax: Map }} Two Maps linking * syntax tree node positions to DOM nodes and vice versa. */ export function linkSyntaxTreeWithDOM(view, syntaxNode, domNode) { /** @type {Map} */ const syntaxToDom = new Map(); /** @type {Map} */ const domToSyntax = new Map(); /** * @param {SyntaxNode} node * @returns {string} */ const getText = node => view.state.doc.sliceString(node.from, node.to); /** * Advance along a syntax-node sibling chain until the first Element is found. * @param {SyntaxNode | null} node * @returns {SyntaxNode | null} */ function findFirstSyntaxElement(node) { while (node) { if (node.name === "Element") return node; node = node.nextSibling; } return null; } /** * Advance along a DOM sibling chain until the first Element is found. * @param {Node | null} node * @returns {Element | null} */ function findFirstDomElement(node) { while (node) { if (node.nodeType === Node.ELEMENT_NODE) return /** @type {Element} */ (node); node = node.nextSibling; } return null; } /** * Resolve the TagName syntax node under an Element. Lezer's XML grammar shapes Elements as * `Element -> (OpenTag | SelfClosingTag) ...`, where the start tag's children are `<`, TagName, * attributes..., `>`. Returns the TagName node or null when the grammar shape is broken (e.g. * during partial parsing of malformed input). * @param {SyntaxNode} element * @returns {SyntaxNode | null} */ function resolveTagNameNode(element) { const tag = element.firstChild?.firstChild?.nextSibling; return tag && tag.name === "TagName" ? tag : null; } /** * Link one element pair and recurse into their element children using parallel sibling cursors. * Side-effect only: writes into the outer syntaxToDom / domToSyntax maps. * @param {SyntaxNode} syntaxElement * @param {Element} domElement */ function linkPair(syntaxElement, domElement) { const syntaxTagNode = resolveTagNameNode(syntaxElement); if (!syntaxTagNode) { throw new Error(`Expected a TagName child node in syntax tree. Found: ${getText(syntaxElement)}`); } const syntaxTagName = getText(syntaxTagNode); const domTagName = domElement.tagName; if (syntaxTagName !== domTagName) { throw new Error(`Tag mismatch: Syntax tree has ${syntaxTagName}, DOM has ${domTagName}`); } // Store references. The syntax tree is regenerated on each lookup, so we key by position. syntaxToDom.set(syntaxElement.from, domElement); domToSyntax.set(domElement, syntaxElement.from); // Two-cursor walk: advance both sides element-by-element without building arrays. let syntaxChild = findFirstSyntaxElement(syntaxElement.firstChild); let domChild = findFirstDomElement(domElement.firstChild); while (syntaxChild && domChild) { linkPair(syntaxChild, domChild); syntaxChild = findFirstSyntaxElement(syntaxChild.nextSibling); domChild = findFirstDomElement(domChild.nextSibling); } // One side has more element children than the other. if (syntaxChild && !domChild) { const extras = []; for (let n = syntaxChild; n; n = findFirstSyntaxElement(n.nextSibling)) { extras.push(getText(n)); } throw new Error(`Syntax tree has more child elements than the DOM tree: ${extras.join(', ')}`); } if (domChild && !syntaxChild) { const extras = []; for (let n = domChild; n; n = findFirstDomElement(n.nextSibling)) { extras.push(n.tagName); } throw new Error(`DOM tree has more child elements than the syntax tree: ${extras.join(', ')}`); } } if (syntaxNode.name !== "Document" || domNode.nodeType !== Node.DOCUMENT_NODE) { throw new Error("Invalid arguments. The root syntax node must be the top Document node and the DOM node must be a document. Received: " + `syntaxNode: ${syntaxNode.name}, domNode: ${Object.keys(Node)[domNode.nodeType - 1]}`); } // Locate the root Element on each side, skipping PIs, comments, and whitespace text nodes. const syntaxRoot = findFirstSyntaxElement(syntaxNode.firstChild); const domRoot = findFirstDomElement(domNode.firstChild); if (!syntaxRoot || !domRoot) { console.warn("Could not find root elements in one or both trees"); return { syntaxToDom, domToSyntax }; } linkPair(syntaxRoot, domRoot); return { syntaxToDom, domToSyntax }; } /** * Function to install the selection change listener * @param {function} onSelectionChange */ export function selectionChangeListener(onSelectionChange) { return ViewPlugin.fromClass( class { /** * @param {EditorView} _view */ constructor(_view) { /** @type {function} */ this.onSelectionChange = onSelectionChange; } /** * @param {ViewUpdate} update */ update(update) { if (update.selectionSet) { const selection = update.state.selection; const ranges = selection.ranges; // Convert ranges to plain JavaScript objects if needed, or pass them directly /** * @param {any} range */ const selectionInfo = ranges.map(range => { return { view: update.view, from: range.from, to: range.to, empty: range.empty } }); this.onSelectionChange(selectionInfo, update); // Pass the current selectionInfo and the update object to the callback } } destroy() { } } ); } /** * Resolves a simple XPath-like expression against a CodeMirror 6 syntax tree * to find the position of the target node. * * The XPath only supports direct and indexed children (e.g., "/TEI/standOff/listBibl/biblStruct[8]/monogr"). * TODO this can be replaced with xmlEditor:: * * @param {EditorView} view The CodeMirror 6 EditorView * @param {string} xpath The XPath-like expression to resolve. * @returns {{from: number, to: number}|null} The `from` and `to` positions of the matching node, or null if not found. */ export function resolveXPath(view, xpath) { const tree = syntaxTree(view.state); const doc = view.state.doc; const pathSegments = xpath.split("/").filter(segment => segment !== ""); let cursor = tree.topNode.cursor(); let foundNode = null; /** * @param {SyntaxNode} node * @param {number|null} [length=null] * @returns {string} */ function text(node, length = null) { return doc.sliceString(node.from, length ? Math.min(node.from + length, node.to, doc.length) : node.to); } // function debugNode(node, textLength=10) { // return node ? `(${node.name}: "${text(node, textLength)}")`: "(null)"; // } for (const segment of pathSegments) { let index = 0; let tagName = segment; const match = segment.match(/^(.*?)\[(\d+)\]$/); if (match) { tagName = match[1]; index = parseInt(match[2], 10) - 1; if (isNaN(index) || index < 0) { console.error(`Invalid child index in ${segment}`); return null; } } let childIndex = 0; let found = false; //console.log("Next segment:" , tagName, index) // move to first child of current cursor if (!cursor.firstChild()) { console.log("cursor has no children") return null; } do { //console.log('Current cursor node: ', debugNode(cursor)) if (cursor.name == "Element") { const element = cursor.node; //console.log(' - cursor[1][1]: ', debugNode(element.firstChild?.firstChild)) //console.log(' - cursor[1][2]: ', debugNode(element.firstChild?.firstChild?.nextSibling)) let tagNameNode = element.firstChild?.firstChild?.nextSibling; if (tagNameNode && tagNameNode.name === "TagName" && text(tagNameNode) === tagName) { if (childIndex === index) { found = true; foundNode = element; break; } childIndex++; } } } while (cursor.nextSibling()); if (!found || !foundNode) { return null; // No matching node found at this level } cursor = foundNode.cursor(); // move the cursor for the next level } if (foundNode) { return { from: foundNode.from, to: foundNode.to }; } else { return null; } } /** * Parses a DOMParser `parsererror` element into a diagnostic object, handling the different * error formats across browsers (Chrome/Blink, Firefox/Gecko, and unknown engines). * @param {Node} errorNode The parsererror DOM node returned by DOMParser * @param {Text} doc The CodeMirror document, used to resolve character positions from line/column * @returns {ExtendedDiagnostic} * @throws {Error} if error node has no text content */ export function parseXmlError(errorNode, doc) { const severity = /** @type {"error"} */ ("error") // Use full textContent to cover both Firefox (text node child) and Chrome (element children) const textContent = errorNode.textContent; if (!textContent) { throw new Error("Error node has no text content"); } // Chrome/Blink format: "error on line X at column Y: message" const chromeMatch = textContent.match(/error on line (\d+) at column (\d+):\s*(.+)/); if (chromeMatch) { const line = parseInt(chromeMatch[1], 10); const column = parseInt(chromeMatch[2], 10); const message = chromeMatch[3].trim(); let { from, to } = doc.line(line); from = from + column - 1; return /** @type {ExtendedDiagnostic} */ ({ message, severity, line, column, from, to }); } // Firefox/Gecko format: message on first line, position info on third line with two numbers const [message, _, location] = textContent.split("\n") const matches = location?.match(/\d+/g); if (matches && matches.length >= 2) { const line = parseInt(matches[0], 10); const column = parseInt(matches[1], 10); let { from, to } = doc.line(line); from = from + column - 1; return /** @type {ExtendedDiagnostic} */ ({ message, severity, line, column, from, to }); } // Unknown browser format: still return a diagnostic so invalid XML mode is triggered console.warn(`Unknown parsererror format, cannot extract line/column. Raw content: "${textContent.slice(0, 200)}"`) return /** @type {ExtendedDiagnostic} */ ({ message: message || "XML parse error", severity, from: 0, to: 0 }) } /** * Checks if an object has the Extension interface (not really doing that currently) * @param {any} extension * @returns {boolean} */ export function isExtension(extension){ return extension && typeof extension == "object" } /** * Given an XML string, figures out whether the XML uses tabs or spaces for indentation, * and, if spaces, calculates the number of spaces per indentation level with some heuristic. * It's more robust against tabs in content or mixed indentation. * * @param {string} xmlString The XML string to analyze. * @param {string} [defaultIndentation=" "] The default indentation to return if the XML cannot be reliably analyzed. * Defaults to two spaces. * @returns {string} '\t' if the majority of indents are tabs, or a number of space characters (2, 4, etc.) if spaces are used. * If the indentation cannot be reliably determined, it returns the default indentation. */ export function detectXmlIndentation(xmlString, defaultIndentation = " ") { const lines = xmlString.split('\n'); let tabIndentedLines = 0; let spaceIndentedLines = 0; /** @type {number[]} */ const spaceIndentations = []; for (const line of lines) { const match = line.match(/^(\s*)/); if (match) { const indentation = match[1]; if (indentation.length > 0) { if (indentation.includes('\t')) { tabIndentedLines++; } else if (indentation.includes(' ')) { spaceIndentedLines++; if (!spaceIndentations.includes(indentation.length)) { spaceIndentations.push(indentation.length); } } } } } // Determine if the majority of indented lines use tabs if (tabIndentedLines > spaceIndentedLines) { return '\t'; } // If the majority is not tabs, proceed with space-based indentation logic if (spaceIndentations.length > 0) { spaceIndentations.sort((a, b) => a - b); if (spaceIndentations.length === 1) { return " ".repeat(spaceIndentations[0]); } // Heuristic: Find the greatest common divisor (GCD) of the indentation differences. const differences = []; for (let i = 1; i < spaceIndentations.length; i++) { const diff = spaceIndentations[i] - spaceIndentations[i-1]; if(diff > 0){ differences.push(diff); } } if(differences.length > 0) { /** * @param {number} a * @param {number} b * @returns {number} */ const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); let result = differences[0]; for (let i = 1; i < differences.length; i++) { result = gcd(result, differences[i]); } // If the GCD is a common indentation number (2 or 4), it's a strong candidate. if (result === 2 || result === 4) { return " ".repeat(result); } } // As a fallback, find the smallest indentation unit. if(spaceIndentations[0] > 1) { return " ".repeat(spaceIndentations[0]); } } return defaultIndentation; // Default value if indentation cannot be reliably determined } /** * Creates a CodeMirror autocomplete data structure from a compressed version sent * by the server, which uses "#x" references which are reused at different places. This * includes "#Mx" references which are macros containing references. Returns the resolved version * with the string references replaced by the actual object references. * * @param {ReferenceMap} data Map to be resolved * @returns {ReferenceMap} Resolved map */ export function resolveDeduplicated(data) { // Create a copy to avoid modifying the original const resolved = JSON.parse(JSON.stringify(data)); // Extract and resolve reference definitions (keys starting with #) /** @type {ReferenceMap} */ const refs = {}; Object.keys(resolved).forEach(key => { if (key.startsWith('#')) { const resolvedObj = /** @type {ReferenceMap} */ (resolved); const refsObj = /** @type {ReferenceMap} */ (refs); refsObj[key] = resolvedObj[key]; delete resolvedObj[key]; } }); // Pre-resolve all references to create shared objects /** @type {ReferenceMap} */ const resolvedRefs = {}; // First pass: resolve simple references and macros Object.keys(refs).forEach(refId => { const refsTyped = /** @type {ReferenceMap} */ (refs); if (refId.startsWith('#M')) { // Macro reference - resolve to composite pattern const macroContent = refsTyped[refId]; if (typeof macroContent === 'string' && macroContent.includes(' ')) { const refIds = macroContent.split(' ').filter(id => id.startsWith('#')); resolvedRefs[refId] = mergeReferences(refIds, refs); } else { resolvedRefs[refId] = refsTyped[refId]; } } else { // Simple reference - use as-is (will be shared) resolvedRefs[refId] = refsTyped[refId]; } }); /** * Recursive function to resolve references using shared objects * @param {any} obj * @returns {any} */ function resolveRefs(obj) { if (typeof obj === 'string' && obj.includes('#')) { if (obj.startsWith('#') && !obj.includes(' ')) { // Simple reference - return shared object and recursively resolve its contents const resolved = /** @type {ReferenceMap} */ (resolvedRefs)[obj]; if (resolved) { return resolveRefs(resolved); // Recursively resolve the contents } return obj; } else if (obj.includes(' ')) { // Composite reference like "#1 #23 #44" const refIds = obj.split(' ').filter(id => id.startsWith('#')); return mergeReferences(refIds, resolvedRefs); } return obj; } else if (Array.isArray(obj)) { return obj.map(resolveRefs); } else if (obj && typeof obj === 'object') { /** @type {ReferenceMap} */ const result = {}; Object.keys(obj).forEach(key => { /** @type {ReferenceMap} */ (result)[key] = resolveRefs(/** @type {ReferenceMap} */ (obj)[key]); }); return result; } return obj; } /** * Function to merge multiple references into a single object/array * @param {string[]} refIds * @param {ReferenceMap} refSource * @returns {any} */ function mergeReferences(refIds, refSource) { /** * @param {string} id */ const resolved = refIds.map(id => /** @type {ReferenceMap} */ (refSource)[id]).filter(Boolean); if (resolved.length === 0) return null; if (resolved.length === 1) return resolved[0]; // Share the single object // Determine merge strategy based on types const firstType = Array.isArray(resolved[0]) ? 'array' : typeof resolved[0]; /** * @param {any} r */ if (firstType === 'object' && resolved.every(r => typeof r === 'object' && !Array.isArray(r))) { // Merge objects - create new object but reference shared values where possible const merged = {}; /** * @param {ReferenceMap} obj */ resolved.forEach(obj => { Object.keys(obj).forEach(key => { if (key === 'doc' && /** @type {ReferenceMap} */ (merged)[key]) { // Merge documentation fields by concatenating with separator /** @type {ReferenceMap} */ (merged)[key] = /** @type {ReferenceMap} */ (merged)[key] + ' | ' + /** @type {ReferenceMap} */ (obj)[key]; } else { /** @type {ReferenceMap} */ (merged)[key] = /** @type {ReferenceMap} */ (obj)[key]; // This shares the value reference } }); }); return merged; /** * @param {any} r */ } else if (firstType === 'array' && resolved.every(r => Array.isArray(r))) { // Concatenate arrays and deduplicate /** @type {any[]} */ const concatenated = /** @type {any[]} */ ([].concat(.../** @type {any[]} */ (resolved))); const seen = new Set(); return concatenated.filter(item => { if (seen.has(item)) return false; seen.add(item); return true; }); } else { // Mixed types - return as array return resolved; } } /** * Helper function to deduplicate arrays, preserving order * @param {any} arr * @returns {any} */ function deduplicateArray(arr) { if (!Array.isArray(arr)) return arr; const seen = new Set(); return arr.filter(item => { if (seen.has(item)) return false; seen.add(item); return true; }); } /** * Helper function to recursively deduplicate values arrays in objects * @param {any} obj * @returns {any} */ function deduplicateValues(obj) { if (Array.isArray(obj)) { return obj.map(deduplicateValues); } else if (obj && typeof obj === 'object') { const result = {}; Object.keys(obj).forEach(key => { if (key === 'values' && Array.isArray(/** @type {ReferenceMap} */ (obj)[key])) { // Deduplicate values arrays specifically /** @type {ReferenceMap} */ (result)[key] = deduplicateArray(/** @type {ReferenceMap} */ (obj)[key]); } else { /** @type {ReferenceMap} */ (result)[key] = deduplicateValues(/** @type {ReferenceMap} */ (obj)[key]); } }); return result; } return obj; } // Resolve all references in the main data Object.keys(resolved).forEach(key => { /** @type {ReferenceMap} */ (resolved)[key] = resolveRefs(/** @type {ReferenceMap} */ (resolved)[key]); }); // Deduplicate all values arrays in the resolved data const finalResolved = deduplicateValues(resolved); return finalResolved; }