Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/fix-rename-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/theme-language-server-common": patch
---

Fix rename not triggering when cursor is at the first character of a LiquidDoc param name
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,68 @@ describe('LiquidVariableRenameProvider', () => {
);
});

it('does not rename type annotation when type and param name share the same text', async () => {
const collisionSource = `{% doc %}@param {food} food - favorite food{% enddoc %}
{% liquid
echo food
%}`;
const collisionDoc = TextDocument.create(textDocumentUri, 'liquid', 1, collisionSource);
documentManager.open(collisionDoc.uri, collisionSource, 1);

const params = {
textDocument: collisionDoc,
position: Position.create(0, collisionSource.indexOf('} food') + 2),
newName: 'meal',
};

const result = await provider.rename(params);
assert(result);
assert(result.documentChanges);
expect((result.documentChanges[0] as TextDocumentEdit).edits).to.applyEdits(
collisionDoc,
`{% doc %}@param {food} meal - favorite food{% enddoc %}
{% liquid
echo meal
%}`,
);
});

it('returns new name when cursor is at first character of liquid doc param name', async () => {
const params = {
textDocument,
position: Position.create(0, documentSource.indexOf('food')),
newName: 'meal',
};

const result = await provider.rename(params);
assert(result);
assert(result.documentChanges);
expect((result.documentChanges[0] as TextDocumentEdit).edits).to.applyEdits(
textDocument,
`{% doc %}@param {string} meal - favorite food{% enddoc %}
{% assign animal = 'dog' %}
{% assign plant = 'cactus' %}
{% liquid
echo plant
echo meal
%}
{% assign painting = 'mona lisa' %}
{% assign paintings = 'starry night, sunday afternoon, the scream' %}
<p>I have a cool animal, a great plant, and my favorite food</p>`,
);
});

it('does not trigger rename when cursor is on type annotation', async () => {
const params = {
textDocument,
position: Position.create(0, documentSource.indexOf('{string}') + 1),
newName: 'meal',
};

const result = await provider.rename(params);
expect(result).to.be.null;
});

it('returns new name after liquid doc param is renamed on variable usage', async () => {
const params = {
textDocument,
Expand Down Expand Up @@ -326,6 +388,50 @@ describe('LiquidVariableRenameProvider', () => {
);
});

it('returns new name when cursor is at first character of untyped param name', async () => {
const params = {
textDocument,
position: Position.create(0, documentSource.indexOf('prod')),
newName: 'ppp',
};
const result = await provider.rename(params);
assert(result);
assert(result.documentChanges);
expect((result.documentChanges[0] as TextDocumentEdit).edits).to.applyEdits(
textDocument,
`{% doc %}@param ppp - product{% enddoc %}
{% liquid
for prod in products
echo prod.title
endfor
echo ppp
%}`,
);
});

[
{ desc: 'at @ symbol', position: Position.create(0, documentSource.indexOf('@param')) },
{
desc: 'at @param keyword',
position: Position.create(0, documentSource.indexOf('@param') + 1),
},
{
desc: 'at space before param name',
position: Position.create(0, documentSource.indexOf('@param') + '@param'.length),
},
].forEach(({ desc, position }) => {
it(`does not trigger rename when cursor is ${desc}`, async () => {
const params = {
textDocument,
position,
newName: 'ppp',
};

const result = await provider.rename(params);
expect(result).to.be.null;
});
});

it('returns new name after variable is renamed inside loop, but is scoped outside it', async () => {
const params = {
textDocument,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AssignMarkup,
ForMarkup,
LiquidDocParamNode,
LiquidHtmlNode,
LiquidTagFor,
LiquidTagTablerow,
Expand Down Expand Up @@ -48,15 +49,18 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
if (!textDocument || !node || !ancestors) return null;
if (!supportedTags(node, ancestors)) return null;

const targetNode = node.type === NodeTypes.LiquidDocParamNode ? node.paramName : node;
const oldName = variableName(node);
const offsetOfVariableNameEnd = node.position.start + oldName.length;
const cursorOffset = textDocument.offsetAt(params.position);
const offsetOfVariableNameEnd = targetNode.position.start + oldName.length;

// The cursor could be past the end of the variable name
if (textDocument.offsetAt(params.position) > offsetOfVariableNameEnd) return null;
// The cursor must be within the variable name range
if (cursorOffset < targetNode.position.start || cursorOffset > offsetOfVariableNameEnd)
return null;

return {
range: Range.create(
textDocument.positionAt(node.position.start),
textDocument.positionAt(targetNode.position.start),
textDocument.positionAt(offsetOfVariableNameEnd),
),
placeholder: oldName,
Expand All @@ -76,6 +80,13 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
if (document.ast instanceof Error) return null;
if (!supportedTags(node, ancestors)) return null;

// When node is a LiquidDocParamNode, ensure cursor is on the param name, not the @param keyword or type annotation
if (node.type === NodeTypes.LiquidDocParamNode) {
const cursorOffset = textDocument.offsetAt(params.position);
const nameEnd = node.paramName.position.start + node.paramName.value.length;
if (cursorOffset < node.paramName.position.start || cursorOffset > nameEnd) return null;
}
Comment on lines +84 to +88
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder if we should actually change the logic for findCurrentNode instead... should probably check how hover behave. If hover is also bugged we might want to revisit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh we don't have a hover for that one.

Copy link
Contributor Author

@charlespwd charlespwd Feb 6, 2026

Choose a reason for hiding this comment

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

Aight after some research the approach in the PR is better.

  • Hover is silly in the doc itself, since the doc is right after it.
  • Completion doesn't make sense, since we're defining it.

Drawbacks of changing isCovered in visitor.ts:

  • It would require changes to how TextNode calculates isCovered which would create an ambiguity for things like <tag>TextNode</tag>. Right now it's resolved by not matching on start so the > matches on the HtmlElement.


const oldName = variableName(node);
const scope = variableNameBlockScope(oldName, ancestors);
const replaceRange = textReplaceRange(oldName, textDocument, scope);
Expand All @@ -87,7 +98,9 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
AssignMarkup: replaceRange,
ForMarkup: replaceRange,
TextNode: (node: LiquidHtmlNode, ancestors: (LiquidHtmlNode | JSONNode)[]) => {
if (ancestors.at(-1)?.type !== NodeTypes.LiquidDocParamNode) return;
const parentNode = ancestors.at(-1);
if (parentNode?.type !== NodeTypes.LiquidDocParamNode || parentNode?.paramName !== node)
return;

liquidDocParamUpdated = true;

Expand Down Expand Up @@ -117,12 +130,13 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
function supportedTags(
node: LiquidHtmlNode,
ancestors: LiquidHtmlNode[],
): node is AssignMarkup | LiquidVariableLookup | ForMarkup | TextNode {
): node is AssignMarkup | LiquidVariableLookup | ForMarkup | TextNode | LiquidDocParamNode {
return (
node.type === NodeTypes.AssignMarkup ||
node.type === NodeTypes.VariableLookup ||
node.type === NodeTypes.ForMarkup ||
isLiquidDocParamNameNode(node, ancestors)
isLiquidDocParamNameNode(node, ancestors) ||
isLiquidDocParamNode(node)
);
}

Expand All @@ -140,6 +154,10 @@ function isLiquidDocParamNameNode(
);
}

function isLiquidDocParamNode(node: LiquidHtmlNode): node is LiquidDocParamNode {
return node.type === NodeTypes.LiquidDocParamNode && node.paramName?.type === NodeTypes.TextNode;
}

function variableName(node: LiquidHtmlNode): string {
switch (node.type) {
case NodeTypes.VariableLookup:
Expand All @@ -149,6 +167,8 @@ function variableName(node: LiquidHtmlNode): string {
return node.variableName ?? '';
case NodeTypes.TextNode:
return node.value;
case NodeTypes.LiquidDocParamNode:
return node.paramName.value;
default:
return '';
}
Expand Down