Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/doc-union-literal-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@shopify/theme-check-common': minor
'@shopify/theme-language-server-common': minor
---

Add support for union types and string literal types in `{% doc %}` `@param` annotations.

Named types can now be combined with `|`: `{string|number}`

String literals are now valid param types: `{'banner'|'label'}`

Unions of named types and literals are also supported: `{string|'banner'|'label'}`
7 changes: 5 additions & 2 deletions packages/liquid-html-parser/grammar/liquid-html.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,11 @@ Liquid <: Helpers {
liquidTagRenderMarkup =
snippetExpression renderVariableExpression? renderAliasExpression? renderArguments

renderArguments = (argumentSeparatorOptionalComma tagArguments) (space* ",")? space*
completionModeRenderArguments = (argumentSeparatorOptionalComma tagArguments) (space* ",")? space* (argumentSeparator? liquidVariableLookup<delimTag> space*)?
renderArguments = (argumentSeparatorOptionalComma renderTagArguments) (space* ",")? space*
completionModeRenderArguments = (argumentSeparatorOptionalComma renderTagArguments) (space* ",")? space* (argumentSeparator? liquidVariableLookup<delimTag> space*)?
renderTagArguments = listOf<renderTagNamedArgument, argumentSeparatorOptionalComma>
renderTagNamedArgument = variableSegment space* ":" space* renderTagArgumentValue
renderTagArgumentValue = liquidComplexExpression<delimTag> liquidFilter<delimTag>* space*
snippetExpression = liquidString<delimTag> | variableSegmentAsLookup
renderVariableExpression = space+ ("for" | "with") space+ liquidExpression<delimTag>
renderAliasExpression = space+ "as" space+ variableSegment
Expand Down
10 changes: 10 additions & 0 deletions packages/liquid-html-parser/src/stage-1-cst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,16 @@ function toCST<T>(
source,
},
renderArguments: 1,
renderTagArguments: 0,
renderTagNamedArgument: {
type: ConcreteNodeTypes.NamedArgument,
name: 0,
value: 4,
locStart,
locEnd,
source,
},
renderTagArgumentValue: 0,
completionModeRenderArguments: function (
_0,
namedArguments,
Expand Down
7 changes: 7 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 @@ -509,6 +509,13 @@ describe('Unit: Stage 2 (AST)', () => {
{ name: 'key2', valueType: 'String' },
],
},
{
expression: `'snippet', for: block.id | append: '-list'`,
snippetType: 'String',
alias: null,
renderVariableExpression: null,
namedArguments: [{ name: 'for', valueType: 'VariableLookup' }],
},
].forEach(
({ expression, snippetType, renderVariableExpression, alias, namedArguments }) => {
for (const { toAST, expectPath, expectPosition } of testCases) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,57 @@ describe('Module: ValidDocParamTypes', () => {
expect(suggestions).to.include(`{% doc %} @param param1 - Example param {% enddoc %}`);
}
});

it('should not report an error for a union of named types', async () => {
const sourceCode = `
{% doc %}
@param {string|number} param1 - A string or number
{% enddoc %}
`;
const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
expect(offenses).to.be.empty;
});

it('should not report an error for a string literal type', async () => {
const sourceCode = `
{% doc %}
@param {'banner'} param1 - Must be banner
{% enddoc %}
`;
const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
expect(offenses).to.be.empty;
});

it('should not report an error for a union of string literals', async () => {
const sourceCode = `
{% doc %}
@param {'banner'|'label'} param1 - Either banner or label
{% enddoc %}
`;
const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
expect(offenses).to.be.empty;
});

it('should not report an error for a mixed union of named types and string literals', async () => {
const sourceCode = `
{% doc %}
@param {string|'banner'|'label'} param1 - A string or specific value
{% enddoc %}
`;
const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
expect(offenses).to.be.empty;
});

it('should report an error when a union contains an invalid named type', async () => {
const sourceCode = `
{% doc %}
@param {string|invalidType} param1 - Example param
{% enddoc %}
`;
const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
expect(offenses).to.have.length(1);
expect(offenses[0].message).to.equal(
"The parameter type 'string|invalidType' is not supported.",
);
});
});
13 changes: 2 additions & 11 deletions packages/theme-check-common/src/liquid-doc/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@ import {
NodeTypes,
} from '@shopify/liquid-html-parser';
import { Context, LiquidDocParameter, SourceCodeType, StringCorrector } from '..';
import {
BasicParamTypes,
getDefaultValueForType,
inferArgumentType,
isTypeCompatible,
} from './utils';
import { getDefaultValueForType, inferArgumentType, isTypeCompatible } from './utils';
import { isLiquidString } from '../checks/utils';

/**
Expand Down Expand Up @@ -124,11 +119,7 @@ export function findTypeMismatchParams(

const liquidDocParamDef = liquidDocParameters.get(arg.name);
if (liquidDocParamDef && liquidDocParamDef.type) {
const paramType = liquidDocParamDef.type.toLowerCase();
const supportedTypes = Object.keys(BasicParamTypes).map((type) => type.toLowerCase());
if (!supportedTypes.includes(paramType)) {
continue;
}
const paramType = liquidDocParamDef.type;

if (!isTypeCompatible(paramType, inferArgumentType(arg.value))) {
typeMismatchParams.push(arg);
Expand Down
103 changes: 100 additions & 3 deletions packages/theme-check-common/src/liquid-doc/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { BasicParamTypes, parseParamType } from './utils';
import { BasicParamTypes, getDefaultValueForType, isTypeCompatible, parseParamType } from './utils';

describe('liquid-doc/utils', () => {
describe('parseParamType', () => {
const validParamTypes = new Set([...Object.values(BasicParamTypes), 'product']);
const validParamTypes = new Set([...Object.values(BasicParamTypes), 'product']);

describe('parseParamType', () => {
it('should parse all values provided in the `validParamTypes` set', () => {
const tests = {
string: ['string', false],
Expand All @@ -19,5 +19,102 @@ describe('liquid-doc/utils', () => {
expect(result).toEqual(expected);
});
});

it('should accept union of named types', () => {
expect(parseParamType(validParamTypes, 'string|number')).toBeDefined();
expect(parseParamType(validParamTypes, 'string|number|boolean')).toBeDefined();
expect(parseParamType(validParamTypes, 'string|product')).toBeDefined();
});

it('should accept string literal types', () => {
expect(parseParamType(validParamTypes, "'banner'")).toBeDefined();
expect(parseParamType(validParamTypes, '"banner"')).toBeDefined();
expect(parseParamType(validParamTypes, "'banner'|'label'")).toBeDefined();
});

it('should accept union of named types and string literals', () => {
expect(parseParamType(validParamTypes, "string|'banner'")).toBeDefined();
expect(parseParamType(validParamTypes, "'banner'|number")).toBeDefined();
});

it('should return string as pseudoType for literal-only unions', () => {
expect(parseParamType(validParamTypes, "'banner'|'label'")).toEqual(['string', false]);
});

it('should return the first named type as pseudoType for mixed unions', () => {
expect(parseParamType(validParamTypes, 'number|string')).toEqual(['number', false]);
});

it('should reject unions containing invalid named types', () => {
expect(parseParamType(validParamTypes, 'string|invalidType')).toBeUndefined();
expect(parseParamType(validParamTypes, "'banner'|invalidType")).toBeUndefined();
});

it('should reject bare unquoted values that are not valid named types', () => {
expect(parseParamType(validParamTypes, 'banner')).toBeUndefined();
});
});

describe('isTypeCompatible', () => {
it('should return true when types match exactly', () => {
expect(isTypeCompatible('string', BasicParamTypes.String)).toBe(true);
expect(isTypeCompatible('number', BasicParamTypes.Number)).toBe(true);
});

it('should return false when types do not match', () => {
expect(isTypeCompatible('string', BasicParamTypes.Number)).toBe(false);
expect(isTypeCompatible('number', BasicParamTypes.String)).toBe(false);
});

it('should return true for boolean regardless of actual type', () => {
expect(isTypeCompatible('boolean', BasicParamTypes.String)).toBe(true);
expect(isTypeCompatible('boolean', BasicParamTypes.Number)).toBe(true);
});

it('should return true when actual type matches any member of a union', () => {
expect(isTypeCompatible('string|number', BasicParamTypes.String)).toBe(true);
expect(isTypeCompatible('string|number', BasicParamTypes.Number)).toBe(true);
});

it('should return false when actual type matches no member of a union', () => {
expect(isTypeCompatible('string|number', BasicParamTypes.Boolean)).toBe(false);
});

it('should return true for string args against a string literal union', () => {
expect(isTypeCompatible("'banner'|'label'", BasicParamTypes.String)).toBe(true);
});

it('should return false for non-string args against a string literal union', () => {
expect(isTypeCompatible("'banner'|'label'", BasicParamTypes.Number)).toBe(false);
});

it('should return true for string args against a mixed literal and named union', () => {
expect(isTypeCompatible("'banner'|number", BasicParamTypes.String)).toBe(true);
expect(isTypeCompatible("'banner'|number", BasicParamTypes.Number)).toBe(true);
});
});

describe('getDefaultValueForType', () => {
it('should return defaults for basic types', () => {
expect(getDefaultValueForType('string')).toBe("''");
expect(getDefaultValueForType('number')).toBe('0');
expect(getDefaultValueForType('boolean')).toBe('false');
expect(getDefaultValueForType('object')).toBe('');
expect(getDefaultValueForType(null)).toBe('');
});

it('should use the first member default for union of named types', () => {
expect(getDefaultValueForType('string|number')).toBe("''");
expect(getDefaultValueForType('number|string')).toBe('0');
});

it('should use the first string literal as default for literal unions', () => {
expect(getDefaultValueForType("'banner'|'label'")).toBe("'banner'");
expect(getDefaultValueForType('"banner"|"label"')).toBe('"banner"');
});

it('should use the first literal as default for mixed unions', () => {
expect(getDefaultValueForType("'banner'|string")).toBe("'banner'");
});
});
});
48 changes: 36 additions & 12 deletions packages/theme-check-common/src/liquid-doc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export enum BasicParamTypes {
Object = 'object',
}

/** Matches single- or double-quoted string literals, e.g. 'banner' or "label" */
const LITERAL_TYPE_RE = /^'[^']*'$|^"[^"]*"$/;

export enum SupportedDocTagTypes {
Param = 'param',
Example = 'example',
Expand All @@ -27,9 +30,16 @@ export enum SupportedDocTagTypes {

/**
* Provides a default completion value for an argument / parameter of a given type.
* For union types, uses the first member. For string literal types, uses the literal itself.
*/
export function getDefaultValueForType(type: string | null) {
switch (type?.toLowerCase()) {
const firstMember = type?.split('|')[0] ?? null;

if (firstMember && LITERAL_TYPE_RE.test(firstMember)) {
return firstMember;
}

switch (firstMember?.toLowerCase()) {
case BasicParamTypes.String:
return "''";
case BasicParamTypes.Number:
Expand Down Expand Up @@ -64,17 +74,23 @@ export function inferArgumentType(arg: LiquidExpression): BasicParamTypes {

/**
* Checks if the provided argument type is compatible with the expected type.
* Supports union types (e.g. string|number) and string literal types (e.g. 'banner'|'label').
* Makes certain types more permissive:
* - Boolean accepts any value, since everything is truthy / falsy in Liquid
* - String literal types (e.g. 'banner') accept any string value
*/
export function isTypeCompatible(expectedType: string, actualType: BasicParamTypes): boolean {
const normalizedExpectedType = expectedType.toLowerCase();
return expectedType.split('|').some((member) => {
if (LITERAL_TYPE_RE.test(member)) {
return actualType === BasicParamTypes.String;
}

if (normalizedExpectedType === BasicParamTypes.Boolean) {
return true;
}
const normalized = member.toLowerCase();

if (normalized === BasicParamTypes.Boolean) return true;

return normalizedExpectedType === actualType;
return normalized === actualType;
});
}

/**
Expand Down Expand Up @@ -112,14 +128,22 @@ export function parseParamType(
validParamTypes: Set<string>,
value: string,
): [pseudoType: string, isArray: boolean] | undefined {
const paramTypeMatch = value.match(/^([a-z_]+)(\[\])?$/);
const members = value.split('|');

if (!paramTypeMatch) return undefined;
for (const member of members) {
if (LITERAL_TYPE_RE.test(member)) continue;

const extractedParamType = paramTypeMatch[1];
const isArrayType = !!paramTypeMatch[2];
const namedTypeMatch = member.match(/^([a-z_]+)(\[\])?$/);
if (!namedTypeMatch) return undefined;
if (!validParamTypes.has(namedTypeMatch[1])) return undefined;
}

if (!validParamTypes.has(extractedParamType)) return undefined;
// Return the first named type member for backward-compat callers; fall back to string for literal-only unions
const firstNamed = members.find((m) => !LITERAL_TYPE_RE.test(m));
if (firstNamed) {
const match = firstNamed.match(/^([a-z_]+)(\[\])?$/)!;
return [match[1], !!match[2]];
}

return [extractedParamType, isArrayType];
return [BasicParamTypes.String, false];
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class LiquidDocParamTypeCompletionProvider implements Provider {
if (
fragments.length > 2 ||
fragments[0] !== `@${SupportedDocTagTypes.Param}` ||
!/^\{[a-zA-Z]*$/.test(fragments[1])
!/^\{[a-zA-Z'"|]*$/.test(fragments[1])
) {
return [];
}
Expand Down
Loading