@shopify/theme-language-server-common
Version:
<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>
320 lines (289 loc) • 12.1 kB
text/typescript
import { LiquidHtmlNode } from '@shopify/theme-check-common';
import { describe, expect, it } from 'vitest';
import { CompletionParams, Position } from 'vscode-languageserver';
import { DocumentManager, AugmentedLiquidSourceCode } from '../../documents';
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', () => {
const context = '{{ }}%}%{%}%$}}{#$%}{% ren█ %}';
const { completionContext } = createLiquidParamsFromContext(context);
expect(completionContext).not.to.exist;
});
describe('completionContext.partialAst', async () => {
it('returns an ast of the file up to the cursor position', async () => {
const context = '{{ "hey" }}\n\n{{ product.id }}{% ren█ %}{% echo "not in the AST" %}';
const { completionContext } = createLiquidParamsFromContext(context);
expect(completionContext).to.exist;
const { partialAst } = completionContext!;
expectPath(partialAst, 'type').to.eql('Document');
expectPath(partialAst, 'name').to.eql('#document');
expectPath(partialAst, 'children.0.type').to.eql('LiquidVariableOutput');
expectPath(partialAst, 'children.0.markup.type').to.eql('LiquidVariable');
expectPath(partialAst, 'children.0.markup.expression.type').to.eql('String');
expectPath(partialAst, 'children.0.markup.expression.value').to.eql('hey');
expectPath(partialAst, 'children.0.markup.rawSource').to.eql('"hey"');
expectPath(partialAst, 'children.1.type').to.eql('LiquidVariableOutput');
expectPath(partialAst, 'children.1.markup.type').to.eql('LiquidVariable');
expectPath(partialAst, 'children.1.markup.expression.type').to.eql('VariableLookup');
expectPath(partialAst, 'children.1.markup.expression.name').to.eql('product');
expectPath(partialAst, 'children.1.markup.expression.lookups.0.type').to.eql('String');
expectPath(partialAst, 'children.1.markup.expression.lookups.0.value').to.eql('id');
expectPath(partialAst, 'children.1.markup.rawSource').to.eql('product.id');
expectPath(partialAst, 'children.2.name').to.eql('ren');
expectPath(partialAst, 'children.2.markup').to.eql('');
expectPath(partialAst, 'children.2.type').to.eql('LiquidTag');
expectPath(partialAst, 'children.3').not.to.exist;
});
});
describe('completionContext.node', async () => {
it('returns the node under the cursor on simple cases', async () => {
const context = '{{ "hey" }}\n\n{{ product.id }}{% ren█ %}';
const { completionContext } = createLiquidParamsFromContext(context);
expect(completionContext).to.exist;
const { node } = completionContext!;
expectPath(node, 'name').to.eql('ren');
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);
});
it('returns the node under the cursor on nested contexts', async () => {
const context = `
{% if product.compare_at_price > product.price %}
<div>
<h1>The product {{ product.tit█ }} is on sale!</h1>
</div>
{% endif %}
`;
const { completionContext } = createLiquidParamsFromContext(context);
expect(completionContext).to.exist;
const { node } = completionContext!;
expectPath(node, 'type').to.eql('VariableLookup');
expectPath(node, 'name').to.eql('product');
expectPath(node, 'lookups.0.type').to.eql('String');
expectPath(node, 'lookups.0.position.start').to.eql(126);
expectPath(node, 'lookups.0.position.end').to.eql(129);
expectPath(node, 'lookups.0.value').to.eql('tit');
expectPath(node, 'position.start').to.eql(118);
expectPath(node, 'position.end').to.eql(129);
});
});
it("returns undefined when you're outside the node", async () => {
const contexts = [
`{% assign x = b %}█`,
`<a href="...">█`,
`<a href="...">█`,
`{% for x in y rev %}█`,
`{% cycle %}█`,
`{% if %}█`,
`{% assign x %}`,
`<img>█`,
`<self-closing />█`,
`<a></a>█`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expect(node, context).to.eql(undefined);
}
});
it('returns the HtmlVoidElement node when inside the tag', async () => {
const context = '<img█';
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('HtmlVoidElement');
});
it("returns the TextNode when you're completing an HTML tag name", async () => {
const contexts = [
`<█`,
`<a█ href="...">`,
`</█`,
`</a█ href="...">`,
`<h1></h█ href="...">`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('TextNode');
}
});
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 '█' %}`,
`{% render 'snip', var: '█' %}`,
`{% content_for '█' %}`,
`{% content_for 'block', id: '█' %}`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('String');
}
});
it('returns a tag', async () => {
const contexts = [
`{% t█ %}`,
`{% end█ %}`,
`{% if cond %}{% end█ %}`,
`{% for markup as string █ %}`,
`{% if markup as string █ %}`,
`{% for x in y reversed █ %}`,
`{% for x in y reversed limit: 10 █ %}`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('LiquidTag');
}
});
it('returns a variable lookup', async () => {
const contexts = [
`{{ a█`,
`{{ a.b█`,
`{{ a['b█`,
`{{ a.b.c█`,
`{% echo a█ %}`,
`{% echo a.b█ %}`,
`{% echo a['b█ %}`,
`{% assign x = a█ %}`,
`{% assign x = a.b█ %}`,
`{% assign x = a['b█ %}`,
`{% for a in b█ %}`,
`{% for a in b reversed limit: a█ %}`,
`{% paginate b by a█ %}`,
`{% paginate b by col, window_size: a█ %}`,
`{% if a█ %}`,
`{% if a > b█ %}`,
`{% if a > b or c█ %}`,
`{% if a > b or c > d█ %}`,
`{% elsif a > b█ %}`,
`{% when a█ %}`,
`{% when a, b█ %}`,
`{% cycle a█ %}`,
`{% cycle 'foo', a█ %}`,
`{% cycle 'foo': a█ %}`,
`{% content_for 'foo', v: █ %}`,
`{% render 'snip', var: a█ %}`,
`{% render 'snip' for col█ as item %}`,
`{% render 'snip' with object█ as name %}`,
`{% liquid
echo a█
%}`,
`{% liquid
if x == 'string'
echo a█
endif
%}`,
`{% for x in (1..a█) %}`,
// `{% paginate a█ by 50 %}`,
`<a-{{ a█ }}`,
`<a data-{{ a█ }}`,
`<a data={{ a█ }}`,
`<a data="{{ a█ }}"`,
`<a data='x{{ a█ }}'`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('VariableLookup');
}
});
it('returns a variable lookup (placeholder mode)', async () => {
const contexts = [
`{{ █`,
`{{ 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': █ %}`,
`{% content_for 'snip', var: █ %}`,
`{% render 'snip', var: █ %}`,
`{% render 'snip' for █ as item %}`,
`{% render 'snip' with █ as name %}`,
`{% for x in (1..█) %}`,
// `{% paginate a█ by 50 %}`,
`<a-{{ █ }}`,
`<a data-{{ █ }}`,
`<a data={{ █ }}`,
`<a data="{{ █ }}"`,
`<a data='x{{ █ }}'`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
expect(completionContext).to.exist;
const { node } = completionContext!;
expectPath(node, 'type', context).to.eql('VariableLookup');
}
});
});
});
});
function expectPath(ast: LiquidHtmlNode | undefined, path: string, message?: string) {
if (!ast) return expect(ast, message);
return expect(deepGet(path.split('.'), ast), message);
}
function deepGet<T = any>(path: (string | number)[], obj: any): T {
return path.reduce((curr: any, k: string | number) => {
if (curr && curr[k] !== undefined) return curr[k];
return undefined;
}, obj);
}
function createLiquidParamsFromContext(
context: string,
cursorPosition: Position = calculatePosition(context),
) {
const regex = new RegExp('█', 'g');
const documentManager = new DocumentManager();
const uri = 'file:///path/to/file.liquid';
documentManager.open(uri, context.replace(regex, ''), 1);
const params = mockCompletionParams({ position: cursorPosition });
return createLiquidCompletionParams(
documentManager.get(uri)! as AugmentedLiquidSourceCode,
params,
);
}
function calculatePosition(context: string): Position {
const index = context.indexOf('█');
const lines = context.substring(0, index === -1 ? context.length : index).split('\n');
const line = lines.length - 1;
const character = lines[line].length;
return { line, character };
}
function mockCompletionParams(params: Partial<CompletionParams> = {}): CompletionParams {
return {
position: {
character: 0,
line: 0,
},
textDocument: {
uri: 'file:///path/to/file.liquid',
},
...params,
};
}