diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts index 3541370b3..75b1a644d 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts @@ -1,4 +1,4 @@ -import { LiquidHtmlNode, LiquidString, NodeTypes } from '@shopify/liquid-html-parser'; +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'; @@ -61,6 +61,20 @@ function documentLinksVisitor( Utils.resolvePath(root, 'sections', sectionName.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 }} @@ -83,8 +97,8 @@ function documentLinksVisitor( } function range(textDocument: TextDocument, node: { position: LiquidHtmlNode['position'] }): Range { - const start = textDocument.positionAt(node.position.start); - const end = textDocument.positionAt(node.position.end); + const start = textDocument.positionAt(node.position.start + 1); + const end = textDocument.positionAt(node.position.end - 1); return Range.create(start, end); } diff --git a/packages/theme-language-server-common/src/references/BaseReferencesProvider.ts b/packages/theme-language-server-common/src/references/BaseReferencesProvider.ts new file mode 100644 index 000000000..f454383d9 --- /dev/null +++ b/packages/theme-language-server-common/src/references/BaseReferencesProvider.ts @@ -0,0 +1,13 @@ +import { LiquidHtmlNode } from '@shopify/liquid-html-parser'; +import { + Location, + ReferenceParams, +} from 'vscode-languageserver-protocol'; + +export interface BaseReferencesProvider { + references( + node: LiquidHtmlNode, + ancestors: LiquidHtmlNode[], + params: ReferenceParams, + ): Promise; +} diff --git a/packages/theme-language-server-common/src/references/ReferencesProvider.ts b/packages/theme-language-server-common/src/references/ReferencesProvider.ts new file mode 100644 index 000000000..d8bf6eaed --- /dev/null +++ b/packages/theme-language-server-common/src/references/ReferencesProvider.ts @@ -0,0 +1,50 @@ +import { Location, ReferenceParams } from "vscode-languageserver-protocol"; +import { AugmentedLiquidSourceCode, DocumentManager } from "../documents"; +import { BaseReferencesProvider } from "./BaseReferencesProvider"; +import { findCurrentNode, SourceCodeType } from "@shopify/theme-check-common"; +import { BlockReferencesProvider } from "./providers/BlockReferencesProvider"; +import { SnippetReferencesProvider } from "./providers/SnippetReferencesProvider"; + +export type GetLiquidFiles = (rootUri: string) => Promise; + +export class ReferencesProvider { + private providers: BaseReferencesProvider[]; + + constructor( + private documentManager: DocumentManager, + private getLiquidFiles: GetLiquidFiles, + ) { + this.providers = [ + new BlockReferencesProvider(this.documentManager, this.getLiquidFiles), + new SnippetReferencesProvider(this.documentManager, this.getLiquidFiles), + ]; + } + + async references(params: ReferenceParams): Promise { + try { + const document = this.documentManager.get(params.textDocument.uri) + + if (!document || document.type !== SourceCodeType.LiquidHtml || document.ast instanceof Error) { + return; + } + + const [currentNode, ancestors] = findCurrentNode(document.ast, document.textDocument.offsetAt(params.position)); + + const promises = this.providers.map((provider) => provider.references(currentNode, ancestors, params)); + + const locations = []; + + for(const promise of promises) { + const result = await promise; + if (result) { + locations.push(...result); + } + } + + return locations; + } catch (error) { + console.error(error); + return; + } + } +} \ No newline at end of file diff --git a/packages/theme-language-server-common/src/references/providers/BlockReferencesProvider.ts b/packages/theme-language-server-common/src/references/providers/BlockReferencesProvider.ts new file mode 100644 index 000000000..3ead99592 --- /dev/null +++ b/packages/theme-language-server-common/src/references/providers/BlockReferencesProvider.ts @@ -0,0 +1,62 @@ +import { Location, ReferenceParams } from "vscode-languageserver-protocol"; +import { DocumentManager } from "../../documents"; +import { BaseReferencesProvider } from "../BaseReferencesProvider"; +import { GetLiquidFiles } from "../ReferencesProvider"; +import { LiquidHtmlNode, LiquidTag, NamedTags } from "@shopify/liquid-html-parser"; +import { SourceCodeType, visit } from "@shopify/theme-check-common"; +import { Range } from "vscode-json-languageservice"; + +export class BlockReferencesProvider implements BaseReferencesProvider { + constructor(private documentManager: DocumentManager, private getLiquidFiles: GetLiquidFiles) {} + + async references(currentNode: LiquidHtmlNode, ancestors: LiquidHtmlNode[], params: ReferenceParams): Promise { + const sources = await this.getLiquidFiles(params.textDocument.uri); + const document = this.documentManager.get(params.textDocument.uri) + + if (!document || document.type !== SourceCodeType.LiquidHtml || document.ast instanceof Error) { + return; + } + + const parentNode = ancestors.at(-1); + const grandparentNode = ancestors.at(-2); + if (!parentNode || !grandparentNode) return; + if ( + currentNode.type !== 'String' || + parentNode.type !== 'NamedArgument' || + grandparentNode.type !== 'ContentForMarkup' + ) return; + + const referenceLocations = [] as Location[] + + for (const source of sources) { + if (source.ast instanceof Error) continue; + + referenceLocations.push(...visit(source.ast, { + LiquidTag(node: LiquidTag) { + if (node.name === NamedTags.content_for) { + if (typeof node.markup === 'string') return; + + const typeArg = node.markup.args.find((arg) => arg.name === 'type'); + + if (!typeArg || typeArg.value.type !== 'String') return; + + if (typeArg.value.value === currentNode.value) { + return { + uri: source.uri, + range: Range.create( + source.textDocument.positionAt(typeArg.value.position.start + 1), + source.textDocument.positionAt(typeArg.value.position.end - 1), + ), + }; + } + } + } + })); + } + + // TODO: check if the block appears in any schema + // Need building blocks to know where in the block it appears (i.e. position) + + return referenceLocations; + } +} diff --git a/packages/theme-language-server-common/src/references/providers/SnippetReferencesProvider.ts b/packages/theme-language-server-common/src/references/providers/SnippetReferencesProvider.ts new file mode 100644 index 000000000..958da060b --- /dev/null +++ b/packages/theme-language-server-common/src/references/providers/SnippetReferencesProvider.ts @@ -0,0 +1,50 @@ +import { Location, ReferenceParams } from "vscode-languageserver-protocol"; +import { DocumentManager } from "../../documents"; +import { BaseReferencesProvider } from "../BaseReferencesProvider"; +import { GetLiquidFiles } from "../ReferencesProvider"; +import { LiquidHtmlNode, LiquidTag, NamedTags, NodeTypes } from "@shopify/liquid-html-parser"; +import { SourceCodeType, visit } from "@shopify/theme-check-common"; +import { Range } from "vscode-json-languageservice"; + +export class SnippetReferencesProvider implements BaseReferencesProvider { + constructor(private documentManager: DocumentManager, private getLiquidFiles: GetLiquidFiles) {} + + async references(currentNode: LiquidHtmlNode, ancestors: LiquidHtmlNode[], params: ReferenceParams): Promise { + const sources = await this.getLiquidFiles(params.textDocument.uri); + const document = this.documentManager.get(params.textDocument.uri) + + if (!document || document.type !== SourceCodeType.LiquidHtml || document.ast instanceof Error) { + return; + } + + const parentNode = ancestors.at(-1); + if (!parentNode || parentNode.type !== 'RenderMarkup' || currentNode.type !== 'String') { + return; + } + + const referenceLocations = [] as Location[]; + + for (const source of sources) { + if (source.ast instanceof Error) continue; + + referenceLocations.push(...visit(source.ast, { + LiquidTag(node: LiquidTag) { + if ((node.name === NamedTags.render || node.name === NamedTags.include) && typeof node.markup !== 'string') { + const snippet = node.markup.snippet; + if (snippet.type === NodeTypes.String && snippet.value === currentNode.value) { + return { + uri: source.uri, + range: Range.create( + source.textDocument.positionAt(snippet.position.start + 1), + source.textDocument.positionAt(snippet.position.end - 1), + ), + }; + } + } + }, + })) + } + + return referenceLocations; + } +} diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index 06aa07082..66207c5b8 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -11,6 +11,7 @@ import { parseJSON, path, recursiveReadDirectory, + SourceCodeType, } from '@shopify/theme-check-common'; import { Connection, @@ -41,6 +42,7 @@ import { snippetName } from '../utils/uri'; import { VERSION } from '../version'; import { CachedFileSystem } from './CachedFileSystem'; import { Configuration } from './Configuration'; +import { ReferencesProvider } from '../references/ReferencesProvider'; const defaultLogger = () => {}; @@ -119,6 +121,11 @@ export function startServer( findThemeRootURI, ); + const referencesProvider = new ReferencesProvider( + documentManager, + getLiquidFiles, + ); + async function findThemeRootURI(uri: string) { const rootUri = await findConfigFileRoot(uri, fileExists); const config = await loadConfig(rootUri, fs); @@ -203,6 +210,26 @@ export function startServer( return blocks.map(([uri]) => path.basename(uri, '.liquid')); } + // TODO: Fix this; seems very costly + async function getLiquidFiles(uri: string) { + const rootUri = await findThemeRootURI(uri); + + const blockFiles = await fs.readDirectory(path.join(rootUri, 'blocks')); + const sectionFiles = await fs.readDirectory(path.join(rootUri, 'sections')); + const snippetFiles = await fs.readDirectory(path.join(rootUri, 'snippets')); + + const sources = []; + for(let [uri, _] of [...blockFiles, ...sectionFiles, ...snippetFiles]) { + const doc = documentManager.get(uri); + if (!doc || doc.type !== SourceCodeType.LiquidHtml) { + continue; + } + sources.push(doc); + } + + return sources; + } + // Defined as a function to solve a circular dependency (doc manager & json // lang service both need each other) async function isValidSchema(uri: string, jsonString: string) { @@ -282,6 +309,7 @@ export function startServer( resolveProvider: false, workDoneProgress: false, }, + referencesProvider: true, documentHighlightProvider: true, linkedEditingRangeProvider: true, renameProvider: { @@ -433,6 +461,12 @@ export function startServer( return linkedEditingRangesProvider.linkedEditingRanges(params); }); + connection.onReferences(async (params) => { + if (hasUnsupportedDocument(params)) return []; + + return referencesProvider.references(params); + }); + connection.onDidChangeWatchedFiles(async (params) => { if (params.changes.length === 0) return;