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

Reference to snippet and blocks #677

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 }}
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Location[] | undefined>;
}
Original file line number Diff line number Diff line change
@@ -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<AugmentedLiquidSourceCode[]>;

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<Location[] | undefined> {
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Location[] | undefined> {
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<SourceCodeType.LiquidHtml, Location>(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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Location[] | undefined> {
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<SourceCodeType.LiquidHtml, Location>(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;
}
}
34 changes: 34 additions & 0 deletions packages/theme-language-server-common/src/server/startServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
parseJSON,
path,
recursiveReadDirectory,
SourceCodeType,
} from '@shopify/theme-check-common';
import {
Connection,
Expand Down Expand Up @@ -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 = () => {};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Comment on lines +213 to +231
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we keep a memoized ast for each file and invalidate it on file change? Reading every file when someone looks up references to the variable seems expensive.


// 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) {
Expand Down Expand Up @@ -282,6 +309,7 @@ export function startServer(
resolveProvider: false,
workDoneProgress: false,
},
referencesProvider: true,
documentHighlightProvider: true,
linkedEditingRangeProvider: true,
renameProvider: {
Expand Down Expand Up @@ -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;

Expand Down
Loading