Skip to content

Commit

Permalink
Support liquid doc inner tags completion + hover (#789)
Browse files Browse the repository at this point in the history
  • Loading branch information
aswamy authored Feb 18, 2025
1 parent 2db3047 commit 261c295
Show file tree
Hide file tree
Showing 13 changed files with 312 additions and 2 deletions.
11 changes: 11 additions & 0 deletions .changeset/soft-ladybugs-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@shopify/theme-language-server-common': minor
'@shopify/liquid-html-parser': minor
---

Support liquid doc inner tags completion + hover

- `@param`, `@description`, `@example` will support code completion
whenever being typed inside of `doc` tag
- `@param`, `@description`, `@example` can be hovered to show their
help doc
12 changes: 12 additions & 0 deletions packages/liquid-html-parser/grammar/liquid-html.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@ WithPlaceholderLiquid <: Liquid {
snippetExpression renderVariableExpression? renderAliasExpression? completionModeRenderArguments
liquidTagName := (letter | "█") (alnum | "_")*
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
liquidDoc :=
liquidDocStart
liquidDocBody
liquidDocEnd?
}

WithPlaceholderLiquidStatement <: LiquidStatement {
Expand All @@ -571,6 +575,10 @@ WithPlaceholderLiquidStatement <: LiquidStatement {
snippetExpression renderVariableExpression? renderAliasExpression? completionModeRenderArguments
liquidTagName := (letter | "█") (alnum | "_")*
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
liquidDoc :=
liquidDocStart
liquidDocBody
liquidDocEnd?
}

WithPlaceholderLiquidHTML <: LiquidHTML {
Expand All @@ -583,4 +591,8 @@ WithPlaceholderLiquidHTML <: LiquidHTML {
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
leadingTagNameTextNode := (letter | "█") (alnum | "-" | ":" | "█")*
trailingTagNameTextNode := (alnum | "-" | ":" | "█")+
liquidDoc :=
liquidDocStart
liquidDocBody
liquidDocEnd?
}
4 changes: 2 additions & 2 deletions packages/liquid-html-parser/src/stage-1-cst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,8 @@ function toCST<T>(
},
whitespaceStart: (tokens: Node[]) => tokens[0].children[1].sourceString,
whitespaceEnd: (tokens: Node[]) => tokens[0].children[7].sourceString,
delimiterWhitespaceStart: (tokens: Node[]) => tokens[2].children[1].sourceString,
delimiterWhitespaceEnd: (tokens: Node[]) => tokens[2].children[7].sourceString,
delimiterWhitespaceStart: (tokens: Node[]) => tokens[2].children[1]?.sourceString || '',
delimiterWhitespaceEnd: (tokens: Node[]) => tokens[2].children[7]?.sourceString || '',
locStart,
locEnd,
source,
Expand Down
23 changes: 23 additions & 0 deletions packages/liquid-html-parser/src/stage-2-ast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,29 @@ describe('Unit: Stage 2 (AST)', () => {
expectPath(ast, 'children.0.attributes.0.children.0.type').to.equal('LiquidBranch');
expectPath(ast, 'children.0.attributes.0.children.0.children.1.type').to.equal('LiquidTag');
});

it('should not freak out when completing doc tags', () => {
ast = toAST(`
{% doc %}
@description This is a description
@example This is an example
@param {String} paramWithDescription - param with description
@p█
`);
expectPath(ast, 'children.0.body.nodes.0.type').to.eql('LiquidDocDescriptionNode');
expectPath(ast, 'children.0.body.nodes.0.content.value').to.eql('This is a description\n');

expectPath(ast, 'children.0.body.nodes.1.type').to.eql('LiquidDocExampleNode');
expectPath(ast, 'children.0.body.nodes.1.name').to.eql('example');
expectPath(ast, 'children.0.body.nodes.1.content.value').to.eql('This is an example\n');

expectPath(ast, 'children.0.body.nodes.2.type').to.eql('LiquidDocParamNode');
expectPath(ast, 'children.0.body.nodes.2.name').to.eql('param');
expectPath(ast, 'children.0.body.nodes.2.paramName.value').to.eql('paramWithDescription');

expectPath(ast, 'children.0.body.nodes.3.type').to.eql('TextNode');
expectPath(ast, 'children.0.body.nodes.3.value').to.eql('@p█');
});
});

function makeExpectPath(message: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from './providers';
import { GetSnippetNamesForURI } from './providers/RenderSnippetCompletionProvider';
import { RenderSnippetParameterCompletionProvider } from './providers/RenderSnippetParameterCompletionProvider';
import { LiquidDocTagCompletionProvider } from './providers/LiquidDocTagCompletionProvider';

export interface CompletionProviderDependencies {
documentManager: DocumentManager;
Expand Down Expand Up @@ -84,6 +85,7 @@ export class CompletionsProvider {
new RenderSnippetCompletionProvider(getSnippetNamesForURI),
new RenderSnippetParameterCompletionProvider(getSnippetDefinitionForURI),
new FilterNamedParameterCompletionProvider(themeDocset),
new LiquidDocTagCompletionProvider(),
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ describe('Module: LiquidCompletionParams', async () => {
}
});

it("returns a TextNode when you're completing a tag within a doc tag", async () => {
const source = `{% doc %} @par█`;
const { completionContext } = createLiquidParamsFromContext(source);

const { node } = completionContext!;
expectPath(node, 'type', source).to.eql('TextNode');
});

it(`returns a String node when you're in the middle of it`, async () => {
const contexts = [
`{% render '█' %}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ function findCurrentNode(
break;

case NodeTypes.LiquidRawTag:
if (current.name === 'doc' && current.body.nodes.length > 0) {
finder.current = current.body.nodes.at(-1);
}
break;

case NodeTypes.AttrDoubleQuoted:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, beforeEach, it, expect } from 'vitest';
import { CompletionsProvider } from '../CompletionsProvider';
import { DocumentManager } from '../../documents';
import { MetafieldDefinitionMap } from '@shopify/theme-check-common';

describe('Module: LiquidDocTagCompletionProvider', async () => {
let provider: CompletionsProvider;

beforeEach(async () => {
provider = new CompletionsProvider({
documentManager: new DocumentManager(),
themeDocset: {
filters: async () => [],
objects: async () => [],
tags: async () => [],
systemTranslations: async () => ({}),
},
getMetafieldDefinitions: async (_rootUri: string) => ({} as MetafieldDefinitionMap),
});
});

it('offers completions within liquid doc tag', async () => {
await expect(provider).to.complete(`{% doc %} @█`, ['param', 'example', 'description']);
await expect(provider).to.complete(`{% doc %} @par█`, ['param']);
});

it("does not offer completion if it doesn't start with @", async () => {
await expect(provider).to.complete(`{% doc %} █`, []);
});

it('does not offer completion if it is not within a doc tag', async () => {
await expect(provider).to.complete(`{% notdoc %} @█`, []);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
import {
CompletionItem,
CompletionItemKind,
InsertTextFormat,
MarkupKind,
Range,
TextEdit,
} from 'vscode-languageserver';
import { LiquidCompletionParams } from '../params';
import { Provider } from './common';
import { formatLiquidDocTagHandle, SUPPORTED_LIQUID_DOC_TAG_HANDLES } from '../../utils/liquidDoc';

export class LiquidDocTagCompletionProvider implements Provider {
constructor() {}

async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
if (!params.completionContext) return [];

const { node, ancestors } = params.completionContext;
const parentNode = ancestors.at(-1);

if (
!node ||
!parentNode ||
node.type !== NodeTypes.TextNode ||
parentNode.type !== NodeTypes.LiquidRawTag ||
parentNode.name !== 'doc' ||
!node.value.startsWith('@')
) {
return [];
}

// Need to offset the '@' symbol by 1
let start = params.document.textDocument.positionAt(node.position.start + 1);
let end = params.document.textDocument.positionAt(node.position.end);

return Object.entries(SUPPORTED_LIQUID_DOC_TAG_HANDLES)
.filter(([label]) => label.startsWith(node.value.slice(1)))
.map(([label, { description, example, template }]) => ({
label,
kind: CompletionItemKind.EnumMember,
documentation: {
kind: MarkupKind.Markdown,
value: formatLiquidDocTagHandle(label, description, example),
},
textEdit: TextEdit.replace(Range.create(start, end), template),
insertTextFormat: InsertTextFormat.Snippet,
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { HtmlAttributeValueHoverProvider } from './providers/HtmlAttributeValueHoverProvider';
import { findCurrentNode } from '@shopify/theme-check-common';
import { GetThemeSettingsSchemaForURI } from '../settings';
import { LiquidDocTagHoverProvider } from './providers/LiquidDocTagHoverProvider';

export class HoverProvider {
private providers: BaseHoverProvider[] = [];
Expand Down Expand Up @@ -56,6 +57,7 @@ export class HoverProvider {
new TranslationHoverProvider(getTranslationsForURI, documentManager),
new RenderSnippetHoverProvider(getSnippetDefinitionForURI),
new RenderSnippetParameterHoverProvider(getSnippetDefinitionForURI),
new LiquidDocTagHoverProvider(),
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, beforeEach, it, expect } from 'vitest';
import { DocumentManager } from '../../documents';
import { HoverProvider } from '../HoverProvider';
import { MetafieldDefinitionMap } from '@shopify/theme-check-common';
import '../../../../theme-check-common/src/test/test-setup';
import { formatLiquidDocTagHandle, SUPPORTED_LIQUID_DOC_TAG_HANDLES } from '../../utils/liquidDoc';

describe('Module: RenderSnippetParameterHoverProvider', async () => {
let provider: HoverProvider;

beforeEach(() => {
provider = new HoverProvider(
new DocumentManager(),
{
filters: async () => [],
objects: async () => [],
tags: async () => [],
systemTranslations: async () => ({}),
},
async (_rootUri: string) => ({} as MetafieldDefinitionMap),
);
});

it('should show the param help doc when hovering over the tag itself', async () => {
await expect(provider).to.hover(
`{% doc %} @para█m {string} name - your name {% enddoc %}`,
formatLiquidDocTagHandle(
'param',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['param'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['param'].example,
),
);
await expect(provider).to.hover(
`{% doc %} @exampl█e my example {% enddoc %}`,
formatLiquidDocTagHandle(
'example',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['example'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['example'].example,
),
);
await expect(provider).to.hover(
`{% doc %} @descrip█tion cool text is cool {% enddoc %}`,
formatLiquidDocTagHandle(
'description',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['description'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['description'].example,
),
);
});

it('should show the param help doc when hovering over the text', async () => {
await expect(provider).to.hover(
`{% doc %} @param {string} name - █your name {% enddoc %}`,
formatLiquidDocTagHandle(
'param',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['param'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['param'].example,
),
);
await expect(provider).to.hover(
`{% doc %} @example my █example {% enddoc %}`,
formatLiquidDocTagHandle(
'example',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['example'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['example'].example,
),
);
await expect(provider).to.hover(
`{% doc %} @description cool text█ is cool {% enddoc %}`,
formatLiquidDocTagHandle(
'description',
SUPPORTED_LIQUID_DOC_TAG_HANDLES['description'].description,
SUPPORTED_LIQUID_DOC_TAG_HANDLES['description'].example,
),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
import { LiquidHtmlNode } from '@shopify/theme-check-common';
import { Hover, MarkupKind } from 'vscode-languageserver';
import { BaseHoverProvider } from '../BaseHoverProvider';
import { formatLiquidDocTagHandle, SUPPORTED_LIQUID_DOC_TAG_HANDLES } from '../../utils/liquidDoc';

export class LiquidDocTagHoverProvider implements BaseHoverProvider {
constructor() {}

async hover(currentNode: LiquidHtmlNode, ancestors: LiquidHtmlNode[]): Promise<Hover | null> {
const parentNode = ancestors.at(-1);

let docTagNode;

// We could be hovering on the liquidDoc tag itself
if (
currentNode.type === NodeTypes.LiquidDocParamNode ||
currentNode.type === NodeTypes.LiquidDocDescriptionNode ||
currentNode.type === NodeTypes.LiquidDocExampleNode
) {
docTagNode = currentNode;
}

// or we could be hovering on the liquidDoc tag's text
if (
(parentNode?.type === NodeTypes.LiquidDocParamNode ||
parentNode?.type === NodeTypes.LiquidDocDescriptionNode ||
parentNode?.type === NodeTypes.LiquidDocExampleNode) &&
currentNode.type === NodeTypes.TextNode
) {
docTagNode = parentNode;
}

if (!docTagNode) {
return null;
}

const docTagData = SUPPORTED_LIQUID_DOC_TAG_HANDLES[docTagNode.name];

if (!docTagData) {
return null;
}

return {
contents: {
kind: MarkupKind.Markdown,
value: formatLiquidDocTagHandle(
docTagNode.name,
docTagData.description,
docTagData.example,
),
},
};
}
}
32 changes: 32 additions & 0 deletions packages/theme-language-server-common/src/utils/liquidDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,35 @@ export function formatLiquidDocParameter(
const descStr = description ? ` - ${description}` : '';
return `- ${nameStr}${typeStr}${descStr}`;
}

export function formatLiquidDocTagHandle(label: string, description: string, example: string) {
return `### @${label}\n\n${description}\n\n` + `**Example**\n\n\`\`\`liquid\n${example}\n\`\`\``;
}

export const SUPPORTED_LIQUID_DOC_TAG_HANDLES = {
param: {
description:
'Provides information about a parameter for the snippet.\n' +
'- The type of parameter is optional and can be `string`, `number`, or `Object`.\n' +
'- An optional parameter is denoted by square brackets around the parameter name.\n' +
'- The description is optional Markdown text.',
example:
'{% doc %}\n' +
" @param {string} name - The person's name\n" +
" @param {number} [fav_num] - The person's favorite number\n" +
'{% enddoc %}\n',
template: `param {$1} $2 - $0`,
},
example: {
description: 'Provides an example on how to use the snippet.',
example:
'{% doc %}\n' + ' @example {% render "snippet-name", arg1: "value" %}\n' + '{% enddoc %}\n',
template: `example\n$0`,
},
description: {
description: 'Provides information on what the snippet does.',
example:
'{% doc %}\n' + ' @description This snippet renders a product image.\n' + '{% enddoc %}\n',
template: `description $0`,
},
};

0 comments on commit 261c295

Please sign in to comment.