@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>
345 lines (328 loc) • 11.4 kB
text/typescript
import {
AssignMarkup,
LiquidVariableOutput,
NamedTags,
NodeTypes,
toLiquidHtmlAST,
} from '@shopify/liquid-html-parser';
import { path as pathUtils } from '@shopify/theme-check-common';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { URI } from 'vscode-uri';
import { SettingsSchemaJSONFile } from './settings';
import { ArrayType, TypeSystem } from './TypeSystem';
import { isLiquidVariableOutput, isNamedLiquidTag } from './utils';
describe('Module: TypeSystem', () => {
let typeSystem: TypeSystem;
let settingsProvider: any;
const literalContexts = [
{ value: `10`, type: 'number' },
{ value: `'string'`, type: 'string' },
{ value: `true`, type: 'boolean' },
// { value: `null`, type: 'untyped' },
];
beforeEach(() => {
settingsProvider = vi.fn().mockResolvedValue([]);
typeSystem = new TypeSystem(
{
tags: async () => [],
objects: async () => [
{
name: 'all_products',
return_type: [{ type: 'array', array_value: 'product' }],
},
{
name: 'product',
access: {
global: false,
parents: [],
template: [],
},
return_type: [],
properties: [
{
name: 'featured_image',
description: 'ze best image for ze product',
return_type: [{ type: 'image', name: '' }],
},
],
},
{
name: 'settings',
return_type: [],
properties: [], // these should be populated dynamically
},
{
name: 'predictive_search',
access: { global: false, parents: [], template: [] },
return_type: [],
},
{
name: 'comment',
access: { global: false, parents: [], template: [] },
return_type: [],
},
{
name: 'recommendations',
access: { global: false, parents: [], template: [] },
return_type: [],
},
{
name: 'app',
access: { global: false, parents: [], template: [] },
return_type: [],
},
{
name: 'section',
access: { global: false, parents: [], template: [] },
return_type: [],
properties: [
{
name: 'settings',
return_type: [{ type: 'untyped', name: '' }],
},
],
},
{
name: 'block',
access: { global: false, parents: [], template: [] },
return_type: [],
properties: [
{
name: 'settings',
return_type: [{ type: 'untyped', name: '' }],
},
],
},
{
name: 'locale',
access: { global: false, parents: [], template: [] },
return_type: [],
},
],
filters: async () => [
{
name: 'size',
return_type: [{ type: 'number', name: '' }],
},
],
systemTranslations: async () => ({}),
},
settingsProvider,
);
});
it('should return the type of assign markup nodes (basic test)', async () => {
for (const { value, type } of literalContexts) {
const ast = toLiquidHtmlAST(`{% assign x = ${value} %}`);
const assignMarkup = (ast as any).children[0].markup as AssignMarkup;
const inferredType = await typeSystem.inferType(assignMarkup, ast, 'file:///file.liquid');
expect(inferredType, value).to.equal(type);
}
});
it('should return the type of other variables', async () => {
for (const { value, type } of literalContexts) {
const ast = toLiquidHtmlAST(`{% assign x = ${value} %}{% assign y = x %}`);
const yVariable = (ast as any).children[1].markup as AssignMarkup;
const inferredType = await typeSystem.inferType(yVariable, ast, 'file:///file.liquid');
expect(inferredType).to.equal(type);
}
});
it('should return the type of expressions', async () => {
for (const { value, type } of literalContexts) {
const ast = toLiquidHtmlAST(`{{ ${value} }}`);
const output = ast.children[0] as LiquidVariableOutput;
const variable = output.markup;
if (typeof variable === 'string') throw new Error('expecting real deal');
const expression = variable.expression;
const inferredType = await typeSystem.inferType(expression, ast, 'file:///file.liquid');
expect(inferredType, value).to.equal(type);
}
});
it('should return the type of array variables', async () => {
const ast = toLiquidHtmlAST(`{% assign x = all_products %}`);
const xVariable = (ast as any).children[0].markup as AssignMarkup;
const inferredType = await typeSystem.inferType(xVariable, ast, 'file:///file.liquid');
expect(inferredType).to.eql({ kind: 'array', valueType: 'product' });
});
it('should return the type of object properties', async () => {
const ast = toLiquidHtmlAST(`{% assign x = all_products[0].featured_image %}`);
const xVariable = (ast as any).children[0].markup as AssignMarkup;
const inferredType = await typeSystem.inferType(xVariable, ast, 'file:///file.liquid');
expect(inferredType).to.equal('image');
});
it('should return the type of filtered variables', async () => {
const ast = toLiquidHtmlAST(`{% assign x = all_products | size %}`);
const xVariable = (ast as any).children[0].markup as AssignMarkup;
const inferredType = await typeSystem.inferType(xVariable, ast, 'file:///file.liquid');
expect(inferredType).to.equal('number');
});
it('should return the type of variables in for loop', async () => {
const ast = toLiquidHtmlAST(`{% for item in all_products %}{{ item }}{% endfor %}`);
const forLoop = ast.children[0];
assert(isNamedLiquidTag(forLoop, NamedTags.for) && forLoop.children?.length === 1);
const branch = forLoop.children[0];
assert(branch.type === NodeTypes.LiquidBranch);
const variableOutput = branch.children[0];
assert(isLiquidVariableOutput(variableOutput));
const variable = variableOutput.markup;
const inferredType = await typeSystem.inferType(variable, ast, 'file:///file.liquid');
expect(inferredType).to.equal('product');
});
it('should patch the properties of settings when a schema is available', async () => {
settingsProvider.mockResolvedValue([
{
name: 'category',
settings: [
{
id: 'slide',
label: 'Slide label',
type: 'checkbox',
},
{
id: 'my_font',
label: 'my font',
type: 'font_picker',
},
],
},
] as SettingsSchemaJSONFile);
const contexts = [
{ id: 'slide', expectedType: 'boolean' },
{ id: 'my_font', expectedType: 'font' },
];
for (const { id, expectedType } of contexts) {
const ast = toLiquidHtmlAST(`{{ settings.${id} }}`);
const variableOutput = ast.children[0];
assert(isLiquidVariableOutput(variableOutput));
const inferredType = await typeSystem.inferType(
variableOutput.markup,
ast,
'file:///file.liquid',
);
expect(inferredType).to.eql(expectedType);
}
});
it('should support section settings in section files', async () => {
const sourceCode = `
{{ section.settings.my_list }}
{% schema %}
{
"name": "section-settings-example",
"tag": "section",
"settings": [
{
"id": "my_list",
"label": "t:my-setting.label",
"type": "product_list"
}
]
}
{% endschema %}
`;
const ast = toLiquidHtmlAST(sourceCode);
const variableOutput = ast.children[0];
assert(isLiquidVariableOutput(variableOutput));
const inferredType = await typeSystem.inferType(
variableOutput.markup,
ast,
'file:///sections/my-section.liquid',
);
expect(inferredType).to.eql({ kind: 'array', valueType: 'product' } as ArrayType);
});
it('should support block settings in blocks files', async () => {
const sourceCode = `
{{ block.settings.my_list }}
{% schema %}
{
"name": "section-settings-example",
"tag": "section",
"settings": [
{
"id": "my_list",
"label": "t:my-setting.label",
"type": "product_list"
}
]
}
{% endschema %}
`;
const ast = toLiquidHtmlAST(sourceCode);
const variableOutput = ast.children[0];
assert(isLiquidVariableOutput(variableOutput));
const inferredType = await typeSystem.inferType(
variableOutput.markup,
ast,
'file:///blocks/my-section.liquid',
);
expect(inferredType).to.eql({ kind: 'array', valueType: 'product' } as ArrayType);
});
// TODO
it.skip('should support narrowing the type of blocks', async () => {
const sourceCode = `
{% for block in section.blocks %}
{% case block.type %}
{% when 'slide' %}
{{ block.settings.image }}
{% else %}
{% endcase }
{% if block.type == 'slide' %}
{{ block.settings.image }}
{% endif %}
{% endfor %}
{% schema %}
{
"name": "Slideshow",
"tag": "section",
"class": "slideshow",
"settings": [],
"blocks": [
{
"name": "Slide",
"type": "slide",
"settings": [
{
"type": "image_picker",
"id": "image",
"label": "Image"
}
]
}
]
}
{% endschema %}
`;
const ast = toLiquidHtmlAST(sourceCode);
});
it('should support path-contextual variable types', async () => {
let inferredType: string | ArrayType;
const contexts: [string, string][] = [
['section', 'sections/my-section.liquid'],
['comment', 'sections/main-article.liquid'],
['block', 'blocks/my-block.liquid'],
['predictive_search', 'sections/predictive-search.liquid'],
['recommendations', 'sections/recommendations.liquid'],
['app', 'blocks/recommendations.liquid'],
['app', 'snippets/recommendations.liquid'],
['locale', 'layout/checkout.liquid'],
];
for (const [object, path] of contexts) {
const sourceCode = `{{ ${object} }}`;
const ast = toLiquidHtmlAST(sourceCode);
const variableOutput = ast.children[0];
assert(isLiquidVariableOutput(variableOutput));
inferredType = await typeSystem.inferType(
variableOutput.markup,
ast,
// This will be different on Windows ^^
pathUtils.normalize(URI.from({ scheme: 'file', path })),
);
expect(inferredType).to.eql(object);
inferredType = await typeSystem.inferType(
variableOutput.markup,
ast,
// This will be different on Windows ^^
pathUtils.normalize(URI.from({ scheme: 'file', path: 'file.liquid' })),
);
expect(inferredType).to.eql('untyped');
}
});
});