Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LiquidDoc] Add snippet hover support inside of {% render %} tag #703

Merged
merged 5 commits into from
Jan 27, 2025
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
6 changes: 6 additions & 0 deletions .changeset/dry-donkeys-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme-language-server-common': minor
'@shopify/theme-check-common': minor
---

Cache liquidDoc fetch results
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comes from the upstream PR I merged in

6 changes: 6 additions & 0 deletions .changeset/little-flowers-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme-language-server-common': minor
'@shopify/theme-check-common': minor
---

Add hover support for Liquid snippets using {% doc %} annotations
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
IsValidSchema,
memo,
Mode,
isError,
} from '@shopify/theme-check-common';
import { Connection } from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { ClientCapabilities } from '../ClientCapabilities';
import { percent, Progress } from '../progress';
import { AugmentedSourceCode } from './types';
import { getSnippetDefinition } from '../liquidDoc';

export class DocumentManager {
/**
Expand Down Expand Up @@ -171,6 +173,12 @@ export class DocumentManager {
const mode = await this.getModeForUri!(uri);
return toSchema(mode, uri, sourceCode, this.isValidSchema);
}),
/** Lazy and only computed once per file version */
getLiquidDoc: memo(async (snippetName: string) => {
karreiro marked this conversation as resolved.
Show resolved Hide resolved
if (isError(sourceCode.ast)) return { name: snippetName };
jamesmengo marked this conversation as resolved.
Show resolved Hide resolved

return getSnippetDefinition(sourceCode.ast, snippetName);
}),
};
default:
return assertNever(sourceCode);
Expand Down
2 changes: 2 additions & 0 deletions packages/theme-language-server-common/src/documents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AppBlockSchema,
} from '@shopify/theme-check-common';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { SnippetDefinition } from '../liquidDoc';

/** Util type to add the common `textDocument` property to the SourceCode. */
type _AugmentedSourceCode<SCT extends SourceCodeType = SourceCodeType> = SourceCode<SCT> & {
Expand All @@ -23,6 +24,7 @@ export type AugmentedJsonSourceCode = _AugmentedSourceCode<SourceCodeType.JSON>;
*/
export type AugmentedLiquidSourceCode = _AugmentedSourceCode<SourceCodeType.LiquidHtml> & {
getSchema: () => Promise<SectionSchema | ThemeBlockSchema | AppBlockSchema | undefined>;
getLiquidDoc: (snippetName: string) => Promise<SnippetDefinition>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
LiquidObjectHoverProvider,
LiquidTagHoverProvider,
TranslationHoverProvider,
RenderSnippetHoverProvider,
} from './providers';
import { HtmlAttributeValueHoverProvider } from './providers/HtmlAttributeValueHoverProvider';
import { findCurrentNode } from '@shopify/theme-check-common';
import { GetThemeSettingsSchemaForURI } from '../settings';
import { GetSnippetDefinitionForURI } from '../liquidDoc';

export class HoverProvider {
private providers: BaseHoverProvider[] = [];
Expand All @@ -26,6 +28,12 @@ export class HoverProvider {
readonly getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>,
readonly getTranslationsForURI: GetTranslationsForURI = async () => ({}),
readonly getSettingsSchemaForURI: GetThemeSettingsSchemaForURI = async () => [],
readonly getSnippetDefinitionForURI: GetSnippetDefinitionForURI = async (
_uri,
jamesmengo marked this conversation as resolved.
Show resolved Hide resolved
snippetName,
) => ({
name: snippetName,
}),
) {
const typeSystem = new TypeSystem(
themeDocset,
Expand All @@ -41,6 +49,7 @@ export class HoverProvider {
new HtmlAttributeHoverProvider(),
new HtmlAttributeValueHoverProvider(),
new TranslationHoverProvider(getTranslationsForURI, documentManager),
new RenderSnippetHoverProvider(getSnippetDefinitionForURI),
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, beforeEach, it, expect } from 'vitest';
import { DocumentManager } from '../../documents';
import { HoverProvider } from '../HoverProvider';
import { MetafieldDefinitionMap } from '@shopify/theme-check-common';
import { GetSnippetDefinitionForURI, SnippetDefinition } from '../../liquidDoc';

describe('Module: RenderSnippetHoverProvider', async () => {
let provider: HoverProvider;
let getSnippetDefinition: GetSnippetDefinitionForURI;
const mockSnippetDefinition: SnippetDefinition = {
name: 'product-card',
liquidDoc: {
parameters: [
{
name: 'title',
description: 'The title of the product',
type: 'string',
},
{
name: 'border-radius',
description: 'The border radius in px',
type: 'number',
},
],
Comment on lines +13 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add more iterations of parameters (e.g. without description, type, etc) since you have logic behind the templating around them.

See review comment above for the ones I've done during tophatting.

},
};

const createProvider = (getSnippetDefinition: GetSnippetDefinitionForURI) => {
return new HoverProvider(
new DocumentManager(),
{
filters: async () => [],
objects: async () => [],
tags: async () => [],
systemTranslations: async () => ({}),
},
async (_rootUri: string) => ({} as MetafieldDefinitionMap),
async () => ({}),
async () => [],
getSnippetDefinition,
);
};

beforeEach(async () => {
getSnippetDefinition = async () => mockSnippetDefinition;
provider = createProvider(getSnippetDefinition);
});

describe('hover', () => {
it('should return snippet definition with all parameters', async () => {
await expect(provider).to.hover(
`{% render 'product-car█d' %}`,
'### product-card\n\n**Parameters:**\n- `title`: string - The title of the product\n- `border-radius`: number - The border radius in px',
);
});

it('should return an H3 with snippet name if no LiquidDocDefinition found', async () => {
getSnippetDefinition = async () => ({ name: 'unknown-snippet' });
provider = createProvider(getSnippetDefinition);
await expect(provider).to.hover(`{% render 'unknown-sni█ppet' %}`, '### unknown-snippet');
});

it('should return nothing if not in render tag', async () => {
await expect(provider).to.hover(`{% assign asdf = 'snip█pet' %}`, null);
await expect(provider).to.hover(`{{ 'snip█pet' }}`, null);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
import { LiquidHtmlNode } from '@shopify/theme-check-common';
import { Hover, HoverParams } from 'vscode-languageserver';
import { BaseHoverProvider } from '../BaseHoverProvider';
import { SnippetDefinition, LiquidDocParameter } from '../../liquidDoc';

export class RenderSnippetHoverProvider implements BaseHoverProvider {
constructor(
private getSnippetDefinitionForURI: (
uri: string,
snippetName: string,
) => Promise<SnippetDefinition>,
) {}

async hover(
currentNode: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
params: HoverParams,
): Promise<Hover | null> {
const parentNode = ancestors.at(-1);
if (
currentNode.type !== NodeTypes.String ||
!parentNode ||
parentNode.type !== NodeTypes.RenderMarkup
) {
return null;
}

const snippetName = currentNode.value;
const snippetDefinition = await this.getSnippetDefinitionForURI(
params.textDocument.uri,
jamesmengo marked this conversation as resolved.
Show resolved Hide resolved
snippetName,
);

const liquidDoc = snippetDefinition.liquidDoc;

if (!liquidDoc) {
return {
contents: {
kind: 'markdown',
value: `### ${snippetDefinition.name}`,
},
};
}

const parts = [`### ${snippetDefinition.name}`];

if (liquidDoc.parameters?.length) {
const parameters = liquidDoc.parameters
?.map(
({ name, type, description }: LiquidDocParameter) =>
`- \`${name}\`${type ? `: ${type}` : ''} ${description ? `- ${description}` : ''}`,
karreiro marked this conversation as resolved.
Show resolved Hide resolved
)
.join('\n');

parts.push('', '**Parameters:**', parameters);
}

return {
contents: {
kind: 'markdown',
value: parts.join('\n'),
},
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { HtmlTagHoverProvider } from './HtmlTagHoverProvider';
export { HtmlAttributeHoverProvider } from './HtmlAttributeHoverProvider';
export { HtmlAttributeValueHoverProvider } from './HtmlAttributeValueHoverProvider';
export { TranslationHoverProvider } from './TranslationHoverProvider';
export { RenderSnippetHoverProvider } from './RenderSnippetHoverProvider';
68 changes: 68 additions & 0 deletions packages/theme-language-server-common/src/liquidDoc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, it } from 'vitest';
import { LiquidHtmlNode } from '@shopify/theme-check-common';
import { toSourceCode } from '@shopify/theme-check-common';
import { describe } from 'vitest';
import { getSnippetDefinition } from './liquidDoc';

describe('Unit: makeGetLiquidDocDefinitions', () => {
function toAST(code: string) {
return toSourceCode('/tmp/foo.liquid', code).ast as LiquidHtmlNode;
}

it('should return name if no valid annotations are present in definition', async () => {
const ast = toAST(`
{% doc %}
just a description
@undefined asdf
{% enddoc %}
`);

const result = getSnippetDefinition(ast, 'product-card');
expect(result).to.deep.equal({
name: 'product-card',
liquidDoc: {
parameters: [],
},
});
});

it('should extract name, description and type from param annotations', async () => {
const ast = toAST(`
{% doc %}
@param {String} firstParam - The first param
@param {Number} secondParam - The second param
@param paramWithNoType - param with no type
@param paramWithOnlyName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also have:

  • 1 param with type but no description
  • 1 @unsupport-param
  • 1 free-form text
image

{% enddoc %}
`);

const result = getSnippetDefinition(ast, 'product-card');
expect(result).to.deep.equal({
name: 'product-card',
liquidDoc: {
parameters: [
{
name: 'firstParam',
description: 'The first param',
type: 'String',
},
{
name: 'secondParam',
description: 'The second param',
type: 'Number',
},
{
name: 'paramWithNoType',
description: 'param with no type',
type: null,
},
{
name: 'paramWithOnlyName',
description: '',
type: null,
},
],
},
});
});
});
50 changes: 50 additions & 0 deletions packages/theme-language-server-common/src/liquidDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { SourceCodeType, visit } from '@shopify/theme-check-common';

import { LiquidHtmlNode } from '@shopify/theme-check-common';

import { LiquidDocParamNode } from '@shopify/liquid-html-parser';

export type GetSnippetDefinitionForURI = (
Copy link
Contributor Author

@jamesmengo jamesmengo Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm placing the types within its own module since this follows the pattern we're using for translations - link

This keeps everything self-contained while it's still subject to change as we continue building functionality into theme check, etc.

uri: string,
snippetName: string,
) => Promise<SnippetDefinition>;

export type LiquidDocParameter = {
name: string;
description: string | null;
type: string | null;
Comment on lines +14 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: string | null;
type: string | null;
description?: string;
type?: string;

};

export type SnippetDefinition = {
name: string;
liquidDoc?: LiquidDocDefinition;
};

type LiquidDocDefinition = {
parameters?: LiquidDocParameter[];
};

export function getSnippetDefinition(
snippet: LiquidHtmlNode,
snippetName: string,
): SnippetDefinition {
const liquidDocParameters: LiquidDocParameter[] = visit<
SourceCodeType.LiquidHtml,
LiquidDocParameter
>(snippet, {
LiquidDocParamNode(node: LiquidDocParamNode) {
return {
name: node.paramName.value,
description: node.paramDescription?.value ?? null,
type: node.paramType?.value ?? null,
Comment on lines +38 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: node.paramDescription?.value ?? null,
type: node.paramType?.value ?? null,
description: node.paramDescription?.value,
type: node.paramType?.value,

};
},
});

return {
name: snippetName,
liquidDoc: {
parameters: liquidDocParameters,
},
};
}
18 changes: 18 additions & 0 deletions packages/theme-language-server-common/src/server/startServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { snippetName } from '../utils/uri';
import { VERSION } from '../version';
import { CachedFileSystem } from './CachedFileSystem';
import { Configuration } from './Configuration';
import { LiquidHtmlNode } from '@shopify/liquid-html-parser';
import { getSnippetDefinition, SnippetDefinition } from '../liquidDoc';

const defaultLogger = () => {};

Expand Down Expand Up @@ -171,6 +173,21 @@ export function startServer(
return getDefaultSchemaTranslations();
};

const getSnippetDefinitionForURI = async (
uri: string,
snippetName: string,
): Promise<SnippetDefinition> => {
const rootUri = await findThemeRootURI(uri);
jamesmengo marked this conversation as resolved.
Show resolved Hide resolved
const snippetURI = path.join(rootUri, 'snippets', `${snippetName}.liquid`);
const snippet = documentManager.get(snippetURI);

if (!snippet || snippet.type !== SourceCodeType.LiquidHtml || isError(snippet.ast)) {
return { name: snippetName };
}

return snippet.getLiquidDoc(snippetName);
};

const snippetFilter = ([uri]: FileTuple) => /\.liquid$/.test(uri) && /snippets/.test(uri);
const getSnippetNamesForURI: GetSnippetNamesForURI = async (uri: string) => {
const rootUri = await findThemeRootURI(uri);
Expand Down Expand Up @@ -252,6 +269,7 @@ export function startServer(
getMetafieldDefinitions,
getTranslationsForURI,
getThemeSettingsSchemaForURI,
getSnippetDefinitionForURI,
);

const executeCommandProvider = new ExecuteCommandProvider(
Expand Down
Loading