@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>
1,159 lines (1,003 loc) • 34.8 kB
text/typescript
import {
AssignMarkup,
LiquidDocParamNode,
LiquidExpression,
LiquidHtmlNode,
LiquidTag,
LiquidTagDecrement,
LiquidTagIncrement,
LiquidVariable,
LiquidVariableLookup,
NamedTags,
NodeTypes,
} from '@shopify/liquid-html-parser';
import {
ArrayReturnType,
DocsetEntry,
FilterEntry,
MetafieldDefinitionMap,
MetafieldDefinition,
ObjectEntry,
ReturnType,
SourceCodeType,
ThemeDocset,
isError,
parseJSON,
path,
FETCHED_METAFIELD_CATEGORIES,
BasicParamTypes,
getValidParamTypes,
parseParamType,
} from '@shopify/theme-check-common';
import {
GetThemeSettingsSchemaForURI,
InputSetting,
isInputSetting,
isSettingsCategory,
} from './settings';
import { findLast, memo } from './utils';
import { visit } from '@shopify/theme-check-common';
export class TypeSystem {
constructor(
private readonly themeDocset: ThemeDocset,
private readonly getThemeSettingsSchemaForURI: GetThemeSettingsSchemaForURI,
private readonly getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>,
) {}
async inferType(
thing: Identifier | LiquidExpression | LiquidVariable | AssignMarkup,
partialAst: LiquidHtmlNode,
uri: string,
): Promise<PseudoType | ArrayType> {
const [objectMap, filtersMap, symbolsTable] = await Promise.all([
this.objectMap(uri, partialAst),
this.filtersMap(),
this.symbolsTable(partialAst, uri),
]);
return inferType(thing, symbolsTable, objectMap, filtersMap);
}
async availableVariables(
partialAst: LiquidHtmlNode,
partial: string,
node: LiquidVariableLookup,
uri: string,
): Promise<{ entry: DocsetEntry; type: PseudoType | ArrayType }[]> {
const [objectMap, filtersMap, symbolsTable] = await Promise.all([
this.objectMap(uri, partialAst),
this.filtersMap(),
this.symbolsTable(partialAst, uri),
]);
return Object.entries(symbolsTable)
.filter(
([key, typeRanges]) =>
key.startsWith(partial) &&
typeRanges.some((typeRange) => isCorrectTypeRange(typeRange, node)),
)
.map(([identifier, typeRanges]) => {
const typeRange = findLast(typeRanges, (typeRange) => isCorrectTypeRange(typeRange, node))!;
const type = resolveTypeRangeType(typeRange.type, symbolsTable, objectMap, filtersMap);
const entry = objectMap[isArrayType(type) ? type.valueType : type] ?? {};
return {
entry: { ...entry, name: identifier },
type,
};
});
}
public async themeSettingProperties(uri: string): Promise<ObjectEntry[]> {
const themeSettingsSchema = await this.getThemeSettingsSchemaForURI(uri);
const categories = themeSettingsSchema.filter(isSettingsCategory);
const result: ObjectEntry[] = [];
for (const category of categories) {
const inputSettings = category.settings.filter(isInputSetting);
for (const setting of inputSettings) {
result.push({
name: setting.id,
summary: '', // TODO, this should lookup the locale file for settings... setting.label
description: '', // TODO , this should lookup the locale file as well... setting.info,
return_type: settingReturnType(setting),
access: {
global: false,
parents: [],
template: [],
},
});
}
}
return result;
}
/**
* An indexed representation of objects.json by name
*
* e.g. objectMap['product'] returns the product ObjectEntry.
*/
public objectMap = async (uri: string, ast: LiquidHtmlNode): Promise<ObjectMap> => {
const [objectMap, themeSettingProperties, metafieldDefinitionsObjectMap] = await Promise.all([
this._objectMap(),
this.themeSettingProperties(uri),
this.metafieldDefinitionsObjectMap(uri),
]);
// Here we shallow mutate `settings.properties` to have the properties made
// available by settings_schema.json
const result: ObjectMap = {
...objectMap,
settings: {
...(objectMap.settings ?? {}),
properties: themeSettingProperties,
},
...customMetafieldTypeEntries(objectMap['metafield']),
...metafieldDefinitionsObjectMap,
};
// For each metafield definition fetched, we need to override existing types with `metafields` property
// to `${category}_metafield`.
//
// WARNING: Since we aren't cloning the object, we are mutating the original type for all themes in
// the workspace. However, this is fine since these changes are not unique to a theme.
for (let category of FETCHED_METAFIELD_CATEGORIES) {
if (!result[category]) continue;
let metafieldsProperty = result[category].properties?.find(
(prop) => prop.name === 'metafields',
);
if (!metafieldsProperty) continue;
metafieldsProperty.return_type = [{ type: `${category}_metafields`, name: '' }];
}
// Deal with sections/file.liquid section.settings by infering the type from the {% schema %}
if (/[\/\\]sections[\/\\]/.test(uri) && result.section) {
result.section = JSON.parse(JSON.stringify(result.section)); // easy deep clone
const settings = result.section.properties?.find((x) => x.name === 'settings');
if (!settings || !settings.return_type) return result;
settings.return_type = [{ type: 'section_settings', name: '' }];
result.section_settings = {
name: 'section_settings',
access: {
global: false,
parents: [],
template: [],
},
properties: schemaSettingsAsProperties(ast),
return_type: [],
};
}
// Deal with blocks/files.liquid block.settings in a similar fashion
if (/[\/\\]blocks[\/\\]/.test(uri) && result.block) {
result.block = JSON.parse(JSON.stringify(result.block)); // easy deep clone
const settings = result.block.properties?.find((x) => x.name === 'settings');
if (!settings || !settings.return_type) return result;
settings.return_type = [{ type: 'block_settings', name: '' }];
result.block_settings = {
name: 'block_settings',
access: {
global: false,
parents: [],
template: [],
},
properties: schemaSettingsAsProperties(ast),
return_type: [],
};
}
return result;
};
public async metafieldDefinitionsObjectMap(uri: string): Promise<ObjectMap> {
let result: ObjectMap = {};
const metafieldDefinitionMap = await this.getMetafieldDefinitions(uri);
for (let [category, definitions] of Object.entries(metafieldDefinitionMap)) {
// Metafield definitions need to be grouped by their namespace
let metafieldNamespaces = new Map<string, ObjectEntry[]>();
for (let definition of definitions as MetafieldDefinition[]) {
if (!metafieldNamespaces.has(definition.namespace)) {
metafieldNamespaces.set(definition.namespace, []);
}
metafieldNamespaces.get(definition.namespace)!.push({
name: definition.key,
description: definition.description,
return_type: metafieldReturnType(definition.type.name),
});
}
let metafieldGroupProperties: ObjectEntry[] = [];
for (let [namespace, namespaceProperties] of metafieldNamespaces) {
const metafieldCategoryNamespaceHandle = `${category}_metafield_${namespace}`;
// Since the namespace can be shared by multiple categories, we need to make sure the return_type
// handle is unique across all categories
metafieldGroupProperties.push({
name: namespace,
return_type: [{ type: metafieldCategoryNamespaceHandle, name: '' }],
access: {
global: false,
parents: [],
template: [],
},
});
result[metafieldCategoryNamespaceHandle] = {
name: metafieldCategoryNamespaceHandle,
properties: namespaceProperties,
access: {
global: false,
parents: [],
template: [],
},
};
}
const metafieldCategoryHandle = `${category}_metafields`;
result[metafieldCategoryHandle] = {
name: metafieldCategoryHandle,
properties: metafieldGroupProperties,
access: {
global: false,
parents: [],
template: [],
},
};
}
return result;
}
// This is the big one we reuse (memoized)
private _objectMap = memo(async (): Promise<ObjectMap> => {
const entries = await this.objectEntries();
return entries.reduce((map, entry) => {
map[entry.name] = entry;
return map;
}, {} as ObjectMap);
});
/** An indexed representation of filters.json by name */
public filtersMap = memo(async (): Promise<FiltersMap> => {
const entries = await this.filterEntries();
return entries.reduce((map, entry) => {
map[entry.name] = entry;
return map;
}, {} as FiltersMap);
});
public filterEntries = memo(async () => {
return this.themeDocset.filters();
});
public objectEntries = memo(async () => {
return this.themeDocset.objects();
});
private async symbolsTable(partialAst: LiquidHtmlNode, uri: string): Promise<SymbolsTable> {
const seedSymbolsTable = await this.seedSymbolsTable(uri);
return buildSymbolsTable(partialAst, seedSymbolsTable, await this.themeDocset.liquidDrops());
}
/**
* The seedSymbolsTable contains all the global variables.
*
* This lets us have the ambient type of things first, but if someone
* reassigns product, then we'll be able to change the type of product on
* the appropriate range.
*
* This is not memo'ed because we would otherwise need to clone the thing.
*/
private seedSymbolsTable = async (uri: string) => {
const [globalVariables, contextualVariables] = await Promise.all([
this.globalVariables(),
this.contextualVariables(uri),
]);
return globalVariables.concat(contextualVariables).reduce((table, objectEntry) => {
table[objectEntry.name] ??= [];
table[objectEntry.name].push({
identifier: objectEntry.name,
type: objectEntryType(objectEntry),
range: [0],
});
return table;
}, {} as SymbolsTable);
};
private globalVariables = memo(async () => {
const entries = await this.objectEntries();
return entries.filter(
(entry) => !entry.access || entry.access.global === true || entry.access.template.length > 0,
);
});
private contextualVariables = async (uri: string) => {
const entries = await this.objectEntries();
const contextualEntries = getContextualEntries(uri);
return entries.filter((entry) => contextualEntries.includes(entry.name));
};
}
const SECTION_FILE_REGEX = /sections[\/\\][^.\\\/]*\.liquid$/;
const BLOCK_FILE_REGEX = /blocks[\/\\][^.\\\/]*\.liquid$/;
const SNIPPET_FILE_REGEX = /snippets[\/\\][^.\\\/]*\.liquid$/;
const LAYOUT_FILE_REGEX = /layout[\/\\]checkout\.liquid$/;
function getContextualEntries(uri: string): string[] {
const normalizedUri = path.normalize(uri);
if (LAYOUT_FILE_REGEX.test(normalizedUri)) {
return [
'locale',
'direction',
'skip_to_content_link',
'checkout_html_classes',
'checkout_stylesheets',
'checkout_scripts',
'content_for_logo',
'breadcrumb',
'order_summary_toggle',
'content_for_order_summary',
'alternative_payment_methods',
'content_for_footer',
'tracking_code',
];
}
if (SECTION_FILE_REGEX.test(normalizedUri)) {
return ['section', 'predictive_search', 'recommendations', 'comment'];
}
if (BLOCK_FILE_REGEX.test(normalizedUri)) {
return ['app', 'section', 'recommendations', 'block'];
}
if (SNIPPET_FILE_REGEX.test(normalizedUri)) {
return ['app'];
}
return [];
}
/** An indexed representation on objects.json (by name) */
type ObjectMap = Record<ObjectEntryName, ObjectEntry>;
/** An indexed representation on filters.json (by name) */
type FiltersMap = Record<FilterEntryName, FilterEntry>;
/** An identifier refers to the name of a variable, e.g. `x`, `product`, etc. */
type Identifier = string;
type ObjectEntryName = ObjectEntry['name'];
type FilterEntryName = FilterEntry['name'];
/** Untyped is for declared variables without a type (like `any`) */
export const Untyped = 'untyped' as const;
export type Untyped = typeof Untyped;
/** Unknown is for variables that don't exist, type would come from context (e.g. snippet var without LiquidDoc) */
export const Unknown = 'unknown' as const;
export type Unknown = typeof Untyped;
const String = 'string' as const;
type String = typeof String;
/** A pseudo-type is the possible values of an ObjectEntry's return_type.type */
export type PseudoType = ObjectEntryName | String | Untyped | Unknown | 'number' | 'boolean';
/**
* A variable can have many types in the same file
*
* Just think of this:
*
* {{ x }} # unknown
* {% assign x = all_products['cool-handle'] %}
* {{ x }} # product
* {% assign x = x.featured_image %}
* {{ x }} # image
* [% assign x = x.src %}]
* {{ x }} # string
*/
interface TypeRange {
/** The name of the variable */
identifier: Identifier;
/** The type of the variable */
type: PseudoType | ArrayType | LazyVariableType | LazyDeconstructedExpression;
/**
* The range may be one of two things:
* - open ended (till end of file, end === undefined)
* - closed (inside for loop)
*/
range: [start: number, end?: number];
}
/** Some things can be an array type (e.g. product.images) */
export type ArrayType = {
kind: 'array';
valueType: PseudoType;
};
const arrayType = (valueType: PseudoType): ArrayType => ({
kind: 'array',
valueType,
});
/**
* Because a type may depend on another, this represents the type of
* something as the type of a LiquidVariable chain.
* {% assign x = y.foo | filter1 | filter2 %}
*/
type LazyVariableType = {
kind: NodeTypes.LiquidVariable;
node: LiquidVariable;
offset: number;
};
const lazyVariable = (node: LiquidVariable, offset: number): LazyVariableType => ({
kind: NodeTypes.LiquidVariable,
node,
offset,
});
/**
* A thing may be the deconstruction of something else.
*
* examples
* - for thing in (0..2)
* - for thing in collection
* - for thing in parent.collection
* - for thing in 'string?'
*/
type LazyDeconstructedExpression = {
kind: 'deconstructed';
node: LiquidExpression;
offset: number;
};
const LazyDeconstructedExpression = (
node: LiquidExpression,
offset: number,
): LazyDeconstructedExpression => ({
kind: 'deconstructed',
node,
offset,
});
/**
* A symbols table is a map of identifiers to TypeRanges.
*
* It stores the mapping of variable name to type by position in the file.
*
* The ranges are sorted in range.start order.
*/
type SymbolsTable = Record<Identifier, TypeRange[]>;
function buildSymbolsTable(
partialAst: LiquidHtmlNode,
seedSymbolsTable: SymbolsTable,
liquidDrops: ObjectEntry[],
): SymbolsTable {
const typeRanges = visit<SourceCodeType.LiquidHtml, TypeRange>(partialAst, {
// {% assign x = foo.x | filter %}
AssignMarkup(node) {
return {
identifier: node.name,
type: lazyVariable(node.value, node.position.start),
range: [node.position.end],
};
},
// {% doc %}
// @param {string} name - your name
// {% enddoc %}
LiquidDocParamNode(node) {
return {
identifier: node.paramName.value,
type: inferLiquidDocParamType(node, liquidDrops),
range: [node.position.end],
};
},
// This also covers tablerow
ForMarkup(node, ancestors) {
const parentNode = ancestors.at(-1)! as LiquidTag;
return {
identifier: node.variableName,
type: LazyDeconstructedExpression(node.collection, node.position.start),
range: [parentNode.blockStartPosition.end, end(parentNode.blockEndPosition?.end)],
};
},
// {% capture foo %}
// ...
// {% endcapture}
LiquidTag(node) {
if (node.name === 'capture' && typeof node.markup !== 'string') {
return {
identifier: node.markup.name!,
type: String,
range: [node.position.end],
};
} else if (['form', 'paginate'].includes(node.name)) {
return {
identifier: node.name,
type: node.name,
range: [node.blockStartPosition.end, end(node.blockEndPosition?.end)],
};
} else if (['for', 'tablerow'].includes(node.name)) {
return {
identifier: node.name + 'loop',
type: node.name + 'loop',
range: [node.blockStartPosition.end, end(node.blockEndPosition?.end)],
};
} else if (isLiquidTagIncrement(node) || isLiquidTagDecrement(node)) {
if (node.markup.name === null) return;
return {
identifier: node.markup.name,
type: 'number',
range: [node.position.start],
};
} else if (node.name === 'layout') {
return {
identifier: 'none',
type: 'keyword',
range: [node.position.start, node.position.end],
};
}
},
});
return typeRanges
.sort(({ range: [startA] }, { range: [startB] }) => startA - startB)
.reduce((table, typeRange) => {
table[typeRange.identifier] ??= [];
table[typeRange.identifier].push(typeRange);
return table;
}, seedSymbolsTable);
}
/**
* Given a TypeRange['type'] (which may be lazy), resolve its type recursively.
*
* The output is a fully resolved PseudoType | ArrayType. Which means we
* could use it to power completions.
*/
function resolveTypeRangeType(
typeRangeType: TypeRange['type'],
symbolsTable: SymbolsTable,
objectMap: ObjectMap,
filtersMap: FiltersMap,
): PseudoType | ArrayType {
if (typeof typeRangeType === 'string') {
return typeRangeType;
}
switch (typeRangeType.kind) {
case 'array': {
return typeRangeType;
}
case 'deconstructed': {
const arrayType = inferType(typeRangeType.node, symbolsTable, objectMap, filtersMap);
if (typeof arrayType === 'string') {
return Untyped;
} else {
return arrayType.valueType;
}
}
default: {
return inferType(typeRangeType.node, symbolsTable, objectMap, filtersMap);
}
}
}
function inferType(
thing: Identifier | LiquidExpression | LiquidVariable | AssignMarkup,
symbolsTable: SymbolsTable,
objectMap: ObjectMap,
filtersMap: FiltersMap,
): PseudoType | ArrayType {
if (typeof thing === 'string') {
return objectMap[thing as PseudoType]?.name ?? Untyped;
}
switch (thing.type) {
case NodeTypes.Number: {
return 'number';
}
case NodeTypes.String: {
return 'string';
}
case NodeTypes.LiquidLiteral: {
return 'boolean';
}
case NodeTypes.Range: {
return arrayType('number');
}
// The type of the assign markup is the type of the right hand side.
// {% assign x = y.property | filter1 | filter2 %}
case NodeTypes.AssignMarkup: {
return inferType(thing.value, symbolsTable, objectMap, filtersMap);
}
// A variable lookup is expression[.lookup]*
// {{ y.property }}
case NodeTypes.VariableLookup: {
return inferLookupType(thing, symbolsTable, objectMap, filtersMap);
}
// A variable is the VariableLookup + Filters
// The type is the return value of the last filter
// {{ y.property | filter1 | filter2 }}
case NodeTypes.LiquidVariable: {
if (thing.filters.length > 0) {
const lastFilter = thing.filters.at(-1)!;
if (lastFilter.name === 'default') {
// default filter is a special case, we need to return the type of the expression
// instead of the filter.
if (lastFilter.args.length > 0 && lastFilter.args[0].type !== NodeTypes.NamedArgument) {
return inferType(lastFilter.args[0], symbolsTable, objectMap, filtersMap);
}
}
const filterEntry = filtersMap[lastFilter.name];
return filterEntry ? filterEntryReturnType(filterEntry) : Untyped;
} else {
return inferType(thing.expression, symbolsTable, objectMap, filtersMap);
}
}
default: {
return Untyped;
}
}
}
function inferLiquidDocParamType(node: LiquidDocParamNode, liquidDrops: ObjectEntry[]) {
const paramTypeValue = node.paramType?.value;
if (!paramTypeValue) return Untyped;
const validParamTypes = getValidParamTypes(liquidDrops);
const parsedParamType = parseParamType(new Set(validParamTypes.keys()), paramTypeValue);
if (!parsedParamType) return Untyped;
const [type, isArray] = parsedParamType;
let transformedParamType;
// BasicParamTypes.Object does not map to any specific type in the type system.
if (type === BasicParamTypes.Object) {
transformedParamType = Untyped;
} else {
transformedParamType = type;
}
if (isArray) {
return arrayType(transformedParamType);
}
return transformedParamType;
}
function inferLookupType(
thing: LiquidVariableLookup,
symbolsTable: SymbolsTable,
objectMap: ObjectMap,
filtersMap: FiltersMap,
): PseudoType | ArrayType {
// we return the type of the drop, so a.b.c
const node = thing;
// We don't complete global lookups. It's too much of an edge case.
if (node.name === null) return Untyped;
/**
* curr stores the type of the variable lookup starting at the beginning.
*
* It starts as the type of the top-level identifier, and the we
* recursively change it to the return type of the lookups.
*
* So, for x.images.first.src we do:
* - curr = infer type of x | x
* - curr = x.images -> ArrayType<image> | x.images
* - curr = images.first -> image | x.images.first
* - curr = first.src -> string | x.images.first.src
*
* Once were done iterating, the type of the lookup is curr.
*/
let curr = inferIdentifierType(node, symbolsTable, objectMap, filtersMap);
for (let lookup of node.lookups) {
// Here we redefine curr to be the returnType of the lookup.
// e.g. images[0] -> image
// e.g. images.first -> image
// e.g. images.size -> number
if (isArrayType(curr)) {
curr = inferArrayTypeLookupType(curr, lookup);
}
// e.g. product.featured_image -> image
// e.g. product.images -> ArrayType<images>
// e.g. product.name -> string
else {
curr = inferPseudoTypePropertyType(curr, lookup, objectMap);
}
// Early return
if (curr === Untyped) {
return Untyped;
}
}
return curr;
}
/**
* Given a VariableLookup node, infer the type of its root (position-relative).
*
* e.g. for the following
* {% assign x = product %}
* {{ x.images.first }}
*
* This function infers the type of `x`.
*/
function inferIdentifierType(
node: LiquidVariableLookup,
symbolsTable: SymbolsTable,
objectMap: ObjectMap,
filtersMap: FiltersMap,
) {
// The name of a variable
const identifier = node.name;
// We don't complete the global access edge case
// e.g. {{ ['all_products'] }}
if (!identifier) {
return Untyped;
}
const typeRanges = symbolsTable[identifier];
if (!typeRanges) {
return Unknown;
}
const typeRange = findLast(typeRanges, (tr) => isCorrectTypeRange(tr, node));
return typeRange
? resolveTypeRangeType(typeRange.type, symbolsTable, objectMap, filtersMap)
: Unknown;
}
/**
* infers the type of a lookup on an ArrayType
* - images[0] becomes 'image'
* - images[index] becomes 'image'
* - images.first becomes 'image'
* - images.last becomes 'image'
* - images.size becomes 'number'
* - anything else becomes 'untyped'
*/
function inferArrayTypeLookupType(curr: ArrayType, lookup: LiquidExpression) {
// images[0]
// images[index]
if (lookup.type === NodeTypes.Number || lookup.type === NodeTypes.VariableLookup) {
return curr.valueType;
}
// images.first
// images.last
// images.size
// anything else is undef
else if (lookup.type === NodeTypes.String) {
switch (lookup.value) {
case 'first':
case 'last': {
return curr.valueType;
}
case 'size': {
return 'number';
}
default: {
return Unknown;
}
}
}
// images[true]
// images[(0..2)]
else {
return Untyped;
}
}
function inferPseudoTypePropertyType(
curr: PseudoType, // settings
lookup: LiquidExpression,
objectMap: ObjectMap,
) {
const parentEntry: ObjectEntry | undefined = objectMap[curr];
// When doing a non string lookup, we don't really know the type. e.g.
// products[0]
// products[true]
// products[(0..10)]
if (lookup.type !== NodeTypes.String) {
return Untyped;
}
// When we don't have docs for the parent entry
if (!parentEntry) {
// It might be that the parent entry is a string.
// We do support a couple of properties for those
if (curr === 'string') {
switch (lookup.value) {
// some_string.first
// some_string.last
case 'first':
case 'last':
return 'string';
// some_string.size
case 'size':
return 'number';
default: {
// For the string type, any property access other than first/last/size
// is unknown. This is different from an untyped/any object where any
// property access would return untyped.
// String is a known type with specific properties, so accessing
// undefined properties returns an unknown.
return Unknown;
}
}
}
// Or it might be that the parent entry is untyped, so its subproperty
// could also be untyped (kind of like if `foo` is `any`, then `foo.bar` is `any`)
return Untyped;
}
const propertyName = lookup.value;
const property = parentEntry.properties?.find((property) => property.name === propertyName);
// When the propety is not known, return Untyped. e.g.
// product.foo
// product.bar
if (!property) {
// Debating between returning Untyped or Unknown here
// Might be that we have outdated docs. Prob better to return Untyped.
return Untyped;
}
// When the property is known & we have docs for it, return its type. e.g.
// product.image
// product.images
return objectEntryType(property);
}
function filterEntryReturnType(entry: FilterEntry): PseudoType | ArrayType {
return docsetEntryReturnType(entry, 'string');
}
function objectEntryType(entry: ObjectEntry): PseudoType | ArrayType {
return docsetEntryReturnType(entry, entry.name);
}
/**
* This function converts the return_type property in one of the .json
* files into a PseudoType or ArrayType.
*/
export function docsetEntryReturnType(
entry: ObjectEntry | FilterEntry,
defaultValue: PseudoType,
): PseudoType | ArrayType {
const returnTypes = entry.return_type;
if (returnTypes && returnTypes.length > 0) {
const returnType = returnTypes[0];
if (isArrayReturnType(returnType)) {
return arrayType(returnType.array_value);
} else {
return returnType.type;
}
}
return defaultValue;
}
function isArrayReturnType(rt: ReturnType): rt is ArrayReturnType {
return rt.type === 'array';
}
export function isArrayType(thing: PseudoType | ArrayType): thing is ArrayType {
return typeof thing !== 'string';
}
/** Assumes findLast */
function isCorrectTypeRange(typeRange: TypeRange, node: LiquidVariableLookup): boolean {
const [start, end] = typeRange.range;
if (end && node.position.start > end) return false;
return node.position.start > start;
}
function end(offset: number | undefined): number | undefined {
if (offset === -1) return undefined;
return offset;
}
function isLiquidTagIncrement(node: LiquidTag): node is LiquidTagIncrement {
return node.name === NamedTags.increment && typeof node.markup !== 'string';
}
function isLiquidTagDecrement(node: LiquidTag): node is LiquidTagDecrement {
return node.name === NamedTags.decrement && typeof node.markup !== 'string';
}
function settingReturnType(setting: InputSetting): ObjectEntry['return_type'] {
switch (setting.type) {
// basic settings
case 'checkbox':
return [{ type: 'boolean', name: '' }];
case 'range':
case 'number':
return [{ type: 'number', name: '' }];
case 'radio':
case 'select':
case 'text':
case 'textarea':
return [{ type: 'string', name: '' }];
// specialized settings
case 'article':
return [{ type: 'article', name: '' }];
case 'blog':
return [{ type: 'blog', name: '' }];
case 'collection':
return [{ type: 'collection', name: '' }];
case 'collection_list':
return [{ type: 'array', array_value: 'collection' }];
case 'color':
return [{ type: 'color', name: '' }];
case 'color_background':
return [{ type: 'string', name: '' }];
case 'color_scheme':
return [{ type: 'color_scheme', name: '' }];
// TODO ??
case 'color_scheme_group':
return [];
case 'font_picker':
return [{ type: 'font', name: '' }];
case 'html':
return [{ type: 'string', name: '' }];
case 'image_picker':
return [{ type: 'image', name: '' }];
case 'inline_richtext':
return [{ type: 'string', name: '' }];
case 'link_list':
return [{ type: 'linklist', name: '' }];
case 'liquid':
return [{ type: 'string', name: '' }];
case 'page':
return [{ type: 'page', name: '' }];
case 'product':
return [{ type: 'product', name: '' }];
case 'product_list':
return [{ type: 'array', array_value: 'product' }];
case 'richtext':
return [{ type: 'string', name: '' }];
case 'text_alignment':
return [{ type: 'string', name: '' }];
case 'url':
return [{ type: 'string', name: '' }];
case 'video':
return [{ type: 'video', name: '' }];
case 'video_url':
return [{ type: 'string', name: '' }];
default:
return [];
}
}
const METAFIELD_TYPE_TO_TYPE = Object.freeze({
single_line_text_field: String,
multi_line_text_field: String,
url_reference: String,
date: String,
date_time: String,
number_integer: 'number',
number_decimal: 'number',
product_reference: 'product',
collection_reference: 'collection',
variant_reference: 'variant',
page_reference: 'page',
boolean: 'boolean',
color: 'color',
weight: 'measurement',
volume: 'measurement',
dimension: 'measurement',
rating: 'rating',
money: 'money',
json: Untyped,
metaobject_reference: 'metaobject',
mixed_reference: Untyped,
rich_text_field: Untyped,
file_reference: Untyped,
});
const REFERENCE_TYPE_METAFIELDS = Object.entries(METAFIELD_TYPE_TO_TYPE)
.filter(([metafieldType, _type]) => metafieldType.endsWith('_reference'))
.map(([_metafieldType, type]) => type);
function metafieldReturnType(metafieldType: string): ObjectEntry['return_type'] {
let isArray = metafieldType.startsWith('list.');
if (isArray) {
metafieldType = metafieldType.split('.')[1];
}
let type = 'metafield_' + ((METAFIELD_TYPE_TO_TYPE as any)[metafieldType] ?? Untyped);
if (isArray) {
return [{ type: `${type}_array`, name: '' }];
}
return [{ type: type, name: '' }];
}
// The default `metafield` type has an untyped `value` property.
// We need to create new metafield types with the labels `metafield_x` and `metafield_x_array`
// where x is the type of metafield inside the `value` property. The metafields ending with `x_array`
// is where the value is an array of type x.
const customMetafieldTypeEntries = memo((baseMetafieldEntry: ObjectEntry) => {
if (!baseMetafieldEntry) return {} as ObjectMap;
return [
...new Set([...Object.values(METAFIELD_TYPE_TO_TYPE), ...FETCHED_METAFIELD_CATEGORIES]),
].reduce((map, type) => {
{
const metafieldEntry = JSON.parse(JSON.stringify(baseMetafieldEntry)); // easy deep clone
const metafieldValueProp = metafieldEntry.properties?.find(
(prop: any) => prop.name === 'value',
);
if (metafieldValueProp) {
metafieldValueProp.return_type = [{ type: type, name: '' }];
metafieldValueProp.description = '';
metafieldEntry.name = `metafield_${type}`;
map[metafieldEntry.name] = metafieldEntry;
}
}
{
const metafieldArrayEntry = JSON.parse(JSON.stringify(baseMetafieldEntry)); // easy deep clone
const metafieldArrayValueProp = metafieldArrayEntry.properties?.find(
(prop: any) => prop.name === 'value',
);
if (metafieldArrayValueProp) {
// A metafield definition using a list of references does not use an array, but a separate type of collection.
// For auto-completion purposes, we can't use the array type
// https://shopify.dev/docs/api/liquid/objects/metafield#metafield-determining-the-length-of-a-list-metafield
if (REFERENCE_TYPE_METAFIELDS.includes(type as any)) {
metafieldArrayValueProp.return_type = [{ type: 'untyped', name: '' }];
} else {
metafieldArrayValueProp.return_type = [{ type: 'array', name: '', array_value: type }];
}
metafieldArrayValueProp.description = '';
metafieldArrayEntry.name = `metafield_${type}_array`;
map[metafieldArrayEntry.name] = metafieldArrayEntry;
}
}
return map;
}, {} as ObjectMap);
});
function schemaSettingsAsProperties(ast: LiquidHtmlNode): ObjectEntry[] {
if (ast.type !== NodeTypes.Document) return [];
try {
const source = ast._source; // (the unfixed source)
const start = /\{%\s*schema\s*%\}/m.exec(source);
const end = /\{%\s*endschema\s*%\}/m.exec(source);
if (!start || !end) return [];
const schema = source.slice(start.index + start[0].length, end.index);
const json = parseJSON(schema);
if (isError(json) || !('settings' in json) || !Array.isArray(json.settings)) return [];
const result: ObjectEntry[] = [];
const inputSettings = json.settings.filter(isInputSetting);
for (const setting of inputSettings) {
result.push({
name: setting.id,
summary: '', // TODO, this should lookup the locale file for settings... setting.label
description: '', // TODO , this should lookup the locale file as well... setting.info,
return_type: settingReturnType(setting),
access: {
global: false,
parents: [],
template: [],
},
});
}
return result;
} catch (_) {
return [];
}
}