From 61fb2caae4c03a284a1aaa2772a961afb88ed052 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 14 Jul 2026 16:50:44 +0200 Subject: [PATCH 1/3] Update theme-language-server-common for new parser --- .../theme-language-server-common/package.json | 2 +- .../params/LiquidCompletionParams.spec.ts | 14 +- .../params/LiquidCompletionParams.ts | 1627 +++++++++++++++-- .../src/completions/params/fix.spec.ts | 159 -- .../src/completions/params/fix.ts | 269 --- .../src/completions/params/index.ts | 1 - ...tentForParameterCompletionProvider.spec.ts | 3 +- .../ContentForParameterCompletionProvider.ts | 16 +- .../FilterCompletionProvider.spec.ts | 3 +- .../providers/FilterCompletionProvider.ts | 58 +- ...erNamedParameterCompletionProvider.spec.ts | 3 +- .../FilterNamedParameterCompletionProvider.ts | 9 +- .../HtmlAttributeCompletionProvider.ts | 8 +- .../HtmlAttributeValueCompletionProvider.ts | 6 +- .../providers/HtmlTagCompletionProvider.ts | 25 +- .../providers/LiquidTagsCompletionProvider.ts | 12 +- .../ObjectAttributeCompletionProvider.ts | 4 +- .../providers/ObjectCompletionProvider.ts | 11 +- ...enderSnippetParameterCompletionProvider.ts | 6 +- .../src/completions/providers/common/index.ts | 74 + .../documentLinks/DocumentLinksProvider.ts | 8 +- ...mentAutoclosingOnTypeFormattingProvider.ts | 29 +- .../EmptyHtmlTagLinkedRangesProvider.ts | 9 +- .../renamed/handlers/SectionRenameHandler.ts | 2 +- 24 files changed, 1722 insertions(+), 636 deletions(-) delete mode 100644 packages/theme-language-server-common/src/completions/params/fix.spec.ts delete mode 100644 packages/theme-language-server-common/src/completions/params/fix.ts diff --git a/packages/theme-language-server-common/package.json b/packages/theme-language-server-common/package.json index 5dfcef127..cfd2e630a 100644 --- a/packages/theme-language-server-common/package.json +++ b/packages/theme-language-server-common/package.json @@ -23,7 +23,7 @@ "build:ci": "pnpm build", "build:ts": "tsc -b src/tsconfig.build.json", "dev": "tsc -b src --watch", - "test": "vitest", + "test": "vitest --root ../.. --run packages/theme-language-server-common/src --exclude \"dist/**\"", "type-check": "tsc --noEmit -p src/tsconfig.json" }, "dependencies": { diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts index 167697165..5eefc756b 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts @@ -7,14 +7,18 @@ import { createLiquidCompletionParams } from './LiquidCompletionParams'; describe('Module: LiquidCompletionParams', async () => { describe('createLiquidCompletionParams', async () => { describe('parsed LiquidHTML template', async () => { - it('returns an undefined completionContext when the template is unsalvageable', () => { + it('recovers a completion context from otherwise-unsalvageable input', () => { const context = '{{ }}%}%{%}%$}}{#$%}{% ren█ %}'; const { completionContext } = createLiquidParamsFromContext(context); - expect(completionContext).not.to.exist; + expect(completionContext).to.exist; + + const { node } = completionContext!; + expectPath(node, 'type').to.eql('LiquidTag'); + expectPath(node, 'name').to.eql('ren'); }); describe('completionContext.partialAst', async () => { - it('returns an ast of the file up to the cursor position', async () => { + it('returns an ast of the full file', async () => { const context = '{{ "hey" }}\n\n{{ product.id }}{% ren█ %}{% echo "not in the AST" %}'; const { completionContext } = createLiquidParamsFromContext(context); @@ -42,7 +46,7 @@ describe('Module: LiquidCompletionParams', async () => { expectPath(partialAst, 'children.2.markup').to.eql(''); expectPath(partialAst, 'children.2.type').to.eql('LiquidTag'); - expectPath(partialAst, 'children.3').not.to.exist; + expectPath(partialAst, 'children.3.type').to.eql('LiquidTag'); }); }); @@ -58,7 +62,7 @@ describe('Module: LiquidCompletionParams', async () => { expectPath(node, 'markup').to.eql(''); expectPath(node, 'type').to.eql('LiquidTag'); expectPath(node, 'position.start').to.eql(29); - expectPath(node, 'position.end').to.eql(37); + expectPath(node, 'position.end').to.eql(38); }); it('returns the node under the cursor on nested contexts', async () => { diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts index 9c7222110..e399a5344 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts @@ -1,16 +1,20 @@ import { + findErrorNodeAtOffset, LiquidHtmlNode, LiquidTag, NodeTypes, Position, - toLiquidHtmlAST, + RAW_TAGS, + VOID_ELEMENTS, + toTolerantLiquidHtmlAST, } from '@shopify/liquid-html-parser'; import { CompletionParams } from 'vscode-languageserver'; import { AugmentedLiquidSourceCode, AugmentedSourceCode } from '../../documents'; -import { fix } from './fix'; +import { isNamedHtmlElementNode } from '../../utils'; +import { completionPartial } from '../providers/common'; interface CompletionContext { - /** The AST of the Liquid template up to the cursor position */ + /** The full resilient AST of the original source */ readonly partialAst: LiquidHtmlNode; /** The node at the cursor position, undefined if cursor is not in a node */ @@ -18,6 +22,12 @@ interface CompletionContext { /** The ancestry that leads to the current node */ readonly ancestors: LiquidHtmlNode[]; + + /** The character offset of the cursor within the source */ + readonly cursor: number; + + /** The source text between the current node's start and the cursor */ + readonly partial: string; } export interface LiquidCompletionParams extends CompletionParams { @@ -52,69 +62,38 @@ function getCompletionContext( sourceCode: AugmentedSourceCode, cursor: number, ): CompletionContext | undefined { - const partialAst = parsePartial(sourceCode, cursor); + const partialAst = parseResilient(sourceCode); if (!partialAst) { return undefined; } - const [node, ancestors] = findCurrentNode(partialAst, cursor); + const [node, ancestors] = findCompletionNode(partialAst, cursor, sourceCode.source); + const partial = completionPartial(node, cursor, sourceCode.source); return { partialAst, ancestors, node, + cursor, + partial, }; } /** - * 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: - * - *
- * {% assign x = product %} - * {% assign y = x | plus: 20 %} - * {% assign z = █ %} - * - * this content is not part of the partial tree - * - *
- * - * Then the contents of the file up until the cursor position is this: + * This function returns the full resilient AST of the original source. * - *
- * {% assign x = product %} - * {% assign y = x | plus: 20 %} - * {% assign z = █ + * Unlike the old fix-and-truncate approach (which rewrote the source to be + * parseable and then truncated it at the caret), we now parse the entire + * source with `toTolerantLiquidHtmlAST`. Its default options recover a + * usable AST even when the source has parse errors, so failed regions become + * `LiquidErrorNode`s rather than aborting the whole parse. * - * Then we'll use `fix(sourceCode, cursorPosition)` to make it parseable. - * Fixed output: - * - *
- * {% 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. + * The result is a complete AST; the node under the cursor is then located by + * `findCompletionNode`, keyed on trustworthy positions rather than on the + * source being truncated at the caret. */ -function parsePartial( - sourceCode: AugmentedSourceCode, - cursorPosition: number, -): LiquidHtmlNode | undefined { - let fixedSource: string | undefined; +function parseResilient(sourceCode: AugmentedSourceCode): LiquidHtmlNode | undefined { try { - fixedSource = fix(sourceCode.source, cursorPosition); - const ast = toLiquidHtmlAST(fixedSource, { - allowUnclosedDocumentNode: true, - mode: 'completion', - }); - ast._source = sourceCode.source; - return ast; + return toTolerantLiquidHtmlAST(sourceCode.source); } catch (err: any) { // We swallow errors here, because we gracefully accept that and // simply don't offer completions when that happens. @@ -147,25 +126,25 @@ class Finder { * * Undefined when you're not really on a node (there's nothing to complete) */ -function findCurrentNode( +function findCompletionNode( partialAst: LiquidHtmlNode, cursor: number, + source: string, ): [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. + // Finding the deepest real typed node whose `position` covers the + // cursor, together with its ancestor chain. // // 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. + // The resilient parser gives us the AST of the whole source (not a copy + // truncated at the caret), so we can no longer assume the node under the + // cursor is the "last-most" node. Instead we descend, at each node, + // into the child whose `position` covers the cursor. That keeps the + // per-type intent (which child to consider, in which order) but keys the + // selection on trustworthy positions. // // How we do it: // We define logic per node type. For example, HTML tags will do this: @@ -173,11 +152,11 @@ function findCurrentNode( // then there's nothing to complete. // We return undefined // - If the node has children, - // then we visit the last children + // then we visit the covering child // - If the node has attributes, - // then we visit the last attribute + // then we visit the covering attribute // - If the node has a name, - // then we visit the last name node () + // then we visit the covering name node () // // 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 @@ -188,7 +167,7 @@ function findCurrentNode( switch (current.type) { case NodeTypes.Document: { if (hasNonEmptyArrayProperty(current, 'children')) { - finder.current = last(current.children); + finder.current = covering(current.children, cursor); } break; } @@ -198,59 +177,159 @@ function findCurrentNode( 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 */ + const child = hasNonEmptyArrayProperty(current, 'children') + ? covering(current.children, cursor) + : undefined; + if (child) { + // The cursor is inside the element body: descend into the covering + // child even when the element itself is closed. + finder.current = child; } else { - finder.current = undefined; // there's nothing to complete + const coveringAttr = hasNonEmptyArrayProperty(current, 'attributes') + ? covering(current.attributes, cursor) + : undefined; + const coveringName = + hasNonEmptyArrayProperty(current, 'name') && + isCoveredExcluded(cursor, current.blockStartPosition) + ? covering(current.name, cursor) + : undefined; + if (coveringAttr) { + // Descend into the covered attribute even when the open tag is + // `>`-terminated (``): a zero-width blockEndPosition + // makes isCompletedTag true, but the caret is in the attribute + // region, so the attribute is the thing to complete. + finder.current = coveringAttr; + } else if (coveringName) { + finder.current = coveringName; + } else if ( + isNamedHtmlElementNode(current) && + isCoveredExcluded(cursor, current.blockStartPosition) + ) { + // The caret sits in an empty attribute slot of an open tag + // (``): no attribute node exists yet. Synthesize the empty slot + // the attribute provider completes against. + finder.current = { + type: NodeTypes.AttrEmpty, + name: [ + { + type: NodeTypes.TextNode, + value: '', + position: { start: cursor, end: cursor }, + }, + ], + position: { start: cursor, end: cursor }, + } as any as LiquidHtmlNode; + } else if (isCompletedTag(current)) { + finder.current = undefined; + } else if (hasNonEmptyArrayProperty(current, 'attributes')) { + finder.current = covering(current.attributes, cursor); + } else if ( + hasNonEmptyArrayProperty(current, 'name') && + isCoveredExcluded(cursor, current.blockStartPosition) + ) { + finder.current = covering(current.name, cursor); + } 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 ( + const child = hasNonEmptyArrayProperty(current, 'children') + ? covering(current.children, cursor) + : undefined; + if (child) { + // The cursor is inside the block body: descend into the covering + // child even when the block is closed ({% if %}…{% endif %}). + finder.current = child; + } else 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; + if (isTrailingLiquidTagMarkupSlot(current, cursor, source)) { + // Keep the covering LiquidTag for trailing tag-modifier positions + // (`{% for x in y reversed ^ %}`) so the tag provider sees the + // same params shape Ohm exposed. + } else if (hasNonNullProperty(current, 'markup') && typeof current.markup !== 'string') { + if (Array.isArray(current.markup)) { + finder.current = covering(current.markup, cursor); + } else if (isCovered(cursor, current.markup.position)) { + finder.current = current.markup; + } + } else if ( + typeof current.markup === 'string' && + isInExpressionSlot(current, cursor, source) && + !isTrailingLiquidTagMarkupSlot(current, cursor, source) + ) { + // The parser left the markup as a raw string, meaning the + // expression slot at the caret is empty or unfinished + // (`{% echo ^ %}`, `{% if a > ^ %}`). Synthesize the node that slot + // expects so the providers can offer completions there. The + // `isInExpressionSlot` guard keeps caret-on-the-tag-name cases + // (`{% ren^ %}`) completing the tag rather than a lookup. + const slot = synthMarkupSlot(current, cursor, source); + const contentForType = + current.name === 'content_for' + ? synthContentForType(current, cursor, source) + : undefined; + if ( + current.name === 'content_for' && + slot.type === NodeTypes.VariableLookup && + contentForType !== undefined + ) { + // The resilient parser leaves `{% content_for "b", partial %}` (a + // bare arg name, no colon) as raw string markup — there is no + // ContentForMarkup node for the parameter provider to hang off. + // Synthesize one as the recovered lookup's parent so the provider + // fires, mirroring the shape the parser builds for a well-formed + // `type: "x"` argument. + finder.current = { + type: NodeTypes.ContentForMarkup, + contentForType, + args: [], + position: current.position, + } as any as LiquidHtmlNode; + } + finder.current = slot; } else { - // Exits the loop and the node is the thing to complete - // (presumably name or something else) + // No markup, or the caret is still on the tag name: the tag itself + // is the thing to complete. // 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); + case NodeTypes.LiquidBranch: { + const child = hasNonEmptyArrayProperty(current, 'children') + ? covering(current.children, cursor) + : undefined; + if (child) { + finder.current = child; + } else if ( + isCovered(cursor, current.blockStartPosition) && + typeof current.markup !== 'string' + ) { + finder.current = Array.isArray(current.markup) + ? covering(current.markup, cursor) + : isCovered(cursor, current.markup.position) + ? current.markup + : undefined; } else { finder.current = undefined; // there's nothing to complete } break; + } case NodeTypes.LiquidRawTag: if (current.name === 'doc' && current.body.nodes.length > 0) { @@ -262,7 +341,9 @@ function findCurrentNode( 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. + const lastNameNode = + covering(current.name, cursor) ?? + 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 ( @@ -285,15 +366,50 @@ function findCurrentNode( } case NodeTypes.LiquidVariableOutput: { - if (typeof current.markup !== 'string') { + if (typeof current.markup !== 'string' && isCovered(cursor, current.markup.position)) { finder.current = current.markup; + } else if (typeof current.markup === 'string') { + // The parser left the output markup as a raw string, so the filter + // or filter-argument slot at the caret is empty (`{{ x | ^ }}`, + // `{{ x | image_url: ^ }}`). Synthesize the `LiquidVariable` subtree + // that slot expects and let the descent below walk it to the empty + // filter / argument leaf. Returns undefined for a pipe-less output + // (`{{ ^ }}`), leaving the previous behaviour intact. + const variable = synthOutputFilterSlot(current, cursor, source); + if (variable) { + finder.current = variable; + } else { + // Pipe-less raw-string output (`{{ x.^ }}`): recover the dotted + // lookup so the object-attribute provider can complete the trailing + // segment. Adopt the recovered node ONLY when it carries a trailing + // lookup segment. A bracket key recovery cannot represent + // (`{{ product[01^ }}`, `{{ x[0].^ }}`) breaks before pushing any + // segment and yields a bare name-only lookup; synthesizing that + // would let the variable-name provider wrongly complete the object + // name. Leaving finder.current unchanged keeps those cases + // returning []. + const exprStart = + current.position.start + + (source.slice(current.position.start, cursor).match(/^\{\{-?\s*/)?.[0].length ?? 0); + const recovered = recoverVariableLookup(source, exprStart, cursor); + if (recovered.type === NodeTypes.VariableLookup && recovered.lookups.length > 0) { + finder.current = recovered; + } + } } break; } case NodeTypes.LiquidVariable: { if (isNotEmpty(current.filters)) { - finder.current = last(current.filters); + // Descend into the filter the caret actually sits in, not blindly the + // last one: a caret in an earlier filter of a chain + // (`{{ x | image_url: █ | image_tag }}`) must complete against that + // filter, not the trailing one. Falls back to the last filter when + // no filter covers the caret, preserving the previous behaviour. + finder.current = + current.filters.find((filter) => isCovered(cursor, filter.position)) ?? + last(current.filters); } else { finder.current = current.expression; } @@ -302,7 +418,33 @@ function findCurrentNode( case NodeTypes.LiquidFilter: { if (isNotEmpty(current.args)) { - finder.current = last(current.args); + // Descend into the argument the caret sits in rather than blindly the + // last one; a caret in an earlier parameter of a multi-argument + // filter (`{{ x | image_url: wid█th: 1, crop: 2 }}`) must complete + // against that parameter. When no argument covers the caret we stay + // on the filter itself (the filter-name provider completes there). + const coveredArg = current.args.find((arg) => isCovered(cursor, arg.position)); + if (coveredArg) { + if ( + coveredArg.type === NodeTypes.NamedArgument && + cursor <= coveredArg.position.start + coveredArg.name.length + ) { + // The caret is in the parameter NAME. The named-parameter + // provider completes a top-level `VariableLookup` whose parent is + // this `LiquidFilter`, so synthesize that lookup here — carrying + // the typed prefix — instead of descending into the + // `NamedArgument` (which would leave the argument, not the filter, + // as the leaf's parent). + finder.current = { + type: NodeTypes.VariableLookup, + name: coveredArg.name.slice(0, cursor - coveredArg.position.start), + lookups: [], + position: { start: coveredArg.position.start, end: cursor }, + } as any as LiquidHtmlNode; + } else { + finder.current = coveredArg; + } + } } break; } @@ -339,12 +481,20 @@ function findCurrentNode( } case NodeTypes.Comparison: { - finder.current = current.right; + if (isCovered(cursor, current.right.position)) { + finder.current = current.right; + } else if (isCovered(cursor, current.left.position)) { + finder.current = current.left; + } break; } case NodeTypes.LogicalExpression: { - finder.current = current.right; + if (isCovered(cursor, current.right.position)) { + finder.current = current.right; + } else if (isCovered(cursor, current.left.position)) { + finder.current = current.left; + } break; } @@ -367,22 +517,72 @@ function findCurrentNode( } case NodeTypes.ContentForMarkup: { - if (isNotEmpty(current.args)) { - finder.current = last(current.args); + const coveredArg = isNotEmpty(current.args) + ? current.args.find((arg) => isCovered(cursor, arg.position)) + : undefined; + if (coveredArg) { + if ( + coveredArg.type === NodeTypes.NamedArgument && + cursor <= coveredArg.position.start + coveredArg.name.length + ) { + // The caret is in the parameter NAME. The content_for parameter + // provider completes a top-level `VariableLookup` whose parent is + // this `ContentForMarkup`, so synthesize that lookup here — + // carrying the typed prefix — instead of descending into the + // `NamedArgument` (which would leave the argument, not the markup, + // as the leaf's parent). + finder.current = { + type: NodeTypes.VariableLookup, + name: coveredArg.name.slice(0, cursor - coveredArg.position.start), + lookups: [], + position: { start: coveredArg.position.start, end: cursor }, + } as any as LiquidHtmlNode; + } else { + finder.current = coveredArg; + } } else if (isCovered(cursor, current.contentForType.position)) { finder.current = current.contentForType; + } else if (isCovered(cursor, current.position)) { + // The caret sits in the argument slot after the block name + // (`{% content_for "b", ^ %}`); the parser recovered the markup but + // left the slot empty. Synthesize the argument lookup the + // content_for parameter provider completes against. + const partial = source + .slice(current.position.start, cursor) + .match(/([a-zA-Z_][\w-]*)$/)?.[1]; + finder.current = synthVariableLookup(cursor, partial); } break; } - case NodeTypes.RenderMarkup: { + // `block` and `section` markup carry a name plus optional named + // arguments, so we walk them the same way as `content_for`. + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: { if (isNotEmpty(current.args)) { + const arg = last(current.args); + finder.current = isBlockArrayArgument(arg) ? arg.value.elements.at(-1) : arg; + } else if (isCovered(cursor, current.name.position)) { + finder.current = current.name; + } + + break; + } + + case NodeTypes.RenderMarkup: { + if (isNotEmpty(current.args) && isCovered(cursor, last(current.args).position)) { 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; + } else if (isCovered(cursor, current.position)) { + // The caret sits in the argument slot after the snippet name + // (`{% render 'snip', ^ %}`); the markup parsed but the slot is + // empty. Synthesize the argument lookup the render-snippet parameter + // provider completes against. + finder.current = synthVariableLookup(cursor); } break; @@ -394,9 +594,20 @@ function findCurrentNode( } case NodeTypes.Range: { - // This means you can't complete the start range as a variable... - // is this bad? - finder.current = current.end; + if (isCovered(cursor, current.end.position)) { + finder.current = current.end; + } else if (isCovered(cursor, current.start.position)) { + finder.current = current.start; + } + break; + } + + // Once the cursor lands inside a `LiquidErrorNode` the finder alone + // cannot say what should be completed there; the D3 intent resolver + // takes over. For now this is a terminal arm (kept out of `assertNever` + // for type-exhaustiveness) and the fallback below maps it to + // `undefined` plus the error node's ancestry. + case NodeTypes.LiquidErrorNode: { break; } @@ -421,16 +632,1192 @@ function findCurrentNode( } } - return [finder.stack.pop()!, finder.stack as NonEmptyArray]; + /* + * Error-node fallback: the D3 intent resolver. When the deepest covering + * node is a `LiquidErrorNode`, the finder alone cannot say what to complete, + * so we reconstruct the intent from the error node and hand it to the + * resolver, which synthesizes the typed node the providers expect. + */ + if (finder.current?.type === NodeTypes.LiquidErrorNode) { + const match = findErrorNodeAtOffset(partialAst, cursor); + const errorNode = match ? match.node : finder.current; + const ancestors = match ? match.ancestors : finder.stack.slice(0, -1).filter(isDefined); + const resolved = resolveErrorNodeCompletion(errorNode, ancestors, cursor, source); + if (resolved[0] !== undefined) return resolved; + + /* + * The frozen resolver could not classify this error node. Fall through to + * the HTML tag-name / attr-name / dangling-close recovery (D5). Its arms + * only match `<`-constructs, so a non-HTML error node still yields the + * frozen resolver's `[undefined, ancestors]`. + */ + return resolveHtmlErrorNodeCompletion(errorNode, ancestors, cursor, source) ?? resolved; + } + + const node = finder.stack.pop()!; + const ancestors = finder.stack as NonEmptyArray; + + // The descent bottomed out without a node (e.g. a dangling `{% e^` inside an + // open block), but the caret may still sit on an unclosed tag name. Recover + // the tag carrying the typed partial so the provider can offer tag names + // (and, via the accumulated ancestry, the matching `end` tag). + if (node === undefined) { + const recovered = synthTagNameNode(source, cursor); + if (recovered) { + return [recovered, ancestors]; + } + + // Inside an open `{% liquid %}` block each newline begins a new inner tag, + // so a caret on a body line completes an inner tag even though the descent + // found no `LiquidTag` there. Recover it from the current line; the + // ancestry already carries the enclosing `{% liquid %}` tag. + const innerTag = recoverLiquidInnerTag(source, cursor, ancestors); + if (innerTag) { + return innerTag; + } + } + + return [node, ancestors]; +} + +/* + * The D3 intent resolver. When the cursor lands inside a `LiquidErrorNode` the + * finder cannot say what to complete, so we reconstruct the intent from the + * error node plus a bounded backward scan of the source and synthesize the + * typed node the providers expect (with an ancestry that satisfies their + * `ancestors.at(-1)` guards). + * + * This narrowed resolver covers the error-node-reachable positions: the + * `{% doc %}` tag handles, and the unterminated-string positions of + * `{% render %}` (snippet names) and `{{ ... }}` output (translation keys). + * Anything it cannot classify keeps the previous behaviour — no completion, + * but the error node's ancestry is preserved. The closed empty-slot + * (string-markup) positions carry no error node and are handled in D3b. + */ +function resolveErrorNodeCompletion( + errorNode: LiquidHtmlNode, + ancestors: LiquidHtmlNode[], + cursor: number, + source: string, +): [node: LiquidHtmlNode | undefined, ancestry: LiquidHtmlNode[]] { + const start = errorNode.position?.start ?? 0; + const construct = source.slice(start, cursor); + + const docOpen = construct.match(/^\s*\{%-?\s*doc\s*-?%\}/); + if (docOpen) { + return resolveLiquidDocCompletion(start + docOpen[0].length, ancestors, cursor, source); + } + + // Output context: an unclosed `{{` sits before the caret. The bounded scan + // (rather than a `/^\s*\{\{/` anchor) also catches the HTML-embedded forms + // whose construct starts with the surrounding tag (` filter -> + // argument subtree the filter providers complete against. Returns undefined + // for a non-filter slot (no pipe, or a trailing value/string slot), leaving + // the bare-lookup recovery below intact. + const filterSlot = synthUnclosedOutputFilter(source, start + open + 2, cursor, ancestors); + if (filterSlot) { + return filterSlot; + } + + // Otherwise the caret sits in an empty or dotted variable lookup + // (`{{ ^`, `{{ a^`, `{{ a.^`, `{{ a['^`, `{{ a.b.^`): recover the lookup + // carrying the name/lookups typed after `{{` so the providers can filter on + // it, rather than the blank placeholder. `open + 2` skips the `{{` so the + // back-scan starts inside the output. + return [ + recoverVariableLookup(source, start + open + 2, cursor), + [...ancestors, synthNode(NodeTypes.LiquidVariable, cursor)], + ]; + } + + // `{% render "product` — an unterminated string offers snippet names. + const openString = unterminatedString(construct, start); + if (openString && /^\s*\{%-?\s*render\b/.test(construct)) { + return [ + synthString(openString, cursor), + [...ancestors, synthNode(NodeTypes.RenderMarkup, cursor)], + ]; + } + + // An unclosed tag open (`{% comm^`, `{% ^`, `{%- if^`) parses as an error + // node rather than a `LiquidTag`. Recover the tag carrying the typed partial + // so tag-name completion still fires when the caret is on the name. When the + // caret's tag is preceded by an unclosed raw tag (`{% comment %}…{% e^`), we + // also recover the raw tag into the ancestry so the provider can offer its + // matching `end` tag. + const recovered = synthTagNameNode(source, cursor); + if (recovered) { + return [recovered, synthRawTagAncestry(source, cursor, ancestors)]; + } + + // An unclosed `{% liquid %}` block collapses to a single error node, so a + // caret on one of its body lines lands here. Recover the inner tag from the + // current line, appending the covering `{% liquid %}` tag the error-node + // ancestry does not carry. + const innerTag = recoverLiquidInnerTag(source, cursor, ancestors); + if (innerTag) { + return innerTag; + } + + // A caret inside an unclosed HTML tag's attribute value (``) do not hide the tag-name context. + const closeTagName = htmlCloseTagNameAtCursor(source, cursor); + if (closeTagName) { + const { partial, partialStart, tagOpen } = closeTagName; + const openName = matchingOpenTagName(source.slice(start, tagOpen)); + if (!openName) { + const leaf = { + type: NodeTypes.TextNode, + value: partial, + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + const danglingClose = { + type: NodeTypes.HtmlDanglingMarkerClose, + name: [leaf], + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + return [leaf, [...ancestors, danglingClose]]; + } + + if (!openName || !openName.startsWith(partial) || openName === partial) { + return undefined; + } + + const leaf = { + type: NodeTypes.TextNode, + value: partial, + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + const danglingClose = { + type: NodeTypes.HtmlDanglingMarkerClose, + name: [leaf], + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + const element = { + type: NodeTypes.HtmlElement, + name: [ + { + type: NodeTypes.TextNode, + value: openName, + position: { start, end: start }, + }, + ], + attributes: [], + children: [], + position: { start, end: cursor }, + } as any as LiquidHtmlNode; + return [leaf, [...ancestors, element, danglingClose]]; + } + + // Arm 2 — attribute-name slot (`` before the caret. The partial anchors on a trailing identifier, so a + // caret after a space or a `%}` yields an empty partial (the full attr list). + const attrOpen = construct.match(/^<([a-zA-Z][\w:-]*)\s/); + if (attrOpen && construct.indexOf('>') === -1) { + const tagName = attrOpen[1]; + const partial = construct.match(/[a-zA-Z_:][\w:.-]*$/)?.[0] ?? ''; + const partialStart = cursor - partial.length; + const leaf = { + type: NodeTypes.TextNode, + value: partial, + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + const attrEmpty = { + type: NodeTypes.AttrEmpty, + name: [leaf], + position: { start: partialStart, end: cursor }, + } as any as LiquidHtmlNode; + const element = { + type: NodeTypes.HtmlElement, + name: [ + { + type: NodeTypes.TextNode, + value: tagName, + position: { start: start + 1, end: start + 1 + tagName.length }, + }, + ], + attributes: [attrEmpty], + children: [], + position: { start, end: cursor }, + } as any as LiquidHtmlNode; + return [leaf, [...ancestors, element, attrEmpty]]; + } + + // Arm 3 — tag name (``. Offer the tag names starting with the typed partial. + const tagOpen = construct.match(/^<([a-zA-Z][\w:-]*)$/); + if (tagOpen) { + const tagName = tagOpen[1]; + if (VOID_ELEMENTS.includes(tagName.toLowerCase())) { + const blockStartPosition = { start, end: cursor }; + const element = { + type: NodeTypes.HtmlVoidElement, + name: tagName, + attributes: [], + blockStartPosition, + position: blockStartPosition, + source, + } as any as LiquidHtmlNode; + return [element, ancestors]; + } + + const leaf = { + type: NodeTypes.TextNode, + value: tagName, + position: { start: start + 1, end: cursor }, + } as any as LiquidHtmlNode; + const element = { + type: NodeTypes.HtmlElement, + name: [leaf], + attributes: [], + children: [], + position: { start, end: cursor }, + } as any as LiquidHtmlNode; + return [leaf, [...ancestors, element]]; + } + + return undefined; +} + +/* + * Returns the close-tag name fragment ending at the caret, bounded by the + * nearest `<` before the cursor and the nearest `>` after it. That keeps the + * recovery local to the tag-name slot while still accepting malformed close + * tags with source after the caret (``). + */ +function htmlCloseTagNameAtCursor( + source: string, + cursor: number, +): { tagOpen: number; partialStart: number; partial: string } | undefined { + const tagOpen = source.lastIndexOf('<', cursor - 1); + if (tagOpen === -1 || source[tagOpen + 1] !== '/') return undefined; + + const previousClose = source.lastIndexOf('>', cursor - 1); + if (previousClose > tagOpen) return undefined; + + const nextClose = source.indexOf('>', cursor); + const nextOpen = source.indexOf('<', cursor); + if (nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose)) return undefined; + + const prefix = source.slice(tagOpen, cursor); + const match = prefix.match(/^<\/([a-zA-Z][\w:-]*)?$/); + if (!match) return undefined; + + const partial = match[1] ?? ''; + return { + tagOpen, + partialStart: cursor - partial.length, + partial, + }; +} + +/* + * Scans an HTML construct left-to-right for the innermost unclosed open tag, + * maintaining a stack of open-tag names: `` -> `['h1']`), so deeper + * nesting is best-effort (innermost-unclosed wins). + */ +function matchingOpenTagName(region: string): string | undefined { + const stack: string[] = []; + const tagPattern = /<(\/?)([a-zA-Z][\w:-]*)/g; + let match: RegExpExecArray | null; + while ((match = tagPattern.exec(region))) { + const [, slash, name] = match; + if (slash) { + const openIndex = stack.lastIndexOf(name); + if (openIndex !== -1) stack.splice(openIndex); + } else { + stack.push(name); + } + } + return stack.length > 0 ? stack[stack.length - 1] : undefined; +} + +/* + * Inside an unclosed `{% doc %}` tag we synthesize a `TextNode` holding the + * text of the current line (leading whitespace stripped), which is what the + * LiquidDoc providers read to classify an `@`-handle or an `@param {type}`. + * The synthesized parent is the enclosing `doc` raw tag their guards expect. + */ +function resolveLiquidDocCompletion( + contentStart: number, + ancestors: LiquidHtmlNode[], + cursor: number, + source: string, +): [node: LiquidHtmlNode, ancestry: LiquidHtmlNode[]] { + const lineStart = Math.max(contentStart, source.lastIndexOf('\n', cursor - 1) + 1); + const value = source.slice(lineStart, cursor).replace(/^\s+/, ''); + const textNode = { + type: NodeTypes.TextNode, + value, + position: { start: cursor, end: cursor }, + } as any as LiquidHtmlNode; + const docTag = { + type: NodeTypes.LiquidRawTag, + name: 'doc', + position: { start: contentStart, end: cursor }, + } as any as LiquidHtmlNode; + return [textNode, [...ancestors, docTag]]; +} + +/* + * Scans `text` (a construct's source from its start up to the caret) and, if + * the caret sits inside an unterminated string literal, returns that string's + * absolute start offset, its content so far, and whether it is single-quoted. + */ +function unterminatedString( + text: string, + offset: number, +): { start: number; value: string; single: boolean } | undefined { + let quote: "'" | '"' | undefined; + let startIndex = -1; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + if (ch === quote) { + quote = undefined; + startIndex = -1; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + startIndex = i; + } + } + + if (!quote) return undefined; + return { + start: offset + startIndex, + value: text.slice(startIndex + 1), + single: quote === "'", + }; +} + +/* + * Scans `construct` (an error node's source up to the caret) for a caret + * sitting inside an unclosed HTML tag's quoted attribute value. Returns the + * tag name, attribute name, the value typed so far, and the quote style, or + * `undefined` when the caret is not in that position. + */ +function unterminatedAttrValue( + construct: string, +): { tagName: string; attrName: string; value: string; single: boolean } | undefined { + const tagOpen = construct.lastIndexOf('<'); + if (tagOpen === -1 || construct.indexOf('>', tagOpen) !== -1) return undefined; + + const tagStr = construct.slice(tagOpen); + const tagName = tagStr.match(/^<([a-zA-Z][\w:-]*)/); + if (!tagName) return undefined; + + // A trailing `attr="value` (or single-quoted) with no closing quote before + // the caret. The value run stops at the quote char, so anchoring to the end + // guarantees the matched quote is the one still open at the caret. + const attr = tagStr.match(/([a-zA-Z_:][\w:.-]*)\s*=\s*(["'])((?:(?!\2).)*)$/); + if (!attr) return undefined; + + return { + tagName: tagName[1], + attrName: attr[1], + value: attr[3], + single: attr[2] === "'", + }; +} + +/* + * Reconstructs the HTML attribute-value context lost when an unclosed tag + * collapses to a bare error node. The value typed so far becomes a `TextNode`; + * it is wrapped in a synthesized `AttrDoubleQuoted` / `AttrSingleQuoted` whose + * `value` holds that same node (the provider's `value.includes(node)` is an + * identity check) and a named `HtmlElement` parent so `getCompoundName` + * resolves the tag name. Ancestry is root-first, so the element then the + * attribute are appended last for the provider's leaf-ward `findLast`. + */ +function resolveHtmlAttrValueCompletion( + construct: string, + ancestors: LiquidHtmlNode[], + cursor: number, +): [node: LiquidHtmlNode, ancestry: LiquidHtmlNode[]] | undefined { + const attr = unterminatedAttrValue(construct); + if (!attr) return undefined; + + const valueStart = cursor - attr.value.length; + const textNode = { + type: NodeTypes.TextNode, + value: attr.value, + position: { start: valueStart, end: cursor }, + } as any as LiquidHtmlNode; + + const attributeNode = { + type: attr.single ? NodeTypes.AttrSingleQuoted : NodeTypes.AttrDoubleQuoted, + name: [ + { + type: NodeTypes.TextNode, + value: attr.attrName, + position: { start: valueStart, end: valueStart }, + }, + ], + value: [textNode], + attributePosition: { start: valueStart, end: cursor }, + position: { start: valueStart, end: cursor }, + } as any as LiquidHtmlNode; + + const tagNode = { + type: NodeTypes.HtmlElement, + name: [ + { + type: NodeTypes.TextNode, + value: attr.tagName, + position: { start: valueStart, end: valueStart }, + }, + ], + attributes: [attributeNode], + children: [], + position: { start: valueStart, end: cursor }, + } as any as LiquidHtmlNode; + + return [textNode, [...ancestors, tagNode, attributeNode]]; +} + +/* + * Synthesizes a `String` node spanning from the opening quote to the caret, + * carrying the text typed so far. Read by the render-snippet and translation + * providers via `node.value`. + */ +function synthString( + str: { start: number; value: string; single: boolean }, + cursor: number, +): LiquidHtmlNode { + return { + type: NodeTypes.String, + value: str.value, + single: str.single, + position: { start: str.start, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * A minimal typed node used purely to carry `type` so a provider's + * `ancestors.at(-1)` guard resolves. Positioned at the caret. + */ +function synthNode(type: NodeTypes, cursor: number): LiquidHtmlNode { + return { + type, + position: { start: cursor, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * Synthesizes the empty `VariableLookup` an unfinished expression slot expects + * (`{{ ^`, `{% echo ^ %}`, `{% if a > ^ %}`). It carries no name and no + * lookups; that is enough for the completion machinery, which keys off the + * node's `type`. Positioned at the caret. + */ +function synthVariableLookup(cursor: number, name = ''): LiquidHtmlNode { + return { + type: NodeTypes.VariableLookup, + name, + lookups: [], + position: { start: cursor - name.length, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * Recovers the variable lookup the caret is completing and builds a real + * `VariableLookup` carrying the name and lookups typed so far — the shape the + * object providers read — instead of the blank placeholder. Used where the + * parser leaves the expression unrecovered: an unclosed `{{` output (a document + * error node) and a tag whose markup stayed a raw string (`{% echo a.b.^ %}`). + * + * `lowerBound` is the earliest offset the lookup can start at (just past the + * `{{`, or the tag's `{%`), so the back-scan never reaches into the surrounding + * markup. From the caret we walk back over the lookup token — identifier chars, + * `.`, and bracket/quote chars — then split it into a leading name plus its + * `.prop` / `['key']` segments. A trailing `.` or `[` leaves an empty final + * segment, which is exactly the "typing the next property" placeholder the + * attribute provider offers against. When nothing has been typed (an empty + * slot: `{{ ^`, `{% echo ^ %}`) we fall back to the blank lookup, preserving + * the placeholder behaviour. + */ +function recoverVariableLookup(source: string, lowerBound: number, cursor: number): LiquidHtmlNode { + let start = cursor; + while (start > lowerBound && /[\w.\-\[\]'"]/.test(source[start - 1])) { + start -= 1; + } + + const token = source.slice(start, cursor); + const nameMatch = token.match(/^[a-zA-Z_][\w-]*/); + if (!nameMatch) return synthVariableLookup(cursor); + + const name = nameMatch[0]; + const lookups: LiquidHtmlNode[] = []; + let i = name.length; + while (i < token.length) { + const ch = token[i]; + if (ch === '.') { + // A `.prop` segment. A trailing `.` matches no identifier, so `seg` is the + // empty-valued placeholder the attribute provider completes against. + i += 1; + const seg = (token.slice(i).match(/^[a-zA-Z_][\w-]*/) ?? [''])[0]; + const segStart = start + i; + lookups.push(synthStringLookup(seg, false, segStart, segStart + seg.length)); + i += seg.length; + } else if (ch === '[') { + // A `['key']` / `["key"]` segment, possibly still unclosed (`a['b`). The + // bracket routes to a lookup (never a string); we carry the key so far. + i += 1; + const quote = token[i]; + if (quote === "'" || quote === '"') { + i += 1; + const seg = (token.slice(i).match(quote === "'" ? /^[^']*/ : /^[^"]*/) ?? [''])[0]; + const segStart = start + i; + lookups.push(synthStringLookup(seg, quote === "'", segStart, segStart + seg.length)); + i += seg.length; + if (token[i] === quote) i += 1; + if (token[i] === ']') i += 1; + } else { + const segmentStart = start + i; + const segment = (token.slice(i).match(/^(?:[a-zA-Z_][\w-]*|\d+)/) ?? [''])[0]; + if (segment === 'true' || segment === 'false') break; + if (!segment || token[i + segment.length] !== ']') break; + lookups.push(synthBracketLookup(segment, segmentStart, segmentStart + segment.length)); + i += segment.length + 1; + } + } else { + break; + } + } + + return { + type: NodeTypes.VariableLookup, + name, + lookups, + position: { start, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * A `String` lookup segment (`.prop` / `['key']`) carrying the property name + * typed so far, mirroring the `String` nodes the parser puts in a + * `VariableLookup`'s `lookups`. The attribute provider keys off `value` and + * `type` to resolve and offer the next property. + */ +function synthStringLookup( + value: string, + single: boolean, + start: number, + end: number, +): LiquidHtmlNode { + return { + type: NodeTypes.String, + value, + single, + position: { start, end }, + } as any as LiquidHtmlNode; +} + +function synthBracketLookup(value: string, start: number, end: number): LiquidHtmlNode { + if (/^\d+$/.test(value)) { + return { + type: NodeTypes.Number, + value, + position: { start, end }, + } as any as LiquidHtmlNode; + } + + return { + type: NodeTypes.VariableLookup, + name: value, + lookups: [], + position: { start, end }, + } as any as LiquidHtmlNode; +} + +/* + * Recovers the `LiquidTag` the caret sits on when the tag is unclosed and the + * caret is still on the name (`{% comm^`, `{% ^`, `{%- if^`, `{% for^ ...`). + * The parser leaves these as error nodes, so we synthesize the tag carrying the + * typed name as `name` (with a span from `{%` to the caret) — the shape + * `completionPartial` and the tag providers read. + * + * The anchored `$` on the name match is the "caret on the name" guard: it holds + * only while nothing but the name has been typed after `{%`, so expression-slot + * carets (`{% for i in (1..3)^`) fall through to no completion. + */ +function synthTagNameNode(source: string, cursor: number): LiquidHtmlNode | undefined { + const open = source.lastIndexOf('{%', cursor); + if (open === -1) return undefined; + + // The tag must be unclosed up to the caret; a `%}` before the caret means we + // are past the tag start and should not recover a name. + const close = source.indexOf('%}', open); + if (close !== -1 && close < cursor) return undefined; + + const construct = source.slice(open, cursor); + const match = construct.match(/^\{%(-?)\s*([a-zA-Z_][\w-]*)?$/); + if (!match) return undefined; + + return { + type: NodeTypes.LiquidTag, + name: match[2] ?? '', + markup: '', + whitespaceStart: match[1], + whitespaceEnd: '', + position: { start: open, end: cursor }, + blockStartPosition: { start: open, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * When the caret sits on a bare `{% end^`/`{% e^`/`{% ^` tag whose enclosing + * raw tag (`{% comment %}`, `{% javascript %}`, …) was never closed, the + * resilient parser emits sibling `LiquidErrorNode`s rather than a raw + * `LiquidTag`, so the provider's raw-tag branch has no sibling to key its + * `end` on. We detect the unclosed raw opener from the source before the + * caret's `{%` and synthesize the raw `LiquidTag` into a parent whose children + * carry it — the shape the provider's raw-tag branch expects — so `endcomment` + * / `endjavascript` are offered. When there is no unclosed raw opener the + * ancestry is returned unchanged. + */ +function synthRawTagAncestry( + source: string, + cursor: number, + ancestors: LiquidHtmlNode[], +): LiquidHtmlNode[] { + const open = source.lastIndexOf('{%', cursor); + if (open === -1) return ancestors; + + const opener = lastUnclosedRawTag(source.slice(0, open)); + if (!opener) return ancestors; + + const rawTag = { + type: NodeTypes.LiquidTag, + name: opener.name, + markup: '', + position: { start: opener.start, end: open }, + blockStartPosition: { start: opener.start, end: opener.end }, + } as any as LiquidHtmlNode; + + const parent = { + type: NodeTypes.Document, + children: [rawTag], + position: { start: opener.start, end: cursor }, + } as any as LiquidHtmlNode; + + return [...ancestors, parent]; +} + +/* + * Scans `text` (source from the document start up to the caret's `{%`) for the + * last raw-tag opener that has no matching `{% end %}` after it. Returns + * that opener's name and span, or undefined when every raw tag is closed. Tag + * openers inside a raw body (e.g. `{% if %}` printed in a `{% comment %}`) are + * neither raw openers nor the current opener's end tag, so they are ignored. + */ +function lastUnclosedRawTag( + text: string, +): { name: string; start: number; end: number } | undefined { + const re = /\{%-?\s*(\w+)\s*-?%\}/g; + let match: RegExpExecArray | null; + let found: { name: string; start: number; end: number } | undefined; + while ((match = re.exec(text)) !== null) { + const name = match[1]; + if (RAW_TAGS.includes(name)) { + found = { name, start: match.index, end: re.lastIndex }; + } else if (found && name === `end${found.name}`) { + found = undefined; + } + } + return found; +} + +/* + * Inside a `{% liquid %}` block the body carries no per-line `{% %}` + * delimiters: each newline begins a new inner tag. When the caret sits on such + * a body line the parser leaves no `LiquidTag` there to complete — an unclosed + * block collapses to a single `LiquidErrorNode`, and a closed block bottoms the + * descent out on a covering node. Either way we synthesize the inner + * `LiquidTag` carrying the identifier typed on the current line so the tag + * provider offers inner tag names in liquid-tag form. + * + * When the incoming ancestry does not already carry the enclosing + * `{% liquid %}` tag we append it. That both marks the liquid-tag context (so + * the snippet drops its `{%`/`%}`) and makes the liquid block the parent, which + * suppresses the `end*` offering the block's own grammar forbids. + * + * Returns undefined when the caret is not on a liquid body line — no enclosing + * open block, the opener line itself, or an expression slot rather than a tag + * name — leaving the previous no-completion behaviour intact. + */ +function recoverLiquidInnerTag( + source: string, + cursor: number, + ancestors: LiquidHtmlNode[], +): [node: LiquidHtmlNode, ancestry: LiquidHtmlNode[]] | undefined { + const block = enclosingLiquidBlock(source, cursor); + if (!block) return undefined; + + const lineStart = source.lastIndexOf('\n', cursor - 1) + 1; + // The opener line (`{% liquid`) is the tag name itself, not a body line. + if (lineStart <= block.bodyStart) return undefined; + + const leading = source.slice(lineStart, cursor).match(/^\s*/)![0].length; + const nameStart = lineStart + leading; + const typed = source.slice(nameStart, cursor); + + // Recover only while the caret is on the inner tag name (or an empty line); + // an expression slot (`echo `, `if a > `) is not a tag-name position. + if (typed !== '' && !/^[a-zA-Z_][\w-]*$/.test(typed)) return undefined; + + const node = { + type: NodeTypes.LiquidTag, + name: typed, + markup: '', + whitespaceStart: '', + whitespaceEnd: '', + position: { start: nameStart, end: cursor }, + blockStartPosition: { start: nameStart, end: cursor }, + } as any as LiquidHtmlNode; + + const ancestry = ancestors.some(isLiquidLiquidTag) ? ancestors : [...ancestors, block.liquidTag]; + + return [node, ancestry]; +} + +/* + * Finds the `{% liquid %}` block enclosing the caret and returns the offset + * just past its `liquid` opener plus a synthesized covering `LiquidTag#liquid`. + * The block encloses the caret when the nearest `{% liquid` before it has no + * closing `%}` before the caret — an unclosed block never closes, a closed one + * closes after the caret. Returns undefined otherwise. + */ +function enclosingLiquidBlock( + source: string, + cursor: number, +): { bodyStart: number; liquidTag: LiquidHtmlNode } | undefined { + const re = /\{%-?\s*liquid\b/g; + let match: RegExpExecArray | null; + let opener: RegExpExecArray | undefined; + while ((match = re.exec(source)) !== null && match.index < cursor) { + opener = match; + } + if (!opener) return undefined; + + const openStart = opener.index; + const bodyStart = openStart + opener[0].length; + const close = source.indexOf('%}', bodyStart); + if (close !== -1 && close < cursor) return undefined; + + const liquidTag = { + type: NodeTypes.LiquidTag, + name: 'liquid', + markup: '', + position: { start: openStart, end: close === -1 ? cursor : close + 2 }, + blockStartPosition: { start: openStart, end: bodyStart }, + } as any as LiquidHtmlNode; + + return { bodyStart, liquidTag }; +} + +/* + * For a tag, branch, or output whose markup the parser left as a raw string, + * the expression slot at the caret is empty or unfinished. We synthesize the + * node that slot expects: the snippet-name slot of `render`/`content_for` wants + * a `String` (snippet names are offered there), while every other slot wants a + * `VariableLookup`. + */ +function synthMarkupSlot(node: any, cursor: number, source: string): LiquidHtmlNode { + const name = node?.name; + if (name === 'render' || name === 'content_for') { + const afterName = source.slice(node.position.start, cursor).replace(/^\{%-?\s*\S+\s*/, ''); + if (afterName.trim() === '') { + return synthString({ start: cursor, value: '', single: false }, cursor); + } + } + // Every other slot wants a `VariableLookup`. The parser only leaves markup as + // a raw string when the expression is unfinished, so a dotted lookup with a + // trailing `.` (`{% echo product.metafields.^ %}`) lands here — recover the + // name/lookups typed so far so the attribute provider can resolve them, + // rather than the blank placeholder. + return recoverVariableLookup(source, node.position.start, cursor); +} + +// Rebuilds the `contentForType` String node for a `content_for` tag whose +// markup the parser left as a raw string (a bare partial arg name with no +// colon). Returns `undefined` while the caret is still in/at the block-name +// slot (no comma yet), so it never misfires on block-name completion. +function synthContentForType( + node: any, + cursor: number, + source: string, +): LiquidHtmlNode | undefined { + const region = source.slice(node.position.start, cursor); + const m = region.match(/^\{%-?\s*content_for\s+(['"])([^'"]*)\1\s*,/); + if (!m) return undefined; + const quote = m[1]; + const value = m[2]; + const start = node.position.start + m[0].indexOf(quote); + return { + type: NodeTypes.String, + value, + single: quote === "'", + position: { start, end: start + value.length + 2 }, + } as any as LiquidHtmlNode; +} + +/* + * For a `{{ ... }}` output whose markup the parser left as a raw string, the + * filter or filter-argument slot after a pipe is empty (`{{ x | ^ }}`, + * `{{ x | upcase | ^ }}`, `{{ x | image_url: ^ }}`). We synthesize the + * `LiquidVariable` subtree that slot expects — the recovered expression plus a + * completed filter chain plus a trailing empty filter — so the descent below + * bottoms out on the empty filter (a bare pipe: the filter-name provider + * completes there) or on the filter's empty argument lookup (after `name:`: + * the named-parameter provider completes there). + * + * Returns undefined for a pipe-less output (`{{ ^ }}`, `{{ a.b^ }}`), leaving + * the previous behaviour intact — those object slots are handled elsewhere. + */ +function synthOutputFilterSlot( + output: any, + cursor: number, + source: string, +): LiquidHtmlNode | undefined { + const open = output.position.start; + const exprStart = open + (source.slice(open, cursor).match(/^\{\{-?\s*/)?.[0].length ?? 0); + const region = source.slice(exprStart, cursor); + const lastPipe = region.lastIndexOf('|'); + if (lastPipe === -1) return undefined; + + const firstPipe = region.indexOf('|'); + const firstPipeStart = exprStart + firstPipe; + const lastPipeStart = exprStart + lastPipe; + + // The expression is everything before the first pipe; recover it (trailing + // whitespace trimmed) so the synthesized variable carries the real lookup. + let exprEnd = firstPipeStart; + while (exprEnd > exprStart && /\s/.test(source[exprEnd - 1])) exprEnd -= 1; + const expression = recoverVariableLookup(source, exprStart, exprEnd); + const completedFilters = synthCompletedFilters(source, firstPipeStart + 1, lastPipeStart); + + // The text after the last pipe decides the slot: `name:` is an empty named + // argument (so we descend to the argument lookup), anything else is an empty + // filter slot (so we stop on the filter itself). + const tail = region.slice(lastPipe + 1); + const named = tail.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*$/); + const filter = named + ? synthFilter(named[1], [synthVariableLookup(cursor)], cursor, source) + : synthFilter('', [], cursor, source); + + return { + type: NodeTypes.LiquidVariable, + expression, + filters: [...completedFilters, filter], + position: { start: exprStart, end: cursor }, + source, + } as any as LiquidHtmlNode; +} + +/* + * Recovers completed filters before the trailing completion pipe in raw output + * markup (`{{ string | split: "" | ^ }}`). The filter-name provider infers the + * input type by cloning the parent variable and removing only the completing + * filter, so the synthesized parent must retain the already-completed chain. + * Arguments are not inspected for normal filter return types; names and source + * spans are enough for the type system to resolve each completed filter. + */ +function synthCompletedFilters(source: string, start: number, end: number): LiquidHtmlNode[] { + const filters: LiquidHtmlNode[] = []; + let segmentStart = start; + let quote: "'" | '"' | undefined; + + for (let i = start; i <= end; i++) { + const ch = source[i]; + if (quote) { + if (ch === quote) quote = undefined; + continue; + } + + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + + if (i === end || ch === '|') { + const filter = synthCompletedFilter(source, segmentStart, i); + if (filter) filters.push(filter); + segmentStart = i + 1; + } + } + + return filters; +} + +function synthCompletedFilter( + source: string, + segmentStart: number, + segmentEnd: number, +): LiquidHtmlNode | undefined { + let start = segmentStart; + while (start < segmentEnd && /\s/.test(source[start])) start += 1; + + const match = source.slice(start, segmentEnd).match(/^([a-zA-Z_][\w-]*)/); + if (!match) return undefined; + + const name = match[1]; + return { + type: NodeTypes.LiquidFilter, + name, + args: [], + source, + position: { start, end: segmentEnd }, + } as any as LiquidHtmlNode; +} + +/* + * Synthesizes a `LiquidFilter` at the caret. An empty `args` array marks an + * empty filter slot (the filter-name provider completes against the filter + * itself); a single synthesized lookup in `args` marks an empty named-argument + * slot (the descent walks into that lookup). `name` holds the filter name the + * slot resolves parameters for, and `source` is the full document source the + * filter provider reads when building its text edits. + */ +function synthFilter( + name: string, + args: LiquidHtmlNode[], + cursor: number, + source: string, +): LiquidHtmlNode { + return { + type: NodeTypes.LiquidFilter, + name, + args, + source, + position: { start: cursor, end: cursor }, + } as any as LiquidHtmlNode; +} + +/* + * For an UNCLOSED `{{ ... | ... ` output (a document error node, so + * `synthOutputFilterSlot` on the closed path never runs), the filter or + * filter-argument slot after a pipe carries the partial the caret is typing. + * We synthesize the `LiquidVariable` -> `LiquidFilter` -> argument subtree the + * filter providers complete against, carrying that partial — which the closed + * helper deliberately drops. `synthOutputFilterSlot` is left untouched. + * + * `exprStart` is the offset just past the `{{`. The expression is everything + * before the first pipe (recovered like `synthOutputFilterSlot`, prior filters + * in a chain dropped). The text after the LAST pipe (`tail`) selects the shape: + * + * - `name: partial` (no comma): an argument slot on a single-argument filter. + * The leaf is the argument `VariableLookup` carrying `partial`. + * - `name: a, b, partial` (a comma): an argument slot on a multi-argument + * filter. The filter name is the first identifier before the first colon; + * the completing slot is the token after the LAST comma. This only fires + * when that trailing slot is a bare identifier (or empty) — never inside a + * value or string (`crop: '^'`), which falls through so no filter-parameter + * completion is offered there. + * - `partial` (no colon): a bare filter-name slot. The leaf is the + * `LiquidFilter` itself (the filter-name provider completes there). + * + * Returns undefined for a pipe-less output or a trailing value/string slot, + * leaving the bare-lookup recovery intact. + */ +function synthUnclosedOutputFilter( + source: string, + exprStart: number, + cursor: number, + ancestors: LiquidHtmlNode[], +): [node: LiquidHtmlNode, ancestry: LiquidHtmlNode[]] | undefined { + const region = source.slice(exprStart, cursor); + const lastPipe = region.lastIndexOf('|'); + if (lastPipe === -1) return undefined; + + let exprEnd = exprStart + region.indexOf('|'); + while (exprEnd > exprStart && /\s/.test(source[exprEnd - 1])) exprEnd -= 1; + const expression = recoverVariableLookup(source, exprStart, exprEnd); + + const tail = region.slice(lastPipe + 1); + + // Builds the argument leaf carrying the typed partial. An empty slot reuses + // the blank `synthVariableLookup`; a partial carries its own name-token span + // so `completionPartial` recovers exactly what was typed. + const synthArg = (ident: string): LiquidHtmlNode => + ident === '' + ? synthVariableLookup(cursor) + : ({ + type: NodeTypes.VariableLookup, + name: ident, + lookups: [], + position: { start: cursor - ident.length, end: cursor }, + } as any as LiquidHtmlNode); + + const buildVariable = (filter: LiquidHtmlNode): LiquidHtmlNode => + ({ + type: NodeTypes.LiquidVariable, + expression, + filters: [filter], + position: { start: exprStart, end: cursor }, + source, + }) as any as LiquidHtmlNode; + + // Multi-argument filter (`name: a, b, ^`). + if (tail.includes(',')) { + const nameMatch = tail.slice(0, tail.indexOf(':')).match(/[a-zA-Z_][\w-]*/); + const filterName = nameMatch ? nameMatch[0] : ''; + const segment = tail.slice(tail.lastIndexOf(',') + 1); + if (!/^\s*([a-zA-Z_][\w-]*)?\s*$/.test(segment)) return undefined; + + const identMatch = segment.match(/[a-zA-Z_][\w-]*/); + const arg = synthArg(identMatch ? identMatch[0] : ''); + const filter = synthFilter(filterName, [arg], cursor, source); + return [arg, [...ancestors, buildVariable(filter), filter]]; + } + + // Single-argument slot (`name: ^`, `name: partial^`). + const named = tail.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*([a-zA-Z_][\w-]*)?\s*$/); + if (named) { + const arg = synthArg(named[2] ?? ''); + const filter = synthFilter(named[1], [arg], cursor, source); + return [arg, [...ancestors, buildVariable(filter), filter]]; + } + + // Bare filter-name slot (`^`, `partial^`) — the leaf is the filter itself. + const bare = tail.match(/^\s*([a-zA-Z_][\w-]*)?\s*$/); + if (bare) { + const partial = bare[1] ?? ''; + const filter = { + type: NodeTypes.LiquidFilter, + name: partial, + args: [], + source, + position: { start: partial ? cursor - partial.length : cursor, end: cursor }, + } as any as LiquidHtmlNode; + return [filter, [...ancestors, buildVariable(filter)]]; + } + + return undefined; +} + +/* + * Whether the caret sits in the tag's expression region rather than on the tag + * name itself. Keyed on there being whitespace between the tag name and the + * caret (`{% echo ^` yes; `{% ren^` no), so caret-on-name cases keep completing + * the tag name instead of a synthesized lookup. + */ +function isInExpressionSlot(node: any, cursor: number, source: string): boolean { + // `[a-zA-Z_]\w*` (rather than `[\w-]+`) so the whitespace-control dash of + // `{%- if^` is not mistaken for a one-character tag name, which would put the + // caret past a "name" and wrongly synthesize a lookup on `{%- if^ %}`. + return /^\{%-?\s*[a-zA-Z_]\w*\s/.test(source.slice(node.position.start, cursor)); +} + +function isTrailingLiquidTagMarkupSlot(node: any, cursor: number, source: string): boolean { + const markup = source.slice(node.position.start, cursor).replace(/^\{%-?\s*[a-zA-Z_]\w*\s*/, ''); + if (!/\s$/.test(markup)) return false; + + if (node.name === 'for') { + return ( + /\bas\s+[a-zA-Z_]\w*\s*$/.test(markup) || + /\breversed\s*$/.test(markup) || + /\breversed\s+limit\s*:\s*(?:\d+(?:\.\d+)?|(['"])[^'"]*\1)\s*$/.test(markup) + ); + } + + if (node.name === 'if') { + return /\bas\s+[a-zA-Z_]\w*\s*$/.test(markup); + } + + return false; } type NonEmptyArray = [T, ...T[]]; -function hasNonNullProperty( - thing: any, +/* + * Returns the last node whose `position` covers the cursor, or undefined when + * none do. This replaces the old `last(children)` selection: on the resilient + * full AST the node under the cursor is not necessarily the trailing node, so + * we descend into the covering child instead. Typed as `any[]` so it reads + * the array off a discriminated-union node without narrowing the whole node. + */ +function covering(nodes: any[], cursor: number): LiquidHtmlNode | undefined { + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (node && node.position && isCovered(cursor, node.position)) { + return node; + } + } + return undefined; +} + +/* + * Narrows away a nullish property the way the `!!` body does at runtime. + * Generic over `T` so the predicate INTERSECTS the input (`T & { [k]: {} }`) + * rather than replacing it: for an `any`-typed caller `any & {…}` stays `any` + * (the `blockEndPosition` sites keep `.start`/`.end`), while for a concrete + * discriminated union the per-arm intersection collapses a `null` property to + * `never` (`null & {}`), stripping the `ifchanged` arm's `markup: null` at the + * `:231` completion walk. (`NonNullable` is literally `T & {}` since TS 4.8; + * the old `NonNullable` resolved to `any` and stripped nothing.) `thing` + * is unconstrained, so the `in`/index touch-points are cast to `any` — runtime + * is unchanged. + */ +function hasNonNullProperty( + thing: T, property: K, -): thing is { [k in K]: NonNullable } { - return thing !== null && property in thing && !!thing[property]; +): thing is T & { [k in K]: {} } { + return thing !== null && property in (thing as any) && !!(thing as any)[property]; } function isIncompleteBlockTag(thing: any): thing is { children: NonEmptyArray } { @@ -449,15 +1836,15 @@ function isCompletedTag(thing: any) { ); } -function hasNonEmptyArrayProperty( - thing: any, +function hasNonEmptyArrayProperty( + thing: T, property: K, -): thing is { [k in K]: NonEmptyArray } { +): thing is T & { [k in K]: NonEmptyArray } { return ( thing !== null && - property in thing && - Array.isArray(thing[property]) && - !isEmpty(thing[property]) + property in (thing as any) && + Array.isArray((thing as any)[property]) && + !isEmpty((thing as any)[property]) ); } @@ -488,6 +1875,14 @@ function isEmpty(x: any[]): x is [] { return x.length === 0; } +function isDefined(x: T | undefined): x is T { + return x !== undefined; +} + +function isBlockArrayArgument(arg: any): arg is { value: { elements: LiquidHtmlNode[] } } { + return arg.value?.type === 'BlockArrayLiteral'; +} + function last(x: NonEmptyArray): T { return x[x.length - 1]; } diff --git a/packages/theme-language-server-common/src/completions/params/fix.spec.ts b/packages/theme-language-server-common/src/completions/params/fix.spec.ts deleted file mode 100644 index 254fe0ee0..000000000 --- a/packages/theme-language-server-common/src/completions/params/fix.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fix } from './fix'; - -const scenarios = fs - .readFileSync(path.join(__dirname, './testcases.txt'), 'utf8') - .replace(/^#[^\n]*\n/gm, '') - .trim() - .split('\n\n') - .map((pairs) => pairs.split('\n')) - .map(([source, expected]) => ({ source: source.replace(/█.*/, ''), expected })); - -describe('Unit: fix', async () => { - it('closes open strings inside Liquid output', () => { - expect(fix(`{{ 'hello`)).to.equal(`{{ 'hello'}}`); - expect(fix(`{{ "hello`)).to.equal(`{{ "hello"}}`); - expect(fix(`{{ 'hello' | append: ' world`)).to.equal(`{{ 'hello' | append: ' world'}}`); - expect(fix(`{{ "hello" | append: " world`)).to.equal(`{{ "hello" | append: " world"}}`); - expect(fix(`{{ 'hello' | replace: 'hello', 'hi`)).to.equal( - `{{ 'hello' | replace: 'hello', 'hi'}}`, - ); - expect(fix(`{{ "hello" | replace: "hello", "hi`)).to.equal( - `{{ "hello" | replace: "hello", "hi"}}`, - ); - expect(fix(`{{ "don't freak out`)).to.equal(`{{ "don't freak out"}}`); - expect(fix(`{{ 'he said: "no way!`)).to.equal(`{{ 'he said: "no way!'}}`); - }); - - it('does not close strings inside text blocks', () => { - expect(fix(`don't freak outdon't freak out`, - ); - expect(fix(`a quote "a quote "`); - }); - - it('does not close parens inside text blocks', () => { - expect(fix(`([([`); - expect(fix(`])])`); - }); - - it('does not care about brackets inside strings', () => { - expect(fix(``); - expect(fix(``); - expect(fix(``); - expect(fix(`{{ "([`)).to.equal(`{{ "(["}}`); - expect(fix(`{% echo "([`)).to.equal(`{% echo "(["%}`); - }); - - it('should panic gracefully', () => { - expect(fix(`{{ "}}`)).to.equal(`{{ "}}`); - expect(fix(`{% "%}`)).to.equal(`{% "%}`); - expect(fix(``); - expect(fix(`%}">`); - }); - - it('closes multiline stuff', () => { - expect(fix(`\n{{\n\n 'hello`)).to.equal(`\n{{\n\n 'hello'}}`); - }); - - it('does not close closed strings inside Liquid output', () => { - expect(fix(`{{ 'hello'`)).to.equal(`{{ 'hello'}}`); - expect(fix(`{{ "hello"`)).to.equal(`{{ "hello"}}`); - expect(fix(`{{ 'hello' | append: ' world`)).to.equal(`{{ 'hello' | append: ' world'}}`); - expect(fix(`{{ "hello" | append: " world`)).to.equal(`{{ "hello" | append: " world"}}`); - expect(fix(`{{ 'hello' | replace: 'hello', 'hi'`)).to.equal( - `{{ 'hello' | replace: 'hello', 'hi'}}`, - ); - expect(fix(`{{ "hello" | replace: "hello", "hi"`)).to.equal( - `{{ "hello" | replace: "hello", "hi"}}`, - ); - }); - - it('appends a cursor character as a placeholder for variable lookups (to be used with completion mode parsing)', () => { - const contexts = [ - `{{ █}}`, - `{{ a.█}}`, - `{{ a[█]}}`, - `{{ a['█']}}`, - `{{ a.b.█}}`, - `{% echo █%}`, - `{% echo a.█%}`, - `{% echo a['█']%}`, - `{% assign x = █%}`, - `{% assign x = a.█%}`, - `{% assign x = a['█']%}`, - `{% for a in █%}`, - `{% for a in b reversed limit: █%}`, - `{% paginate b by █%}`, - `{% paginate b by col, window_size: █%}`, - `{% if █%}`, - `{% if a > █%}`, - `{% if a > b or █%}`, - `{% if a > b or c > █%}`, - `{% elsif a > █%}`, - `{% when █%}`, - `{% when a, █%}`, - `{% cycle █%}`, - `{% cycle 'foo', █%}`, - `{% cycle 'foo': █%}`, - `{% render 'snip', var: █%}`, - `{% render 'snip' for █%}`, - `{% render 'snip' with █%}`, - `{% for x in (1..█)%}`, - // `{% paginate █ by 50 %}`, - ``, - ``, - ``, - ``, - ``, - ]; - for (const context of contexts) { - expect(fix(context.replace(/█.*$/, ''))).to.equal(context); - } - }); - - it('appends a cursor character as a placeholder for html element names (to be used with completion mode parsing)', () => { - // prettier-ignore - const contexts = [ - `<█>`, - ``, - ]; - for (const context of contexts) { - expect(fix(context.replace(/█.*$/, ''))).to.equal(context); - } - }); - - it('appends a cursor character as a placeholder for html attribute names (to be used with completion mode parsing)', () => { - // prettier-ignore - const contexts = [ - ``, - ``, - ``, - ``, - ]; - for (const context of contexts) { - expect(fix(context.replace(/█.*$/, ''))).to.equal(context); - } - }); - - it('appends a cursor character as a placeholder for html attribute values (to be used with completion mode parsing)', () => { - // prettier-ignore - const contexts = [ - ``, - ``, - ]; - for (const context of contexts) { - expect(fix(context.replace(/█.*$/, ''))).to.equal(context); - } - }); - - describe('Case: the rest of the fucking owl', () => { - it('fixes templates properly', () => { - for (const { source, expected } of scenarios) { - expect(fix(source), source).to.eql(expected); - } - }); - }); -}); diff --git a/packages/theme-language-server-common/src/completions/params/fix.ts b/packages/theme-language-server-common/src/completions/params/fix.ts deleted file mode 100644 index 3209e4e94..000000000 --- a/packages/theme-language-server-common/src/completions/params/fix.ts +++ /dev/null @@ -1,269 +0,0 @@ -export const CURSOR = '█'; - -const SINGLE_QUOTE = `'` as const; -const DOUBLE_QUOTE = `"` as const; -const HTML_TOKENS: Token[] = ['<', '>']; -const SHOULD_IGNORE_HTML_TOKENS: Token[] = [SINGLE_QUOTE, DOUBLE_QUOTE, '{{', '{%']; - -const QUOTES: Token[] = [SINGLE_QUOTE, DOUBLE_QUOTE]; -const CONTROL_TOKENS: Token[] = ['{{', '{%', '<']; - -const TokenPairs = { - "'": "'", - '"': '"', - '{{': '}}', - '{%': '%}', - '<': '>', - '[': ']', - '(': ')', -} as const; - -type Token = (typeof TokenPairs)[keyof typeof TokenPairs] | keyof typeof TokenPairs; - -/** - * Fix the source code and return the new fixed source with the new absolute - * position - * - * @param source - source code - * @param position - absolute position - * - * @returns new fixed source - */ -export function fix(source: string, position: number = source.length): string { - const fixer = new Fixer(source, position); - return fixer.fix(); -} - -class Fixer { - public markup: string; - - /** - * A list of quotes, <, [, etc. We'll pass through that list to determine - * what needs to be closed at the end. - */ - private tokens: Token[] = []; - - /** - * A stack of closing tokens such that when we're done we simply pop - * everything out into the string to get a fixed string. - */ - private stack: Token[] = []; - private cursor: number; - - constructor(source: string, position: number) { - this.cursor = 0; - this.markup = source.slice(0, position); - } - - /** - * This is cool, so bear with me. - * - * We'll scan the entire string up to the cursor position and turn that - * into a list of tokens - * - * input: - * ` # pop close < - * - * And there we go, we have a fixed string. - */ - fix() { - this.scanTokens(); - this.buildStack(); - let markup = this.markup; - - if (this.shouldIncludeCursorPlaceholder()) { - markup += CURSOR; - } - - while (this.stack.length !== 0) { - markup += this.stack.pop(); - } - - return markup; - } - - buildStack() { - for (let token of this.tokens) { - if (this.shouldPanic(token)) { - while (token !== this.stack.pop()) {} - } else if (this.shouldSkipToken(token)) { - /* do nothing */ - } else if (this.isClosingToken(token)) { - this.stack.pop(); - } else { - const closingToken = TokenPairs[token as keyof typeof TokenPairs]; - if (closingToken) { - this.stack.push(closingToken); - } - } - } - } - - isClosingToken(token: Token) { - return this.current === token; - } - - shouldPanic(token: Token) { - const isInStringContext = QUOTES.includes(this.current as any); - return isInStringContext && this.stack.at(-2) === token; - } - - shouldSkipToken(token: Token) { - const current = this.current; - const isInTextContext = !current; - const isInStringContext = QUOTES.includes(current as any); - const isInLiquidContext = this.stack.includes('}}') || this.stack.includes('%}'); - return ( - (isInTextContext && !CONTROL_TOKENS.includes(token)) || - (isInStringContext && !CONTROL_TOKENS.includes(token) && !QUOTES.includes(token)) || - (isInLiquidContext && token === '<') || - (current === SINGLE_QUOTE && token === DOUBLE_QUOTE) || - (current === DOUBLE_QUOTE && token === SINGLE_QUOTE) || - (SHOULD_IGNORE_HTML_TOKENS.includes(current!) && HTML_TOKENS.includes(token)) - ); - } - - scanTokens() { - while (!this.isAtEnd()) { - const character = this.peek(); - switch (character) { - case DOUBLE_QUOTE: - case SINGLE_QUOTE: - case '(': - case ')': - case '[': - case ']': { - this.pushToken(character); - break; - } - - case '{': { - if (this.matchNext('%')) { - this.pushToken('{%'); - } else if (this.matchNext('{')) { - this.pushToken('{{'); - } - break; - } - - case '%': { - if (this.matchNext('}')) { - this.pushToken('%}'); - } - break; - } - - case '}': { - if (this.matchNext('}')) { - this.pushToken('}}'); - } - break; - } - - case '<': { - if (this.testNext(/[a-z{\/]/i)) { - this.pushToken('<'); - } - break; - } - - case '>': { - this.pushToken('>'); - break; - } - - default: { - /* do nothing */ - } - } - - this.advance(); - } - } - - shouldIncludeCursorPlaceholder() { - const prevCharacter = this.markup.at(-1) ?? ''; - const prevPrevCharacter = this.markup.at(-2) ?? ''; - const isInLiquidContext = this.stack.includes('%}') || this.stack.includes('}}'); - const isInHtmlContext = this.current === '>'; - const isInStringContext = QUOTES.includes(this.current as any); - return ( - (isInStringContext && this.stack.at(-2) === '>' && QUOTES.includes(prevCharacter as any)) || - (isInHtmlContext && - (/\s/.test(prevCharacter) || - prevCharacter === '<' || - (prevPrevCharacter === '<' && prevCharacter === '/') || - (prevPrevCharacter === '%' && prevCharacter === '}'))) || - (isInLiquidContext && - ((!isInStringContext && [' ', '\t', '\n', '.', '{', '[', ','].includes(prevCharacter)) || - (isInStringContext && prevPrevCharacter === '['))) - ); - } - - get current() { - return this.stack.at(-1); - } - - advance() { - this.cursor += 1; - } - - matchNext(character: string) { - if (this.peekNext() !== character) return false; - this.cursor++; - return true; - } - - testNext(regex: RegExp) { - return regex.test(this.peekNext()); - } - - peek(cursor = this.cursor) { - return this.markup[cursor]; - } - - peekNext(cursor = this.cursor) { - return this.markup[cursor + 1]; - } - - pushToken(token: Token) { - this.tokens.push(token); - } - - isAtEnd() { - return this.cursor >= this.markup.length; - } -} diff --git a/packages/theme-language-server-common/src/completions/params/index.ts b/packages/theme-language-server-common/src/completions/params/index.ts index c7fcaa309..ee1f0f9c3 100644 --- a/packages/theme-language-server-common/src/completions/params/index.ts +++ b/packages/theme-language-server-common/src/completions/params/index.ts @@ -1,2 +1 @@ export { LiquidCompletionParams, createLiquidCompletionParams } from './LiquidCompletionParams'; -export { CURSOR } from './fix'; diff --git a/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.spec.ts index cb1c6f6e1..a71025f7a 100644 --- a/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.spec.ts @@ -4,7 +4,8 @@ import { DocumentManager } from '../../documents'; import { MetafieldDefinitionMap } from '@shopify/theme-check-common'; import { TextEdit, InsertTextFormat } from 'vscode-languageserver-protocol'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { CURSOR } from '../params'; + +const CURSOR = '█'; vi.mock('./data/contentForParameterCompletionOptions', async () => { const actual = (await vi.importActual( diff --git a/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.ts index bb9392f3c..cfb9d1005 100644 --- a/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/ContentForParameterCompletionProvider.ts @@ -5,7 +5,7 @@ import { InsertTextFormat, TextEdit, } from 'vscode-languageserver'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider } from './common'; import { AugmentedLiquidSourceCode } from '../../documents'; import { DEFAULT_COMPLETION_OPTIONS } from './data/contentForParameterCompletionOptions'; @@ -35,7 +35,7 @@ export class ContentForParameterCompletionProvider implements Provider { return []; } - if (!node.name || node.lookups.length > 0) { + if (node.lookups.length > 0) { return []; } @@ -61,9 +61,15 @@ export class ContentForParameterCompletionProvider implements Provider { } } - // We need to find out existing params in the content_for tag so we don't offer it again for completion + // We need to find out existing params in the content_for tag so we don't offer it again for completion. + // Exclude the parameter the caret is currently editing, so typing over an + // existing `type:` still offers `type` as a completion. When the caret sits + // in an argument name, the finder synthesizes a `VariableLookup` starting at + // that argument's position, so matching on `position.start` identifies the + // argument being typed over. const existingParams = parentNode.args .filter((arg) => arg.type === NodeTypes.NamedArgument) + .filter((arg) => arg.position.start !== node.position.start) .map((arg) => arg.name); return completionItems.filter((item) => !existingParams.includes(item.label)); @@ -105,7 +111,7 @@ export class ContentForParameterCompletionProvider implements Provider { // options and should not replace any text. // e.g. `{% content_for "block", █type: "button" %}` // e.g. `{% content_for "block", █ %}` - if (node.name === CURSOR) { + if (node.name === '') { end = start; // If we're inserting text in front of an existing parameter then we need @@ -134,7 +140,7 @@ export class ContentForParameterCompletionProvider implements Provider { ) { let options = DEFAULT_COMPLETION_OPTIONS; - const partial = node.name!.replace(CURSOR, ''); + const partial = node.name!; if (isTypeBlocks) { options = { diff --git a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts index 334abe158..e82f70ef0 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts @@ -4,7 +4,8 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { describe, beforeEach, it, expect } from 'vitest'; import { DocumentManager } from '../../documents'; import { CompletionsProvider } from '../CompletionsProvider'; -import { CURSOR } from '../params'; + +const CURSOR = '█'; const filters: FilterEntry[] = [ { diff --git a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts index 040377298..530cfff94 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts @@ -9,7 +9,7 @@ import { import { PseudoType, TypeSystem, isArrayType } from '../../TypeSystem'; import { memoize } from '../../utils'; import { AugmentedLiquidSourceCode } from '../../documents'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem, sortByName } from './common'; export class FilterCompletionProvider implements Provider { @@ -18,15 +18,15 @@ export class FilterCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { partialAst, node, ancestors } = params.completionContext; + const { partialAst, node, ancestors, partial, cursor } = params.completionContext; if (!node || node.type !== NodeTypes.LiquidFilter) { return []; } - if (node.args.length > 0) { - // We only do name completion - return []; - } + // A LiquidFilter only reaches this provider when the caret sits on its + // name (the finder descends into a covered argument otherwise), so we still + // offer name completions even when the filter already carries arguments — + // the text edit below preserves any trailing parameters. // We'll fake a LiquidVariable let parentVariable = ancestors.at(-1); @@ -46,13 +46,12 @@ export class FilterCompletionProvider implements Provider { partialAst, params.textDocument.uri, ); - const partial = node.name.replace(CURSOR, ''); const options = await this.options(isArrayType(inputType) ? 'array' : inputType); return options .filter(({ name }) => name.startsWith(partial)) .map((entry) => { - const { textEdit, format } = this.textEdit(node, params.document, entry); + const { textEdit, format } = this.textEdit(node, params.document, entry, cursor, partial); return createCompletionItem( entry, @@ -70,47 +69,44 @@ export class FilterCompletionProvider implements Provider { node: LiquidFilter, document: AugmentedLiquidSourceCode, entry: MaybeDeprioritisedFilterEntry, + cursor: number, + partial: string, ): { textEdit: TextEdit; format: InsertTextFormat; } { - const remainingText = document.source.slice(node.position.end); - - // Match all the way up to the termination of the filter which could be - // another filter (`|`), or the end of a liquid statement. - const matchEndOfFilter = remainingText.match(/^(.*?)\s*(?=\||-?\}\}|-?\%\})|^(.*)$/); - const endOffset = matchEndOfFilter ? matchEndOfFilter[1].length : remainingText.length; - - // The start position for a LiquidFilter node includes the `|`. We need to - // ignore the pipe and any spaces for our starting position. - const pipeRegex = new RegExp(`(\\s*\\|\\s*)(?:${node.name}(?:\\}|\\%)\\})`); - const matchFilterPipe = node.source.match(pipeRegex); - const startOffet = matchFilterPipe ? matchFilterPipe[1].length : 0; - - let start = document.textDocument.positionAt(node.position.start + startOffet); - let end = document.textDocument.positionAt(node.position.end + endOffset); + // `partial` is the text typed from the filter name's start up to the + // cursor, so the name begins `partial.length` characters before the caret. + // This keeps the start relative to the cursor rather than deriving it from + // the pipe-anchored `node.position.start`. + const nameStart = cursor - partial.length; + + let start = document.textDocument.positionAt(nameStart); + // By default we replace the whole filter — name and any trailing + // parameters — so swapping to a different filter doesn't leave the old + // parameters behind. e.g. `{{ string | d█efault: true }}` -> `downcase`. + let end = document.textDocument.positionAt(node.position.end); const { insertText, insertStyle } = appendRequiredParemeters(entry); let newText = insertText; let format = insertStyle; - // If the cursor is inside the filter or at the end and it's the same - // value as the one we're offering a completion for then we want to restrict - // the insert to just the name of the filter. - // e.g. `{{ product | imag█e_url: crop: 'center' }}` and we're offering `imag█e_url` - const existingFilterOffset = remainingText.match(/[^a-zA-Z_]/)?.index ?? remainingText.length; - if (node.name + remainingText.slice(0, existingFilterOffset) === entry.name) { + // If we're offering a completion for the same filter that's already there, + // we restrict the replacement to just the name so any trailing parameters + // are left untouched. + // e.g. `{{ product | imag█e_url: crop: 'center' }}` and we're offering `image_url` + if (node.name === entry.name) { newText = entry.name; format = InsertTextFormat.PlainText; - end = document.textDocument.positionAt(node.position.end + existingFilterOffset); + end = document.textDocument.positionAt(nameStart + node.name.length); } // If the cursor is at the beginning of the string we can consider all // options and should not replace any text. // e.g. `{{ product | █image_url: crop: 'center' }}` // e.g. `{{ product | █ }}` - if (node.name === CURSOR) { + if (node.name === '') { end = start; } diff --git a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts index 2e52b3e2a..2309c1e6e 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts @@ -6,7 +6,8 @@ import { MetafieldDefinitionMap } from '@shopify/theme-check-common'; import { DocumentManager } from '../../documents'; import { CompletionsProvider } from '../CompletionsProvider'; -import { CURSOR } from '../params'; + +const CURSOR = '█'; describe('Module: ObjectCompletionProvider', async () => { let provider: CompletionsProvider; diff --git a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts index 778c5ec1f..5784845ed 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts @@ -6,7 +6,7 @@ import { InsertTextFormat, TextEdit, } from 'vscode-languageserver'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem } from './common'; import { AugmentedLiquidSourceCode } from '../../documents'; @@ -16,18 +16,17 @@ export class FilterNamedParameterCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { node } = params.completionContext; + const { node, partial } = params.completionContext; if (!node || node.type !== NodeTypes.VariableLookup) { return []; } - if (!node.name || node.lookups.length > 0) { + if (node.lookups.length > 0) { // We only do top level in this one. return []; } - const partial = node.name.replace(CURSOR, ''); const currentContext = params.completionContext.ancestors.at(-1); if (!currentContext || currentContext?.type !== NodeTypes.LiquidFilter) { @@ -103,7 +102,7 @@ export class FilterNamedParameterCompletionProvider implements Provider { // options and should not replace any text. // e.g. `{{ product | image_url: █crop: 'center' }}` // e.g. `{{ product | image_url: █ }}` - if (node.name === '█') { + if (node.name === '') { end = start; } diff --git a/packages/theme-language-server-common/src/completions/providers/HtmlAttributeCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/HtmlAttributeCompletionProvider.ts index fa0d71554..14e29876e 100644 --- a/packages/theme-language-server-common/src/completions/providers/HtmlAttributeCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/HtmlAttributeCompletionProvider.ts @@ -14,7 +14,7 @@ import { isNamedHtmlElementNode, isTextNode, } from '../../utils'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, sortByName } from './common'; import { LiquidHtmlNode } from '@shopify/liquid-html-parser'; @@ -24,7 +24,7 @@ export class HtmlAttributeCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { node, ancestors } = params.completionContext; + const { node, ancestors, partial } = params.completionContext; const parentNode = findLast(ancestors, isAttrEmpty); const grandParentNode = findLast(ancestors, isNamedHtmlElementNode); const document = this.documentManager.get(params.textDocument.uri); @@ -38,8 +38,6 @@ export class HtmlAttributeCompletionProvider implements Provider { } const grandParentNodeName = getCompoundName(grandParentNode); - const name = node.value; - const partial = name.replace(CURSOR, ''); const options = getOptions(partial, grandParentNodeName); const attributeTagRange = this.attributeTagRange(node, document); @@ -66,7 +64,7 @@ export class HtmlAttributeCompletionProvider implements Provider { // Find the range of the attribute partial. If the attribute contains any liquid code, the range // will end before the first character of the liquid block. attributeTagRange(node: LiquidHtmlNode, document: AugmentedSourceCode): Range { - if (node.type === 'TextNode' && node.value === CURSOR) { + if (node.type === 'TextNode' && node.value === '') { // If you try to auto-complete with no provided attribute tag, // we will not try to override the subsequent character. // E.g. diff --git a/packages/theme-language-server-common/src/completions/providers/HtmlAttributeValueCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/HtmlAttributeValueCompletionProvider.ts index 05eef40ca..b7efb1b49 100644 --- a/packages/theme-language-server-common/src/completions/providers/HtmlAttributeValueCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/HtmlAttributeValueCompletionProvider.ts @@ -8,7 +8,7 @@ import { isNamedHtmlElementNode, isTextNode, } from '../../utils'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, sortByName } from './common'; export class HtmlAttributeValueCompletionProvider implements Provider { @@ -17,7 +17,7 @@ export class HtmlAttributeValueCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { node, ancestors } = params.completionContext; + const { node, ancestors, partial } = params.completionContext; const attributeNode = findLast(ancestors, isHtmlAttribute); const tagNode = findLast(ancestors, isNamedHtmlElementNode); @@ -36,8 +36,6 @@ export class HtmlAttributeValueCompletionProvider implements Provider { const tagName = getCompoundName(tagNode); const attrName = getCompoundName(attributeNode); - const name = node.value; - const partial = name.replace(CURSOR, ''); const tagEntry = HtmlData.tags.find((tag) => tag.name === tagName); const attribute = HtmlData.globalAttributes.find((attr) => attr.name === attrName) ?? diff --git a/packages/theme-language-server-common/src/completions/providers/HtmlTagCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/HtmlTagCompletionProvider.ts index fd170f7a4..972626102 100644 --- a/packages/theme-language-server-common/src/completions/providers/HtmlTagCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/HtmlTagCompletionProvider.ts @@ -8,7 +8,7 @@ import { LiquidHtmlNode } from '@shopify/theme-check-common'; import { CompletionItem, CompletionItemKind } from 'vscode-languageserver'; import { HtmlData, Tag, renderHtmlEntry } from '../../docset'; import { isTextNode } from '../../utils'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, sortByName } from './common'; type CompletableParentNode = (HtmlElement | HtmlDanglingMarkerClose) & { name: [TextNode] }; @@ -19,7 +19,7 @@ export class HtmlTagCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { node, ancestors } = params.completionContext; + const { node, ancestors, partial } = params.completionContext; const parentNode = ancestors.at(-1); const grandParentNode = ancestors.at(-2); @@ -28,12 +28,27 @@ export class HtmlTagCompletionProvider implements Provider { return options.map(toCompletionItem); } + if (node && isTextNode(node) && node.value === '<') { + /* + * A bare `<` (`<█`, `<█`): a real trailing TextNode that isn't a + * member of any element's name array, so canComplete rejects it. Treat + * it as the start of a tag name (empty partial) and offer all tags, plus + * the enclosing element's close option when there is one. + */ + const enclosing = ancestors.at(-1); + const closeName = getGrandParentName(enclosing); + const options = HtmlData.tags.concat( + closeName + ? [{ name: '/' + closeName, description: '', attributes: [], references: [] }] + : [], + ); + return options.sort(sortByName).map(toCompletionItem); + } + if (!node || !parentNode || !isTextNode(node) || !canComplete(node, parentNode)) { return []; } - const name = node.value; - const partial = name.replace(CURSOR, ''); const options = getOptions(partial, parentNode, grandParentNode); return options.sort(sortByName).map(toCompletionItem); } @@ -108,7 +123,7 @@ function getGrandParentName(grandParentNode: LiquidHtmlNode | undefined): string grandParentNode.name.length === 1 && isTextNode(grandParentNode.name[0]) ) { - return grandParentNode.name[0].value.replace(CURSOR, ''); + return grandParentNode.name[0].value; } } diff --git a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts index 84de7f308..583ececfb 100644 --- a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts @@ -19,7 +19,7 @@ import { } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { findLast } from '../../utils'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem, sortByName } from './common'; export class LiquidTagsCompletionProvider implements Provider { @@ -28,16 +28,16 @@ export class LiquidTagsCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { node, ancestors } = params.completionContext; + const { node, ancestors, partial } = params.completionContext; if (!node || node.type !== NodeTypes.LiquidTag) { return []; } - if (typeof node.markup !== 'string' || node.markup !== '') { - return []; - } + // The finder only returns a `LiquidTag` here when the caret is on the tag + // name, so the markup content is irrelevant to whether a name is offered + // (`{% if| a > b %}` still completes `if`). Snippet-vs-plaintext is decided + // later by `shouldSnippetComplete`/`existingMarkup`. - const partial = node.name.replace(CURSOR, ''); const blockParent = findParentNode(partial, ancestors); const tags = await this.themeDocset.tags(); return tags diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.ts index 3f8c90cd3..95f062f0a 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.ts @@ -2,7 +2,7 @@ import { NodeTypes } from '@shopify/liquid-html-parser'; import { ObjectEntry } from '@shopify/theme-check-common'; import { CompletionItem, CompletionItemKind } from 'vscode-languageserver'; import { TypeSystem, isArrayType } from '../../TypeSystem'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem, sortByName } from './common'; import { GetThemeSettingsSchemaForURI } from '../../settings'; @@ -34,7 +34,7 @@ export class ObjectAttributeCompletionProvider implements Provider { return []; } - const partial = lastLookup.value.replace(CURSOR, ''); + const partial = lastLookup.value; // Fake a VariableLookup up to the last one. const parentLookup = { ...node }; diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts index 98f3d6572..6fa56ec32 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts @@ -1,7 +1,7 @@ import { NodeTypes } from '@shopify/liquid-html-parser'; import { CompletionItem, CompletionItemKind } from 'vscode-languageserver'; import { TypeSystem } from '../../TypeSystem'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem } from './common'; export class ObjectCompletionProvider implements Provider { @@ -10,15 +10,17 @@ export class ObjectCompletionProvider implements Provider { async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; - const { partialAst, node, ancestors } = params.completionContext; + const { partialAst, node, ancestors, partial } = params.completionContext; const parentNode = ancestors.at(-1); if (!node || node.type !== NodeTypes.VariableLookup) { return []; } - if (!node.name || node.lookups.length > 0) { - // We only do top level in this one. + if (node.lookups.length > 0) { + // We only do top level in this one. An empty name (partial `''`) is a + // valid placeholder — an empty output should still offer every object — + // so we no longer bail on `!node.name`. return []; } @@ -30,7 +32,6 @@ export class ObjectCompletionProvider implements Provider { return []; } - const partial = node.name.replace(CURSOR, ''); const options = await this.typeSystem.availableVariables( partialAst, partial, diff --git a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts index b12197c58..72ed461a0 100644 --- a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts @@ -7,7 +7,7 @@ import { Range, TextEdit, } from 'vscode-languageserver'; -import { CURSOR, LiquidCompletionParams } from '../params'; +import { LiquidCompletionParams } from '../params'; import { Provider } from './common'; import { formatLiquidDocParameter, getParameterCompletionTemplate } from '../../utils/liquidDoc'; import { GetDocDefinitionForURI } from '@shopify/theme-check-common'; @@ -33,7 +33,7 @@ export class RenderSnippetParameterCompletionProvider implements Provider { return []; } - const userInputStr = node.name?.replace(CURSOR, '') || ''; + const userInputStr = node.name || ''; const snippetDefinition = await this.getDocDefinitionForURI( params.textDocument.uri, @@ -47,7 +47,7 @@ export class RenderSnippetParameterCompletionProvider implements Provider { return []; } - let offset = node.name === CURSOR ? 1 : 0; + let offset = node.name === '' ? 1 : 0; let start = params.document.textDocument.positionAt(node.position.start); let end = params.document.textDocument.positionAt(node.position.end - offset); diff --git a/packages/theme-language-server-common/src/completions/providers/common/index.ts b/packages/theme-language-server-common/src/completions/providers/common/index.ts index 97cac9dba..78665da6b 100644 --- a/packages/theme-language-server-common/src/completions/providers/common/index.ts +++ b/packages/theme-language-server-common/src/completions/providers/common/index.ts @@ -1,3 +1,5 @@ +import { NodeTypes } from '@shopify/liquid-html-parser'; + export { createCompletionItem } from './CompletionItemProperties'; export { Provider } from './Provider'; @@ -16,3 +18,75 @@ export function sortByName( // names must be equal return 0; } + +/* + * A node that may carry the text the user is currently completing. Kept + * structural so both real AST nodes (whose `name`/`value` can be a string, + * null, or a sub-node array) and synthesized completion nodes satisfy it. + */ +type CompletionPartialNode = { + type?: string; + position: { start: number }; + name?: unknown; + value?: unknown; +}; + +/* + * Where the node's name/value token actually begins. + * + * `position.start` is the name-token start for token-aligned nodes + * (VariableLookup, String, TextNode, synth nodes, ...), but for + * delimiter-anchored nodes it points at the delimiter, not the name: + * + * - LiquidTag / LiquidBranch start at the opening `{%`. We skip the + * delimiter, an optional whitespace-stripping `-`, and any whitespace so + * the partial begins at the tag name. In a `{% liquid %}` block the + * sub-tags have no `{%`, so the regex simply does not match and the start + * is left where it already sits — on the name. + * - LiquidFilter starts at the `|`. We skip the pipe and its surrounding + * whitespace, mirroring the offset FilterCompletionProvider computes at + * FilterCompletionProvider.ts:84-88. + */ +function nameTokenStart(node: CompletionPartialNode, source: string): number { + const start = node.position.start; + const rest = source.slice(start); + + if (node.type === NodeTypes.LiquidTag || node.type === NodeTypes.LiquidBranch) { + const match = rest.match(/^\{%-?\s*/); + return start + (match ? match[0].length : 0); + } + + if (node.type === NodeTypes.LiquidFilter) { + const match = rest.match(/^\s*\|\s*/); + return start + (match ? match[0].length : 0); + } + + return start; +} + +/* + * Returns what the user has typed at the caret: the identifier or value of + * `node` sliced from its name-token start up to `cursor`, clamped to the + * node's own name/value span so the delimiter, trailing whitespace, or later + * tokens never leak in. + * + * Synthesized empty nodes (name/value `''` at a zero-width caret span) and a + * missing node both yield `''`. This replaces the old + * `node.name.replace(CURSOR, '')` idiom now that no sentinel is injected. + */ +export function completionPartial( + node: CompletionPartialNode | undefined, + cursor: number, + source: string, +): string { + if (!node) return ''; + + const start = nameTokenStart(node, source); + const text = + typeof node.name === 'string' ? node.name : typeof node.value === 'string' ? node.value : ''; + const end = Math.min(cursor, start + text.length); + + if (end <= start) return ''; + + return source.slice(start, end); +} diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts index 0b1a366b5..96bd36e85 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts @@ -55,12 +55,8 @@ function documentLinksVisitor( } // {% section 'section' %} - if ( - node.name === 'section' && - typeof node.markup !== 'string' && - isLiquidString(node.markup) - ) { - const sectionName = node.markup; + if (node.name === 'section' && typeof node.markup !== 'string') { + const sectionName = node.markup.name; return DocumentLink.create( range(textDocument, sectionName), Utils.resolvePath(root, 'sections', sectionName.value + '.liquid').toString(), diff --git a/packages/theme-language-server-common/src/formatting/providers/HtmlElementAutoclosingOnTypeFormattingProvider.ts b/packages/theme-language-server-common/src/formatting/providers/HtmlElementAutoclosingOnTypeFormattingProvider.ts index adff431c1..bd02676e8 100644 --- a/packages/theme-language-server-common/src/formatting/providers/HtmlElementAutoclosingOnTypeFormattingProvider.ts +++ b/packages/theme-language-server-common/src/formatting/providers/HtmlElementAutoclosingOnTypeFormattingProvider.ts @@ -1,5 +1,4 @@ import { - getName, HtmlElement, LiquidHTMLASTParsingError, LiquidHtmlNode, @@ -76,6 +75,12 @@ export class HtmlElementAutoclosingOnTypeFormattingProvider implements BaseOnTyp ) { defer(() => this.setCursorPosition(textDocument, params.position)); return [TextEdit.insert(Position.create(line, character), ``)]; + } else if (ast instanceof LiquidHTMLASTParsingError) { + const node = nodeAtCursor(textDocument, params.position); + if (node && isDanglingHtmlElement(node)) { + defer(() => this.setCursorPosition(textDocument, params.position)); + return [TextEdit.insert(Position.create(line, character), ``)]; + } } else if (!(ast instanceof Error)) { // Even though we accept dangling
s inside {% if condition %}, we prefer to auto-insert the
const [node] = findCurrentNode(ast, textDocument.offsetAt(params.position)); @@ -123,3 +128,25 @@ function isDanglingHtmlElement(node: LiquidHtmlNode): node is HtmlElement { node.blockEndPosition.start === node.blockEndPosition.end ); } + +function getName(node: LiquidHtmlNode): string | null { + switch (node.type) { + case NodeTypes.HtmlElement: + case NodeTypes.HtmlDanglingMarkerClose: + case NodeTypes.HtmlSelfClosingElement: + return node.name + .map((part) => { + if (part.type === NodeTypes.TextNode) return part.value; + if (part.type === NodeTypes.LiquidVariableOutput) { + return typeof part.markup === 'string' + ? `{{${part.markup.trim()}}}` + : `{{${part.markup.rawSource}}}`; + } + // LiquidTag | LiquidRawTag segment (new compound-name arm) + return part.source.slice(part.position.start, part.position.end); + }) + .join(''); + default: + return 'name' in node && typeof node.name === 'string' ? node.name : null; + } +} diff --git a/packages/theme-language-server-common/src/linkedEditingRanges/providers/EmptyHtmlTagLinkedRangesProvider.ts b/packages/theme-language-server-common/src/linkedEditingRanges/providers/EmptyHtmlTagLinkedRangesProvider.ts index 971a334ef..542869039 100644 --- a/packages/theme-language-server-common/src/linkedEditingRanges/providers/EmptyHtmlTagLinkedRangesProvider.ts +++ b/packages/theme-language-server-common/src/linkedEditingRanges/providers/EmptyHtmlTagLinkedRangesProvider.ts @@ -17,9 +17,12 @@ export class EmptyHtmlTagLinkedRangesProvider implements BaseLinkedEditingRanges ancestors: LiquidHtmlNode[] | null, { textDocument: { uri }, position }: LinkedEditingRangeParams, ): Promise { - // We're strictly checking for <> and cursor in either branch, that's a parse error - // ... but it's fine because <> is rather easy to find. - if (node !== null || ancestors !== null) return null; + // We're strictly checking for <> and cursor in either branch. Ohm treated + // this as a parse error (node/ancestors both null), but the ported parser now + // parses <> into real nodes, so we can no longer bail solely on non-null + // node/ancestors. The source-text checks below (`<>`/`< ` and ``) are the + // real discriminator and already return null for well-formed tags like + //
, so we let them do the work instead. const document = this.documentManager.get(uri); const textDocument = document?.textDocument; diff --git a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts index 07bee8d42..6b200ead7 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts @@ -192,7 +192,7 @@ export class SectionRenameHandler implements BaseRenameHandler { // Note the type assertion to the LHS of the expression. // The type assertions above are enough for this to be true. // But I'm making the explicit annotation here to make it clear. - const typeNode: LiquidString = node.markup; + const typeNode: LiquidString = node.markup.name; if (typeNode.value !== oldSectionName) return; return { From 6032fad98db4d8c960af06f1c48a39b8600ca633 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 16 Jul 2026 20:20:52 +0200 Subject: [PATCH 2/3] Fix NEW parser completion parity regressions Restores completion behavior lost in the NEW liquid parser so it matches OLD across empty/unclosed slots, pipe/filter routing, literal type-narrowing, liquid-inner-statement, named-filter-arg value slots, and abutting end-tag cases (A-G, R1, R2, R3). Fix branch-tag completion parity when abutting end tag Extends the abutting-endtag recovery to LiquidBranch (elsif/when); the R3 fix covered LiquidTag/ForMarkup only. --- .../params/LiquidCompletionParams.ts | 482 ++++++++++++++++-- .../FilterCompletionProvider.spec.ts | 38 ++ .../ObjectAttributeCompletionProvider.spec.ts | 29 ++ .../ObjectCompletionProvider.spec.ts | 101 ++++ .../TranslationCompletionProvider.spec.ts | 15 + 5 files changed, 618 insertions(+), 47 deletions(-) diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts index e399a5344..2a2c877e2 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts @@ -258,11 +258,42 @@ function findCompletionNode( // Keep the covering LiquidTag for trailing tag-modifier positions // (`{% for x in y reversed ^ %}`) so the tag provider sees the // same params shape Ohm exposed. + } else if ( + hasNonNullProperty(current, 'markup') && + typeof current.markup !== 'string' && + hasTrailingEmptyFilterSlot(current.markup, cursor, source) + ) { + /* + * The tolerant parser keeps a bare trailing `|` inside the markup + * span (`{% echo x | ^ %}`, `{% assign v = x | ^ %}`), so the markup + * COVERS the caret and the branch below would descend into the + * pre-pipe variable and complete an object. Route the empty filter + * slot to the filter subtree so the filter providers fire. + * `synthTagFilterSlot` takes the tag (`current`) and re-derives the + * expression from source (handling the `assign` `=`), so the + * AssignMarkup wrapper needs no special handling here. + */ + const slot = synthTagFilterSlot(current, cursor, source); + if (slot) finder.current = slot; } else if (hasNonNullProperty(current, 'markup') && typeof current.markup !== 'string') { if (Array.isArray(current.markup)) { finder.current = covering(current.markup, cursor); } else if (isCovered(cursor, current.markup.position)) { finder.current = current.markup; + } else if ( + current.markup.position.start > cursor && + isInExpressionSlot(current, cursor, source) + ) { + /* + * The parser mis-absorbed an abutting end tag as this tag's + * condition markup (`{% if ^{% endif %}`, + * `{% unless ^{% endunless %}`): the markup node lives entirely + * downstream of the caret, so it covers nothing here. Recover the + * empty expression slot at the caret so the object provider fires, + * matching the old parser's sentinel behaviour instead of + * completing the bare tag name. + */ + finder.current = synthMarkupSlot(current, cursor, source); } } else if ( typeof current.markup === 'string' && @@ -275,30 +306,42 @@ function findCompletionNode( // expects so the providers can offer completions there. The // `isInExpressionSlot` guard keeps caret-on-the-tag-name cases // (`{% ren^ %}`) completing the tag rather than a lookup. - const slot = synthMarkupSlot(current, cursor, source); - const contentForType = - current.name === 'content_for' - ? synthContentForType(current, cursor, source) - : undefined; - if ( - current.name === 'content_for' && - slot.type === NodeTypes.VariableLookup && - contentForType !== undefined - ) { - // The resilient parser leaves `{% content_for "b", partial %}` (a - // bare arg name, no colon) as raw string markup — there is no - // ContentForMarkup node for the parameter provider to hang off. - // Synthesize one as the recovered lookup's parent so the provider - // fires, mirroring the shape the parser builds for a well-formed - // `type: "x"` argument. - finder.current = { - type: NodeTypes.ContentForMarkup, - contentForType, - args: [], - position: current.position, - } as any as LiquidHtmlNode; + if (source.slice(current.position.start, cursor).includes('|')) { + /* + * A pipe in the tag's post-name region means the caret sits in a + * filter or filter-argument slot (`{% echo s | ^ %}`, + * `{% assign x = "s" | ^ %}`), not an object slot. Route to the + * filter subtree so the filter providers fire instead of the + * object provider, skipping the `synthMarkupSlot` / content_for + * path. + */ + finder.current = synthTagFilterSlot(current, cursor, source); + } else { + const slot = synthMarkupSlot(current, cursor, source); + const contentForType = + current.name === 'content_for' + ? synthContentForType(current, cursor, source) + : undefined; + if ( + current.name === 'content_for' && + slot.type === NodeTypes.VariableLookup && + contentForType !== undefined + ) { + // The resilient parser leaves `{% content_for "b", partial %}` (a + // bare arg name, no colon) as raw string markup — there is no + // ContentForMarkup node for the parameter provider to hang off. + // Synthesize one as the recovered lookup's parent so the provider + // fires, mirroring the shape the parser builds for a well-formed + // `type: "x"` argument. + finder.current = { + type: NodeTypes.ContentForMarkup, + contentForType, + args: [], + position: current.position, + } as any as LiquidHtmlNode; + } + finder.current = slot; } - finder.current = slot; } else { // No markup, or the caret is still on the tag name: the tag itself // is the thing to complete. @@ -320,11 +363,58 @@ function findCompletionNode( isCovered(cursor, current.blockStartPosition) && typeof current.markup !== 'string' ) { - finder.current = Array.isArray(current.markup) - ? covering(current.markup, cursor) - : isCovered(cursor, current.markup.position) - ? current.markup - : undefined; + if (Array.isArray(current.markup)) { + const covered = covering(current.markup, cursor); + if (covered) { + finder.current = covered; + } else if ( + isNotEmpty(current.markup) && + current.markup[0].position.start > cursor && + isInExpressionSlot(current, cursor, source) + ) { + /* + * A `when` branch abutting its `{% endcase %}` + * (`{% case x %}{% when ^{% endcase %}`): the resilient parser + * mis-absorbs the end tag as this branch's condition, so every + * markup element lives downstream of the caret and covers nothing + * here. Recover the empty expression slot so the object provider + * fires, mirroring the LiquidTag abutting-endtag arm and matching + * the old parser instead of returning zero completions. + */ + finder.current = synthMarkupSlot(current, cursor, source); + } else { + finder.current = undefined; + } + } else if (isCovered(cursor, current.markup.position)) { + finder.current = current.markup; + } else if ( + current.markup.position.start > cursor && + isInExpressionSlot(current, cursor, source) + ) { + /* + * An `elsif` branch abutting its `{% endif %}` + * (`{% if a %}{% elsif ^{% endif %}`): the mis-absorbed end tag + * became this branch's single condition node, sitting entirely + * downstream of the caret. Recover the empty slot as above so the + * object provider offers completions rather than nothing. + */ + finder.current = synthMarkupSlot(current, cursor, source); + } else { + finder.current = undefined; + } + } else if ( + typeof current.markup === 'string' && + isInExpressionSlot(current, cursor, source) + ) { + /* + * The parser left the branch markup a raw string, so the expression + * slot at the caret is empty (`{% elsif ^ %}`, `{% when ^ %}`). Mirror + * the `LiquidTag` arm and synthesize the lookup that slot expects. + * `synthMarkupSlot` sees an `elsif`/`when` name (not render/content_for) + * and recovers a `VariableLookup`; the `isInExpressionSlot` guard keeps + * caret-on-the-branch-name cases from synthesizing a lookup. + */ + finder.current = synthMarkupSlot(current, cursor, source); } else { finder.current = undefined; // there's nothing to complete } @@ -366,7 +456,23 @@ function findCompletionNode( } case NodeTypes.LiquidVariableOutput: { - if (typeof current.markup !== 'string' && isCovered(cursor, current.markup.position)) { + const emptyFilterSlot = + typeof current.markup !== 'string' && + hasTrailingEmptyFilterSlot(current.markup, cursor, source) + ? synthOutputFilterSlot(current, cursor, source) + : undefined; + if (emptyFilterSlot) { + /* + * The tolerant parser keeps a bare trailing `|` inside the markup span + * (`{{ x | ^ }}`, `{{ x | upcase | ^ }}`), so the markup covers the + * caret and the descent below would complete the pre-pipe expression. + * Route to the filter subtree so the filter providers fire. + */ + finder.current = emptyFilterSlot; + } else if ( + typeof current.markup !== 'string' && + isCovered(cursor, current.markup.position) + ) { finder.current = current.markup; } else if (typeof current.markup === 'string') { // The parser left the output markup as a raw string, so the filter @@ -391,9 +497,44 @@ function findCompletionNode( const exprStart = current.position.start + (source.slice(current.position.start, cursor).match(/^\{\{-?\s*/)?.[0].length ?? 0); - const recovered = recoverVariableLookup(source, exprStart, cursor); - if (recovered.type === NodeTypes.VariableLookup && recovered.lookups.length > 0) { - finder.current = recovered; + const region = source.slice(exprStart, cursor); + const openString = unterminatedString(region, exprStart); + if (region.trim() === '') { + /* + * An empty closed output (`{{ ^ }}`): the parser left the markup a + * raw string with nothing before the caret. Synthesize the blank + * lookup the object provider completes against, mirroring the + * unclosed `{{ ^` path. This branch is additive — any non-empty + * region (including a bracket key like `x[0].`) still flows through + * the recover+guard below, so that behaviour is unchanged. + */ + finder.current = synthVariableLookup(cursor); + } else if (openString && /^\s*['"]/.test(region)) { + /* + * A closed output whose expression is a string the caret still + * sits inside, trailed by an empty filter (`{{ "general.^" | }}`). + * The malformed `| }}` leaves the markup a raw string and the pipe + * sits after the caret, so the filter-slot synth sees no pipe and + * the finder never reaches a parsed LiquidVariable. Synthesize the + * LiquidVariable -> String subtree the translation provider needs + * (its guard requires a LiquidVariable parent); the no-filter + * LiquidVariable arm below then walks down to the String. Mirrors + * the error-node `{{ "genera` path. `unterminatedString` returns + * undefined once the quote is closed, so `x[0].`, `"done".`, and + * empty regions never take this branch. + */ + finder.current = { + type: NodeTypes.LiquidVariable, + expression: synthString(openString, cursor), + filters: [], + position: { start: exprStart, end: cursor }, + source, + } as any as LiquidHtmlNode; + } else { + const recovered = recoverVariableLookup(source, exprStart, cursor); + if (recovered.type === NodeTypes.VariableLookup && recovered.lookups.length > 0) { + finder.current = recovered; + } } } } @@ -405,11 +546,19 @@ function findCompletionNode( // Descend into the filter the caret actually sits in, not blindly the // last one: a caret in an earlier filter of a chain // (`{{ x | image_url: █ | image_tag }}`) must complete against that - // filter, not the trailing one. Falls back to the last filter when - // no filter covers the caret, preserving the previous behaviour. - finder.current = - current.filters.find((filter) => isCovered(cursor, filter.position)) ?? - last(current.filters); + // filter, not the trailing one. When no filter covers the caret but it + // still sits within the expression (`{{ 'general.█' | t }}` — the caret + // is on the string, the pipe is a real `t` filter), complete the + // expression so translation keys are offered rather than filter names. + // Otherwise fall back to the last filter, preserving prior behaviour. + const covered = current.filters.find((filter) => isCovered(cursor, filter.position)); + if (covered) { + finder.current = covered; + } else if (current.expression && cursor <= current.expression.position.end) { + finder.current = current.expression; + } else { + finder.current = last(current.filters); + } } else { finder.current = current.expression; } @@ -469,6 +618,19 @@ function findCompletionNode( finder.current = current.collection; } else if (isNotEmpty(current.args) && isCovered(cursor, last(current.args).position)) { finder.current = last(current.args); + } else if ( + current.collection.position.start > cursor && + /\bin\s/.test(source.slice(current.position.start, cursor)) + ) { + /* + * The parser mis-absorbed an abutting `{% endfor %}` as the loop + * collection (`{% for x in ^{% endfor %}`): the collection node lives + * entirely downstream of the caret. The `in ` guard confirms the caret + * is past the `in` keyword (a real collection slot, not the + * loop-variable slot), so recover the empty lookup there for the + * object provider. + */ + finder.current = recoverVariableLookup(source, current.position.start, cursor); } break; } @@ -776,6 +938,29 @@ function resolveErrorNodeCompletion( return innerTag; } + // An unclosed tag whose caret sits PAST the tag name in an expression slot + // (`{% if ^`, `{% for x in ^`, `{% assign x = ^`, `{% echo ^`, with no `%}`) + // parses as a bare error node, so no `LiquidTag` finder arm ever runs. + // Recover the lookup that slot expects. This runs only after the caret-on-name + // (`synthTagNameNode`) and `{% liquid %}` body (`recoverLiquidInnerTag`) + // recoveries above have declined, so those keep completing tag / inner-tag + // names. The `\s` after the tag name keeps caret-on-name cases out; + // `recoverVariableLookup`'s back-scan isolates the trailing token past `=` / + // `in` / operators. + const tagOpen = source.lastIndexOf('{%', cursor); + if (tagOpen !== -1) { + const close = source.indexOf('%}', tagOpen); + const region = source.slice(tagOpen, cursor); + const nameMatch = region.match(/^\{%-?\s*[a-zA-Z_]\w*/); + if ((close === -1 || close >= cursor) && nameMatch && /\s/.test(region[nameMatch[0].length])) { + const lowerBound = tagOpen + nameMatch[0].length; + return [ + recoverVariableLookup(source, lowerBound, cursor), + [...ancestors, synthNode(NodeTypes.LiquidTag, cursor)], + ]; + } + } + // A caret inside an unclosed HTML tag's attribute value (` ^`), not a tag-name position. Recover the + // lookup that slot expects instead of a tag-name node. `recoverVariableLookup` + // back-scans the trailing token past `=` / operators; an empty slot yields the + // blank lookup so the object provider fires. + if (/^[a-zA-Z_][\w-]*\s+/.test(typed)) { + const innerName = typed.match(/^[a-zA-Z_][\w-]*/)![0]; + const nameEnd = nameStart + innerName.length; + const ancestry = ancestors.some(isLiquidLiquidTag) + ? ancestors + : [...ancestors, block.liquidTag]; + + /* + * A pipe in the inner statement means the caret sits in a filter or + * filter-argument slot (`echo x | ^`, `assign v = x | ^`), not an object + * slot. Delegate to the shared tag filter-slot synth (which narrows by the + * pre-pipe expression's type) and descend to the completion leaf ourselves, + * since this recovery returns the final pair rather than re-entering the + * finder loop. Mirrors the top-level tag filter path. + */ + if (source.slice(nameStart, cursor).includes('|')) { + const innerNode = { + name: innerName, + position: { start: nameStart, end: cursor }, + } as any as LiquidHtmlNode; + const variable = synthTagFilterSlot(innerNode, cursor, source); + if (variable) { + // `synthFilterSlot` always appends the trailing filter, so `filters` is + // non-empty by construction; cast so `last` accepts it. + const filters = (variable as any).filters as NonEmptyArray; + const filter = last(filters); + return isNotEmpty((filter as any).args) + ? [last((filter as any).args), [...ancestry, variable, filter]] + : [filter, [...ancestry, variable]]; + } + } + + return [recoverVariableLookup(source, nameEnd, cursor), ancestry]; + } + // Recover only while the caret is on the inner tag name (or an empty line); // an expression slot (`echo `, `if a > `) is not a tag-name position. if (typed !== '' && !/^[a-zA-Z_][\w-]*$/.test(typed)) return undefined; @@ -1516,6 +1781,32 @@ function synthContentForType( } as any as LiquidHtmlNode; } +/* + * The tolerant parser KEEPS a bare trailing `|` inside the markup node's span + * (it is not dropped), so the markup covers the caret in both the empty-slot and + * the partial-name cases — `position.end` vs caret cannot tell them apart. The + * real empty-filter-slot signal is source-level: the text from the end of the + * last parsed filter (or, with no filters, the end of the pre-pipe expression) + * up to the caret is a single trailing bare `|` with a whitespace-only tail. + * + * `{% assign %}` markup is an `AssignMarkup` whose filters live on the child + * `value` (a `LiquidVariable`), so unwrap it first. A partial filter name + * (`{{ x | de^ }}`) is already parsed into a filter ending at the caret, so the + * scanned tail is empty and this returns false — leaving those to the normal + * parsed-filter descent. + */ +function hasTrailingEmptyFilterSlot(markup: any, cursor: number, source: string): boolean { + const variable = markup?.type === NodeTypes.AssignMarkup ? markup.value : markup; + if (!variable || typeof variable !== 'object') return false; + const filters = variable.filters; + const scanStart = + Array.isArray(filters) && isNotEmpty(filters) + ? last(filters)?.position?.end + : variable.expression?.position?.end; + if (typeof scanStart !== 'number' || scanStart > cursor) return false; + return /^\s*\|\s*$/.test(source.slice(scanStart, cursor)); +} + /* * For a `{{ ... }}` output whose markup the parser left as a raw string, the * filter or filter-argument slot after a pipe is empty (`{{ x | ^ }}`, @@ -1536,6 +1827,27 @@ function synthOutputFilterSlot( ): LiquidHtmlNode | undefined { const open = output.position.start; const exprStart = open + (source.slice(open, cursor).match(/^\{\{-?\s*/)?.[0].length ?? 0); + return synthFilterSlot(source, exprStart, cursor); +} + +/* + * Builds the `LiquidVariable` -> filter-chain subtree an empty filter or + * filter-argument slot after a pipe expects, given the offset where the pre-pipe + * expression begins (`exprStart`). Shared by the output path + * (`synthOutputFilterSlot`) and the tag path (`synthTagFilterSlot`), so both + * narrow completions by the pre-pipe expression's type via `recoverExpression`. + * The descent below bottoms out on the empty filter (a bare pipe: the + * filter-name provider completes there) or on the filter's empty argument lookup + * (after `name:`: the named-parameter provider completes there). + * + * Returns undefined when there is no pipe before the caret (a non-filter slot), + * leaving the caller's pipe-less behaviour intact. + */ +function synthFilterSlot( + source: string, + exprStart: number, + cursor: number, +): LiquidHtmlNode | undefined { const region = source.slice(exprStart, cursor); const lastPipe = region.lastIndexOf('|'); if (lastPipe === -1) return undefined; @@ -1545,20 +1857,55 @@ function synthOutputFilterSlot( const lastPipeStart = exprStart + lastPipe; // The expression is everything before the first pipe; recover it (trailing - // whitespace trimmed) so the synthesized variable carries the real lookup. + // whitespace trimmed) so the synthesized variable carries the real lookup — + // or a typed literal node, so a literal pre-pipe narrows the filter set. let exprEnd = firstPipeStart; while (exprEnd > exprStart && /\s/.test(source[exprEnd - 1])) exprEnd -= 1; - const expression = recoverVariableLookup(source, exprStart, exprEnd); + const expression = recoverExpression(source, exprStart, exprEnd); const completedFilters = synthCompletedFilters(source, firstPipeStart + 1, lastPipeStart); // The text after the last pipe decides the slot: `name:` is an empty named // argument (so we descend to the argument lookup), anything else is an empty // filter slot (so we stop on the filter itself). const tail = region.slice(lastPipe + 1); - const named = tail.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*$/); - const filter = named - ? synthFilter(named[1], [synthVariableLookup(cursor)], cursor, source) - : synthFilter('', [], cursor, source); + + /* + * `tail` includes the filter name. A trailing `filterName: ...argName:` with + * an empty value is a named-argument VALUE slot — offer value expressions, + * not filter names. The first-argument slot (`filter: ^`) has an empty args + * portion and keeps its existing bare-lookup shape (do not regress it). + */ + const colonIdx = tail.indexOf(':'); + const filterName = + colonIdx === -1 + ? undefined + : tail + .slice(0, colonIdx) + .trim() + .match(/^[a-zA-Z_][\w-]*$/)?.[0]; + const argsPortion = colonIdx === -1 ? '' : tail.slice(colonIdx + 1); + const valueSlot = argsPortion.match(/([a-zA-Z_][\w-]*)\s*:\s*$/); + + let filter: LiquidHtmlNode; + if (filterName && valueSlot) { + /* + * Absolute offset of the argument name so the finder's name-guard resolves + * to the VALUE (cursor is past `argName:`), not the name. + */ + const argStart = exprStart + lastPipe + 1 + colonIdx + 1 + valueSlot.index!; + const namedArg = { + type: NodeTypes.NamedArgument, + name: valueSlot[1], + value: synthVariableLookup(cursor), + position: { start: argStart, end: cursor }, + } as any as LiquidHtmlNode; + filter = synthFilter(filterName, [namedArg], cursor, source); + } else { + const named = tail.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*$/); + filter = named + ? synthFilter(named[1], [synthVariableLookup(cursor)], cursor, source) + : synthFilter('', [], cursor, source); + } return { type: NodeTypes.LiquidVariable, @@ -1569,6 +1916,40 @@ function synthOutputFilterSlot( } as any as LiquidHtmlNode; } +/* + * For a TAG whose markup the parser left a raw string, the filter or + * filter-argument slot after a pipe is empty (`{% echo s | ^ %}`, + * `{% assign x = "s" | ^ %}`). Locate where the pre-pipe expression begins — + * just past the tag name, or, for `assign`, past the `= ` that separates the + * target from the value — and delegate to the shared `synthFilterSlot` so tag + * filters narrow by input type exactly like the output path. + */ +function synthTagFilterSlot(node: any, cursor: number, source: string): LiquidHtmlNode | undefined { + const region = source.slice(node.position.start, cursor); + /* + * The `{%` prefix is optional so `exprStart` also skips the bare tag name of + * a `{% liquid %}` inner statement (which has no `{%`). Needed so + * `recoverExpression` sees a bare literal (`echo "s" | ^` -> String -> string + * filters, not an untyped lookup). Harmless for non-literal inner statements + * (`recoverVariableLookup` back-scans regardless) and byte-identical for + * top-level `{%`-prefixed tags. + */ + let exprStart = + node.position.start + (region.match(/^(?:\{%-?\s*)?[a-zA-Z_]\w*\s+/)?.[0].length ?? 0); + + if (node.name === 'assign') { + // `assign` binds a variable, so the pre-pipe expression is the assigned + // value: skip past the first `=` and its trailing whitespace. + const eq = source.indexOf('=', exprStart); + if (eq !== -1 && eq < cursor) { + exprStart = eq + 1; + while (exprStart < cursor && /\s/.test(source[exprStart])) exprStart += 1; + } + } + + return synthFilterSlot(source, exprStart, cursor); +} + /* * Recovers completed filters before the trailing completion pipe in raw output * markup (`{{ string | split: "" | ^ }}`). The filter-name provider infers the @@ -1757,10 +2138,17 @@ function synthUnclosedOutputFilter( * the tag name instead of a synthesized lookup. */ function isInExpressionSlot(node: any, cursor: number, source: string): boolean { - // `[a-zA-Z_]\w*` (rather than `[\w-]+`) so the whitespace-control dash of - // `{%- if^` is not mistaken for a one-character tag name, which would put the - // caret past a "name" and wrongly synthesize a lookup on `{%- if^ %}`. - return /^\{%-?\s*[a-zA-Z_]\w*\s/.test(source.slice(node.position.start, cursor)); + /* + * `[a-zA-Z_]\w*` (rather than `[\w-]+`) so the whitespace-control dash of + * `{%- if^` is not mistaken for a one-character tag name, which would put the + * caret past a "name" and wrongly synthesize a lookup on `{%- if^ %}`. + * + * The `{%` prefix is optional so this also covers `{% liquid %}` inner + * statements, whose region begins at the bare tag name (`echo`/`assign`), + * not at `{%`. Top-level regions start with `{`, so the optional-prefix + * branch matches inner-liquid statements only. + */ + return /^(?:\{%-?\s*)?[a-zA-Z_]\w*\s/.test(source.slice(node.position.start, cursor)); } function isTrailingLiquidTagMarkupSlot(node: any, cursor: number, source: string): boolean { diff --git a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts index e82f70ef0..27df1b3a4 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts @@ -174,6 +174,44 @@ describe('Module: FilterCompletionProvider', async () => { await expect(provider).to.complete('{{ string | █ }}', stringFilters.concat(anyFilters)); }); + it('completes filters (not objects) in an empty tag filter slot', async () => { + // E: a pipe inside a tag routes the caret to the filter slot, narrowing by + // the pre-pipe expression's type. + await expect(provider).to.complete('{% echo string | █ %}', stringFilters.concat(anyFilters)); + await expect(provider).to.complete( + '{% assign x = "s" | █ %}', + stringFilters.concat(anyFilters), + ); + }); + + it('completes filters (not objects) in a `{% liquid %}` inner tag filter slot', async () => { + // R1b: a pipe on a `{% liquid %}` body line routes the caret to the filter + // slot, narrowing by the pre-pipe expression's type — an object lookup, a + // string literal, and a multi-pipe chain each narrow like the output path. + await expect(provider).to.complete( + '{% liquid\n echo string | █ %}', + stringFilters.concat(anyFilters), + ); + await expect(provider).to.complete( + '{% liquid\n echo "s" | █ %}', + stringFilters.concat(anyFilters), + ); + await expect(provider).to.complete( + '{% liquid\n assign x = string | split: "" | █ %}', + arrayFilters.concat(anyFilters), + ); + }); + + it('narrows the filter set by a literal pre-pipe expression', async () => { + // G: string / number / range literals narrow filters by input type. + await expect(provider).to.complete('{{ "1" | █ }}', stringFilters.concat(anyFilters)); + await expect(provider).to.complete('{{ (1..3) | █ }}', arrayFilters.concat(anyFilters)); + // A number literal narrows the same as the `number` object; the fixture has + // no number-input filter, so it falls back to all filters. + await expect(provider).to.complete('{{ 1 | █ }}', allFilters); + await expect(provider).to.complete('{{ number | █ }}', allFilters); + }); + it('handles assignment the same way as output', async () => { // char 31 ⌄ ⌄ char 33 const liquid = '{% assign imageUrl = product | de█ %}'; diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.spec.ts index 61ddb4bd8..c01df2717 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectAttributeCompletionProvider.spec.ts @@ -159,6 +159,35 @@ describe('Module: ObjectAttributeCompletionProvider', async () => { await expect(provider).to.complete(source, ['src']); }); + it('returns attributes in `{% liquid %}` inner echo/assign/render slots', async () => { + // R1a: a covering inner statement's markup is a raw string; the + // expression-slot gate now matches even without a `{%` prefix, so the + // caret after a lookup dot resolves to that object's attributes. + const sources = [ + '{% liquid\n echo product.█ %}', + '{% liquid\n assign x = product.█ %}', + '{% liquid\n render "c", x: product.█ %}', + ]; + for (const source of sources) { + await expect(provider, source).to.complete(source, ['images']); + } + }); + + it('returns deep attributes in a `{% liquid %}` inner echo slot', async () => { + // R1a: a multi-lookup expression narrows through the chain to the leaf + // type's attributes on an inner body line (results sorted by label). + const source = '{% liquid\n echo product.images.first.█ %}'; + await expect(provider, source).to.complete(source, ['height', 'src', 'width']); + }); + + it('keeps a `{% liquid %}` inner name-only caret on the object, not attributes', async () => { + // Guard: no dot means the caret is on the lookup name, not an attribute + // slot; the broadened expression-slot gate must not turn this into an + // attribute completion — it stays the plain object completion. + const source = '{% liquid\n echo product█ %}'; + await expect(provider, source).to.complete(source, ['product']); + }); + describe('When: inside a for/tablerow loop', () => { it('returns the properties of the array_value of the array', async () => { for (const tag of ['for', 'tablerow']) { diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts index 60e84a2b6..cdb49970d 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts @@ -192,6 +192,107 @@ describe('Module: ObjectCompletionProvider', async () => { ); }); + it('should complete objects in empty/unclosed expression slots', async () => { + const contexts = [ + // A: empty closed output. + `{{ █ }}`, + // B: unclosed tags (no `%}`). + `{% if █`, + `{% for x in █`, + `{% assign x = █`, + `{% echo █`, + // C: empty closed branch. + `{% elsif █ %}`, + `{% when █ %}`, + // D: `{% liquid %}` inline empties. + `{% liquid\n echo █`, + `{% liquid\n assign x = █`, + `{% liquid\n if █`, + ]; + + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('completes objects when a tag abuts its own end tag (R3)', async () => { + // The resilient parser absorbs the abutting end tag as the tag's condition / + // collection; recover the empty slot at the caret so all objects are offered, + // matching the old parser (OLD 47 / NEW 47). + const contexts = [ + `{% if █{% endif %}`, + `{% unless █{% endunless %}`, + `{% for x in █{% endfor %}`, + ]; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('completes objects when a branch abuts its enclosing end tag (R4)', async () => { + // Same class as R3, one node-type short: inside a real if/case block the + // resilient parser mis-absorbs the abutting end tag as the branch's + // condition (elsif -> single node, when -> array), so it lands downstream of + // the caret and covers nothing. Recover the empty slot so all objects are + // offered, matching the old parser (OLD 47 / NEW 47). + const contexts = [ + `{% if a %}{% elsif █{% endif %}`, + `{% if a %}{% elsif b %}{% elsif █{% endif %}`, + `{% case x %}{% when █{% endcase %}`, + `{% case x %}{% when 1 %}{% when █{% endcase %}`, + ]; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('still completes closed branch slots inside a real block (R4 boundary)', async () => { + // The fix must not disturb the closed-branch path: here markup is the empty + // string, so the string-markup arm (not the new non-string recovery) fires. + const contexts = [`{% if a %}{% elsif █ %}`, `{% case x %}{% when █ %}`]; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('completes objects in closed `{% liquid %}` inner empty slots', async () => { + // R1 guard: the empty echo/if slots on a `{% liquid %}` body line (closed + // form) still offer all objects — the pipe-aware branch is skipped when + // there is no pipe. + const contexts = ['{% liquid\n echo █ %}', '{% liquid\n if █ %}']; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('completes value expressions in a named-filter-argument VALUE slot', async () => { + // R2: `filterName: argName:` with a trailing empty value is a value slot, + // so it offers value expressions (objects), not filter names — including + // after a preceding positional/named argument. + const contexts = ['{{ x | image_url: width: █ }}', '{{ x | image_url: width: 100, crop: █ }}']; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['all_products', 'global', 'product']), + ), + ); + }); + + it('keeps bracket-key and caret-on-name positions non-completing', async () => { + // A must not disturb bracket recovery: these stay empty. + await expect(provider, '{{ x[0].█ }}').to.complete('{{ x[0].█ }}', []); + await expect(provider, '{{ product[01█ }}').to.complete('{{ product[01█ }}', []); + }); + it('should complete contextual variables', async () => { const contexts: [string, string][] = [ ['{% paginate all_products by 5 %}{{ pagi█ }}{% endpaginate %}', 'paginate'], diff --git a/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.spec.ts index 42ad76c52..d33386e3e 100644 --- a/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/TranslationCompletionProvider.spec.ts @@ -63,6 +63,21 @@ describe('Module: TranslationCompletionProvider', async () => { ]); }); + it('completes translation keys (not filter names) when the caret is on the string before a pipe', async () => { + // F: the caret sits on the translation-key string, with a `t` filter (or a + // bare pipe) after it. Complete translation keys, not the filter. + await expect(provider).to.complete('{{ "general.█" | t }}', [ + '"general.username_html" | t', + '"general.password" | t', + '"general.comments" | t', + ]); + await expect(provider).to.complete('{{ "general.█" | }}', [ + '"general.username_html" | t', + '"general.password" | t', + '"general.comments" | t', + ]); + }); + it('should add snippet tab stops for translation variables', async () => { await expect(provider).to.complete('{{ "general.comments█" }}', [ '"general.username_html" | t', From 92d24e1d3d92db819fa4517954a63d187efb6ebc Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 17 Jul 2026 14:05:56 +0200 Subject: [PATCH 3/3] Update theme-check-common, prettier-plugin-liquid, and theme-graph for new parser (#1257) * Update theme-check-common, prettier-plugin-liquid, and theme-graph for new parser * Restore baseline fixture output for 10 parser and printer regressions Parser: - paginate tolerant argument parsing - comment/doc depth-balance - conditional comments - filter comma leniency - curly quotes - markdown script - nested same-name HTML tags - inline # indentation Printer: - liquid-doc trims - tablerow branched-body * Add changeset for hand-written liquid-html-parser (#1258) * Remove unnecessary change --- .changeset/hand-written-liquid-html-parser.md | 16 ++ packages/liquid-html-parser/src/ast.test.ts | 20 ++ .../liquid-html-parser/src/document/base.ts | 11 ++ .../src/document/factories.ts | 5 +- .../liquid-html-parser/src/document/html.ts | 59 ++++-- .../src/document/liquid-blocks.ts | 3 + .../src/document/liquid-hybrid.ts | 1 + .../src/document/liquid-lines.test.ts | 37 ++++ .../src/document/liquid-lines.ts | 49 ++++- .../src/document/liquid-raw.ts | 58 ++++++ .../src/document/liquid-tags.ts | 1 + .../src/document/liquid-variable-output.ts | 1 + .../src/document/tokenizer.ts | 36 +++- .../src/document/tolerant-parser.ts | 8 + .../src/liquid-doc/parser.ts | 42 +++++ .../liquid-html-parser/src/markup/parser.ts | 30 ++- .../liquid-html-parser/src/tags/liquid.ts | 6 +- .../liquid-html-parser/src/tags/paginate.ts | 6 + packages/prettier-plugin-liquid/src/parser.ts | 4 +- .../preprocess/augment-with-css-properties.ts | 6 + .../src/printer/print/liquid.ts | 76 ++++++-- .../src/printer/print/tag.ts | 15 +- .../src/printer/printer-liquid-html.ts | 50 ++++- .../src/printer/utils/node.ts | 13 +- .../test/liquid-tag-ifchanged/fixed.liquid | 5 + .../test/liquid-tag-ifchanged/index.liquid | 5 + .../test/liquid-tag-ifchanged/index.spec.ts | 7 + .../prettier-plugin-liquid/vitest.config.mjs | 1 - .../src/checks/cdn-preconnect/index.ts | 48 +++-- .../src/checks/deprecate-lazysizes/index.ts | 57 +++--- .../checks/InvalidBooleanExpression.spec.ts | 2 +- .../checks/InvalidConditionalNode.ts | 10 + .../checks/InvalidFilterName.ts | 94 ++++++++-- .../liquid-html-syntax-error/index.spec.ts | 16 +- .../src/checks/missing-template/index.ts | 16 +- .../src/checks/remote-asset/index.ts | 35 +++- .../index.ts | 4 +- .../theme-check-common/src/checks/utils.ts | 22 +++ .../valid-visible-if/visible-if-utils.ts | 6 +- .../src/disabled-checks/test-checks.ts | 5 +- .../src/liquid-doc/arguments.ts | 46 +++-- .../theme-check-common/src/visitor.spec.ts | 5 +- packages/theme-check-common/src/visitor.ts | 4 +- packages/theme-graph/src/graph/traverse.ts | 4 +- pnpm-lock.yaml | 177 +++++++++--------- 45 files changed, 873 insertions(+), 249 deletions(-) create mode 100644 .changeset/hand-written-liquid-html-parser.md create mode 100644 packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid create mode 100644 packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid create mode 100644 packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts diff --git a/.changeset/hand-written-liquid-html-parser.md b/.changeset/hand-written-liquid-html-parser.md new file mode 100644 index 000000000..0ee06def3 --- /dev/null +++ b/.changeset/hand-written-liquid-html-parser.md @@ -0,0 +1,16 @@ +--- +'@shopify/liquid-html-parser': minor +'@shopify/theme-language-server-common': minor +'@shopify/theme-check-common': minor +'@shopify/prettier-plugin-liquid': minor +'@shopify/theme-graph': minor +--- + +Adopt the hand-written `liquid-html-parser` for performance + +Replace the parser-combinator-based Liquid/HTML parser with a hand-written +recursive-descent parser. The new parser is significantly faster, adds +resilient parsing (it recovers from malformed input instead of bailing), and is +adapted to the theme-tools source model. `theme-language-server-common`, +`theme-check-common`, `prettier-plugin-liquid`, and `theme-graph` are updated to +consume the new parser. diff --git a/packages/liquid-html-parser/src/ast.test.ts b/packages/liquid-html-parser/src/ast.test.ts index 3fc517c4b..996d97a6d 100644 --- a/packages/liquid-html-parser/src/ast.test.ts +++ b/packages/liquid-html-parser/src/ast.test.ts @@ -1345,6 +1345,26 @@ describe('Unit: Stage 2 (AST)', () => { expect(useChild.type).to.eql('TextNode'); }); + it('should depth-balance nested same-name raw tags and close at the outer tag (issue 156)', () => { + const expectPath = makeExpectPath('toLiquidHtmlAST - nested raw close balancing'); + + // Nested elements: the outer must close at the LAST + // , keeping the inner as raw body text rather than + // closing early at the first (which previously threw / mis-parsed). + ast = toLiquidHtmlAST('abc'); + expectPath(ast, 'children').to.have.lengthOf(1); + expectPath(ast, 'children.0.type').to.eql('HtmlRawNode'); + expectPath(ast, 'children.0.name').to.eql('svg'); + expectPath(ast, 'children.0.body.value').to.eql('abc'); + + // Same balancing for a non-svg raw tag (c'); + expectPath(ast, 'children').to.have.lengthOf(1); + expectPath(ast, 'children.0.type').to.eql('HtmlRawNode'); + expectPath(ast, 'children.0.name').to.eql('script'); + expectPath(ast, 'children.0.body.value').to.eql('ac'); + }); + it(`should parse a basic text node into a TextNode`, () => { for (const { toAST, expectPath, expectPosition } of testCases) { ast = toAST('Hello world!'); diff --git a/packages/liquid-html-parser/src/document/base.ts b/packages/liquid-html-parser/src/document/base.ts index e362275a7..6d9125017 100644 --- a/packages/liquid-html-parser/src/document/base.ts +++ b/packages/liquid-html-parser/src/document/base.ts @@ -54,6 +54,17 @@ export class ParserBase { return this.source; } + /** + * Whether this document parse is tolerant. The strict/default parser returns + * false; `TolerantDocumentParser` overrides it to true. Markup-construction + * sites consult this to enable the markup parser's tolerant recovery axis + * (`enableTolerant()`), leaving strict (and theme-check) parses untouched. + * Disjoint from the render-tree lax axis. + */ + isTolerant(): boolean { + return false; + } + tokenAt(index: number): Token { return this.tokens[index]; } diff --git a/packages/liquid-html-parser/src/document/factories.ts b/packages/liquid-html-parser/src/document/factories.ts index 38aba5ff7..1640e6f5b 100644 --- a/packages/liquid-html-parser/src/document/factories.ts +++ b/packages/liquid-html-parser/src/document/factories.ts @@ -105,12 +105,15 @@ export function makeLiquidTagBaseCase( blockEndPosition?: Position, delimiterWhitespace?: { start: LiquidOpenWhitespace; end: LiquidCloseWhitespace }, reason?: string, + // `#`-comment lines in `{% liquid %}` keep their inner indentation verbatim; + // every other base-case tag trims (the default). + preserveMarkup: boolean = false, ): LiquidTagBaseCase { const posEnd = blockEndPosition ? blockEndPosition.end : envelope.blockStartPosition.end; return { type: NodeTypes.LiquidTag, name: envelope.tagName, - markup: envelope.markupString.trim(), + markup: preserveMarkup ? envelope.markupString : envelope.markupString.trim(), children, whitespaceStart: envelope.whitespaceStart, whitespaceEnd: envelope.whitespaceEnd, diff --git a/packages/liquid-html-parser/src/document/html.ts b/packages/liquid-html-parser/src/document/html.ts index 18ad20c67..a296519f4 100644 --- a/packages/liquid-html-parser/src/document/html.ts +++ b/packages/liquid-html-parser/src/document/html.ts @@ -217,11 +217,15 @@ export function parseHtmlComment(parser: HtmlParserDelegate): HtmlComment { const openToken = parser.consume(TokenType.HtmlCommentOpen); const bodyStart = openToken.end; - while (!parser.isAtEnd() && !parser.check(TokenType.HtmlCommentClose)) { - parser.advance(); - } - - if (parser.isAtEnd()) { + // Find the closing `-->` by scanning the source rather than walking tokens. + // A conditional comment body (``) contains + // `` as an HtmlTagClose, so no + // HtmlCommentClose token is ever emitted and the token walk would run to EOF. + // A source scan pins us to the real comment close, so the (unchanged) + // HtmlComment node feeds getConditionalComment correctly in the printer. + const closeIdx = source.indexOf('-->', bodyStart); + if (closeIdx === -1) { throw new LiquidHTMLASTParsingError( `Attempting to end parsing before HtmlComment '` const body = source.slice(bodyStart, bodyEnd).trim(); - return makeHtmlComment(body, openToken.start, closeToken.end, source); + parser.seekToSourceOffset(commentEnd); + + return makeHtmlComment(body, openToken.start, commentEnd, source); } // htmlDoctype := "" @@ -499,7 +505,11 @@ function parseAttributeList(parser: HtmlParserDelegate): AttributeNode[] { const attrEnd = closeQuote.end; const attributePosition: Position = { start: valueStart, end: valueEnd }; - if (quoteChar === '"') { + // Double straight quote and double curly quotes (“ ”) map to a + // double-quoted attr; single straight quote and single curly quotes + // (‘ ’) map to a single-quoted attr. The printer normalizes the curly + // variants to straight quotes. + if (quoteChar === '"' || quoteChar === '“' || quoteChar === '”') { attrs.push( makeAttrDoubleQuoted(name, value, attributePosition, attrStart, attrEnd, source), ); @@ -738,13 +748,39 @@ export function scanForHtmlCloseTag(parser: ParserBase, tagName: string): number const tokenCount = parser.tokenCount(); const pos = parser.getPosition(); + // Depth-balance nested same-name elements so the OUTER close tag is + // returned, not the first inner one (e.g. ``). A + // nested open tag is an `HtmlTagOpen` followed by a `Text` token whose first + // word matches the tag name — the tokenizer folds the tag name and any + // trailing attributes into a single text token, so we take the first word. + // The scan begins after the outer open tag has been consumed, so the outer + // open is never counted. Mirrors `scanForEndTagNested`. + let depth = 0; for (let i = pos; i < tokenCount; i++) { - if (parser.tokenAt(i).type !== TokenType.HtmlCloseTagOpen) continue; + const token = parser.tokenAt(i); + + if (token.type === TokenType.HtmlTagOpen) { + const textIdx = i + 1; + if (textIdx >= tokenCount) continue; + if (parser.tokenAt(textIdx).type !== TokenType.Text) continue; + const trimmed = source + .slice(parser.tokenAt(textIdx).start, parser.tokenAt(textIdx).end) + .trimStart(); + const firstWs = trimmed.search(/\s/); + const name = firstWs === -1 ? trimmed.trim() : trimmed.slice(0, firstWs); + if (name.toLowerCase() === lowerName) depth++; + continue; + } + + if (token.type !== TokenType.HtmlCloseTagOpen) continue; const textIdx = i + 1; if (textIdx >= tokenCount) continue; if (parser.tokenAt(textIdx).type !== TokenType.Text) continue; const text = source.slice(parser.tokenAt(textIdx).start, parser.tokenAt(textIdx).end); - if (text.trim().toLowerCase() === lowerName) return i; + if (text.trim().toLowerCase() === lowerName) { + if (depth === 0) return i; + depth--; + } } return -1; } @@ -768,6 +804,7 @@ function scriptKindFromAttributes(attributes: AttributeNode[]): RawMarkupKinds { const typeValue = extractPlainAttributeValue(attributes, 'type'); if (typeValue === null) return RawMarkupKinds.javascript; if (typeValue === 'text/html') return RawMarkupKinds.html; + if (typeValue === 'text/markdown') return RawMarkupKinds.markdown; if ( typeValue.endsWith('json') || typeValue.endsWith('importmap') || diff --git a/packages/liquid-html-parser/src/document/liquid-blocks.ts b/packages/liquid-html-parser/src/document/liquid-blocks.ts index 470c4ac37..ba08d651e 100644 --- a/packages/liquid-html-parser/src/document/liquid-blocks.ts +++ b/packages/liquid-html-parser/src/document/liquid-blocks.ts @@ -68,6 +68,7 @@ export function parseBlockTag( const markupStringStart = closeToken.start - envelope.markupString.length; const tokens = tokenizeMarkup(envelope.markupString, markupStringStart); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { markup = undefined; @@ -341,6 +342,7 @@ export function parseBranchMarkup( try { const tokens = tokenizeMarkup(envelope.markupString, markupStringStart); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); const result = elsifBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); return result; @@ -352,6 +354,7 @@ export function parseBranchMarkup( try { const tokens = tokenizeMarkup(envelope.markupString, markupStringStart); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); const result = whenBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); return result; diff --git a/packages/liquid-html-parser/src/document/liquid-hybrid.ts b/packages/liquid-html-parser/src/document/liquid-hybrid.ts index 4262f2dc3..f855efa19 100644 --- a/packages/liquid-html-parser/src/document/liquid-hybrid.ts +++ b/packages/liquid-html-parser/src/document/liquid-hybrid.ts @@ -31,6 +31,7 @@ export function parseHybridTag( const markupStringStart = closeToken.start - envelope.markupString.length; const tokens = tokenizeMarkup(envelope.markupString, markupStringStart); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { markup = undefined; diff --git a/packages/liquid-html-parser/src/document/liquid-lines.test.ts b/packages/liquid-html-parser/src/document/liquid-lines.test.ts index 0741ef38a..e5c1729cb 100644 --- a/packages/liquid-html-parser/src/document/liquid-lines.test.ts +++ b/packages/liquid-html-parser/src/document/liquid-lines.test.ts @@ -112,6 +112,14 @@ describe('Unit: liquid-lines', () => { expectPath(ast, 'children.0.markup.1.name').to.eql('echo'); }); + it('should preserve inner indentation after `#`, stripping only one separator space (Category J)', () => { + // `# fancy` must keep four spaces of the ASCII-art indent — only a + // single separator space after `#` is stripped (ohm `"#" space?`). + const ast = toLiquidHtmlAST('{% liquid\n# fancy\necho "hi"\n%}'); + expectPath(ast, 'children.0.markup.0.name').to.eql('#'); + expectPath(ast, 'children.0.markup.0.markup').to.eql(' fancy'); + }); + it('should skip empty lines', () => { const ast = toLiquidHtmlAST('{% liquid\n\necho "hi"\n\n%}'); expectPath(ast, 'children.0.markup').to.have.lengthOf(1); @@ -226,4 +234,33 @@ describe('Unit: liquid-lines', () => { expectPath(ast, `${branch}.blockEndPosition.end`).to.eql(source.indexOf('endif')); }); }); + + describe('nested comment/doc balancing (Category B)', () => { + it('should balance nested comments so the outer endcomment closes the block', () => { + // The inner `comment`/`endcomment` pair must not close the outer + // block early; the outer comment ends at the final `endcomment`. + const source = '{% liquid\ncomment\nouter\ncomment\ninner\nendcomment\nendcomment\n%}'; + const ast = toLiquidHtmlAST(source); + expectPath(ast, 'children.0.markup').to.have.lengthOf(1); + expectPath(ast, 'children.0.markup.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.markup.0.name').to.eql('comment'); + expectPath(ast, 'children.0.markup.0.body.value').to.eql( + 'outer\ncomment\ninner\nendcomment\n', + ); + }); + + it('should carve out a nested raw so a literal endcomment inside it does not close early', () => { + // The `endcomment` line sits inside a nested `raw`…`endraw` block, so it + // must be ignored by the depth scan; the outer comment closes at the + // real trailing `endcomment`. + const source = '{% liquid\ncomment\nbefore\nraw\nendcomment\nendraw\nafter\nendcomment\n%}'; + const ast = toLiquidHtmlAST(source); + expectPath(ast, 'children.0.markup').to.have.lengthOf(1); + expectPath(ast, 'children.0.markup.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.markup.0.name').to.eql('comment'); + expectPath(ast, 'children.0.markup.0.body.value').to.eql( + 'before\nraw\nendcomment\nendraw\nafter\n', + ); + }); + }); }); diff --git a/packages/liquid-html-parser/src/document/liquid-lines.ts b/packages/liquid-html-parser/src/document/liquid-lines.ts index 92499f105..f00cf46a1 100644 --- a/packages/liquid-html-parser/src/document/liquid-lines.ts +++ b/packages/liquid-html-parser/src/document/liquid-lines.ts @@ -64,7 +64,9 @@ export function parseLiquidStatement( const envelope = envelopeFromLine(line, parser.getSource()); if (tagName === '#') { - return makeLiquidTagBaseCase(envelope); + // Preserve the inline comment's inner indentation (`preserveMarkup`); only + // the single separator space after `#` was stripped upstream. + return makeLiquidTagBaseCase(envelope, undefined, undefined, undefined, undefined, true); } if (tagName.startsWith('end')) { @@ -93,6 +95,7 @@ export function parseLiquidStatement( markupOffset, envelope.markupEnd, ); + if (parser.isTolerant()) markupParser.enableTolerant(); const markup = def.parse(tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -140,6 +143,7 @@ export function parseLineBlockTag( try { const tokens = tokenizeMarkup(markupString, markupOffset); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -196,11 +200,41 @@ export function parseLineRawTag( ? ctx.lines[ctx.index - 1].lineEnd + 1 : envelope.blockStartPosition.end; + // `comment`/`doc` bodies balance nested opens of the same tag before + // matching their end line, mirroring the document-path scan in + // liquid-raw.ts (Ruby comment.rb v5.13.0 `comment_tag_depth`). A nested + // `raw` block is carved out: `raw` is first-match and does not nest, so an + // `endcomment`/`comment` word sitting on a line inside a raw body must not + // affect the depth. Every other raw tag (`raw`, `javascript`, `schema`, + // `style`) keeps the original first-match scan and stays byte-identical. + const balanced = tagName === 'comment' || tagName === 'doc'; let endLineIndex = -1; - for (let i = ctx.index; i < ctx.lines.length; i++) { - if (ctx.lines[i].tagName === endTagName) { - endLineIndex = i; - break; + if (balanced) { + let depth = 0; + for (let i = ctx.index; i < ctx.lines.length; i++) { + const name = ctx.lines[i].tagName; + if (name === 'raw') { + // Skip past the nested raw block; its body lines never affect depth. + i++; + while (i < ctx.lines.length && ctx.lines[i].tagName !== 'endraw') i++; + continue; + } + if (name === tagName) { + depth++; + } else if (name === endTagName) { + if (depth === 0) { + endLineIndex = i; + break; + } + depth--; + } + } + } else { + for (let i = ctx.index; i < ctx.lines.length; i++) { + if (ctx.lines[i].tagName === endTagName) { + endLineIndex = i; + break; + } } } @@ -262,6 +296,7 @@ export function parseLineHybridTag( try { const tokens = tokenizeMarkup(markupString, markupOffset); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -385,6 +420,7 @@ export function parseLineBranchedBody( line.tagName as BranchName, branchEnvelope, parser.getSource(), + parser.isTolerant(), ); currentBranch = makeLiquidBranchNamed(branchEnvelope, branchMarkup); currentChildren = []; @@ -423,12 +459,14 @@ export function parseLineBranchMarkup( branchName: BranchName, envelope: LiquidTagEnvelope, source: string, + tolerant: boolean = false, ): unknown { switch (branchName) { case 'elsif': { try { const tokens = tokenizeMarkup(envelope.markupString, envelope.markupOffset); const markupParser = new MarkupParser(tokens, source); + if (tolerant) markupParser.enableTolerant(); const result = elsifBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); @@ -441,6 +479,7 @@ export function parseLineBranchMarkup( try { const tokens = tokenizeMarkup(envelope.markupString, envelope.markupOffset); const markupParser = new MarkupParser(tokens, source); + if (tolerant) markupParser.enableTolerant(); const result = whenBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); diff --git a/packages/liquid-html-parser/src/document/liquid-raw.ts b/packages/liquid-html-parser/src/document/liquid-raw.ts index dd136716a..56690aad5 100644 --- a/packages/liquid-html-parser/src/document/liquid-raw.ts +++ b/packages/liquid-html-parser/src/document/liquid-raw.ts @@ -188,6 +188,14 @@ export function scanForEndTag(parser: ParserBase, endTagName: string): EndTagSca const source = parser.getSource(); const searchStart = parser.tokenAt(parser.getPosition()).start; + // `comment` and `doc` bodies balance nested opens of the same tag before + // matching their end tag, mirroring Ruby comment.rb v5.13.0's + // `comment_tag_depth`. `raw` (and every other raw tag) keeps first-match + // semantics — Ruby's raw does not balance. + if (endTagName === 'endcomment' || endTagName === 'enddoc') { + return scanForBalancedEndTag(source, searchStart, endTagName); + } + const pattern = new RegExp(`\\{%(-?)\\s*${endTagName}\\s*(-?)%\\}`); const match = pattern.exec(source.slice(searchStart)); if (!match) return null; @@ -200,6 +208,56 @@ export function scanForEndTag(parser: ParserBase, endTagName: string): EndTagSca return { tagStart, tagEnd, wsStart, wsEnd }; } +/** + * Depth-balancing end-tag scan for `comment`/`doc`. Counts nested opens of the + * same tag so a `{% comment %}` inside the body is paired with its own + * `{% endcomment %}` rather than closing the outer block early. + * + * `searchStart` is already positioned past the outer open tag, so depth starts + * at 0 and every `{% comment %}` encountered is a nested open. A nested + * `{% raw %}`…`{% endraw %}` is carved out — its literal contents (which may + * contain `{% comment %}`/`{% endcomment %}` text) do not affect the depth — + * mirroring Ruby's `parse_raw_tag_body`. + */ +function scanForBalancedEndTag( + source: string, + searchStart: number, + endTagName: string, +): EndTagScanResult | null { + const openTagName = endTagName.slice(3); // "endcomment" -> "comment" + const scanner = new RegExp( + `\\{%(-?)\\s*(${openTagName}|${endTagName}|raw|endraw)\\b\\s*(-?)%\\}`, + 'g', + ); + scanner.lastIndex = searchStart; + + let depth = 0; + let inRaw = false; + let match: RegExpExecArray | null; + while ((match = scanner.exec(source)) !== null) { + const name = match[2]; + if (inRaw) { + if (name === 'endraw') inRaw = false; + continue; + } + if (name === 'raw') { + inRaw = true; + } else if (name === openTagName) { + depth++; + } else if (name === endTagName) { + if (depth === 0) { + const tagStart = match.index; + const tagEnd = tagStart + match[0].length; + const wsStart: LiquidOpenWhitespace = match[1] === '-' ? '-' : ''; + const wsEnd: LiquidCloseWhitespace = match[3] === '-' ? '-' : ''; + return { tagStart, tagEnd, wsStart, wsEnd }; + } + depth--; + } + } + return null; +} + export function rawMarkupKindForTag(tagName: string, bodySource: string = ''): RawMarkupKinds { switch (tagName) { case 'javascript': diff --git a/packages/liquid-html-parser/src/document/liquid-tags.ts b/packages/liquid-html-parser/src/document/liquid-tags.ts index e23cfff21..604761a90 100644 --- a/packages/liquid-html-parser/src/document/liquid-tags.ts +++ b/packages/liquid-html-parser/src/document/liquid-tags.ts @@ -98,6 +98,7 @@ function parseStandaloneTag( markupStringStart, markupStringEnd, ); + if (parser.isTolerant()) markupParser.enableTolerant(); const markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { return makeLiquidTagBaseCase( diff --git a/packages/liquid-html-parser/src/document/liquid-variable-output.ts b/packages/liquid-html-parser/src/document/liquid-variable-output.ts index d7af5452a..e3ad2f7f2 100644 --- a/packages/liquid-html-parser/src/document/liquid-variable-output.ts +++ b/packages/liquid-html-parser/src/document/liquid-variable-output.ts @@ -17,6 +17,7 @@ export function parseLiquidVariableOutput(parser: ParserBase): LiquidVariableOut try { const tokens = tokenizeMarkup(rawMarkup, openToken.end); const markupParser = new MarkupParser(tokens, source); + if (parser.isTolerant()) markupParser.enableTolerant(); const liquidVariable = markupParser.liquidVariable(); if (!markupParser.isAtEnd()) { diff --git a/packages/liquid-html-parser/src/document/tokenizer.ts b/packages/liquid-html-parser/src/document/tokenizer.ts index f32b2ac1d..dcef56ec4 100644 --- a/packages/liquid-html-parser/src/document/tokenizer.ts +++ b/packages/liquid-html-parser/src/document/tokenizer.ts @@ -250,7 +250,18 @@ export function tokenize(source: string, options: TokenizeOptions = {}): Token[] continue; } - if (ch(0) === '"' || ch(0) === "'") { + // Accept straight quotes and curly (smart) quotes as attribute-value + // openers. Curly quotes get normalized to straight quotes downstream; + // recognizing them here (HTML scope only — Liquid tokenization is + // untouched) stops the value from splitting at interior spaces. + if ( + ch(0) === '"' || + ch(0) === "'" || + ch(0) === '“' || + ch(0) === '”' || + ch(0) === '‘' || + ch(0) === '’' + ) { quoteChar = ch(0); emit(TokenType.HtmlQuoteOpen, 1); pushMode(Mode.QuotedValue); @@ -265,7 +276,10 @@ export function tokenize(source: string, options: TokenizeOptions = {}): Token[] case Mode.QuotedValue: { if (scanLiquidOpen()) continue; - if (ch(0) === quoteChar) { + // Curly quotes are directional, so a value opened with a left curly + // quote closes on its right partner (and vice-versa); straight quotes + // close on themselves. + if (ch(0) === closingQuoteFor(quoteChar) || ch(0) === quoteChar) { emit(TokenType.HtmlQuoteClose, 1); popMode(); continue; @@ -293,3 +307,21 @@ enum Mode { LiquidTag = 'LiquidTag', LiquidVariableOutput = 'LiquidVariableOutput', } + +// Curly (smart) quotes come in directional pairs: a value opened with a left +// curly quote closes on its right partner and vice-versa. Straight quotes are +// their own partner, so they close on an identical character. +function closingQuoteFor(open: string): string { + switch (open) { + case '“': + return '”'; + case '”': + return '“'; + case '‘': + return '’'; + case '’': + return '‘'; + default: + return open; + } +} diff --git a/packages/liquid-html-parser/src/document/tolerant-parser.ts b/packages/liquid-html-parser/src/document/tolerant-parser.ts index 465c1452e..f942cd61f 100644 --- a/packages/liquid-html-parser/src/document/tolerant-parser.ts +++ b/packages/liquid-html-parser/src/document/tolerant-parser.ts @@ -62,6 +62,14 @@ export function makeLiquidErrorNode( * different entry point and is left byte-identical. */ export class TolerantDocumentParser extends DocumentParser { + /** + * Marks this parse as tolerant so the markup-construction sites enable the + * markup parser's tolerant recovery axis. Disjoint from lax. + */ + isTolerant(): boolean { + return true; + } + /* * Wraps the polymorphic node parse. On a LiquidHTMLASTParsingError it emits a * LiquidErrorNode covering the skipped region and resynchronizes onto the next diff --git a/packages/liquid-html-parser/src/liquid-doc/parser.ts b/packages/liquid-html-parser/src/liquid-doc/parser.ts index 364a418a9..b0bb4bec0 100644 --- a/packages/liquid-html-parser/src/liquid-doc/parser.ts +++ b/packages/liquid-html-parser/src/liquid-doc/parser.ts @@ -119,6 +119,18 @@ export class LiquidDocParser { const annotationToken = this.consume(LiquidDocTokenType.Annotation); const name = annotationToken.value; + // `@descriptionText` — no space between `@description` and its content. The + // level-1 tokenizer greedily folds the leading text into the annotation + // name (`descriptionText`), so recognize the `description` prefix here and + // treat the glued remainder as the start of the inline description content. + // This matches the pre-swap parser, which printed `@description Text`. + if ( + name !== LiquidDocAnnotation.Description && + name.startsWith(LiquidDocAnnotation.Description) + ) { + return this.parseGluedDescription(annotationToken); + } + if (!isKnownAnnotation(name)) { return this.parseUnsupportedAnnotation(annotationToken); } @@ -282,6 +294,36 @@ export class LiquidDocParser { ); } + // gluedDescription := "@description" gluedContent (no separating space) + // + // The glued suffix and any same-line text that follows are one inline + // description. Content is read straight from source (not by concatenating + // token values) so the original spacing between the suffix and the trailing + // text is preserved — the suffix's own token boundary is mid-word. + private parseGluedDescription(annotationToken: LiquidDocToken): LiquidDocDescriptionNode { + const start = annotationToken.start; + // Source offset immediately past the literal `@description`; the glued + // remainder (e.g. `This` in `@descriptionThis`) begins here. + const contentStart = start + 1 + LiquidDocAnnotation.Description.length; + + const inlineToken = this.accept(LiquidDocTokenType.TextLine); + const { value: rest, endPos } = this.collectContentLines(); + const hasRest = rest.trim().length > 0; + + let end: number; + if (hasRest) end = endPos; + else if (inlineToken) end = inlineToken.end; + else end = annotationToken.end; + + const content = makeTextNode( + this.source.slice(contentStart, end), + contentStart, + end, + this.source, + ); + return makeLiquidDocDescriptionNode(content, false, true, start, end, this.source); + } + // example := "@example" content private parseExample(annotationToken: LiquidDocToken): LiquidDocExampleNode { const start = annotationToken.start; diff --git a/packages/liquid-html-parser/src/markup/parser.ts b/packages/liquid-html-parser/src/markup/parser.ts index a5a1c5961..de78f751b 100644 --- a/packages/liquid-html-parser/src/markup/parser.ts +++ b/packages/liquid-html-parser/src/markup/parser.ts @@ -52,6 +52,14 @@ export class MarkupParser { * `liquid-render-tree/src/lax-recover.ts`. */ private lax: boolean; + /** + * Tolerant recovery axis, DISJOINT from `lax`. Enabled exclusively by the + * tolerant document parser (`TolerantDocumentParser`) so the formatter can + * build a best-effort tag/markup node instead of discarding markup. It never + * affects strict parsing (`toLiquidHtmlAST` / `theme-check`) and it does NOT + * change lax (Ruby-render-parity) behavior. + */ + private tolerant: boolean; /** * True while parsing a condition (`if`/`unless`/`elsif`). In lax mode this * permits stripping meaningless grouping parens (`(false || true)`), which is @@ -69,6 +77,7 @@ export class MarkupParser { this.markupStart = markupStart ?? tokens[0]?.start ?? 0; this.markupEnd = markupEnd ?? this.computeLastTokenEnd(); this.lax = false; + this.tolerant = false; this.inCondition = false; } @@ -86,6 +95,21 @@ export class MarkupParser { return this.lax; } + /** Enables tolerant recovery for subsequent parse calls. Returns `this` for + * chaining. Only the tolerant document parser calls this. Disjoint from + * lax — enabling one does not enable the other. */ + enableTolerant(): this { + this.tolerant = true; + return this; + } + + /** True when tolerant recovery is enabled. Tag/markup parse callbacks consult + * this to build a best-effort node instead of discarding markup, without + * affecting strict or lax parsing. */ + isTolerant(): boolean { + return this.tolerant; + } + /** Returns the raw source text from the current token up to (but excluding) * the token whose type is `stop` (or end of markup), trimmed. Advances the * cursor past everything consumed. Used by lax recovery to capture an @@ -1173,7 +1197,7 @@ export class MarkupParser { const result: LiquidFilter[] = []; while (this.consumeOptional(MarkupTokenType.Pipe)) { // Lax: a `|` not followed by a filter name (e.g. trailing `|`) is dropped. - if (this.lax && !this.look(MarkupTokenType.Id)) { + if ((this.lax || this.tolerant) && !this.look(MarkupTokenType.Id)) { this.skipToNextPipe(); continue; } @@ -1199,7 +1223,7 @@ export class MarkupParser { if (this.consumeOptional(MarkupTokenType.Colon)) { // Lax: a colon with no following argument (`upcase:`) is tolerated; only // parse arguments when something argument-like actually follows. - if (!this.lax || this.atArgumentStart()) { + if (!(this.lax || this.tolerant) || this.atArgumentStart()) { args = this.arguments(); } if (args.length > 0) { @@ -1259,7 +1283,7 @@ export class MarkupParser { while (this.consumeOptional(MarkupTokenType.Comma)) { // Lax: a trailing or empty comma (`append: "x",`) ends the argument list // rather than forcing another argument parse. - if (this.lax && !this.atArgumentStart()) { + if ((this.lax || this.tolerant) && !this.atArgumentStart()) { break; } args.push(this.argument()); diff --git a/packages/liquid-html-parser/src/tags/liquid.ts b/packages/liquid-html-parser/src/tags/liquid.ts index c2b1e8554..a0d2c4592 100644 --- a/packages/liquid-html-parser/src/tags/liquid.ts +++ b/packages/liquid-html-parser/src/tags/liquid.ts @@ -78,7 +78,11 @@ function parseLines(body: string, bodyStart: number): LiquidLine[] { if (firstLineTrimmed.startsWith('#')) { tagName = '#'; - markup = trimmed.slice(1).trimStart(); + // Strip at most one separator whitespace after `#`, mirroring the + // pre-swap ohm grammar `"#" space?`. Trimming all leading whitespace + // would collapse intentional indentation in `#`-comment art such as + // `# fancy`, which the printer re-pads with a single space. + markup = trimmed.slice(1).replace(/^[ \t]/, ''); nameOffset = startIndex + leadingWs; markupOffset = startIndex + leadingWs + 1 + (trimmed.length - 1 - markup.length); } else { diff --git a/packages/liquid-html-parser/src/tags/paginate.ts b/packages/liquid-html-parser/src/tags/paginate.ts index 8bd707721..9d8422bcb 100644 --- a/packages/liquid-html-parser/src/tags/paginate.ts +++ b/packages/liquid-html-parser/src/tags/paginate.ts @@ -19,6 +19,12 @@ export const paginateTag: TagDefinitionBlock = { const args: LiquidNamedArgument[] = []; if (markup.consumeOptional(MarkupTokenType.Comma)) { args.push(...markup.namedArguments()); + } else if (markup.isTolerant() && markup.peek().type === MarkupTokenType.Id) { + // Tolerant: paginate accepts whitespace-separated named attrs with no + // leading comma (`... by N window_size: 50`). Consume them so the tag + // builds as PaginateMarkup instead of leaving leftover tokens. Strict is + // unchanged. + args.push(...markup.namedArguments()); } return { diff --git a/packages/prettier-plugin-liquid/src/parser.ts b/packages/prettier-plugin-liquid/src/parser.ts index 143889a7a..51bcb66d8 100644 --- a/packages/prettier-plugin-liquid/src/parser.ts +++ b/packages/prettier-plugin-liquid/src/parser.ts @@ -1,9 +1,9 @@ -import { toLiquidHtmlAST, LiquidHtmlNode } from '@shopify/liquid-html-parser'; +import { toTolerantLiquidHtmlAST, LiquidHtmlNode } from '@shopify/liquid-html-parser'; import { locEnd, locStart } from './utils'; export function parse(text: string): LiquidHtmlNode { - return toLiquidHtmlAST(text); + return toTolerantLiquidHtmlAST(text); } export const liquidHtmlAstFormat = 'liquid-html-ast'; diff --git a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts index f5d204b09..9aac64c54 100644 --- a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts +++ b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts @@ -121,6 +121,8 @@ function getCssDisplay(node: AugmentedNode, options: LiquidParserO case NodeTypes.Range: case NodeTypes.VariableLookup: case NodeTypes.AssignMarkup: + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: case NodeTypes.CycleMarkup: case NodeTypes.ContentForMarkup: case NodeTypes.ForMarkup: @@ -134,6 +136,7 @@ function getCssDisplay(node: AugmentedNode, options: LiquidParserO case NodeTypes.LiquidDocExampleNode: case NodeTypes.LiquidDocDescriptionNode: case NodeTypes.LiquidDocPromptNode: + case NodeTypes.LiquidErrorNode: return 'should not be relevant'; default: @@ -232,6 +235,8 @@ function getNodeCssStyleWhiteSpace( case NodeTypes.Range: case NodeTypes.VariableLookup: case NodeTypes.AssignMarkup: + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: case NodeTypes.CycleMarkup: case NodeTypes.ContentForMarkup: case NodeTypes.ForMarkup: @@ -245,6 +250,7 @@ function getNodeCssStyleWhiteSpace( case NodeTypes.LiquidDocExampleNode: case NodeTypes.LiquidDocDescriptionNode: case NodeTypes.LiquidDocPromptNode: + case NodeTypes.LiquidErrorNode: return 'should not be relevant'; default: diff --git a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts index 7e8628311..3e750ea66 100644 --- a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts +++ b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts @@ -7,6 +7,7 @@ import { LiquidDocExampleNode, LiquidDocDescriptionNode, LiquidDocPromptNode, + TAGS_WITHOUT_MARKUP, } from '@shopify/liquid-html-parser'; import { Doc, doc } from 'prettier'; @@ -192,13 +193,28 @@ function printNamedLiquidBlockStart( case NamedTags.increment: case NamedTags.decrement: case NamedTags.layout: - case NamedTags.section: { + case NamedTags.section: + /* + * `block` prints like `section` (both carry a name plus optional + * named arguments) and `partial` prints like `sections` (a bare + * string markup). Both delegate markup rendering to `printNode`. + */ + case NamedTags.block: + case NamedTags.partial: { return tag(' '); } case NamedTags.sections: { return tag(' '); } + /* + * `ifchanged` has no markup (`markup: null`), so we print just the + * tag name — there is nothing to render between the name and `%}`. + */ + case NamedTags.ifchanged: { + return wrapper([...prefix, node.name, ...suffix(' ')]); + } + case NamedTags.form: { const trailingWhitespace = node.markup.length > 1 ? line : ' '; return tagWithArrayMarkup(trailingWhitespace); @@ -342,16 +358,16 @@ export function printLiquidBlockStart( } const markup = node.markup; - return group([ - '{%', - whitespaceStart, - ' ', - node.name, - markup ? ` ${markup}` : '', - ' ', - whitespaceEnd, - '%}', - ]); + /* + * A few tags — `break`, `continue`, `else`, and friends listed in + * TAGS_WITHOUT_MARKUP — accept no markup at all. When one of them is + * written with stray arguments, e.g. `{% break huh?? %}`, we drop the + * markup rather than echo the invalid text back out. This restores the + * pre-port behaviour, where the parser blanked the markup before the + * printer ever saw it. + */ + const printedMarkup = markup && !TAGS_WITHOUT_MARKUP.includes(node.name) ? ` ${markup}` : ''; + return group(['{%', whitespaceStart, ' ', node.name, printedMarkup, ' ', whitespaceEnd, '%}']); } export function printLiquidBlockEnd( @@ -426,7 +442,15 @@ export function printLiquidTag( let body: Doc = []; - if (isBranchedTag(node)) { + /* + * `tablerow` is not a branched tag, but like `for` it wraps its body in a + * single default `LiquidBranch`. Routing it through `printChildren` (the + * non-branched path below) would indent that branch twice and prepend a + * blank line; the branched-path `path.map` prints the default branch with + * `for`'s single-indent, no-leading-blank shape. This is a printer-only + * special-case — `tablerow` is deliberately kept out of `isBranchedTag`. + */ + if (isBranchedTag(node) || node.name === 'tablerow') { body = cleanDoc( path.map( (p) => @@ -480,7 +504,13 @@ export function printLiquidRawTag( ' ', node.name, ' ', - node.markup ? `${node.markup} ` : '', + /* + * Argument-less raw tags such as `style` keep no markup. When one + * carries stray arguments, e.g. `{% style what %}`, we strip them + * instead of printing the invalid text; other raw tags keep their + * markup as before. + */ + node.markup && !TAGS_WITHOUT_MARKUP.includes(node.name) ? `${node.markup} ` : '', node.whitespaceEnd, '%}', ]); @@ -530,16 +560,30 @@ export function printLiquidDocParam( _args: LiquidPrinterArgs, ): Doc { const node = path.getValue(); + + /* + * A malformed `@param` line (e.g. an unclosed `[missingTail`) parses to a + * degenerate node with an empty `paramName`. Synthesizing the parts below + * would emit a spurious `@param - ...`, so emit the raw source span + * verbatim instead to preserve a faithful round-trip. `@param` is kept as + * the first part so `printLiquidDoc`'s tag grouping still treats this as a + * `@param` node and does not insert a blank line before the next param. + */ + if (node.paramName.value === '') { + const raw = node.source.slice(node.position.start, node.position.end); + return ['@param', raw.slice('@param'.length)]; + } + const parts: Doc[] = ['@param']; if (node.paramType?.value) { - parts.push(' ', `{${node.paramType.value}}`); + parts.push(' ', `{${node.paramType.value.trim()}}`); } if (node.required) { - parts.push(' ', node.paramName.value); + parts.push(' ', node.paramName.value.trim()); } else { - parts.push(' ', `[${node.paramName.value}]`); + parts.push(' ', `[${node.paramName.value.trim()}]`); } if (node.paramDescription?.value) { diff --git a/packages/prettier-plugin-liquid/src/printer/print/tag.ts b/packages/prettier-plugin-liquid/src/printer/print/tag.ts index 3c28506ee..675ce5bda 100644 --- a/packages/prettier-plugin-liquid/src/printer/print/tag.ts +++ b/packages/prettier-plugin-liquid/src/printer/print/tag.ts @@ -437,11 +437,18 @@ function getCompoundName( .map((part) => { if (part.type === NodeTypes.TextNode) { return part.value; - } else if (typeof part.markup === 'string') { - return `{{ ${part.markup.trim()} }}`; - } else { - return `{{ ${part.markup.rawSource} }}`; } + if (part.type === NodeTypes.LiquidVariableOutput) { + return typeof part.markup === 'string' + ? `{{ ${part.markup.trim()} }}` + : `{{ ${part.markup.rawSource} }}`; + } + /* + * Remaining compound-name arms are LiquidTag | LiquidRawTag + * (e.g. `<{% if c %}a{% endif %}>`). Neither carries a `.rawSource`, + * so reproduce the original source span verbatim. + */ + return part.source.slice(part.position.start, part.position.end); }) .join(''); } diff --git a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts index 57f4f91bf..1f61701e0 100644 --- a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts +++ b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts @@ -194,6 +194,18 @@ function printNode( args: LiquidPrinterArgs = {}, ): Doc { const node = path.getValue(); + + if ((node as any).type === 'BlockArrayLiteral') { + return [ + '[', + join( + [',', line], + (path as any).map((p: any) => print(p), 'elements'), + ), + ']', + ]; + } + switch (node.type) { case NodeTypes.Document: { return [printChildren(path as AstPath, options, print, args), hardline]; @@ -343,7 +355,7 @@ function printNode( whitespace, join( [',', whitespace], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ); @@ -362,7 +374,7 @@ function printNode( line, join( line, - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ]); } @@ -384,7 +396,7 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ]); } @@ -401,7 +413,29 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), + ), + ); + } + + return doc; + } + + /* + * `block` and `section` markup share the same shape: a name (a + * LiquidString) followed by optional named arguments. We print them + * the same way `content_for` prints its markup. + */ + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: { + const doc: Doc = [path.call((p: any) => print(p), 'name')]; + if (node.args.length > 0) { + doc.push( + ',', + line, + join( + [',', line], + path.map((p: any) => print(p), 'args'), ), ); } @@ -428,7 +462,7 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ); } @@ -479,7 +513,7 @@ function printNode( let args: Doc[] = []; if (node.args.length > 0) { - const printed = path.map((p) => print(p), 'args'); + const printed = path.map((p: any) => print(p), 'args'); const shouldPrintFirstArgumentSameLine = node.args[0].type !== NodeTypes.NamedArgument; if (shouldPrintFirstArgumentSameLine) { @@ -590,6 +624,10 @@ function printNode( return printLiquidDocPrompt(path as AstPath, options, print, args); } + case NodeTypes.LiquidErrorNode: { + return node.source.slice(node.position.start, node.position.end); + } + default: { return assertNever(node); } diff --git a/packages/prettier-plugin-liquid/src/printer/utils/node.ts b/packages/prettier-plugin-liquid/src/printer/utils/node.ts index 42c723bc1..5341de3e8 100644 --- a/packages/prettier-plugin-liquid/src/printer/utils/node.ts +++ b/packages/prettier-plugin-liquid/src/printer/utils/node.ts @@ -1,4 +1,10 @@ -import { NodeTypes, LiquidNodeTypes, HtmlNodeTypes, Position } from '@shopify/liquid-html-parser'; +import { + NodeTypes, + LiquidNodeTypes, + HtmlNodeTypes, + Position, + CompoundNameSegment, +} from '@shopify/liquid-html-parser'; import { HtmlSelfClosingElement, LiquidHtmlNode, @@ -330,10 +336,7 @@ export function getLastDescendant(node: LiquidHtmlNode): LiquidHtmlNode { return node.lastChild ? getLastDescendant(node.lastChild) : node; } -function isTagNameIncluded( - collection: string[], - name: (TextNode | LiquidVariableOutput)[], -): boolean { +function isTagNameIncluded(collection: string[], name: CompoundNameSegment[]): boolean { if (name.length !== 1 || name[0].type !== NodeTypes.TextNode) return false; return collection.includes(name[0].value); } diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid new file mode 100644 index 000000000..a6b10d22f --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid @@ -0,0 +1,5 @@ +It should format ifchanged block tags +{% ifchanged %}content{% endifchanged %} + +It should normalize whitespace control on ifchanged tags +{%- ifchanged -%}content{%- endifchanged -%} diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid new file mode 100644 index 000000000..f8130f74a --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid @@ -0,0 +1,5 @@ +It should format ifchanged block tags +{% ifchanged %}content{% endifchanged %} + +It should normalize whitespace control on ifchanged tags +{%- ifchanged -%}content{%- endifchanged -%} diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts new file mode 100644 index 000000000..0a3e8d617 --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts @@ -0,0 +1,7 @@ +import { test } from 'vitest'; +import { assertFormattedEqualsFixed } from '../test-helpers'; +import * as path from 'path'; + +test('Unit: liquid-tag-ifchanged', async () => { + await assertFormattedEqualsFixed(__dirname); +}); diff --git a/packages/prettier-plugin-liquid/vitest.config.mjs b/packages/prettier-plugin-liquid/vitest.config.mjs index 8c27203ac..488dbfd86 100644 --- a/packages/prettier-plugin-liquid/vitest.config.mjs +++ b/packages/prettier-plugin-liquid/vitest.config.mjs @@ -9,6 +9,5 @@ export default defineConfig({ maxWorkers: 1, isolate: true, globalSetup: ['./src/test/test-setup.js'], - setupFiles: ['../liquid-html-parser/build/shims.js'], }, }); diff --git a/packages/theme-check-common/src/checks/cdn-preconnect/index.ts b/packages/theme-check-common/src/checks/cdn-preconnect/index.ts index 0bb04972a..12b4b60e1 100644 --- a/packages/theme-check-common/src/checks/cdn-preconnect/index.ts +++ b/packages/theme-check-common/src/checks/cdn-preconnect/index.ts @@ -1,5 +1,6 @@ +import { HtmlSelfClosingElement, HtmlVoidElement } from '@shopify/liquid-html-parser'; import { Severity, SourceCodeType, LiquidCheckDefinition } from '../../types'; -import { isAttr, isValuedHtmlAttribute, valueIncludes } from '../utils'; +import { getHtmlNodeName, isAttr, isValuedHtmlAttribute, valueIncludes } from '../utils'; export const CdnPreconnect: LiquidCheckDefinition = { meta: { @@ -17,26 +18,37 @@ export const CdnPreconnect: LiquidCheckDefinition = { }, create(context) { - return { - async HtmlVoidElement(node) { - if (node.name !== 'link') return; + function checkNode(node: HtmlVoidElement | HtmlSelfClosingElement) { + if (getHtmlNodeName(node) !== 'link') return; + + const isPreconnect = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'rel') && valueIncludes(attr, 'preconnect')); + if (!isPreconnect) return; - const isPreconnect = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'rel') && valueIncludes(attr, 'preconnect')); - if (!isPreconnect) return; + const isShopifyCdn = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'href') && valueIncludes(attr, '.+cdn.shopify.com.+')); + if (!isShopifyCdn) return; - const isShopifyCdn = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'href') && valueIncludes(attr, '.+cdn.shopify.com.+')); - if (!isShopifyCdn) return; + context.report({ + message: + 'Preconnecting to cdn.shopify.com is unnecessary and can lead to worse performance', + startIndex: node.position.start, + endIndex: node.position.end, + }); + } - context.report({ - message: - 'Preconnecting to cdn.shopify.com is unnecessary and can lead to worse performance', - startIndex: node.position.start, - endIndex: node.position.end, - }); + return { + async HtmlVoidElement(node) { + checkNode(node); + }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as ``, whereas the previous parser emitted + // `HtmlVoidElement` regardless of the trailing slash. Visit both so the + // check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkNode(node); }, }; }, diff --git a/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts b/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts index 783582d32..2986faa48 100644 --- a/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts +++ b/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts @@ -1,6 +1,8 @@ +import { HtmlSelfClosingElement, HtmlVoidElement } from '@shopify/liquid-html-parser'; import { Severity, SourceCodeType, LiquidCheckDefinition } from '../../types'; import { ValuedHtmlAttribute, + getHtmlNodeName, isAttr, isValuedHtmlAttribute, isHtmlAttribute, @@ -28,30 +30,41 @@ export const DeprecateLazysizes: LiquidCheckDefinition = { }, create(context) { + function checkNode(node: HtmlVoidElement | HtmlSelfClosingElement) { + if (getHtmlNodeName(node) !== 'img') return; + + const attributes = node.attributes.filter(isHtmlAttribute); + const hasSrc = attributes.some((attr) => isAttr(attr, 'src')); + const hasNativeLoading = attributes.some((attr) => isAttr(attr, 'loading')); + if (hasSrc && hasNativeLoading) return; + + const hasLazyloadClass = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'class') && valueIncludes(attr, 'lazyload')); + if (!hasLazyloadClass) return; + + const hasLazysizesAttribute = node.attributes + .filter(isValuedHtmlAttribute) + .some(showsLazysizesUsage); + if (!hasLazysizesAttribute) return; + + context.report({ + message: 'Use the native loading="lazy" attribute instead of lazysizes', + startIndex: node.position.start, + endIndex: node.position.end, + }); + } + return { async HtmlVoidElement(node) { - if (node.name !== 'img') return; - - const attributes = node.attributes.filter(isHtmlAttribute); - const hasSrc = attributes.some((attr) => isAttr(attr, 'src')); - const hasNativeLoading = attributes.some((attr) => isAttr(attr, 'loading')); - if (hasSrc && hasNativeLoading) return; - - const hasLazyloadClass = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'class') && valueIncludes(attr, 'lazyload')); - if (!hasLazyloadClass) return; - - const hasLazysizesAttribute = node.attributes - .filter(isValuedHtmlAttribute) - .some(showsLazysizesUsage); - if (!hasLazysizesAttribute) return; - - context.report({ - message: 'Use the native loading="lazy" attribute instead of lazysizes', - startIndex: node.position.start, - endIndex: node.position.end, - }); + checkNode(node); + }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as ``, whereas the previous parser emitted + // `HtmlVoidElement` regardless of the trailing slash. Visit both so the + // check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkNode(node); }, }; }, diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts index 88af441fc..fc0afd32a 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts @@ -28,7 +28,7 @@ describe('detectTrailingAssignValue', async () => { [`{% assign foo = something == else %}`, '{% assign foo = something %}'], [`{% echo foo != bar %}`, '{% echo foo %}'], [`{{ this > that }}`, '{{ this }}'], - [`{{ bool and cond }}`, '{{ bool}}'], + [`{{ bool and cond }}`, '{{ bool }}'], ]; for (const [sourceCode, expected] of testCases) { diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts index a1a62ac83..ed20aa5ef 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts @@ -70,6 +70,16 @@ function isOperatorToken(token: Token): boolean { function checkInvalidStartingToken(tokens: Token[]): ExpressionIssue | null { const firstToken = tokens[0]; + // `contains` is the only word-operator in the comparison pattern that is + // also a valid identifier (the symbolic operators == != >= <= > < never + // are). When it stands alone it is a bare variable named `contains`, not + // the comparison operator, so it must not be flagged as an invalid + // starting token. This mirrors the previous parser, which produced a + // structured VariableLookup here; the ported parser falls back to string + // markup instead. + if (tokens.length === 1 && firstToken.value === 'contains') { + return null; + } if (firstToken.type === 'invalid' || firstToken.type === 'comparison') { return { message: `Conditional cannot start with '${firstToken.value}'. Use a variable or value instead`, diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts index ee98a086d..66458117c 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts @@ -1,4 +1,10 @@ -import { LiquidVariableOutput, LiquidTag, NodeTypes, NamedTags } from '@shopify/liquid-html-parser'; +import { + LiquidVariableOutput, + LiquidTag, + LiquidVariable, + NodeTypes, + NamedTags, +} from '@shopify/liquid-html-parser'; import { Problem, SourceCodeType, Context, FilterEntry } from '../../..'; import { INVALID_SYNTAX_MESSAGE } from './utils'; @@ -11,30 +17,90 @@ export async function detectInvalidFilterName( } if (node.type === NodeTypes.LiquidVariableOutput) { - if (typeof node.markup !== 'string') { - return []; + // When the markup parses cleanly the parser hands us a structured + // `LiquidVariable`; only a bail-out leaves the raw markup as a string. + // Both carry the same offenses, they just live in different places. + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); } - return detectInvalidFilterNameInMarkup(node, node.markup, filters); + return detectInvalidFilterNameInFilters(node, node.markup, filters); } if (node.type === NodeTypes.LiquidTag) { - if (node.name === NamedTags.echo && typeof node.markup !== 'string') { - return []; - } - if (node.name === NamedTags.assign && typeof node.markup !== 'string') { - return []; + if (node.name === NamedTags.echo) { + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); + } + if (node.markup.type === NodeTypes.LiquidVariable) { + return detectInvalidFilterNameInFilters(node, node.markup, filters); + } } - if ( - typeof node.markup === 'string' && - (node.name === NamedTags.echo || node.name === NamedTags.assign) - ) { - return detectInvalidFilterNameInMarkup(node, node.markup, filters); + + if (node.name === NamedTags.assign) { + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); + } + if (node.markup.type === NodeTypes.AssignMarkup) { + return detectInvalidFilterNameInFilters(node, node.markup.value, filters); + } } } return []; } +// When the tokenizer meets an invalid trailing character (`@`, `!`, `#`, ...) +// it silently drops the byte, so the filter parses cleanly and the markup ends +// up as a structured `LiquidVariable` — the raw-markup regex above never sees +// it. The dropped byte still lives in the document `source` right after the +// filter name, so we recover the offense from the source span instead. +async function detectInvalidFilterNameInFilters( + node: LiquidVariableOutput | LiquidTag, + variable: LiquidVariable, + filters: FilterEntry[], +): Promise[]> { + const knownFilters = filters; + const source = node.source; + const markupEnd = variable.position.end; + const problems: Problem[] = []; + + for (const filter of variable.filters) { + if (!knownFilters.some((known) => known.name === filter.name)) { + continue; + } + + // `filter.position.start` sits before this filter's pipe, so the first + // occurrence of the name from there is this filter's name. + const nameStartInSource = source.indexOf(filter.name, filter.position.start); + if (nameStartInSource === -1) { + continue; + } + + const trailingStartInSource = nameStartInSource + filter.name.length; + + // Capture the run of characters wedged between the filter name and its + // next valid boundary (whitespace, `:` before arguments, `|` before the + // next filter, or the end of the markup). Anything there is junk. + const trailing = source.slice(trailingStartInSource, markupEnd).match(/^([^\s:|]+)/)?.[1]; + if (!trailing) { + continue; + } + + const trailingEndInSource = trailingStartInSource + trailing.length; + + problems.push({ + message: `${INVALID_SYNTAX_MESSAGE} Filter '${filter.name}' has trailing characters '${trailing}' that should be removed.`, + startIndex: trailingStartInSource, + endIndex: trailingEndInSource, + fix: (corrector) => { + corrector.replace(trailingStartInSource, trailingEndInSource, ''); + }, + }); + } + + return problems; +} + async function detectInvalidFilterNameInMarkup( node: LiquidVariableOutput | LiquidTag, markup: string, diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts index 566b273c8..0858f1b35 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts @@ -54,7 +54,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - `Attempting to close LiquidTag 'if' before HtmlElement 'a' was closed`, + `Attempting to close LiquidTag 'endif' before HtmlElement 'a' was closed`, ); }); @@ -65,7 +65,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal(`SyntaxError: expected "%}"`); + expect(offenses[0].message).to.equal(`Expected LiquidTagClose but got EndOfInput`); }); it('should report unexpected tokens (2)', async () => { @@ -75,7 +75,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal(`SyntaxError: expected ">", not """`); + expect(offenses[0].message).to.equal(`Expected HtmlTagClose but got EndOfInput`); }); it('should report unexpected tokens (3)', async () => { @@ -85,9 +85,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", "comment", or "doc"`, - ); + expect(offenses[0].message).to.equal(`Expected LiquidTagClose but got EndOfInput`); }); it('should not report syntax error in valid Liquid code', async () => { @@ -114,16 +112,16 @@ describe('Module: LiquidHTMLSyntaxError', () => { source = `
`; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include('
'); + expect(highlights).to.include(''); source = ``; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include('{% endif %}'); + expect(highlights).to.include(''); source = ``; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include('"'); + expect(highlights).to.include('>'); }); }); diff --git a/packages/theme-check-common/src/checks/missing-template/index.ts b/packages/theme-check-common/src/checks/missing-template/index.ts index f1cda6a07..d2b3c4b67 100644 --- a/packages/theme-check-common/src/checks/missing-template/index.ts +++ b/packages/theme-check-common/src/checks/missing-template/index.ts @@ -58,7 +58,12 @@ export const MissingTemplate: LiquidCheckDefinition = { return { async RenderMarkup(node) { - if (node.snippet.type === NodeTypes.VariableLookup) return; + if ( + node.snippet.type === NodeTypes.VariableLookup || + node.snippet.type === NodeTypes.Range + ) { + return; + } const snippet = node.snippet; const relativePath = `snippets/${snippet.value}.liquid`; @@ -71,9 +76,14 @@ export const MissingTemplate: LiquidCheckDefinition = { if (node.name !== NamedTags.section) return; const markup = node.markup; - const relativePath = `sections/${markup.value}.liquid`; + // The ported parser wraps the section name in a `SectionMarkup` node + // whose name lives at `markup.name` (a String node); the previous + // parser exposed the name directly as `markup.value`. Read the name and + // report against the String node so the offense covers the quoted + // string rather than the whole markup. + const relativePath = `sections/${markup.name.value}.liquid`; - await maybeReportMissing(relativePath, markup); + await maybeReportMissing(relativePath, markup.name); }, }; }, diff --git a/packages/theme-check-common/src/checks/remote-asset/index.ts b/packages/theme-check-common/src/checks/remote-asset/index.ts index 31ff07da3..3baefd36d 100644 --- a/packages/theme-check-common/src/checks/remote-asset/index.ts +++ b/packages/theme-check-common/src/checks/remote-asset/index.ts @@ -1,5 +1,6 @@ import { HtmlRawNode, + HtmlSelfClosingElement, HtmlVoidElement, TextNode, LiquidVariable, @@ -14,7 +15,13 @@ import { LiquidHtmlNode, SchemaProp, } from '../../types'; -import { isAttr, isValuedHtmlAttribute, isNodeOfType, ValuedHtmlAttribute } from '../utils'; +import { + getHtmlNodeName, + isAttr, + isValuedHtmlAttribute, + isNodeOfType, + ValuedHtmlAttribute, +} from '../utils'; import { last } from '../../utils'; const RESOURCE_TAGS = ['img', 'link', 'source', 'script']; @@ -51,6 +58,10 @@ function isDataUri(url: string): boolean { return /^data:/i.test(url); } +function cleanUrlText(url: string): string { + return url.replace(/[“”‘’]/g, ''); +} + /** * Checks if the attribute value starts with a variable lookup. * When a value starts with a VariableLookup (e.g., {{ source.url }}, {{ image }}), @@ -89,8 +100,8 @@ function valueIsDefinitelyNotShopifyHosted( allowedDomains: string[] = [], ): boolean { return attr.value.some((node) => { - if (node.type === NodeTypes.TextNode && /^(https?:)?\/\//.test(node.value)) { - if (!isUrlHostedbyShopify(node.value, allowedDomains)) { + if (node.type === NodeTypes.TextNode && /^(https?:)?\/\//.test(cleanUrlText(node.value))) { + if (!isUrlHostedbyShopify(cleanUrlText(node.value), allowedDomains)) { return true; } } @@ -175,8 +186,9 @@ export const RemoteAsset: LiquidCheckDefinition = { create(context) { const allowedDomains = normaliseAllowedDomains(context.settings.allowedDomains || []); - function checkHtmlNode(node: HtmlVoidElement | HtmlRawNode) { - if (!RESOURCE_TAGS.includes(node.name)) return; + function checkHtmlNode(node: HtmlVoidElement | HtmlSelfClosingElement | HtmlRawNode) { + const nodeName = getHtmlNodeName(node); + if (!nodeName || !RESOURCE_TAGS.includes(nodeName)) return; const urlAttribute: ValuedHtmlAttribute | undefined = node.attributes .filter(isValuedHtmlAttribute) @@ -187,14 +199,14 @@ export const RemoteAsset: LiquidCheckDefinition = { const firstTextNode = urlAttribute.value.find( (node): node is TextNode => node.type === NodeTypes.TextNode, ); - if (firstTextNode && isHashUrl(firstTextNode.value)) return; - if (firstTextNode && isDataUri(firstTextNode.value)) return; + if (firstTextNode && isHashUrl(cleanUrlText(firstTextNode.value))) return; + if (firstTextNode && isDataUri(cleanUrlText(firstTextNode.value))) return; if (startsWithVariableLookup(urlAttribute)) return; const isShopifyUrl = urlAttribute.value .filter((node): node is TextNode => node.type === NodeTypes.TextNode) - .some((textNode) => isUrlHostedbyShopify(textNode.value, allowedDomains)); + .some((textNode) => isUrlHostedbyShopify(cleanUrlText(textNode.value), allowedDomains)); if (isShopifyUrl) return; @@ -265,6 +277,13 @@ export const RemoteAsset: LiquidCheckDefinition = { async HtmlVoidElement(node) { checkHtmlNode(node); }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as `` and ``, whereas the previous + // parser emitted `HtmlVoidElement` regardless of the trailing slash. + // Visit both so the check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkHtmlNode(node); + }, async HtmlRawNode(node) { checkHtmlNode(node); }, diff --git a/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts b/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts index e7588831d..16eef77e5 100644 --- a/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts +++ b/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts @@ -34,7 +34,7 @@ export const UnrecognizedRenderSnippetArguments: LiquidCheckDefinition = { const variable = node.variable; if (alias && !liquidDocParameters.has(alias.value) && variable) { - const startIndex = variable.position.start + 1; + const startIndex = variable.position.start; context.report({ message: `Unknown argument '${alias.value}' in render tag for snippet '${snippetName}'.`, @@ -45,7 +45,7 @@ export const UnrecognizedRenderSnippetArguments: LiquidCheckDefinition = { message: `Remove '${alias.value}'`, fix: (fixer: any) => { if (variable) { - return fixer.remove(variable.position.start, alias.position.end); + return fixer.remove(variable.position.start - 1, alias.position.end); } }, }, diff --git a/packages/theme-check-common/src/checks/utils.ts b/packages/theme-check-common/src/checks/utils.ts index 73a89500c..bb7a483c1 100644 --- a/packages/theme-check-common/src/checks/utils.ts +++ b/packages/theme-check-common/src/checks/utils.ts @@ -2,6 +2,9 @@ import { Position, NodeTypes, HtmlElement, + HtmlSelfClosingElement, + HtmlVoidElement, + HtmlRawNode, TextNode, AttrEmpty, AttrSingleQuoted, @@ -31,6 +34,25 @@ export function isLiquidBranch(node: LiquidHtmlNode): node is LiquidBranch { return isNodeOfType(NodeTypes.LiquidBranch, node); } +/** + * Returns the static tag name of an HTML node as a string. + * + * Void and raw nodes (``, `