@shopify/theme-language-server-common
Version:
<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>
289 lines (250 loc) • 8.88 kB
text/typescript
import { BaseRenameProvider } from '../BaseRenameProvider';
import { AugmentedLiquidSourceCode, DocumentManager, isLiquidSourceCode } from '../../documents';
import {
LiquidHtmlNode,
LiquidTagFor,
LiquidTagTablerow,
NamedTags,
NodeTypes,
Position,
RenderMarkup,
AssignMarkup,
TextNode,
LiquidVariableLookup,
ForMarkup,
} from '@shopify/liquid-html-parser';
import { Connection, Range } from 'vscode-languageserver';
import {
ApplyWorkspaceEditRequest,
PrepareRenameParams,
PrepareRenameResult,
RenameParams,
TextDocumentEdit,
TextEdit,
WorkspaceEdit,
} from 'vscode-languageserver-protocol';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { JSONNode, SourceCodeType, visit } from '@shopify/theme-check-common';
import { snippetName } from '../../utils/uri';
import { ClientCapabilities } from '../../ClientCapabilities';
export class LiquidVariableRenameProvider implements BaseRenameProvider {
constructor(
private connection: Connection,
private clientCapabilities: ClientCapabilities,
private documentManager: DocumentManager,
private findThemeRootURI: (uri: string) => Promise<string>,
) {}
async prepare(
node: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
params: PrepareRenameParams,
): Promise<null | PrepareRenameResult> {
const document = this.documentManager.get(params.textDocument.uri);
const textDocument = document?.textDocument;
if (!textDocument || !node || !ancestors) return null;
if (!supportedTags(node, ancestors)) return null;
const oldName = variableName(node);
const offsetOfVariableNameEnd = node.position.start + oldName.length;
// The cursor could be past the end of the variable name
if (textDocument.offsetAt(params.position) > offsetOfVariableNameEnd) return null;
return {
range: Range.create(
textDocument.positionAt(node.position.start),
textDocument.positionAt(offsetOfVariableNameEnd),
),
placeholder: oldName,
};
}
async rename(
node: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
params: RenameParams,
): Promise<null | WorkspaceEdit> {
const document = this.documentManager.get(params.textDocument.uri);
const rootUri = await this.findThemeRootURI(params.textDocument.uri);
const textDocument = document?.textDocument;
if (!textDocument || !node || !ancestors) return null;
if (document.ast instanceof Error) return null;
if (!supportedTags(node, ancestors)) return null;
const oldName = variableName(node);
const scope = variableNameBlockScope(oldName, ancestors);
const replaceRange = textReplaceRange(oldName, textDocument, scope);
let liquidDocParamUpdated = false;
const ranges: Range[] = visit(document.ast, {
VariableLookup: replaceRange,
AssignMarkup: replaceRange,
ForMarkup: replaceRange,
TextNode: (node: LiquidHtmlNode, ancestors: (LiquidHtmlNode | JSONNode)[]) => {
if (ancestors.at(-1)?.type !== NodeTypes.LiquidDocParamNode) return;
liquidDocParamUpdated = true;
return replaceRange(node, ancestors);
},
});
if (this.clientCapabilities.hasApplyEditSupport && liquidDocParamUpdated) {
const themeFiles = this.documentManager.theme(rootUri, true);
const liquidSourceCodes = themeFiles.filter(isLiquidSourceCode);
const name = snippetName(params.textDocument.uri);
updateRenderTags(this.connection, liquidSourceCodes, name, oldName, params.newName);
}
const textDocumentEdit = TextDocumentEdit.create(
{ uri: textDocument.uri, version: textDocument.version },
ranges.map((range) => TextEdit.replace(range, params.newName)),
);
return {
documentChanges: [textDocumentEdit],
};
}
}
function supportedTags(
node: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
): node is AssignMarkup | LiquidVariableLookup | ForMarkup | TextNode {
return (
node.type === NodeTypes.AssignMarkup ||
node.type === NodeTypes.VariableLookup ||
node.type === NodeTypes.ForMarkup ||
isLiquidDocParamNameNode(node, ancestors)
);
}
function isLiquidDocParamNameNode(
node: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
): node is TextNode {
const parentNode = ancestors.at(-1);
return (
!!parentNode &&
parentNode.type === NodeTypes.LiquidDocParamNode &&
parentNode.paramName === node &&
node.type === NodeTypes.TextNode
);
}
function variableName(node: LiquidHtmlNode): string {
switch (node.type) {
case NodeTypes.VariableLookup:
case NodeTypes.AssignMarkup:
return node.name ?? '';
case NodeTypes.ForMarkup:
return node.variableName ?? '';
case NodeTypes.TextNode:
return node.value;
default:
return '';
}
}
/*
* Find the scope where the variable name is used. Looks at defined in `tablerow` and `for` tags.
*/
function variableNameBlockScope(
variableName: string,
ancestors: (LiquidHtmlNode | JSONNode)[],
): Position | undefined {
let scopedAncestor: LiquidTagTablerow | LiquidTagFor | undefined;
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (
ancestor.type === NodeTypes.LiquidTag &&
(ancestor.name === NamedTags.tablerow || ancestor.name === NamedTags.for) &&
typeof ancestor.markup !== 'string' &&
ancestor.markup.variableName === variableName
) {
scopedAncestor = ancestor as LiquidTagTablerow | LiquidTagFor;
break;
}
}
if (!scopedAncestor || !scopedAncestor.blockEndPosition) return;
return {
start: scopedAncestor.blockStartPosition.start,
end: scopedAncestor.blockEndPosition.end,
};
}
function textReplaceRange(
oldName: string,
textDocument: TextDocument,
selectedVariableScope?: Position,
) {
return (node: LiquidHtmlNode, ancestors: (LiquidHtmlNode | JSONNode)[]) => {
if (variableName(node) !== oldName) return;
const ancestorScope = variableNameBlockScope(oldName, ancestors);
if (
ancestorScope?.start !== selectedVariableScope?.start ||
ancestorScope?.end !== selectedVariableScope?.end
) {
return;
}
return Range.create(
textDocument.positionAt(node.position.start),
textDocument.positionAt(node.position.start + oldName.length),
);
};
}
async function updateRenderTags(
connection: Connection,
liquidSourceCodes: AugmentedLiquidSourceCode[],
snippetName: string,
oldParamName: string,
newParamName: string,
) {
const editLabel = `Rename snippet parameter '${oldParamName}' to '${newParamName}'`;
const annotationId = 'renameSnippetParameter';
const workspaceEdit: WorkspaceEdit = {
documentChanges: [],
changeAnnotations: {
[annotationId]: {
label: editLabel,
needsConfirmation: false,
},
},
};
for (const sourceCode of liquidSourceCodes) {
if (sourceCode.ast instanceof Error) continue;
const textDocument = sourceCode.textDocument;
const edits: TextEdit[] = visit<SourceCodeType.LiquidHtml, TextEdit>(sourceCode.ast, {
RenderMarkup(node: RenderMarkup) {
if (node.snippet.type !== NodeTypes.String || node.snippet.value !== snippetName) {
return;
}
const renamedNameParamNode = node.args.find((arg) => arg.name === oldParamName);
if (renamedNameParamNode) {
return {
newText: `${newParamName}: `,
range: Range.create(
textDocument.positionAt(renamedNameParamNode.position.start),
textDocument.positionAt(renamedNameParamNode.value.position.start),
),
};
}
if (node.alias?.value === oldParamName && node.variable) {
// `as variable` is not captured in our liquid parser yet,
// so we have to check it manually and replace it
const aliasMatch = /as\s+([^\s,]+)/g;
const match = aliasMatch.exec(node.source.slice(node.position.start, node.position.end));
if (!match) return;
return {
newText: `as ${newParamName}`,
range: Range.create(
textDocument.positionAt(node.position.start + match.index),
textDocument.positionAt(node.position.start + match.index + match[0].length),
),
};
}
},
});
if (edits.length === 0) continue;
workspaceEdit.documentChanges!.push({
textDocument: {
uri: textDocument.uri,
version: sourceCode.version ?? null /* null means file from disk in this API */,
},
annotationId,
edits,
});
}
if (workspaceEdit.documentChanges!.length === 0) {
console.error('Nothing to do!');
return;
}
await connection.sendRequest(ApplyWorkspaceEditRequest.type, {
label: editLabel,
edit: workspaceEdit,
});
}