Skip to content

Commit

Permalink
Helper functions for Autocomplete for Macros (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
siefkenj authored Nov 30, 2023
1 parent a03aa89 commit 645a04f
Show file tree
Hide file tree
Showing 15 changed files with 656 additions and 235 deletions.
11 changes: 9 additions & 2 deletions packages/lsp-tools/src/auto-completer/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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" };
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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") {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/lsp-tools/src/dev-site.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
133 changes: 45 additions & 88 deletions packages/lsp-tools/src/doenet-source-object/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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. `<a><b>x</b></a>` 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
Expand All @@ -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;

/**
Expand All @@ -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" &&
Expand Down Expand Up @@ -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`.
*/
Expand All @@ -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);
}
Expand All @@ -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`.
Expand Down
Loading

0 comments on commit 645a04f

Please sign in to comment.