Repository: Shopify/theme-tools
Pull request #919: Support LiquidDoc param array types
Author: login: aswamy
Labels: name: #gsd:44310
description: LiquidDoc
Created: 2025-04-29T21:09:39Z | Merged: 2025-04-30T14:49:16Z

## What are you adding in this PR?

Closes https://github.com/Shopify/developer-tools-team/issues/682

- Add support for array types in LiquidDoc

## Tophat

- Open the branch in VSCode
- `yarn install && yarn build`
- Press F5
- Open a theme and create a snippet/block with LiquidDoc
- Try out different types (`string`, `string[]`, `product`, `product[]`, `object`, `object[]`, etc.)
- NOTE: `object` is not a real type. It is treated as "untyped", so `object[]` is treated an array of untyped objects

## Before you deploy
- [x] I included a minor bump `changeset`
- [x] My feature is backward compatible



Diff:
diff --git a/.changeset/proud-seahorses-cheer.md b/.changeset/proud-seahorses-cheer.md
new file mode 100644
index 000000000..29df942c3
--- /dev/null
+++ b/.changeset/proud-seahorses-cheer.md
@@ -0,0 +1,17 @@
+---
+'@shopify/theme-language-server-common': minor
+'@shopify/theme-check-common': minor
+---
+
+Support LiquidDoc param array types
+
+- LiquidDoc param types supports 1D arrays as param types
+- Square brackets are placed after the param type to denote arrays
+
+E.g.
+```
+{% doc %}
+  @param {product[]} my_products - an array of products
+  @param {string[]} my_strings - an array of strings
+{% enddoc %}
+```
diff --git a/packages/theme-check-common/src/checks/valid-doc-param-types/index.spec.ts b/packages/theme-check-common/src/checks/valid-doc-param-types/index.spec.ts
index 00a3533b5..2e66619f2 100644
--- a/packages/theme-check-common/src/checks/valid-doc-param-types/index.spec.ts
+++ b/packages/theme-check-common/src/checks/valid-doc-param-types/index.spec.ts
@@ -30,6 +30,18 @@ describe('Module: ValidDocParamTypes', () => {
     expect(offenses).to.be.empty;
   });
 
+  it(`should not report an error when a valid liquid object array parameter (product[]) type is used`, async () => {
+    const sourceCode = `
+      {% doc %}
+        @param {product[]} param1 - Example param
+      {% enddoc %}
+    `;
+
+    const offenses = await runLiquidCheck(ValidDocParamTypes, sourceCode);
+
+    expect(offenses).to.be.empty;
+  });
+
   it('should report an error with suggestions when an invalid parameter type is used', async () => {
     const sourceCode = `
       {% doc %}
diff --git a/packages/theme-check-common/src/checks/valid-doc-param-types/index.ts b/packages/theme-check-common/src/checks/valid-doc-param-types/index.ts
index d7f681e5d..584fe82d4 100644
--- a/packages/theme-check-common/src/checks/valid-doc-param-types/index.ts
+++ b/packages/theme-check-common/src/checks/valid-doc-param-types/index.ts
@@ -1,5 +1,5 @@
 import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
-import { getValidParamTypes, BasicParamTypes } from '../../liquid-doc/utils';
+import { getValidParamTypes, parseParamType } from '../../liquid-doc/utils';
 
 export const ValidDocParamTypes: LiquidCheckDefinition = {
   meta: {
@@ -22,22 +22,29 @@ export const ValidDocParamTypes: LiquidCheckDefinition = {
       return {};
     }
 
+    // To avoid recalculating valid param types during theme-check, constructing
+    // the promise beforehand.
+    const validParamTypesPromise = context
+      .themeDocset!.liquidDrops()
+      .then((entries) => new Set(getValidParamTypes(entries).keys()));
+
     return {
       async LiquidDocParamNode(node) {
         if (!node.paramType) {
           return;
         }
 
-        const objectEntries = await context.themeDocset!.objects();
+        const parsedParamType = parseParamType(await validParamTypesPromise, node.paramType.value);
 
-        if (getValidParamTypes(objectEntries).has(node.paramType.value)) {
+        if (parsedParamType) {
           return;
         }
 
         context.report({
           message: `The parameter type '${node.paramType.value}' is not supported.`,
-          startIndex: node.paramType.position.start,
-          endIndex: node.paramType.position.end,
+          // Index is offset to include the curly brackets around the param type
+          startIndex: node.paramType.position.start - 1,
+          endIndex: node.paramType.position.end + 1,
           suggest: [
             {
               message: 'Remove invalid parameter type',
@@ -50,7 +57,7 @@ export const ValidDocParamTypes: LiquidCheckDefinition = {
                   node.source.slice(node.position.start, node.position.end).replace(
                     // We could have padded spaces around + inside the param type
                     // e.g. `{ string }`, `{string}`, or ` { string } `
-                    new RegExp(`\\s*\\{\\s*${node.paramType.value}\\s*\\}\\s*`),
+                    /\s*\{\s*[^\s]+\s*\}\s*/,
                     ' ',
                   ),
                 );
diff --git a/packages/theme-check-common/src/liquid-doc/utils.spec.ts b/packages/theme-check-common/src/liquid-doc/utils.spec.ts
new file mode 100644
index 000000000..322e7c27b
--- /dev/null
+++ b/packages/theme-check-common/src/liquid-doc/utils.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { BasicParamTypes, parseParamType } from './utils';
+
+describe('liquid-doc/utils', () => {
+  describe('parseParamType', () => {
+    const validParamTypes = new Set([...Object.values(BasicParamTypes), 'product']);
+
+    it('should parse all values provided in the `validParamTypes` set', () => {
+      const tests = {
+        string: ['string', false],
+        product: ['product', false],
+        'string[]': ['string', true],
+        'product[]': ['product', true],
+        invalid: undefined,
+      };
+
+      Object.entries(tests).forEach(([input, expected]) => {
+        const result = parseParamType(validParamTypes, input);
+        expect(result).toEqual(expected);
+      });
+    });
+  });
+});
diff --git a/packages/theme-check-common/src/liquid-doc/utils.ts b/packages/theme-check-common/src/liquid-doc/utils.ts
index 4477b85ae..bd47d15ef 100644
--- a/packages/theme-check-common/src/liquid-doc/utils.ts
+++ b/packages/theme-check-common/src/liquid-doc/utils.ts
@@ -107,3 +107,19 @@ export function getValidParamTypes(objectEntries: ObjectEntry[]): Map<string, st
 
   return paramTypes;
 }
+
+export function parseParamType(
+  validParamTypes: Set<string>,
+  value: string,
+): [pseudoType: string, isArray: boolean] | undefined {
+  const paramTypeMatch = value.match(/^([a-z_]+)(\[\])?$/);
+
+  if (!paramTypeMatch) return undefined;
+
+  const extractedParamType = paramTypeMatch[1];
+  const isArrayType = !!paramTypeMatch[2];
+
+  if (!validParamTypes.has(extractedParamType)) return undefined;
+
+  return [extractedParamType, isArrayType];
+}
diff --git a/packages/theme-language-server-common/src/TypeSystem.spec.ts b/packages/theme-language-server-common/src/TypeSystem.spec.ts
index 3521054fa..737cc88dc 100644
--- a/packages/theme-language-server-common/src/TypeSystem.spec.ts
+++ b/packages/theme-language-server-common/src/TypeSystem.spec.ts
@@ -493,6 +493,27 @@ describe('Module: TypeSystem', () => {
       );
       expect(inferredType).to.eql('product');
     });
+
+    it(`should support array liquid doc params type: product[]`, async () => {
+      const sourceCode = `
+        {% doc %}
+          @param {product[]} data - some data
+        {% enddoc %}
+        {{ data }}
+      `;
+      const ast = toLiquidHtmlAST(sourceCode);
+      const variableOutput = ast.children[1];
+      assert(isLiquidVariableOutput(variableOutput));
+      const inferredType = await typeSystem.inferType(
+        variableOutput.markup,
+        ast,
+        'file:///snippets/example.liquid',
+      );
+      expect(inferredType).to.eql({
+        kind: 'array',
+        valueType: 'product',
+      });
+    });
   });
 
   describe('metafieldDefinitionsObjectMap', async () => {
diff --git a/packages/theme-language-server-common/src/TypeSystem.ts b/packages/theme-language-server-common/src/TypeSystem.ts
index c6a549172..e4011a582 100644
--- a/packages/theme-language-server-common/src/TypeSystem.ts
+++ b/packages/theme-language-server-common/src/TypeSystem.ts
@@ -27,6 +27,7 @@ import {
   FETCHED_METAFIELD_CATEGORIES,
   BasicParamTypes,
   getValidParamTypes,
+  parseParamType,
 } from '@shopify/theme-check-common';
 import {
   GetThemeSettingsSchemaForURI,
@@ -659,20 +660,32 @@ function inferType(
 }
 
 function inferLiquidDocParamType(node: LiquidDocParamNode, liquidDrops: ObjectEntry[]) {
-  if (
-    !node.paramType ||
-    // BasicParamTypes.Object does not map to any specific
-    // type in the type system.
-    node.paramType.value === BasicParamTypes.Object
-  ) {
-    return Untyped;
+  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 (getValidParamTypes(liquidDrops).has(node.paramType.value)) {
-    return node.paramType.value;
+  if (isArray) {
+    return arrayType(transformedParamType);
   }
 
-  return Untyped;
+  return transformedParamType;
 }
 
 function inferLookupType(


Reviews:
user: login: jamesmengo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T22:02:57Z
user: login: jamesmengo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T22:11:44Z
user: login: jamesmengo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T22:14:15Z
user: login: jamesmengo
body: Excited about this change! Just 1 comment re: return types that I'd like to align on.

Side note: how much effort would it take to implement a hover on the array accessors so that `asdf.first` in the screenshot below would display a `product`?

<img width="262" alt="image" src="https://github.com/user-attachments/assets/83f477a4-72db-4d3a-b814-5d3594061706" />

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T22:20:34Z
user: login: charlespwd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:06:22Z
user: login: charlespwd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:08:13Z
user: login: charlespwd
body: Pretty good! Could make it a bit easier to read, but overall the approach is solid! 🔥 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:08:50Z
user: login: EvilGenius13
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T13:27:55Z
user: login: aswamy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T13:59:24Z

Review comments:
diff_hunk: @@ -28,16 +28,23 @@ export const ValidDocParamTypes: LiquidCheckDefinition = {
           return;
         }
 
-        const objectEntries = await context.themeDocset!.objects();
+        const objectEntries = await context.themeDocset!.liquidDrops();
 
-        if (getValidParamTypes(objectEntries).has(node.paramType.value)) {
+        const validParamTypes = getValidParamTypes(objectEntries);
path: packages/theme-check-common/src/checks/valid-doc-param-types/index.ts
user: login: jamesmengo
body: Outside the scope of this PR:
Does `objectEntries` change in different areas of the system? If not, I wonder if we can avoid computing this multiple times, wdyt?

Maybe not enough benefit there, but would like to discuss for the sake of learning if anything!
created_at: 2025-04-29T22:02:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,3 +107,19 @@ export function getValidParamTypes(objectEntries: ObjectEntry[]): Map<string, st
 
   return paramTypes;
 }
+
+export function parseParamType(
+  validParamTypes: Set<string>,
+  value: string,
+): [string, boolean] | undefined {
path: packages/theme-check-common/src/liquid-doc/utils.ts
user: login: jamesmengo
body: Are you open to returning a structured type here?
created_at: 2025-04-29T22:11:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -659,20 +660,26 @@ function inferType(
 }
 
 function inferLiquidDocParamType(node: LiquidDocParamNode, liquidDrops: ObjectEntry[]) {
-  if (
-    !node.paramType ||
-    // BasicParamTypes.Object does not map to any specific
-    // type in the type system.
-    node.paramType.value === BasicParamTypes.Object
-  ) {
-    return Untyped;
-  }
+  const paramTypeValue = node.paramType?.value;
+
+  if (!paramTypeValue) return Untyped;
+
+  const validParamTypes = getValidParamTypes(liquidDrops);
+
+  const parsedParamType = parseParamType(new Set(validParamTypes.keys()), paramTypeValue);
 
-  if (getValidParamTypes(liquidDrops).has(node.paramType.value)) {
-    return node.paramType.value;
+  if (!parsedParamType) return Untyped;
+
+  const [type, isArray] = parsedParamType;
+
+  // BasicParamTypes.Object does not map to any specific type in the type system.
+  const transformedParamType = type === BasicParamTypes.Object ? Untyped : type;
path: packages/theme-language-server-common/src/TypeSystem.ts
user: login: jamesmengo
body: mega-nit: slightly confusing to read but I don't feel strongly about this
created_at: 2025-04-29T22:14:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -493,6 +493,27 @@ describe('Module: TypeSystem', () => {
       );
       expect(inferredType).to.eql('product');
     });
+
+    it(`should support array liquid doc params type: product[]`, async () => {
+      const sourceCode = `
+        {% doc %}
+          @param {product[]} data - some data
+        {% enddoc %}
+        {{ data }}
+      `;
+      const ast = toLiquidHtmlAST(sourceCode);
+      const variableOutput = ast.children[1];
+      assert(isLiquidVariableOutput(variableOutput));
+      const inferredType = await typeSystem.inferType(
+        variableOutput.markup,
+        ast,
+        'file:///snippets/example.liquid',
+      );
+      expect(inferredType).to.eql({
+        kind: 'array',
+        valueType: 'product',
+      });
path: packages/theme-language-server-common/src/TypeSystem.spec.ts
user: login: charlespwd
body: :chef-kiss:
created_at: 2025-04-30T12:06:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,3 +107,19 @@ export function getValidParamTypes(objectEntries: ObjectEntry[]): Map<string, st
 
   return paramTypes;
 }
+
+export function parseParamType(
+  validParamTypes: Set<string>,
+  value: string,
+): [string, boolean] | undefined {
path: packages/theme-check-common/src/liquid-doc/utils.ts
user: login: charlespwd
body: We could also name the tuple to make it more obvious that the second boolean means `isArray`

```
): [pseudoType: PseudoType, isArray: boolean] | undefined
```
created_at: 2025-04-30T12:08:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,16 +28,23 @@ export const ValidDocParamTypes: LiquidCheckDefinition = {
           return;
         }
 
-        const objectEntries = await context.themeDocset!.objects();
+        const objectEntries = await context.themeDocset!.liquidDrops();
 
-        if (getValidParamTypes(objectEntries).has(node.paramType.value)) {
+        const validParamTypes = getValidParamTypes(objectEntries);
path: packages/theme-check-common/src/checks/valid-doc-param-types/index.ts
user: login: aswamy
body: Hmm yes - i can't do this memoization across code completion AND theme checks because they both have different `themeDocset` injected into it, but maybe i can memoize this within the theme check. Good catch!
created_at: 2025-04-30T13:59:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: charlespwd
created_at: 2025-04-30T12:10:05Z
body: > Side note: how much effort would it take to implement a hover on the array accessors so that asdf.first in the screenshot below would display a product?

I thought we did do that? 🤔 Is that bugged?

https://github.com/Shopify/theme-tools/blob/971acf27d8e58839aa28cfd49eb829c41a0ad458/packages/theme-language-server-common/src/TypeSystem.ts#L791-L792
author_association: CONTRIBUTOR
user: login: aswamy
created_at: 2025-04-30T14:00:50Z
body: >> Side note: how much effort would it take to implement a hover on the array accessors so that asdf.first in the screenshot below would display a product?

> I thought we did do that? 🤔 Is that bugged?

@charlespwd @jamesmengo is right! Something is bugged out about this. Ill do a quick follow-up after i do the PR comments + merge this PR.
author_association: CONTRIBUTOR
user: login: aswamy
created_at: 2025-04-30T14:39:14Z
body: Did a bit of investigation, and see like we do support **some** attributes, but not all. Hover works for attributes that exist IN the docset's object map.

E.g. Imagine you have an array of colors

```
{{ color_array.first.rgba.size }}
```

Hover would work on `color_array` (it would recognize correctly as `color[]`), and `rgba` (it would recognize correctly as `string`).

Hover on `first` and `size` does not work for two different reasons:
- `size` does not work because `string` type does not exist in objectMap ([here](https://github.com/Shopify/theme-tools/blob/main/packages/theme-language-server-common/src/hover/providers/LiquidObjectAttributeHoverProvider.ts#L37))
- `first` does not work because we explicitly don't support `array` support ([here](https://github.com/Shopify/theme-tools/blob/main/packages/theme-language-server-common/src/hover/providers/LiquidObjectAttributeHoverProvider.ts#L33))
author_association: CONTRIBUTOR
user: login: charlespwd
created_at: 2025-04-30T17:03:06Z
body: ![image](https://github.com/user-attachments/assets/44de6e73-e5d3-4693-aa1a-a9d02e6ff1f2)

lol. hi! that's me again! 🙈 
author_association: CONTRIBUTOR