Skip to content
Open
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/quiet-snakes-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme-check-common': minor
---

Report LiquidDoc parameters that are used in a snippet but never passed by its callers.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,72 @@ describe('Module: UnusedDocParam', () => {
expect(offenses[0]!.suggest![0].message).to.equal("Remove unused parameter 'param2'");
});

it('should report a used parameter that is never passed to the snippet', async () => {
const sourceCode = `
{% doc %}
@param {string} [style] - Example style
{% enddoc %}

{{ style }}
`;

const offenses = await runLiquidCheck(
UnusedDocParam,
sourceCode,
'snippets/card.liquid',
{
async getReferences() {
return [
{
source: { uri: 'file:///templates/product.liquid' },
target: { uri: 'file:///snippets/card.liquid' },
type: 'direct',
},
];
},
},
{ 'templates/product.liquid': "{% render 'card' %}" },
);

expect(offenses).to.have.length(1);
expect(offenses[0].message).to.equal("The parameter 'style' is never passed to this snippet.");
});

it('should not report a used parameter that is passed to the snippet', async () => {
const sourceCode = `
{% doc %}
@param {string} [style] - Example style
{% enddoc %}

{{ style }}
`;

const offenses = await runLiquidCheck(
UnusedDocParam,
sourceCode,
'snippets/card.liquid',
{
async getReferences() {
return [
{
source: { uri: 'file:///templates/product.liquid' },
target: { uri: 'file:///snippets/card.liquid' },
type: 'direct',
},
];
},
},
{
'templates/product.liquid': `
{% render 'card' %}
{% render 'card', style: 'default' %}
`,
},
);

expect(offenses).to.be.empty;
});

it('should apply suggestion when a variable is defined but not used', async () => {
const sourceCode = `
{% doc %}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { LiquidDocParamNode, NodeTypes } from '@shopify/liquid-html-parser';
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
import {
isLiquidHtmlNode,
LiquidDocParamNode,
NodeTypes,
RenderMarkup,
} from '@shopify/liquid-html-parser';
import { Context, LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
import { isLoopScopedVariable } from '../utils';
import { getSnippetName } from '../../liquid-doc/arguments';
import { toSourceCode } from '../../to-source-code';
import { isSnippet } from '../../to-schema';
import { visit } from '../../visitor';

export const UnusedDocParam: LiquidCheckDefinition = {
meta: {
code: 'UnusedDocParam',
name: 'Prevent unused doc parameters',
docs: {
description:
'This check exists to ensure any parameters defined in the `doc` tag are used within the snippet.',
'This check ensures parameters defined in the `doc` tag are used within the snippet and passed by its callers.',
recommended: true,
url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/unused-doc-param',
},
Expand Down Expand Up @@ -38,6 +47,10 @@ export const UnusedDocParam: LiquidCheckDefinition = {
},

async onCodePathEnd() {
if (definedLiquidDocParams.size === 0) return;

const providedParams = await getProvidedParams(context);

for (const [variable, node] of definedLiquidDocParams.entries()) {
if (!usedVariables.has(variable)) {
context.report({
Expand All @@ -51,9 +64,55 @@ export const UnusedDocParam: LiquidCheckDefinition = {
},
],
});
} else if (providedParams && !providedParams.has(variable)) {
context.report({
message: `The parameter '${variable}' is never passed to this snippet.`,
startIndex: node.position.start,
endIndex: node.position.end,
});
}
}
},
};
},
};

async function getProvidedParams(context: Context<SourceCodeType.LiquidHtml>) {
const { getReferences, fs, file, toRelativePath } = context;
if (!getReferences || !isSnippet(file.uri)) return;

const snippetName = toRelativePath(file.uri)
.replace(/^snippets\//, '')
.replace(/\.liquid$/, '');
let references;
try {
references = await getReferences(file.uri);
} catch {
return;
}
const sourceUris = new Set(
references.filter((reference) => reference.type === 'direct').map(({ source }) => source.uri),
);
const providedParams = new Set<string>();

for (const sourceUri of sourceUris) {
let source: string;
try {
source = await fs.readFile(sourceUri);
} catch {
return;
}

const sourceCode = toSourceCode(sourceUri, source);
if (sourceCode.type !== SourceCodeType.LiquidHtml || !isLiquidHtmlNode(sourceCode.ast)) return;

visit(sourceCode.ast, {
RenderMarkup(node: RenderMarkup) {
if (getSnippetName(node) !== snippetName) return;
node.args.forEach(({ name }) => providedParams.add(name));
},
});
}

return providedParams;
}
Loading