From d5d9bd2c3b514580bdf03bec29702ea30b61a603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Tue, 20 Oct 2026 21:57:31 +0300 Subject: [PATCH] fix(language-server): use theme graph for document links --- .changeset/theme-graph-document-links.md | 5 + .../DocumentLinksProvider.spec.ts | 163 ++++++++++++++---- .../documentLinks/DocumentLinksProvider.ts | 109 ++---------- .../src/server/startServer.ts | 4 +- 4 files changed, 152 insertions(+), 129 deletions(-) create mode 100644 .changeset/theme-graph-document-links.md diff --git a/.changeset/theme-graph-document-links.md b/.changeset/theme-graph-document-links.md new file mode 100644 index 000000000..0cc67e879 --- /dev/null +++ b/.changeset/theme-graph-document-links.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-language-server-common': minor +--- + +Add document links for theme graph references, including schema block types and custom element definitions. diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts index 235ed1aa7..1e5427002 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts @@ -1,5 +1,11 @@ +import { path } from '@shopify/theme-check-common'; +import { MockFileSystem } from '@shopify/theme-check-common/src/test'; import { beforeEach, describe, expect, it } from 'vitest'; +import { Range } from 'vscode-languageserver'; +import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../documents'; +import { ThemeGraphManager } from '../server/ThemeGraphManager'; +import { mockConnection } from '../test/MockConnection'; import { DocumentLinksProvider } from './DocumentLinksProvider'; describe('DocumentLinksProvider', () => { @@ -9,32 +15,27 @@ describe('DocumentLinksProvider', () => { let uriString: string; beforeEach(() => { + rootUri = path.normalize('file:///path/to/project'); documentManager = new DocumentManager(); - documentLinksProvider = new DocumentLinksProvider(documentManager, async () => rootUri); + documentLinksProvider = new DocumentLinksProvider(documentManager, async () => []); }); it('should return an empty array for non-LiquidHtml documents', async () => { - uriString = 'file:///path/to/non-liquid-html-document.txt'; - rootUri = 'file:///path/to/project'; - - documentManager.open(uriString, 'Sample plain text content', 1); + uriString = path.join(rootUri, 'templates/index.json'); + documentManager.open(uriString, '{"sections": {}}', 1); const result = await documentLinksProvider.documentLinks(uriString); expect(result).toEqual([]); }); it('should return an empty array for non-existent documents', async () => { - uriString = 'file:///path/to/non-existent-document.txt'; - rootUri = 'file:///path/to/project'; + uriString = path.join(rootUri, 'sections/non-existent.liquid'); const result = await documentLinksProvider.documentLinks(uriString); expect(result).toEqual([]); }); - it('should return a list of document links with correct URLs for a LiquidHtml document', async () => { - uriString = 'file:///path/to/liquid-html-document.liquid'; - rootUri = 'file:///path/to/project'; - + it('should create links for graph dependencies in a LiquidHtml document', async () => { const liquidHtmlContent = ` {% render 'snippet1' %} {% include 'snippet2' %} @@ -46,37 +47,131 @@ describe('DocumentLinksProvider', () => { {% block 'container' %}{% endblock %} {% block '_private' %}{% endblock %} {% block 'with_args', block.settings.alignment: 'center' %}{% endblock %} + `; + const theme = createTheme(liquidHtmlContent, rootUri, { + 'snippets/snippet1.liquid': '', + 'snippets/snippet2.liquid': '', + 'sections/header.liquid': '', + 'assets/echo.js': '', + 'assets/assign.css': '', + 'assets/asset.js': '', + 'assets/product-gallery.js': + "customElements.define('product-gallery', class extends HTMLElement {});", + 'blocks/block_name.liquid': '', + 'blocks/container.liquid': '', + 'blocks/_private.liquid': '', + 'blocks/with_args.liquid': '', + }); + uriString = theme.uri; + documentManager = theme.documentManager; + documentLinksProvider = theme.documentLinksProvider; - documentManager.open(uriString, liquidHtmlContent, 1); + const result = await documentLinksProvider.documentLinks(uriString); + + expect(result.map(({ target }) => target)).toEqual([ + path.join(rootUri, 'snippets/snippet1.liquid'), + path.join(rootUri, 'snippets/snippet2.liquid'), + path.join(rootUri, 'sections/header.liquid'), + path.join(rootUri, 'assets/echo.js'), + path.join(rootUri, 'assets/assign.css'), + path.join(rootUri, 'assets/asset.js'), + path.join(rootUri, 'blocks/block_name.liquid'), + path.join(rootUri, 'blocks/container.liquid'), + path.join(rootUri, 'blocks/_private.liquid'), + path.join(rootUri, 'blocks/with_args.liquid'), + path.join(rootUri, 'assets/product-gallery.js'), + ]); + }); + + it('should link schema block types and custom elements to their definitions', async () => { + const liquidHtmlContent = `{% schema %} + {"blocks":[{"type":"feature"}],"presets":[{"name":"Feature","blocks":[{"type":"feature"}]}]} + {% endschema %} + `; + const theme = createTheme(liquidHtmlContent, rootUri, { + 'blocks/feature.liquid': '', + 'assets/product-gallery.js': + "customElements.define('product-gallery', class extends HTMLElement {});", + }); + uriString = theme.uri; + documentManager = theme.documentManager; + documentLinksProvider = theme.documentLinksProvider; const result = await documentLinksProvider.documentLinks(uriString); - const expectedUrls = [ - 'file:///path/to/project/snippets/snippet1.liquid', - 'file:///path/to/project/snippets/snippet2.liquid', - 'file:///path/to/project/sections/header.liquid', - 'file:///path/to/project/assets/echo.js', - 'file:///path/to/project/assets/assign.css', - 'file:///path/to/project/assets/asset.js', - 'file:///path/to/project/blocks/block_name.liquid', - 'file:///path/to/project/blocks/container.liquid', - 'file:///path/to/project/blocks/_private.liquid', - 'file:///path/to/project/blocks/with_args.liquid', - ]; - - expect(result.length).toBe(expectedUrls.length); - for (let i = 0; i < expectedUrls.length; i++) { - expect(result[i].target).toBe(expectedUrls[i]); - } + + const featureUri = path.join(rootUri, 'blocks/feature.liquid'); + const featureLinks = result.filter(({ target }) => target === featureUri); + const textDocument = TextDocument.create(uriString, 'liquid', 1, liquidHtmlContent); + const expectedFeatureRanges = [...liquidHtmlContent.matchAll(/"type":"feature"/g)] + .map(([value], index, matches) => { + const start = matches[index].index!; + return Range.create( + textDocument.positionAt(start), + textDocument.positionAt(start + value.length), + ); + }) + .sort((a, b) => a.start.line - b.start.line || a.start.character - b.start.character); + const actualFeatureRanges = featureLinks + .map(({ range }) => range) + .sort((a, b) => a.start.line - b.start.line || a.start.character - b.start.character); + const galleryLink = result.find( + ({ target }) => target === path.join(rootUri, 'assets/product-gallery.js'), + ); + const galleryStart = liquidHtmlContent.indexOf(' { - uriString = 'file:///path/to/liquid-html-document.liquid'; - rootUri = 'file:///path/to/project'; + it('should not create multiple links for the special @theme block type', async () => { + const liquidHtmlContent = `{% schema %}{"blocks":[{"type":"@theme"}]}{% endschema %}`; + const theme = createTheme(liquidHtmlContent, rootUri, { + 'blocks/feature.liquid': '', + }); - documentManager.open(uriString, `{% partial 'x' %}{% endpartial %}`, 1); + const result = await theme.documentLinksProvider.documentLinks(theme.uri); - const result = await documentLinksProvider.documentLinks(uriString); + expect(result).toEqual([]); + }); + + it('should not create a link for the {% partial %} tag', async () => { + const theme = createTheme(`{% partial 'x' %}{% endpartial %}`, rootUri, {}); + const result = await theme.documentLinksProvider.documentLinks(theme.uri); expect(result).toEqual([]); }); }); + +function createTheme(source: string, rootUri: string, files: Record) { + const uri = path.join(rootUri, 'sections/main.liquid'); + const fs = new MockFileSystem({ ...files, 'sections/main.liquid': source }, rootUri); + const connection = mockConnection(rootUri); + const getModeForURI = async () => 'theme' as const; + const documentManager = new DocumentManager( + fs, + connection, + undefined, + getModeForURI, + async () => true, + ); + const themeGraphManager = new ThemeGraphManager( + connection, + documentManager, + fs, + async () => rootUri, + getModeForURI, + ); + const documentLinksProvider = new DocumentLinksProvider(documentManager, (uri) => + themeGraphManager.getDependencies(uri, undefined, { includeIndirect: false }), + ); + + documentManager.open(uri, source, 1); + + return { uri, documentManager, documentLinksProvider }; +} diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts index 911b7de6f..fce0832c4 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts @@ -1,21 +1,18 @@ -import { LiquidHtmlNode, LiquidString, NamedTags, NodeTypes } from '@shopify/liquid-html-parser'; import { SourceCodeType } from '@shopify/theme-check-common'; import { DocumentLink, Range } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { URI, Utils } from 'vscode-uri'; - -import { visit, Visitor } from '@shopify/theme-check-common'; import { DocumentManager } from '../documents'; -import { FindThemeRootURI } from '../internal-types'; +import { AugmentedReference } from '../types'; + +type GetDependencies = (uri: string) => Promise; export class DocumentLinksProvider { constructor( private documentManager: DocumentManager, - private findThemeRootURI: FindThemeRootURI, + private getDependencies: GetDependencies, ) {} - async documentLinks(uriString: string): Promise { - const sourceCode = this.documentManager.get(uriString); + async documentLinks(uri: string): Promise { + const sourceCode = this.documentManager.get(uri); if ( !sourceCode || sourceCode.type !== SourceCodeType.LiquidHtml || @@ -24,91 +21,15 @@ export class DocumentLinksProvider { return []; } - const rootUri = await this.findThemeRootURI(uriString); - if (!rootUri) { - return []; - } - - const visitor = documentLinksVisitor(sourceCode.textDocument, URI.parse(rootUri)); - return visit(sourceCode.ast, visitor); - } -} - -function documentLinksVisitor( - textDocument: TextDocument, - root: URI, -): Visitor { - return { - LiquidTag(node) { - // {% render 'snippet' %} - // {% include 'snippet' %} - if ( - (node.name === 'render' || node.name === 'include') && - typeof node.markup !== 'string' && - isLiquidString(node.markup.snippet) - ) { - const snippet = node.markup.snippet; - return DocumentLink.create( - range(textDocument, snippet), - Utils.resolvePath(root, 'snippets', snippet.value + '.liquid').toString(), - ); - } - - // {% section 'section' %} - if (node.name === 'section' && typeof node.markup !== 'string') { - const sectionName = node.markup.name; - return DocumentLink.create( - range(textDocument, sectionName), - Utils.resolvePath(root, 'sections', sectionName.value + '.liquid').toString(), - ); - } + const dependencies = await this.getDependencies(uri); + return dependencies.flatMap(({ source, target }) => { + if (!source.range) return []; - // {% block 'name' %} - if (node.name === NamedTags.block && typeof node.markup !== 'string') { - const blockName = node.markup.name; - return DocumentLink.create( - range(textDocument, blockName), - Utils.resolvePath(root, 'blocks', blockName.value + '.liquid').toString(), - ); - } - - // {% content_for 'block', type: 'block_name' %} - if (node.name === NamedTags.content_for && typeof node.markup !== 'string') { - const typeArg = node.markup.args.find((arg) => arg.name === 'type'); - if (typeArg && typeArg.value.type === 'String') { - return DocumentLink.create( - range(textDocument, typeArg.value), - Utils.resolvePath(root, 'blocks', typeArg.value.value + '.liquid').toString(), - ); - } - } - }, - - // {{ 'theme.js' | asset_url }} - LiquidVariable(node) { - if (node.filters.length === 0 || node.filters[0].name !== 'asset_url') { - return; - } - - if (!isLiquidString(node.expression)) { - return; - } - - const expression = node.expression; - return DocumentLink.create( - range(textDocument, node.expression), - Utils.resolvePath(root, 'assets', expression.value).toString(), + const range = Range.create( + sourceCode.textDocument.positionAt(source.range[0]), + sourceCode.textDocument.positionAt(source.range[1]), ); - }, - }; -} - -function range(textDocument: TextDocument, node: { position: LiquidHtmlNode['position'] }): Range { - const start = textDocument.positionAt(node.position.start + 1); - const end = textDocument.positionAt(node.position.end - 1); - return Range.create(start, end); -} - -function isLiquidString(node: LiquidHtmlNode): node is LiquidString { - return node.type === NodeTypes.String; + return [DocumentLink.create(range, target.uri)]; + }); + } } diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index 00026e797..33352009b 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -123,7 +123,9 @@ export function startServer( getModeForURI, ); const diagnosticsManager = new DiagnosticsManager(connection); - const documentLinksProvider = new DocumentLinksProvider(documentManager, findThemeRootURI); + const documentLinksProvider = new DocumentLinksProvider(documentManager, (uri) => + themeGraphManager.getDependencies(uri, undefined, { includeIndirect: false }), + ); const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager); const onTypeFormattingProvider = new OnTypeFormattingProvider( documentManager,