@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>
496 lines (437 loc) • 14.9 kB
text/typescript
import {
LiquidHtmlNode,
LiquidTag,
NodeTypes,
Position,
toLiquidHtmlAST,
} from '@shopify/liquid-html-parser';
import { CompletionParams } from 'vscode-languageserver';
import { AugmentedLiquidSourceCode, AugmentedSourceCode } from '../../documents';
import { fix } from './fix';
interface CompletionContext {
/** The AST of the Liquid template up to the cursor position */
readonly partialAst: LiquidHtmlNode;
/** The node at the cursor position, undefined if cursor is not in a node */
readonly node: LiquidHtmlNode | undefined;
/** The ancestry that leads to the current node */
readonly ancestors: LiquidHtmlNode[];
}
export interface LiquidCompletionParams extends CompletionParams {
/**
* The completion context represents additional information that would
* allow you to offer completions at the cursor position.
*
* If undefined, then the file is unparseable.
*/
readonly completionContext: CompletionContext | undefined;
/** The document from the document manager */
readonly document: AugmentedLiquidSourceCode;
}
export function createLiquidCompletionParams(
sourceCode: AugmentedLiquidSourceCode,
params: CompletionParams,
): LiquidCompletionParams {
const { textDocument } = sourceCode;
const cursor = textDocument.offsetAt(params.position);
const completionContext = getCompletionContext(sourceCode, cursor);
return {
...params,
completionContext,
document: sourceCode,
};
}
function getCompletionContext(
sourceCode: AugmentedSourceCode,
cursor: number,
): CompletionContext | undefined {
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: AugmentedSourceCode,
cursorPosition: number,
): LiquidHtmlNode | undefined {
let fixedSource: string | undefined;
try {
fixedSource = fix(sourceCode.source, cursorPosition);
const ast = toLiquidHtmlAST(fixedSource, {
allowUnclosedDocumentNode: true,
mode: 'completion',
});
ast._source = sourceCode.source;
return ast;
} catch (err: any) {
// We swallow errors here, because we gracefully accept that and
// simply don't offer completions when that happens.
return undefined;
}
}
class Finder {
public stack: NonEmptyArray<LiquidHtmlNode | undefined>;
constructor(ast: LiquidHtmlNode) {
this.stack = [ast];
}
get current(): LiquidHtmlNode | undefined {
return last(this.stack);
}
get parent(): LiquidHtmlNode | undefined {
return this.stack.at(-2);
}
set current(node: LiquidHtmlNode | undefined) {
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: LiquidHtmlNode,
cursor: number,
): [node: LiquidHtmlNode | undefined, ancestry: LiquidHtmlNode[]] {
// The current node is the "last" node in the AST.
const finder = new Finder(partialAst);
let current: LiquidHtmlNode = { ...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 NodeTypes.Document: {
if (hasNonEmptyArrayProperty(current, 'children')) {
finder.current = last(current.children);
}
break;
}
case NodeTypes.HtmlRawNode:
case NodeTypes.HtmlVoidElement:
case NodeTypes.HtmlDanglingMarkerClose:
case NodeTypes.HtmlSelfClosingElement:
case 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 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 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 NodeTypes.LiquidRawTag:
if (current.name === 'doc' && current.body.nodes.length > 0) {
finder.current = current.body.nodes.at(-1);
}
break;
case NodeTypes.AttrDoubleQuoted:
case NodeTypes.AttrSingleQuoted:
case NodeTypes.AttrEmpty:
case NodeTypes.AttrUnquoted: {
const lastNameNode = last(current.name as NonEmptyArray<(typeof current.name)[number]>); // there's at least one... guaranteed.
if (isCovered(cursor, lastNameNode.position)) {
finder.current = lastNameNode;
} else if (
current.type !== NodeTypes.AttrEmpty &&
isCovered(cursor, current.attributePosition) &&
isNotEmpty(current.value)
) {
finder.current = last(current.value);
} else {
finder.current = undefined;
}
break;
}
case NodeTypes.YAMLFrontmatter:
case NodeTypes.HtmlDoctype:
case NodeTypes.HtmlComment:
case NodeTypes.RawMarkup: {
break;
}
case NodeTypes.LiquidVariableOutput: {
if (typeof current.markup !== 'string') {
finder.current = current.markup;
}
break;
}
case NodeTypes.LiquidVariable: {
if (isNotEmpty(current.filters)) {
finder.current = last(current.filters);
} else {
finder.current = current.expression;
}
break;
}
case NodeTypes.LiquidFilter: {
if (isNotEmpty(current.args)) {
finder.current = last(current.args);
}
break;
}
case NodeTypes.VariableLookup: {
if (
hasNonEmptyArrayProperty(current, 'lookups') &&
last(current.lookups).type === NodeTypes.VariableLookup
) {
finder.current = last(current.lookups);
}
break;
}
case NodeTypes.AssignMarkup: {
finder.current = current.value;
break;
}
case 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 NodeTypes.NamedArgument: {
if (isCovered(cursor, current.value.position)) {
finder.current = current.value;
}
break;
}
case NodeTypes.Comparison: {
finder.current = current.right;
break;
}
case NodeTypes.LogicalExpression: {
finder.current = current.right;
break;
}
case NodeTypes.CycleMarkup: {
if (isNotEmpty(current.args)) {
finder.current = last(current.args);
}
break;
}
case 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 NodeTypes.ContentForMarkup: {
if (isNotEmpty(current.args)) {
finder.current = last(current.args);
} else if (isCovered(cursor, current.contentForType.position)) {
finder.current = current.contentForType;
}
break;
}
case NodeTypes.RenderMarkup: {
if (isNotEmpty(current.args)) {
finder.current = last(current.args);
} else if (current.variable && isCovered(cursor, current.variable.position)) {
finder.current = current.variable;
} else if (current.snippet && isCovered(cursor, current.snippet.position)) {
finder.current = current.snippet;
}
break;
}
case NodeTypes.RenderVariableExpression: {
finder.current = current.name;
break;
}
case 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 NodeTypes.TextNode:
case NodeTypes.LiquidLiteral:
case NodeTypes.String:
case NodeTypes.Number:
case NodeTypes.LiquidDocParamNode:
case NodeTypes.LiquidDocExampleNode:
case NodeTypes.LiquidDocDescriptionNode:
case NodeTypes.RenderAliasExpression: {
break;
}
default: {
return assertNever(current);
}
}
}
return [finder.stack.pop()!, finder.stack as NonEmptyArray<LiquidHtmlNode>];
}
type NonEmptyArray<T> = [T, ...T[]];
function hasNonNullProperty<K extends PropertyKey>(
thing: any,
property: K,
): thing is { [k in K]: NonNullable<any> } {
return thing !== null && property in thing && !!thing[property];
}
function isIncompleteBlockTag(thing: any): thing is { children: NonEmptyArray<any> } {
return (
hasNonEmptyArrayProperty(thing, 'children') &&
(!hasNonNullProperty(thing, 'blockEndPosition') ||
(thing.blockEndPosition.start === -1 && thing.blockEndPosition.end === -1))
);
}
function isCompletedTag(thing: any) {
return (
hasNonNullProperty(thing, 'blockEndPosition') &&
thing.blockEndPosition.start !== -1 &&
thing.blockEndPosition.end !== -1
);
}
function hasNonEmptyArrayProperty<K extends PropertyKey>(
thing: any,
property: K,
): thing is { [k in K]: NonEmptyArray<any> } {
return (
thing !== null &&
property in thing &&
Array.isArray(thing[property]) &&
!isEmpty(thing[property])
);
}
function isInLiquidLiquidTagContext(finder: Finder): boolean {
return finder.stack.some(isLiquidLiquidTag);
}
function isLiquidLiquidTag(
node: LiquidHtmlNode | undefined,
): node is LiquidTag & { name: 'liquid' } {
if (!node) return false;
return node.type === NodeTypes.LiquidTag && node.name === 'liquid';
}
function isCoveredExcluded(cursor: number, position: Position): boolean {
return position.start <= cursor && cursor < position.end;
}
function isCovered(cursor: number, position: Position): boolean {
return position.start <= cursor && cursor <= position.end;
}
function isNotEmpty<T = any>(x: T[]): x is NonEmptyArray<T> {
return x.length > 0;
}
function isEmpty(x: any[]): x is [] {
return x.length === 0;
}
function last<T = any>(x: NonEmptyArray<T>): T {
return x[x.length - 1];
}
function assertNever(x: never): never {
throw new Error(`This function should never be called, but was called with ${x}`);
}