diff --git a/packages/lsp-tools/src/auto-completer/index.ts b/packages/lsp-tools/src/auto-completer/index.ts index 8dd6948c5..5a17ed987 100644 --- a/packages/lsp-tools/src/auto-completer/index.ts +++ b/packages/lsp-tools/src/auto-completer/index.ts @@ -1,8 +1,9 @@ import { DoenetSourceObject, RowCol } from "../doenet-source-object"; import { doenetSchema } from "@doenet/static-assets"; import { DastAttribute, DastElement } from "@doenet/parser"; -import { getCompletionItems } from "./get-completion-items"; -import { getSchemaViolations } from "./get-schema-violations"; +import { getCompletionItems } from "./methods/get-completion-items"; +import { getSchemaViolations } from "./methods/get-schema-violations"; +import { getCompletionContext } from "./methods/get-completion-context"; type ElementSchema = { name: string; @@ -99,6 +100,12 @@ export class AutoCompleter { */ getSchemaViolations = getSchemaViolations; + /** + * Get context about the current cursor position to determine whether completions should be offered or not, + * and what type of completions should be offered. + */ + getCompletionContext = getCompletionContext; + /** * Get the children allowed inside an `elementName` named element. * The search is case insensitive. diff --git a/packages/lsp-tools/src/auto-completer/methods/get-completion-context.ts b/packages/lsp-tools/src/auto-completer/methods/get-completion-context.ts new file mode 100644 index 000000000..5fc4ef1f1 --- /dev/null +++ b/packages/lsp-tools/src/auto-completer/methods/get-completion-context.ts @@ -0,0 +1,58 @@ +import { RowCol } from "../../doenet-source-object"; +import { DastMacro, showCursor } from "@doenet/parser"; +import { AutoCompleter } from ".."; + +export type CompletionContext = + | { cursorPos: "body" } + | { cursorPos: "element"; complete: boolean; } + | { cursorPos: "macro"; complete: boolean;node: DastMacro | null }; + +/** + * Get context about the current cursor position to determine whether completions should be offered or not, + * and what type of completions should be offered. + */ +export function getCompletionContext( + this: AutoCompleter, + offset: number | RowCol, +): CompletionContext { + if (typeof offset !== "number") { + offset = this.sourceObj.rowColToOffset(offset); + } + + const prevChar = this.sourceObj.source.charAt(offset - 1); + const prevPrevChar = this.sourceObj.source.charAt(offset - 2); + const nextChar = this.sourceObj.source.charAt(offset + 1); + let prevNonWhitespaceCharOffset = offset - 1; + while ( + this.sourceObj.source + .charAt(prevNonWhitespaceCharOffset) + .match(/(\s|\n)/) + ) { + prevNonWhitespaceCharOffset--; + } + const prevNonWhitespaceChar = this.sourceObj.source.charAt( + prevNonWhitespaceCharOffset, + ); + + const leftNode = this.sourceObj.nodeAtOffset(offset, {side:"left"}); + + // Check for status inside a macro + let macro = this.sourceObj.nodeAtOffset(offset, { + type: "macro", + side: "left", + }); + if (!macro && (prevChar === "." || prevChar === "[") && prevPrevChar !== ")") { + macro = this.sourceObj.nodeAtOffset(offset - 1, { + type: "macro", + side: "left", + }); + } + if (macro) { + // Since macros are terminal, if the node to our immediate left is a macro, + // the macro is complete. + const complete = leftNode?.type === "macro"; + return { cursorPos: "macro", complete, node: macro }; + } + + return { cursorPos: "body" }; +} diff --git a/packages/lsp-tools/src/auto-completer/get-completion-items.ts b/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts similarity index 92% rename from packages/lsp-tools/src/auto-completer/get-completion-items.ts rename to packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts index 87acbaf48..ee3b5245d 100644 --- a/packages/lsp-tools/src/auto-completer/get-completion-items.ts +++ b/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts @@ -1,8 +1,8 @@ -import { RowCol } from "../doenet-source-object"; +import { RowCol } from "../../doenet-source-object"; import type { CompletionItem } from "vscode-languageserver/browser"; import { CompletionItemKind } from "vscode-languageserver/browser"; import { showCursor } from "@doenet/parser"; -import { AutoCompleter } from "."; +import { AutoCompleter } from "../index"; /** * Get a list of completion items at the given offset. @@ -15,13 +15,6 @@ export function getCompletionItems( offset = this.sourceObj.rowColToOffset(offset); } - { - // XXX Debug - const cursor = this.sourceObj.lezerCursor; - cursor.moveTo(offset); - console.log("Cursor at pos:", showCursor(cursor)); - } - const prevChar = this.sourceObj.source.charAt(offset - 1); const prevPrevChar = this.sourceObj.source.charAt(offset - 2); let prevNonWhitespaceCharOffset = offset - 1; @@ -37,7 +30,7 @@ export function getCompletionItems( ); let containingNode = this.sourceObj.nodeAtOffset(offset); - let containingElement = this.sourceObj.elementAtOffset(offset); + let containingElement = this.sourceObj.elementAtOffsetWithContext(offset); const element = containingElement.node; let cursorPosition = containingElement.cursorPosition; if (!element && containingNode && containingNode.type === "text") { @@ -71,8 +64,6 @@ export function getCompletionItems( const { tagComplete, closed } = this.sourceObj.isCompleteElement(element); - console.log({ tagComplete, closed, element: element.name }); - if ( cursorPosition === "body" && containingElement.node && @@ -114,7 +105,7 @@ export function getCompletionItems( // We're in the open tag name. Suggest everything that starts with the current text. const currentText = element.name.toLowerCase(); const parent = this.sourceObj.getParent(element); - if (!parent) { + if (!parent || parent.type === "root") { return this.schemaTopAllowedElements .filter((name) => name.toLowerCase().startsWith(currentText)) .map((name) => ({ diff --git a/packages/lsp-tools/src/auto-completer/get-schema-violations.ts b/packages/lsp-tools/src/auto-completer/methods/get-schema-violations.ts similarity index 98% rename from packages/lsp-tools/src/auto-completer/get-schema-violations.ts rename to packages/lsp-tools/src/auto-completer/methods/get-schema-violations.ts index 72134f6e5..df4fa4aa0 100644 --- a/packages/lsp-tools/src/auto-completer/get-schema-violations.ts +++ b/packages/lsp-tools/src/auto-completer/methods/get-schema-violations.ts @@ -1,4 +1,4 @@ -import { RowCol } from "../doenet-source-object"; +import { RowCol } from "../../doenet-source-object"; import type { CompletionItem, Diagnostic } from "vscode-languageserver/browser"; import { CompletionItemKind, @@ -13,7 +13,7 @@ import { toXml, visit, } from "@doenet/parser"; -import { AutoCompleter } from "."; +import { AutoCompleter } from ".."; /** * Get a list of completion items at the given offset. diff --git a/packages/lsp-tools/src/dev-site.tsx b/packages/lsp-tools/src/dev-site.tsx index 9f3643814..a0555b6c9 100644 --- a/packages/lsp-tools/src/dev-site.tsx +++ b/packages/lsp-tools/src/dev-site.tsx @@ -80,7 +80,9 @@ function App() { sourceObj.setSource(doenetSource); console.log( { currentPos }, - sourceObj.elementAtOffset(currentPos), + sourceObj.elementAtOffsetWithContext(currentPos), + "elm2 left", sourceObj.nodeAtOffset(currentPos, {side: "left"})?.type || null, + "elm2 right", sourceObj.nodeAtOffset(currentPos, {side: "right"})?.type || null, sourceObj.attributeAtOffset(currentPos), completionObj.getCompletionItems(currentPos), ); diff --git a/packages/lsp-tools/src/doenet-source-object/index.ts b/packages/lsp-tools/src/doenet-source-object/index.ts index bc6425592..080f7ca34 100644 --- a/packages/lsp-tools/src/doenet-source-object/index.ts +++ b/packages/lsp-tools/src/doenet-source-object/index.ts @@ -12,18 +12,24 @@ import { initLezer, initLezerCursor, initDescendentNamesMap, - initOffsetToNodeMap, + initOffsetToNodeMapRight, initOffsetToRowCache, initParentMap, initRowToOffsetCache, + initOffsetToNodeMapLeft, } from "./initializers"; import { LazyDataObject } from "./lazy-data"; -import { elementAtOffset } from "./element-at-offset"; +import { elementAtOffsetWithContext } from "./methods/element-at-offset"; +import { + getAddressableNamesAtOffset, + getMacroReferentAtOffset, +} from "./methods/macro-resolvers"; import { DastMacro } from "@doenet/parser"; import type { Position as LSPPosition, Range as LSPRange, } from "vscode-languageserver"; +import { elementAtOffset, nodeAtOffset } from "./methods/at-offset"; /** * A row/column position. All values are 1-indexed. This is compatible with UnifiedJs's @@ -57,7 +63,8 @@ export class DoenetSourceObject extends LazyDataObject { _offsetToRowCache = this._lazyDataGetter(initOffsetToRowCache); _rowToOffsetCache = this._lazyDataGetter(initRowToOffsetCache); _parentMap = this._lazyDataGetter(initParentMap); - _offsetToNodeMap = this._lazyDataGetter(initOffsetToNodeMap); + _offsetToNodeMapRight = this._lazyDataGetter(initOffsetToNodeMapRight); + _offsetToNodeMapLeft = this._lazyDataGetter(initOffsetToNodeMapLeft); _descendentNamesMap = this._lazyDataGetter(initDescendentNamesMap); constructor(source?: string) { @@ -128,34 +135,13 @@ export class DoenetSourceObject extends LazyDataObject { * Return the node that contains the current offset and is furthest down the tree. * E.g. `x` at offset equal to the position of `x` return a text node. * + * If `side === "left"`, the node to the immediate left of the offset is returned. + * If `side === "right"`, the node to the immediate right of the offset is returned. + * * If `type` is passed in, then `nodeAtOffset` will walk up the parent tree until it finds * a node of that type. It returns `null` if no such node can be found. */ - nodeAtOffset( - offset: number | RowCol, - type?: DastNodes["type"], - ): DastNodes | null { - if (typeof offset !== "number") { - offset = this.rowColToOffset(offset); - } - if (offset < 0 || offset > this.source.length) { - return null; - } - if (offset > 0 && offset === this.source.length) { - // If we ask for a node at the "end" of the file, we probably want - // the last node, not null; walk back one character. - offset -= 1; - } - const offsetToNodeMap = this._offsetToNodeMap(); - let ret = offsetToNodeMap[offset] || null; - if (type != null) { - while (ret && ret.type !== type) { - ret = this.getParent(ret); - } - } - - return ret; - } + nodeAtOffset = nodeAtOffset; /** * Get the element containing the position `offset`. `null` is returned if the position is not @@ -164,6 +150,7 @@ export class DoenetSourceObject extends LazyDataObject { * Details about the `offset` position within the element are also returned, e.g., if `offset` is in * the open tag, etc.. */ + elementAtOffsetWithContext = elementAtOffsetWithContext; elementAtOffset = elementAtOffset; /** @@ -174,7 +161,7 @@ export class DoenetSourceObject extends LazyDataObject { offset = this.rowColToOffset(offset); } const _offset = offset; - const containingElm = this.elementAtOffset(offset); + const containingElm = this.elementAtOffsetWithContext(offset); if ( !containingElm.node || (containingElm.cursorPosition !== "attributeName" && @@ -276,11 +263,29 @@ export class DoenetSourceObject extends LazyDataObject { /** * Get the parent of `node`. Node must be in `this.dast`. */ - getParent(node: DastNodes): DastElement | null { + getParent(node: DastNodes): DastElement | DastRoot | null { const parentMap = this._parentMap(); return parentMap.get(node) || null; } + /** + * Get all parents of `node`. The first element in the array is the immediate parent followed + * by more distant ancestors. + * + * Node must be in `this.dast`. + */ + getParents(node: DastNodes): (DastElement | DastRoot)[] { + const ret: (DastElement | DastRoot)[] = []; + + let parent = this.getParent(node); + while (parent && parent.type !== "root") { + ret.push(parent); + parent = this.getParent(parent); + } + ret.push(this.dast); + return ret; + } + /** * Get the unique descendent of `node` with name `name`. */ @@ -305,10 +310,10 @@ export class DoenetSourceObject extends LazyDataObject { * Get the unique item with name `name` resolved from position `offset`. */ getReferentAtOffset(offset: number | RowCol, name: string) { - const { node } = this.elementAtOffset(offset); - let parent: DastElement | undefined | null = node; + const { node } = this.elementAtOffsetWithContext(offset); + let parent: DastElement | DastRoot | undefined | null = node; let referent = this.getNamedChild(parent, name); - while (parent && !referent) { + while (parent && parent.type !== "root" && !referent) { parent = this._parentMap().get(parent); referent = this.getNamedChild(parent, name); } @@ -325,61 +330,13 @@ export class DoenetSourceObject extends LazyDataObject { * for the largest matching initial segment and returns any unmatched parts * of the macro. */ - getMacroReferentAtOffset(offset: number | RowCol, macro: DastMacro) { - if (isOldMacro(macro)) { - throw new Error( - `Cannot resolve v0.6 style macro "${toXml(macro)}"`, - ); - } - let pathPart = macro.path[0]; - if (pathPart.index.length > 0) { - throw new Error( - `The first part of a macro path must be just a name without an index. Failed to resolve "${toXml( - macro, - )}"`, - ); - } - // If we made it here, we are just a name, so proceed with the lookup! - let referent = this.getReferentAtOffset(offset, pathPart.name); - if (!referent) { - return null; - } - // If there are no ".foo" accesses, the referent gets returned. - if (!macro.accessedProp) { - return { - node: referent, - accessedProp: null, - }; - } - // Otherwise, we walk down the tree trying to - // resolve whatever `accessedProp` refers to until we find something - // that doesn't exist. - let prop: DastMacro | null = macro.accessedProp; - let propReferent: DastElement | null = referent; - while (prop) { - if (prop.path[0].index.length > 0) { - // Indexing can only be used on synthetic nodes. - return { - node: referent, - accessedProp: prop, - }; - } - propReferent = this.getNamedChild(referent, prop.path[0].name); - if (!propReferent) { - return { - node: referent, - accessedProp: prop, - }; - } - // Step down one level - referent = propReferent; - prop = prop.accessedProp; - } - return { - node: referent, - accessedProp: null, - }; - } + getMacroReferentAtOffset = getMacroReferentAtOffset; + + /** + * Get a list of all names that can be addressed from `offset`. These names can be used + * in a macro path. + */ + getAddressableNamesAtOffset = getAddressableNamesAtOffset; /** * Return the smallest range that contains all of the nodes in `nodes`. diff --git a/packages/lsp-tools/src/doenet-source-object/initializers.ts b/packages/lsp-tools/src/doenet-source-object/initializers.ts index dae64f344..741ddce5f 100644 --- a/packages/lsp-tools/src/doenet-source-object/initializers.ts +++ b/packages/lsp-tools/src/doenet-source-object/initializers.ts @@ -45,7 +45,10 @@ export function initDast(this: DoenetSourceObject) { } export function initParentMap(this: DoenetSourceObject) { - const parentMap = new Map(); + const parentMap = new Map(); + for (const node of this.dast.children) { + parentMap.set(node, this.dast); + } visit(this.dast, (node) => { if (node.type === "element") { for (const child of node.children) { @@ -56,7 +59,12 @@ export function initParentMap(this: DoenetSourceObject) { return parentMap; } -export function initOffsetToNodeMap(this: DoenetSourceObject) { +/** + * Create an array the same length as `source.length` whose entries point to the node furthest + * down the tree that contains the character at that position. This array prefers the right-most node. + * So `` at position 5 returns ``. + */ +export function initOffsetToNodeMapRight(this: DoenetSourceObject) { const dast = this.dast; const offsetToNodeMap: (DastNodes | null)[] = Array.from(this.source).map( () => null, @@ -80,6 +88,20 @@ export function initOffsetToNodeMap(this: DoenetSourceObject) { return offsetToNodeMap; } +/** + * Create an array the same length as `source.length` whose entries point to the node furthest + * down the tree that contains the character at that position. This array prefers the right-most node. + * So `` at position 5 returns ``. + */ +export function initOffsetToNodeMapLeft(this: DoenetSourceObject) { + // The left map is the same as the right map except index 0 should return the root. + const dast = this.dast; + const offsetToNodeMap = [dast, ...this._offsetToNodeMapRight()]; + offsetToNodeMap.pop(); + + return offsetToNodeMap; +} + export type AccessList = { name: string; element: DastElement }[]; export function initDescendentNamesMap(this: DoenetSourceObject) { const dast = this.dast; @@ -90,21 +112,24 @@ export function initDescendentNamesMap(this: DoenetSourceObject) { if (!(node.type === "element")) { return; } + if (!namesInScope.has(node)) { + namesInScope.set(node, []); + } const nameAttr = node.attributes.find((a) => a.name === "name"); if (!nameAttr) { return; } + const name = toXml(nameAttr.children); // We have a name. Push our name to all of our parents. for (const parent of info.parents) { - let accessList = namesInScope.get(parent); - if (!accessList) { - accessList = []; - namesInScope.set(parent, accessList); + if (!namesInScope.has(parent)) { + namesInScope.set(parent, []); } - accessList.push({ name: toXml(nameAttr.children), element: node }); + const accessList = namesInScope.get(parent)!; + accessList.push({ name, element: node }); } // Make sure our name is also viewable from the root element. - rootAccessList.push({ name: toXml(nameAttr.children), element: node }); + rootAccessList.push({ name, element: node }); }); return namesInScope; } diff --git a/packages/lsp-tools/src/doenet-source-object/methods/at-offset.ts b/packages/lsp-tools/src/doenet-source-object/methods/at-offset.ts new file mode 100644 index 000000000..d473ea1c7 --- /dev/null +++ b/packages/lsp-tools/src/doenet-source-object/methods/at-offset.ts @@ -0,0 +1,69 @@ +import { CursorPosition, DoenetSourceObject, RowCol } from "../index"; +import { + DastElement, + DastNodes, + DastRoot, + LezerSyntaxNodeName, +} from "@doenet/parser"; + +/** + * Return the node that contains the current offset and is furthest down the tree. + * E.g. `x` at offset equal to the position of `x` return a text node. + * + * If `side === "left"`, the node to the immediate left of the offset is returned. + * If `side === "right"`, the node to the immediate right of the offset is returned. + * + * If `type` is passed in, then `nodeAtOffset` will walk up the parent tree until it finds + * a node of that type. It returns `null` if no such node can be found. + */ +export function nodeAtOffset( + this: DoenetSourceObject, + offset: number | RowCol, + options?: { type?: T; side?: "left" | "right" }, +): Extract | null { + let { type, side = "right" } = options || {}; + if (typeof offset !== "number") { + offset = this.rowColToOffset(offset); + } + + // Handle out of bounds cases + if (offset < 0 || offset > this.source.length) { + return null; + } + if (offset > 0 && offset === this.source.length && side === "left") { + // If we ask for a node at the "end" of the file, we probably want + // the last node, not null; walk back one character. + side = "right" + offset -= 1; + } + + // Lookup the node at the offset + const offsetToNodeMap = + side === "right" + ? this._offsetToNodeMapRight() + : this._offsetToNodeMapLeft(); + let ret = offsetToNodeMap[offset] || null; + if (type != null) { + while (ret && ret.type !== "root" && ret.type !== type) { + ret = this.getParent(ret); + } + if (ret && ret.type !== type) { + return null; + } + } + + return ret as any; +} + +export function elementAtOffset( + this: DoenetSourceObject, + offset: number | RowCol, + options?: { side?: "left" | "right" }, +) { + const { side = "right" } = options || {}; + if (typeof offset !== "number") { + offset = this.rowColToOffset(offset); + } + + return this.nodeAtOffset(offset, { type: "element", side }); +} diff --git a/packages/lsp-tools/src/doenet-source-object/element-at-offset.ts b/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts similarity index 92% rename from packages/lsp-tools/src/doenet-source-object/element-at-offset.ts rename to packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts index b30cd30f2..4a2c4b149 100644 --- a/packages/lsp-tools/src/doenet-source-object/element-at-offset.ts +++ b/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts @@ -1,5 +1,5 @@ -import { CursorPosition, DoenetSourceObject, RowCol } from "./index"; -import { DastElement, LezerSyntaxNodeName } from "@doenet/parser"; +import { CursorPosition, DoenetSourceObject, RowCol } from "../index"; +import { DastElement, DastRoot, LezerSyntaxNodeName } from "@doenet/parser"; /** * Get the element containing the position `offset`. `null` is returned if the position is not @@ -8,7 +8,7 @@ import { DastElement, LezerSyntaxNodeName } from "@doenet/parser"; * Details about the `offset` position within the element are also returned, e.g., if `offset` is in * the open tag, etc.. */ -export function elementAtOffset( +export function elementAtOffsetWithContext( this: DoenetSourceObject, offset: number | RowCol, ): { @@ -22,7 +22,9 @@ export function elementAtOffset( let cursorPosition: CursorPosition = "unknown"; const prevChar = this.source.charAt(offset - 1); const exactNodeAtOffset = this.nodeAtOffset(offset); - let node = this.nodeAtOffset(offset, "element") as DastElement | null; + let node = this.nodeAtOffset(offset, { + type: "element", + }); if (exactNodeAtOffset && exactNodeAtOffset !== node) { // If our exact node is not the same as our containing element, then we're a child of the containing @@ -58,10 +60,9 @@ export function elementAtOffset( // auto-completion expects. cursorPosition = "body"; lezerNode = leftNode; - node = this.nodeAtOffset( - lezerNode.from, - "element", - ) as DastElement | null; + node = this.nodeAtOffset(lezerNode.from, { + type: "element", + }) as DastElement | null; } const lezerNodeType = lezerNode.type.name as LezerSyntaxNodeName; @@ -108,7 +109,7 @@ export function elementAtOffset( // If we're not in an element and the previous character is a word character or `<`, then // we might be part of an incomplete element. In this case, we return the element _before_ `offset`. if (!node && prevChar.match(/(\w|<)/)) { - return this.elementAtOffset(offset - 1); + return this.elementAtOffsetWithContext(offset - 1); } const prevCharIsWhitespace = Boolean(prevChar.match(/(\s|\n)/)); @@ -170,11 +171,15 @@ export function elementAtOffset( } } + if (node?.type !== "element") { + node = null; + } + // If `node.name === ""`, then there is some error. The user has probably typed `<` and nothing else. // In this case, pretend we are the node before the cursor. if (node && node.name === "") { if (offset > 0) { - return this.elementAtOffset(offset - 1); + return this.elementAtOffsetWithContext(offset - 1); } return { node: null, cursorPosition: "unknown" }; } diff --git a/packages/lsp-tools/src/doenet-source-object/methods/macro-resolvers.ts b/packages/lsp-tools/src/doenet-source-object/methods/macro-resolvers.ts new file mode 100644 index 000000000..2734ae389 --- /dev/null +++ b/packages/lsp-tools/src/doenet-source-object/methods/macro-resolvers.ts @@ -0,0 +1,182 @@ +import { DastElement, DastMacro, DastRoot, toXml } from "@doenet/parser"; +import { DoenetSourceObject, RowCol, isOldMacro } from "../index"; +import { AccessList } from "../initializers"; + +/** + * Get the element that `macro` is referring to at position `offset`. + * Because a macro may end in attribute access, the algorithm searches + * for the largest matching initial segment and returns any unmatched parts + * of the macro. + */ +export function getMacroReferentAtOffset( + this: DoenetSourceObject, + offset: number | RowCol, + macro: DastMacro, +) { + if (isOldMacro(macro)) { + throw new Error(`Cannot resolve v0.6 style macro "${toXml(macro)}"`); + } + let pathPart = macro.path[0]; + if (pathPart.index.length > 0) { + throw new Error( + `The first part of a macro path must be just a name without an index. Failed to resolve "${toXml( + macro, + )}"`, + ); + } + // If we made it here, we are just a name, so proceed with the lookup! + let referent = this.getReferentAtOffset(offset, pathPart.name); + if (!referent) { + return null; + } + // If there are no ".foo" accesses, the referent gets returned. + if (!macro.accessedProp) { + return { + node: referent, + accessedProp: null, + }; + } + // Otherwise, we walk down the tree trying to + // resolve whatever `accessedProp` refers to until we find something + // that doesn't exist. + let prop: DastMacro | null = macro.accessedProp; + let propReferent: DastElement | null = referent; + while (prop) { + if (prop.path[0].index.length > 0) { + // Indexing can only be used on synthetic nodes. + return { + node: referent, + accessedProp: prop, + }; + } + propReferent = this.getNamedChild(referent, prop.path[0].name); + if (!propReferent) { + return { + node: referent, + accessedProp: prop, + }; + } + // Step down one level + referent = propReferent; + prop = prop.accessedProp; + } + return { + node: referent, + accessedProp: null, + }; +} + +/** + * Get a list of all names that can be addressed from `offset`. These names can be used + * in a macro path. + */ +export function getAddressableNamesAtOffset( + this: DoenetSourceObject, + offset: number | RowCol, +) { + const currElement = this.elementAtOffsetWithContext(offset).node || this.dast; + const descendentNamesMap = this._descendentNamesMap(); + + const addressableChildren = getMacroAddressableChildrenAtElement( + currElement, + descendentNamesMap, + ); + const parents = this.getParents(currElement); + const addressableParents = parents.map((ancestor) => + getMacroAddressableChildrenAtElement(ancestor, descendentNamesMap), + ); + + return mergeLeftUniquePrefixes(addressableChildren, addressableParents); +} + +/** + * Get the addresses of all children of `element`. Addresses are lists of nodes that have a `name` property. + * For example `` would return `[["x", "y"], ["x"]]`. This function + * ensures that addresses are unique. + */ +export function getMacroAddressableChildrenAtElement( + currElement: DastElement | DastRoot, + descendentNamesMap: Map, +): string[][] { + const accessList = descendentNamesMap.get(currElement); + if (!accessList) { + throw new Error( + `Expected accessList to be defined for ${ + currElement.type === "root" ? "root" : currElement.name + }`, + ); + } + + const ret: string[][] = []; + // First we find all names that address children of `currElement`. + const addressableNames = new Set( + filterUnique(accessList.map((n) => n.name)), + ); + for (const name of addressableNames) { + ret.push([name]); + } + for (const { name, element } of accessList.filter((n) => + addressableNames.has(n.name), + )) { + const childNames = getMacroAddressableChildrenAtElement( + element, + descendentNamesMap, + ); + ret.push(...childNames.map((childName) => [name, ...childName])); + } + + return ret; +} + +/** + * Returns a list of all the names that occur exactly once. + */ +function filterUnique(names: string[]): string[] { + const counts = new Map(); + for (const name of names) { + counts.set(name, (counts.get(name) || 0) + 1); + } + + return names.filter((name) => counts.get(name) === 1); +} + +/** + * Merge `toMerge` into `base` but only include lists whose prefixes are unique. + * For example `base=[["a", "b"], ["a", "c"]]` and `toMerge=[["a", "b", "c"], ["a", "b", "d"], ["z"]]` + * would result in `[["a", "b"], ["a", "c"], ["z"]]` + */ +export function mergeLeftUniquePrefixes( + base: string[][], + toMerge: string[][][], +): string[][] { + const ret: string[][] = [...base]; + const normalizedPrefixes = new Set( + base.flatMap(getPrefixes).map((p) => p.join(",")), + ); + for (const addresses of toMerge) { + const newPrefixes: string[] = []; + for (const address of addresses) { + if ( + getPrefixes(address).some((p) => + normalizedPrefixes.has(p.join(",")), + ) + ) { + // We share a prefix with an existing address, so we don't add this address. + continue; + } + ret.push(address); + newPrefixes.push(...getPrefixes(address).map((p) => p.join(","))); + } + for (const prefix of newPrefixes) { + normalizedPrefixes.add(prefix); + } + } + return ret; +} + +/** + * Get all prefixes of `address`. + */ +export function getPrefixes(address: string[]): string[][] { + return address.map((_, i) => address.slice(0, i + 1)); +} diff --git a/packages/lsp-tools/test/doenet-auto-complete.test.ts b/packages/lsp-tools/test/doenet-auto-complete.test.ts index 5abf7601d..322d9586f 100644 --- a/packages/lsp-tools/test/doenet-auto-complete.test.ts +++ b/packages/lsp-tools/test/doenet-auto-complete.test.ts @@ -166,4 +166,33 @@ describe("AutoCompleter", () => { `); } }); + it("Can get completion context", () => { + let source: string; + let autoCompleter: AutoCompleter; + + source = ` $foo.bar. `; + autoCompleter = new AutoCompleter(source, schema.elements); + { + let offset = 0; + let elm = autoCompleter.getCompletionContext(offset); + expect(elm).toEqual({ + cursorPos: "body", + }); + + offset = source.indexOf("$foo") + 4; + elm = autoCompleter.getCompletionContext(offset); + expect(elm).toMatchObject({ + complete: true, + cursorPos: "macro", + }); + + // Matching at the . following the macro. + offset = source.indexOf("bar") + 4; + elm = autoCompleter.getCompletionContext(offset); + expect(elm).toMatchObject({ + complete: false, + cursorPos: "macro", + }); + } + }); }); diff --git a/packages/lsp-tools/test/doenet-source-object.test.ts b/packages/lsp-tools/test/doenet-source-object.test.ts index 622ee8831..b7a698952 100644 --- a/packages/lsp-tools/test/doenet-source-object.test.ts +++ b/packages/lsp-tools/test/doenet-source-object.test.ts @@ -130,22 +130,22 @@ describe("DoenetSourceObject", () => { source = ` hi`; sourceObj = new DoenetSourceObject(source); { - let { cursorPosition, node } = sourceObj.elementAtOffset(4); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(4); expect(cursorPosition).toEqual("openTagName"); expect(node).toMatchObject({ type: "element", name: "b" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(6); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(6); expect(cursorPosition).toEqual("attributeName"); expect(node).toMatchObject({ type: "element", name: "b" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(11); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(11); expect(cursorPosition).toEqual("attributeValue"); expect(node).toMatchObject({ type: "element", name: "b" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(17); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(17); expect(cursorPosition).toEqual("body"); expect(node).toMatchObject({ type: "element", name: "b" }); } @@ -153,22 +153,22 @@ describe("DoenetSourceObject", () => { source = ` `; sourceObj = new DoenetSourceObject(source); { - let { cursorPosition, node } = sourceObj.elementAtOffset(3); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(3); expect(cursorPosition).toEqual("openTag"); expect(node).toMatchObject({ type: "element", name: "a" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(5); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(5); expect(cursorPosition).toEqual("body"); expect(node).toMatchObject({ type: "element", name: "a" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(9); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(9); expect(cursorPosition).toEqual("unknown"); expect(node).toMatchObject({ type: "element", name: "a" }); } { - let { cursorPosition, node } = sourceObj.elementAtOffset(11); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(11); expect(cursorPosition).toEqual("unknown"); expect(node).toMatchObject({ type: "element", name: "a" }); } @@ -177,7 +177,7 @@ describe("DoenetSourceObject", () => { sourceObj = new DoenetSourceObject(source); { const offset = source.indexOf("") - 1; - let { cursorPosition, node } = sourceObj.elementAtOffset(offset); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(offset); expect(cursorPosition).toEqual("body"); expect(node).toMatchObject({ type: "element", name: "b" }); } @@ -186,7 +186,7 @@ describe("DoenetSourceObject", () => { // is a non-closed element inside, we should claim to be inside the body // of the non-closed element. const offset = source.indexOf(""); - let { cursorPosition, node } = sourceObj.elementAtOffset(offset); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(offset); expect(cursorPosition).toEqual("body"); expect(node).toMatchObject({ type: "element", name: "b" }); } @@ -196,7 +196,7 @@ describe("DoenetSourceObject", () => { sourceObj = new DoenetSourceObject(source); { const offset = source.indexOf(""); - let { cursorPosition, node } = sourceObj.elementAtOffset(offset); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(offset); expect(cursorPosition).toEqual("body"); expect(node).toMatchObject({ type: "element", name: "b" }); } @@ -210,7 +210,7 @@ describe("DoenetSourceObject", () => { sourceObj = new DoenetSourceObject(source); { let offset = source.indexOf("$x"); - let { cursorPosition, node } = sourceObj.elementAtOffset( + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext( offset + 1, ); expect(cursorPosition).toEqual("body"); @@ -225,7 +225,7 @@ describe("DoenetSourceObject", () => { source = ` { source = `

`; sourceObj = new DoenetSourceObject(source); { - let { cursorPosition, node } = sourceObj.elementAtOffset(7); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(7); expect(cursorPosition).toEqual("openTag"); expect(node).toMatchObject({ type: "element", name: "a" }); } @@ -241,100 +241,12 @@ describe("DoenetSourceObject", () => { source = `<`; sourceObj = new DoenetSourceObject(source); { - let { cursorPosition, node } = sourceObj.elementAtOffset(1); + let { cursorPosition, node } = sourceObj.elementAtOffsetWithContext(1); expect(cursorPosition).toEqual("unknown"); expect(node).toEqual(null); } }); - it("Can find named referents", () => { - let source: string; - let sourceObj: DoenetSourceObject; - - source = ` - - - - - `; - sourceObj = new DoenetSourceObject(source); - { - let offset = source.indexOf(" { - let source: string; - let sourceObj: DoenetSourceObject; - let macro: DastMacro; - - source = ` - - - - - `; - sourceObj = new DoenetSourceObject(source); - { - let offset = source.indexOf(" { - let source: string; - let sourceObj: DoenetSourceObject; - let macro: DastMacro; - - source = `$foo.bar[2].baz`; - sourceObj = new DoenetSourceObject(source); - macro = sourceObj.dast.children[0] as DastMacro; - expect(isOldMacro(macro)).toEqual(false); - - source = `$(foo.bar[2].baz)`; - sourceObj = new DoenetSourceObject(source); - macro = sourceObj.dast.children[0] as DastMacro; - expect(isOldMacro(macro)).toEqual(false); - - source = `$(foo/x.bar[2].baz)`; - sourceObj = new DoenetSourceObject(source); - macro = sourceObj.dast.children[0] as DastMacro; - expect(isOldMacro(macro)).toEqual(true); - }); - it("Can get element ranges", () => { let source: string; let sourceObj: DoenetSourceObject; @@ -342,7 +254,7 @@ describe("DoenetSourceObject", () => { source = ` hi`; sourceObj = new DoenetSourceObject(source); { - let { node } = sourceObj.elementAtOffset(1); + let { node } = sourceObj.elementAtOffsetWithContext(1); expect(node?.name).toEqual("a"); expect(sourceObj.getElementTagRanges(node!)).toMatchInlineSnapshot(` [ @@ -358,7 +270,7 @@ describe("DoenetSourceObject", () => { `); } { - let { node } = sourceObj.elementAtOffset(4); + let { node } = sourceObj.elementAtOffsetWithContext(4); expect(node?.name).toEqual("b"); expect(sourceObj.getElementTagRanges(node!)).toMatchInlineSnapshot(` [ diff --git a/packages/lsp-tools/test/macro-resolution.test.ts b/packages/lsp-tools/test/macro-resolution.test.ts new file mode 100644 index 000000000..aaf11837e --- /dev/null +++ b/packages/lsp-tools/test/macro-resolution.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import util from "util"; + +import { filterPositionInfo, DastMacro, DastElement } from "@doenet/parser"; +import { DoenetSourceObject, isOldMacro } from "../src/doenet-source-object"; +import { + getPrefixes, + mergeLeftUniquePrefixes, +} from "../src/doenet-source-object/methods/macro-resolvers"; + +const origLog = console.log; +console.log = (...args) => { + origLog(...args.map((x) => util.inspect(x, false, 10, true))); +}; + +describe("DoenetSourceObject", () => { + it("Can find named referents", () => { + let source: string; + let sourceObj: DoenetSourceObject; + + source = ` + + + + + `; + sourceObj = new DoenetSourceObject(source); + { + let offset = source.indexOf(" { + let source: string; + let sourceObj: DoenetSourceObject; + let macro: DastMacro; + + source = ` + + + + + `; + sourceObj = new DoenetSourceObject(source); + { + let offset = source.indexOf(" { + let source: string; + let sourceObj: DoenetSourceObject; + let macro: DastMacro; + + source = `$foo.bar[2].baz`; + sourceObj = new DoenetSourceObject(source); + macro = sourceObj.dast.children[0] as DastMacro; + expect(isOldMacro(macro)).toEqual(false); + + source = `$(foo.bar[2].baz)`; + sourceObj = new DoenetSourceObject(source); + macro = sourceObj.dast.children[0] as DastMacro; + expect(isOldMacro(macro)).toEqual(false); + + source = `$(foo/x.bar[2].baz)`; + sourceObj = new DoenetSourceObject(source); + macro = sourceObj.dast.children[0] as DastMacro; + expect(isOldMacro(macro)).toEqual(true); + }); + + it("Can uniquely merge prefixes", () => { + expect(getPrefixes(["a", "b", "c"])).toEqual([ + ["a"], + ["a", "b"], + ["a", "b", "c"], + ]); + + expect( + mergeLeftUniquePrefixes([], [[["a"]], [["b"]], [["a", "x"]]]), + ).toEqual([["a"], ["b"]]); + + expect( + mergeLeftUniquePrefixes( + [ + ["a", "b"], + ["a", "c"], + ], + [[["a", "b", "c"], ["a", "b", "d"], ["z"]]], + ), + ).toEqual([["a", "b"], ["a", "c"], ["z"]]); + }); + + it("Can generate addresses of children", () => { + let source: string; + let sourceObj: DoenetSourceObject; + let elm: DastElement; + + source = ` + + + + + + + + `; + sourceObj = new DoenetSourceObject(source); + expect(sourceObj.getAddressableNamesAtOffset(0)).toEqual([ + ["x"], + ["z"], + ["x", "z"], + ]); + expect( + sourceObj.getAddressableNamesAtOffset(source.indexOf(" + + + + + + + + `; + sourceObj = new DoenetSourceObject(source); + expect(sourceObj.getAddressableNamesAtOffset(0)).toEqual([ + ["x"], + ["z"], + ["w"], + ["x", "y"], + ["x", "z"], + ["x", "w"], + ["x", "y", "z"], + ]); + expect( + sourceObj.getAddressableNamesAtOffset(source.indexOf("