UNPKG

@shopify/theme-language-server-common

Version:

<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>

379 lines 15 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createLiquidCompletionParams = void 0; const liquid_html_parser_1 = require("@shopify/liquid-html-parser"); const fix_1 = require("./fix"); function createLiquidCompletionParams(sourceCode, params) { const { textDocument } = sourceCode; const cursor = textDocument.offsetAt(params.position); const completionContext = getCompletionContext(sourceCode, cursor); return { ...params, completionContext, document: sourceCode, }; } exports.createLiquidCompletionParams = createLiquidCompletionParams; function getCompletionContext(sourceCode, cursor) { const partialAst = parsePartial(sourceCode, cursor); if (!partialAst) { return undefined; } const [node, ancestors] = findCurrentNode(partialAst, cursor); return { partialAst, ancestors, node, }; } /** * This function will return an AST of the entire file up until the cursor * position. * * So if you accept that we use █ to represent the cursor, and a have a file that * looks like this: * * <div> * {% assign x = product %} * {% assign y = x | plus: 20 %} * {% assign z = █ %} * <span> * this content is not part of the partial tree * </span> * </div> * * Then the contents of the file up until the cursor position is this: * * <div> * {% assign x = product %} * {% assign y = x | plus: 20 %} * {% assign z = █ * * Then we'll use `fix(sourceCode, cursorPosition)` to make it parseable. * Fixed output: * * <div> * {% assign x = product %} * {% assign y = x | plus: 20 %} * {% assign z = █%} * * Then we'll parse this with `allowUnclosedDocumentNode` and * `mode: completion` to allow parsing of placeholder characters (█) * * The result is a partial AST whose last-most node is probably the one * under the cursor. */ function parsePartial(sourceCode, cursorPosition) { let fixedSource; try { fixedSource = (0, fix_1.fix)(sourceCode.source, cursorPosition); const ast = (0, liquid_html_parser_1.toLiquidHtmlAST)(fixedSource, { allowUnclosedDocumentNode: true, mode: 'completion', }); ast._source = sourceCode.source; return ast; } catch (err) { // We swallow errors here, because we gracefully accept that and // simply don't offer completions when that happens. return undefined; } } class Finder { constructor(ast) { this.stack = [ast]; } get current() { return last(this.stack); } get parent() { return this.stack.at(-2); } set current(node) { this.stack.push(node); } } /** * @returns the node at the cursor position and its ancestry. * * Undefined when you're not really on a node (there's nothing to complete) */ function findCurrentNode(partialAst, cursor) { // The current node is the "last" node in the AST. const finder = new Finder(partialAst); let current = { ...partialAst }; // Our objective: // Finding the "last-most node" in the partial AST. // // Context: // A generic visitor doesn't quite work in this context because we // cannot trust the position, blockStartPosition, blockEndPosition of // nodes when we use `allowUnclosedDocumentNode`. You see, these // properties are updated when the nodes are closed. An {% if cond %} // node without its closing {% endif %} would have its position.end be // the one of the starting block. Which means that any children it may // have wouldn't be covered. // // How we do it: // We define logic per node type. For example, HTML tags will do this: // - If the node is closed (<a>child</a>), // then there's nothing to complete. // We return undefined // - If the node has children, // then we visit the last children // - If the node has attributes, // then we visit the last attribute // - If the node has a name, // then we visit the last name node (<a--{{ product.id }}>) // // It's different per node type, because each node type has a different // concept of child node and because they have to be traversed in a // specific order. while (finder.current !== undefined && current !== finder.current) { current = finder.current; switch (current.type) { case liquid_html_parser_1.NodeTypes.Document: { if (hasNonEmptyArrayProperty(current, 'children')) { finder.current = last(current.children); } break; } case liquid_html_parser_1.NodeTypes.HtmlRawNode: case liquid_html_parser_1.NodeTypes.HtmlVoidElement: case liquid_html_parser_1.NodeTypes.HtmlDanglingMarkerClose: case liquid_html_parser_1.NodeTypes.HtmlSelfClosingElement: case liquid_html_parser_1.NodeTypes.HtmlElement: { if (isCompletedTag(current)) { finder.current = undefined; } else if (hasNonEmptyArrayProperty(current, 'children')) { finder.current = last(current.children); } else if (hasNonEmptyArrayProperty(current, 'attributes')) { finder.current = last(current.attributes); } else if (hasNonEmptyArrayProperty(current, 'name') && isCoveredExcluded(cursor, current.blockStartPosition)) { finder.current = last(current.name); } else if (typeof current.name === 'string' && isCoveredExcluded(cursor, current.blockStartPosition)) { /* break */ } else { finder.current = undefined; // there's nothing to complete } break; } case liquid_html_parser_1.NodeTypes.LiquidTag: { if (isLiquidLiquidTag(finder.current) || isCoveredExcluded(cursor, current.blockStartPosition) || // wouldn't want to complete {% if cond %} after the }. (isInLiquidLiquidTagContext(finder) && isCovered(cursor, current.blockStartPosition))) { if (hasNonNullProperty(current, 'markup') && typeof current.markup !== 'string') { finder.current = Array.isArray(current.markup) ? current.markup.at(-1) : current.markup; } else { // Exits the loop and the node is the thing to complete // (presumably name or something else) // finder.current = finder.current; } } else if (isIncompleteBlockTag(current)) { finder.current = last(current.children); } else { finder.current = undefined; // we're done and there's nothing to complete } break; } case liquid_html_parser_1.NodeTypes.LiquidBranch: if (isCovered(cursor, current.blockStartPosition) && typeof current.markup !== 'string') { finder.current = Array.isArray(current.markup) ? current.markup.at(-1) : current.markup; } else if (hasNonEmptyArrayProperty(current, 'children')) { finder.current = last(current.children); } else { finder.current = undefined; // there's nothing to complete } break; case liquid_html_parser_1.NodeTypes.LiquidRawTag: break; case liquid_html_parser_1.NodeTypes.AttrDoubleQuoted: case liquid_html_parser_1.NodeTypes.AttrSingleQuoted: case liquid_html_parser_1.NodeTypes.AttrEmpty: case liquid_html_parser_1.NodeTypes.AttrUnquoted: { const lastNameNode = last(current.name); // there's at least one... guaranteed. if (isCovered(cursor, lastNameNode.position)) { finder.current = lastNameNode; } else if (current.type !== liquid_html_parser_1.NodeTypes.AttrEmpty && isCovered(cursor, current.attributePosition) && isNotEmpty(current.value)) { finder.current = last(current.value); } else { finder.current = undefined; } break; } case liquid_html_parser_1.NodeTypes.YAMLFrontmatter: case liquid_html_parser_1.NodeTypes.HtmlDoctype: case liquid_html_parser_1.NodeTypes.HtmlComment: case liquid_html_parser_1.NodeTypes.RawMarkup: { break; } case liquid_html_parser_1.NodeTypes.LiquidVariableOutput: { if (typeof current.markup !== 'string') { finder.current = current.markup; } break; } case liquid_html_parser_1.NodeTypes.LiquidVariable: { if (isNotEmpty(current.filters)) { finder.current = last(current.filters); } else { finder.current = current.expression; } break; } case liquid_html_parser_1.NodeTypes.LiquidFilter: { if (isNotEmpty(current.args)) { finder.current = last(current.args); } break; } case liquid_html_parser_1.NodeTypes.VariableLookup: { if (hasNonEmptyArrayProperty(current, 'lookups') && last(current.lookups).type === liquid_html_parser_1.NodeTypes.VariableLookup) { finder.current = last(current.lookups); } break; } case liquid_html_parser_1.NodeTypes.AssignMarkup: { finder.current = current.value; break; } case liquid_html_parser_1.NodeTypes.ForMarkup: { if (isCovered(cursor, current.collection.position)) { finder.current = current.collection; } else if (isNotEmpty(current.args) && isCovered(cursor, last(current.args).position)) { finder.current = last(current.args); } break; } case liquid_html_parser_1.NodeTypes.NamedArgument: { if (isCovered(cursor, current.value.position)) { finder.current = current.value; } break; } case liquid_html_parser_1.NodeTypes.Comparison: { finder.current = current.right; break; } case liquid_html_parser_1.NodeTypes.LogicalExpression: { finder.current = current.right; break; } case liquid_html_parser_1.NodeTypes.CycleMarkup: { if (isNotEmpty(current.args)) { finder.current = last(current.args); } break; } case liquid_html_parser_1.NodeTypes.PaginateMarkup: { if (isNotEmpty(current.args)) { finder.current = last(current.args); } else if (isCovered(cursor, current.collection.position)) { finder.current = current.collection; } else if (isCovered(cursor, current.pageSize.position)) { finder.current = current.pageSize; } break; } case liquid_html_parser_1.NodeTypes.RenderMarkup: { if (isNotEmpty(current.args)) { finder.current = last(current.args); } else if (current.variable && isCovered(cursor, current.variable.position)) { finder.current = current.variable; } break; } case liquid_html_parser_1.NodeTypes.RenderVariableExpression: { finder.current = current.name; break; } case liquid_html_parser_1.NodeTypes.Range: { // This means you can't complete the start range as a variable... // is this bad? finder.current = current.end; break; } // If you end up on any of these. You're done. // That's the current node. case liquid_html_parser_1.NodeTypes.TextNode: case liquid_html_parser_1.NodeTypes.LiquidLiteral: case liquid_html_parser_1.NodeTypes.String: case liquid_html_parser_1.NodeTypes.Number: { break; } default: { return assertNever(current); } } } return [finder.stack.pop(), finder.stack]; } function hasNonNullProperty(thing, property) { return thing !== null && property in thing && !!thing[property]; } function isIncompleteBlockTag(thing) { return (hasNonEmptyArrayProperty(thing, 'children') && (!hasNonNullProperty(thing, 'blockEndPosition') || (thing.blockEndPosition.start === -1 && thing.blockEndPosition.end === -1))); } function isCompletedTag(thing) { return (hasNonNullProperty(thing, 'blockEndPosition') && thing.blockEndPosition.start !== -1 && thing.blockEndPosition.end !== -1); } function hasNonEmptyArrayProperty(thing, property) { return (thing !== null && property in thing && Array.isArray(thing[property]) && !isEmpty(thing[property])); } function isInLiquidLiquidTagContext(finder) { return finder.stack.some(isLiquidLiquidTag); } function isLiquidLiquidTag(node) { if (!node) return false; return node.type === liquid_html_parser_1.NodeTypes.LiquidTag && node.name === 'liquid'; } function isCoveredExcluded(cursor, position) { return position.start <= cursor && cursor < position.end; } function isCovered(cursor, position) { return position.start <= cursor && cursor <= position.end; } function isNotEmpty(x) { return x.length > 0; } function isEmpty(x) { return x.length === 0; } function last(x) { return x[x.length - 1]; } function assertNever(x) { throw new Error(`This function should never be called, but was called with ${x}`); } //# sourceMappingURL=LiquidCompletionParams.js.map