Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/block-parameter-language-features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@shopify/theme-language-server-common': patch
---

Complete and hover `block` tag arguments from the target block's schema settings, LiquidDoc parameters, and built-in `content`.

Completion offers plain parameter names with schema-derived value templates and skips arguments the call already passes. Completion and hover show requiredness, the Liquid type, the LiquidDoc description, and the theme setting's label and info, resolving `t:` keys from the default schema locale. The `block` tag is offered only in `templates/**/*.liquid` and `layout/*.liquid` files.

Inside `blocks/*.liquid`, each schema setting ID also completes and hovers as a plain variable with the same schema-derived type as `block.settings.<id>`. A same-named LiquidDoc parameter describes that variable, and the schema sets its type.
154 changes: 154 additions & 0 deletions packages/theme-language-server-common/src/TypeSystem.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
AssignMarkup,
LiquidHtmlNode,
LiquidVariable,
LiquidVariableLookup,
LiquidVariableOutput,
NamedTags,
NodeTypes,
Expand All @@ -11,6 +13,8 @@ import {
path as pathUtils,
BasicParamTypes,
ObjectEntry,
SourceCodeType,
visit,
} from '@shopify/theme-check-common';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { URI } from 'vscode-uri';
Expand Down Expand Up @@ -453,6 +457,136 @@ describe('Module: TypeSystem', () => {
expect(inferredType).toEqual(expectedType);
});

describe('when a theme block schema defines settings', () => {
const articleCardSource = `
{% doc %}
@param {object} image - Card image
@param {string} heading - Card heading
{% enddoc %}
<div style="background: {{ background_color }}">
{{ image }}
{{ block.settings.image }}
{{ heading }}
{{ block.settings.heading }}
{{ block.settings.background_color }}
</div>
{% schema %}
{
"name": "Article card",
"settings": [
{ "type": "color_background", "id": "background_color", "label": "Background" },
{ "type": "image_picker", "id": "image", "label": "Image" },
{ "type": "text", "id": "heading", "label": "Heading" }
]
}
{% endschema %}
`;
const blockUri = 'file:///blocks/article-card.liquid';

it.each([
['background_color', 'string'],
['image', 'image'],
['heading', 'string'],
])(
'infers the same schema type for %s and its block.settings alias',
async (settingId, expectedType) => {
const ast = toLiquidHtmlAST(articleCardSource);

const bareType = await typeSystem.inferType(liquidVariable(ast, settingId), ast, blockUri);
const aliasType = await typeSystem.inferType(
liquidVariable(ast, `block.settings.${settingId}`),
ast,
blockUri,
);

expect(bareType).toEqual(expectedType);
expect(aliasType).toEqual(expectedType);
},
);

it('uses the schema type over a same-named LiquidDoc parameter', async () => {
const ast = toLiquidHtmlAST(articleCardSource);

const inferredType = await typeSystem.inferType(liquidVariable(ast, 'image'), ast, blockUri);

expect(inferredType).toEqual('image');
});

describe('when a schema setting ID is reassigned', () => {
const reassignedSource = `
{% doc %}
@param {number} image - Card image
{% enddoc %}
{{ background_color }}
{{ image }}
{% assign background_color = 1 %}
{% assign image = 'hero.png' %}
{{ background_color }}
{{ image }}
{% schema %}
{
"name": "Article card",
"settings": [
{ "type": "color_background", "id": "background_color", "label": "Background" },
{ "type": "image_picker", "id": "image", "label": "Image" }
]
}
{% endschema %}
`;

it('infers the schema type before the assignment and the assigned type after it', async () => {
const ast = toLiquidHtmlAST(reassignedSource);
const [beforeAssign, afterAssign] = liquidVariables(ast, 'background_color');

const typeBeforeAssign = await typeSystem.inferType(beforeAssign, ast, blockUri);
const typeAfterAssign = await typeSystem.inferType(afterAssign, ast, blockUri);

expect(typeBeforeAssign).toEqual('string');
expect(typeAfterAssign).toEqual('number');
});

it('infers the schema type over a same-named LiquidDoc parameter until the assignment', async () => {
const ast = toLiquidHtmlAST(reassignedSource);
const [beforeAssign, afterAssign] = liquidVariables(ast, 'image');

const typeBeforeAssign = await typeSystem.inferType(beforeAssign, ast, blockUri);
const typeAfterAssign = await typeSystem.inferType(afterAssign, ast, blockUri);

expect(typeBeforeAssign).toEqual('image');
expect(typeAfterAssign).toEqual('string');
});
});

it('makes each setting ID available once, and never the setting type value', async () => {
const ast = toLiquidHtmlAST(articleCardSource);
const lookup = variableLookup(ast, 'heading');

const variables = await typeSystem.availableVariables(ast, '', lookup, blockUri);
const namesAndTypes = variables.map(({ entry, type }) => [entry.name, type]);

expect(namesAndTypes.filter(([name]) => name === 'image')).toEqual([['image', 'image']]);
expect(namesAndTypes.filter(([name]) => name === 'heading')).toEqual([['heading', 'string']]);
expect(namesAndTypes).toContainEqual(['background_color', 'string']);
expect(namesAndTypes.map(([name]) => name)).not.toContain('color_background');
expect(namesAndTypes.map(([name]) => name)).not.toContain('image_picker');
});

it.each(['sections/article-card.liquid', 'snippets/article-card.liquid'])(
'does not expose schema setting IDs as variables in %s',
async (relativePath) => {
const ast = toLiquidHtmlAST(articleCardSource);

const inferredType = await typeSystem.inferType(
liquidVariable(ast, 'background_color'),
ast,
`file:///${relativePath}`,
);

expect(inferredType).toEqual('unknown');
},
);
});

// TODO
it.skip('should support narrowing the type of blocks', async () => {
const sourceCode = `
Expand Down Expand Up @@ -673,3 +807,23 @@ describe('Module: TypeSystem', () => {
});
});
});

function liquidVariable(ast: LiquidHtmlNode, expression: string): LiquidVariable {
const [variable] = liquidVariables(ast, expression);
assert(variable, `expected a {{ ${expression} }} output`);
return variable;
}

function liquidVariables(ast: LiquidHtmlNode, expression: string): LiquidVariable[] {
return visit<SourceCodeType.LiquidHtml, LiquidVariable>(ast, {
LiquidVariable: (node) => node,
}).filter(
(node) => node.source.slice(node.position.start, node.position.end).trim() === expression,
);
}

function variableLookup(ast: LiquidHtmlNode, expression: string): LiquidVariableLookup {
const { expression: lookup } = liquidVariable(ast, expression);
assert(lookup.type === NodeTypes.VariableLookup);
return lookup;
}
54 changes: 50 additions & 4 deletions packages/theme-language-server-common/src/TypeSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,17 @@ export class TypeSystem {
});

private async symbolsTable(partialAst: LiquidHtmlNode, uri: string): Promise<SymbolsTable> {
const seedSymbolsTable = await this.seedSymbolsTable(uri);
return buildSymbolsTable(partialAst, seedSymbolsTable, await this.themeDocset.liquidDrops());
const schemaSettingTypes = blockSchemaSettingTypes(partialAst, uri);
const seedSymbolsTable = seedSchemaSettingVariables(
await this.seedSymbolsTable(uri),
schemaSettingTypes,
);
return buildSymbolsTable(
partialAst,
seedSymbolsTable,
await this.themeDocset.liquidDrops(),
schemaSettingTypes,
);
}

/**
Expand Down Expand Up @@ -489,6 +498,7 @@ function buildSymbolsTable(
partialAst: LiquidHtmlNode,
seedSymbolsTable: SymbolsTable,
liquidDrops: ObjectEntry[],
schemaSettingTypes: SchemaSettingTypes,
): SymbolsTable {
const typeRanges = visit<SourceCodeType.LiquidHtml, TypeRange>(partialAst, {
// {% assign x = foo.x | filter %}
Expand All @@ -503,10 +513,13 @@ function buildSymbolsTable(
// {% doc %}
// @param {string} name - your name
// {% enddoc %}
//
// In a theme block, a schema setting with the same ID decides the type.
LiquidDocParamNode(node) {
const identifier = node.paramName.value;
return {
identifier: node.paramName.value,
type: inferLiquidDocParamType(node, liquidDrops),
identifier,
type: schemaSettingTypes.get(identifier) ?? inferLiquidDocParamType(node, liquidDrops),
range: [node.position.end],
};
},
Expand Down Expand Up @@ -569,6 +582,39 @@ function buildSymbolsTable(
}, seedSymbolsTable);
}

/** The type of each schema setting ID, by setting ID. */
type SchemaSettingTypes = Map<Identifier, PseudoType | ArrayType>;

/**
* A theme block's schema settings are also plain variables in the block file.
* Other files get no schema setting variables.
*/
function blockSchemaSettingTypes(partialAst: LiquidHtmlNode, uri: string): SchemaSettingTypes {
if (!BLOCK_FILE_REGEX.test(path.normalize(uri))) return new Map();

return new Map(
schemaSettingsAsProperties(partialAst).map((setting) => [
setting.name,
objectEntryType(setting),
]),
);
}

/**
* Schema setting variables start at the top of the file, like other seeded
* variables, so later assigns and captures change their type by position.
*/
function seedSchemaSettingVariables(
seedSymbolsTable: SymbolsTable,
schemaSettingTypes: SchemaSettingTypes,
): SymbolsTable {
for (const [identifier, type] of schemaSettingTypes) {
seedSymbolsTable[identifier] ??= [];
seedSymbolsTable[identifier].push({ identifier, type, range: [0] });
}
return seedSymbolsTable;
}

/**
* Given a TypeRange['type'] (which may be lazy), resolve its type recursively.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import {
import { CompletionItem, CompletionParams } from 'vscode-languageserver';
import { TypeSystem } from '../TypeSystem';
import { DocumentManager } from '../documents';
import { FindThemeRootURI } from '../internal-types';
import { GetThemeBlockSchema } from '../json/JSONContributions';
import { GetThemeSettingsSchemaForURI } from '../settings';
import { GetTranslationsForURI } from '../translations';
import { makeGetBlockParametersForURI } from '../utils/blockParameters';
import { createLiquidCompletionParams } from './params';
import {
BlockParameterCompletionProvider,
ContentForCompletionProvider,
ContentForBlockTypeCompletionProvider,
ContentForParameterCompletionProvider,
Expand All @@ -36,12 +40,15 @@ export interface CompletionProviderDependencies {
documentManager: DocumentManager;
themeDocset: ThemeDocset;
getTranslationsForURI?: GetTranslationsForURI;
getSchemaTranslationsForURI?: GetTranslationsForURI;
getSnippetNamesForURI?: GetSnippetNamesForURI;
getThemeSettingsSchemaForURI?: GetThemeSettingsSchemaForURI;
getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>;
getDocDefinitionForURI?: GetDocDefinitionForURI;
getThemeBlockNames?: (rootUri: string, includePrivate: boolean) => Promise<string[]>;
getThemeBlockSchema?: GetThemeBlockSchema;
getModeForURI?: (uri: string) => Promise<Mode>;
findThemeRootURI?: FindThemeRootURI;
log?: (message: string) => void;
}

Expand All @@ -56,11 +63,14 @@ export class CompletionsProvider {
themeDocset,
getMetafieldDefinitions,
getTranslationsForURI = async () => ({}),
getSchemaTranslationsForURI = async () => ({}),
getSnippetNamesForURI = async () => [],
getThemeSettingsSchemaForURI = async () => [],
getDocDefinitionForURI = async (uri, _relativePath) => ({ uri }),
getThemeBlockNames = async (_rootUri: string, _includePrivate: boolean) => [],
getThemeBlockSchema = async (_uri: string, _name: string) => undefined,
getModeForURI,
findThemeRootURI = async (_uri: string) => null,
log = () => {},
}: CompletionProviderDependencies) {
this.documentManager = documentManager;
Expand All @@ -77,10 +87,14 @@ export class CompletionsProvider {
new ContentForCompletionProvider(),
new ContentForBlockTypeCompletionProvider(getThemeBlockNames),
new ContentForParameterCompletionProvider(getDocDefinitionForURI),
new BlockParameterCompletionProvider(
makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI),
getSchemaTranslationsForURI,
),
new HtmlTagCompletionProvider(),
new HtmlAttributeCompletionProvider(documentManager),
new HtmlAttributeValueCompletionProvider(),
new LiquidTagsCompletionProvider(themeDocset),
new LiquidTagsCompletionProvider(themeDocset, findThemeRootURI),
new ObjectCompletionProvider(typeSystem),
new ObjectAttributeCompletionProvider(typeSystem, getThemeSettingsSchemaForURI),
new FilterCompletionProvider(typeSystem),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,54 @@ describe('Module: LiquidCompletionParams', async () => {
}
});

it('returns an argument-name lookup whose parent is the block markup', async () => {
const contexts: [context: string, name: string][] = [
[`{% block 'card', █ %}{% endblock %}`, ''],
[`{% block 'card', he█ %}{% endblock %}`, 'he'],
[`{% block 'card', he█ading: 'x' %}{% endblock %}`, 'he'],
[`{% block 'card', █heading: 'x' %}{% endblock %}`, ''],
[`{% block 'card', heading: 'x', █ %}{% endblock %}`, ''],
[`{% block 'card', heading: 'x', fe█ %}{% endblock %}`, 'fe'],
[`{% block 'card', heading: 'x', █, id: 'y' %}{% endblock %}`, ''],
[`{% block 'card',\n heading: 'x',\n █\n%}{% endblock %}`, ''],
[`{% block 'card', █`, ''],
[`{% block 'card', heading: 'x', he█`, 'he'],
[`{% liquid\n block 'card', he█\n endblock\n%}`, 'he'],
];
for (const [context, name] of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { node, ancestors } = completionContext!;
expectPath(node, 'type', context).to.eql('VariableLookup');
expectPath(node, 'name', context).to.eql(name);
expectPath(ancestors.at(-1), 'type', context).to.eql('BlockMarkup');
expectPath(ancestors.at(-1), 'name.value', context).to.eql('card');
}
});

it('recovers the argument names of unfinished block markup', async () => {
const context = `{% block 'card', heading: 'x', █, block.settings.id: 'y', id: 'z' %}{% endblock %}`;
const { completionContext } = createLiquidParamsFromContext(context);
const { ancestors } = completionContext!;
expectPath(ancestors.at(-1), 'args.0.name').to.eql('heading');
expectPath(ancestors.at(-1), 'args.1.name').to.eql('id');
expectPath(ancestors.at(-1), 'args.2').to.eql(undefined);
});

it('does not return an argument-name lookup outside a block argument-name slot', async () => {
const contexts = [
`{% block 'card' █ %}{% endblock %}`,
`{% block 'card', heading: 'x' █ %}{% endblock %}`,
`{% block 'card', heading: █ %}{% endblock %}`,
`{% block 'card', heading: pr█ %}{% endblock %}`,
`{% block 'card', heading: 'x', block.█ %}{% endblock %}`,
];
for (const context of contexts) {
const { completionContext } = createLiquidParamsFromContext(context);
const { ancestors } = completionContext!;
expectPath(ancestors.at(-1), 'type', context).not.to.eql('BlockMarkup');
}
});

it('returns a variable lookup (placeholder mode)', async () => {
const contexts = [
`{{ █`,
Expand Down
Loading
Loading