-
Notifications
You must be signed in to change notification settings - Fork 84
Add UnsupportedFilterArguments check #1248
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
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| '@shopify/theme-check-common': minor | ||
| --- | ||
|
|
||
| Add `UnsupportedFilterArguments` check to error when a filter is used in `render`, `include`, and `content_for` tags. | ||
|
|
||
| Filters are not supported on values passed as arguments to `render`/`include`/`content_for` tags. In production, they are silently dropped, leading to unexpected outcomes when rendered. | ||
|
|
||
| In the example below, the `bar` argument passes through unchanged when the snippet is rendered. | ||
|
|
||
| ```liquid | ||
| {% render 'foo', bar: 'hello' | append: ' world' %} | ||
| ``` | ||
|
|
||
| This check now reports an error pointing at the offending filter so the broken behavior is caught instead of failing silently. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
121 changes: 121 additions & 0 deletions
121
packages/theme-check-common/src/checks/unsupported-filter-arguments/index.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { expect, describe, it } from 'vitest'; | ||
| import { highlightedOffenses, runLiquidCheck } from '../../test'; | ||
| import { UnsupportedFilterArguments } from './index'; | ||
|
|
||
| describe('Module: UnsupportedFilterArguments', () => { | ||
| it('reports an offense when a render argument uses a filter', async () => { | ||
| const sourceCode = `{% render 'foo', param1: 'bar', param2: 'hello' | append: ' world' %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
| expect(offenses[0].message).toContain( | ||
| "Filters cannot be used on arguments passed to the 'render' tag", | ||
| ); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| append: ' world'`]); | ||
| }); | ||
|
|
||
| it('reports an offense when an include argument uses a filter', async () => { | ||
| const sourceCode = `{% include 'foo', x: bar | upcase %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
| expect(offenses[0].message).toContain( | ||
| "Filters cannot be used on arguments passed to the 'include' tag", | ||
| ); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| upcase`]); | ||
| }); | ||
|
|
||
| it('does not report when no filter is used', async () => { | ||
| const sourceCode = `{% render 'foo', param1: 'bar', param2: 'hello' %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('reports an offense when a content_for argument uses a filter', async () => { | ||
| const sourceCode = `{% content_for 'block', type: 'foo', id: 'bar' | append: 'x' %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
| expect(offenses[0].message).toContain( | ||
| "Filters cannot be used on arguments passed to the 'content_for' tag", | ||
| ); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| append: 'x'`]); | ||
| }); | ||
|
|
||
| it('does not report a false positive for a pipe inside a string literal', async () => { | ||
| const sourceCode = `{% render 'foo', sep: 'a|b' %}`; | ||
|
mbarak marked this conversation as resolved.
|
||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('does not report a false positive for a quoted pipe even when strict parse fails', async () => { | ||
| // `invalid_argument` forces the strict parse to fail, so the raw-markup | ||
| // scanner runs while the only pipe is still inside a quoted string literal. | ||
| const sourceCode = `{% render 'foo', sep: 'a|b', invalid_argument %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('does not report a false positive for a double-quoted pipe even when strict parse fails', async () => { | ||
| const sourceCode = `{% render 'foo', sep: "a|b", invalid_argument %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('highlights the real filter when a quoted pipe precedes it', async () => { | ||
| const sourceCode = `{% render 'foo', sep: 'a|b', x: bar | upcase %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| upcase`]); | ||
| }); | ||
|
|
||
| it('reports distinct highlights for two render tags on one line', async () => { | ||
| const sourceCode = `{% render 'foo', x: a | upcase %} {% render 'bar', y: b | downcase %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(2); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| upcase`, `| downcase`]); | ||
| }); | ||
|
|
||
| it('reports an offense for a render statement inside a {% liquid %} tag', async () => { | ||
| const sourceCode = `{% liquid\n render 'foo', x: bar | upcase\n%}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
|
|
||
| const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); | ||
| expect(highlights).toEqual([`| upcase`]); | ||
| }); | ||
|
|
||
| it('does not report on a plain render with no arguments', async () => { | ||
| const sourceCode = `{% render 'foo' %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('reports offenses on nested render tags', async () => { | ||
| const sourceCode = `{% if true %}{% render 'foo', x: bar | upcase %}{% endif %}`; | ||
| const offenses = await runLiquidCheck(UnsupportedFilterArguments, sourceCode); | ||
|
|
||
| expect(offenses).toHaveLength(1); | ||
| expect(offenses[0].message).toContain( | ||
| "Filters cannot be used on arguments passed to the 'render' tag", | ||
| ); | ||
| }); | ||
| }); | ||
89 changes: 89 additions & 0 deletions
89
packages/theme-check-common/src/checks/unsupported-filter-arguments/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { NamedTags } from '@shopify/liquid-html-parser'; | ||
| import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; | ||
|
|
||
| const CHECKED_TAGS: string[] = [NamedTags.render, NamedTags.include, NamedTags.content_for]; | ||
|
|
||
| /** | ||
| * Finds the index of the first filter pipe (`|`) in a raw render/include/content_for | ||
| * markup string that is *not* inside a quoted string literal. Returns -1 if none is | ||
| * found. | ||
| * | ||
| * This is a heuristic on the raw markup because, when a `render`/`include`/`content_for` | ||
| * tag contains a filter, the strict grammar refuses it and the parser falls back to | ||
| * a base-case `LiquidTag` whose `markup` is a raw string (there is no structured | ||
| * `RenderMarkup` node to inspect). | ||
| */ | ||
| function indexOfFilterPipe(markup: string): number { | ||
| let quote: "'" | '"' | null = null; | ||
|
|
||
| for (let i = 0; i < markup.length; i++) { | ||
| const char = markup[i]; | ||
|
|
||
| if (quote) { | ||
| if (char === quote) { | ||
| quote = null; | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| if (char === "'" || char === '"') { | ||
| quote = char; | ||
| continue; | ||
| } | ||
|
|
||
| if (char === '|') { | ||
| return i; | ||
| } | ||
| } | ||
|
|
||
| return -1; | ||
| } | ||
|
|
||
| export const UnsupportedFilterArguments: LiquidCheckDefinition = { | ||
| meta: { | ||
| code: 'UnsupportedFilterArguments', | ||
| name: 'Unsupported Filter Arguments', | ||
| docs: { | ||
| description: | ||
| "This check warns against using filters on values passed as arguments to a 'render', 'include', or 'content_for' tag. " + | ||
| 'Filters are not applied in that position and the value is passed through unchanged, which silently ' + | ||
| 'produces incorrect output.', | ||
| recommended: true, | ||
| url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/unsupported-filter-arguments', | ||
| }, | ||
| type: SourceCodeType.LiquidHtml, | ||
| severity: Severity.ERROR, | ||
|
mbarak marked this conversation as resolved.
|
||
| schema: {}, | ||
| targets: [], | ||
| }, | ||
|
|
||
| create(context) { | ||
| return { | ||
| async LiquidTag(node) { | ||
|
mbarak marked this conversation as resolved.
|
||
| if (!CHECKED_TAGS.includes(node.name)) return; | ||
|
|
||
| // When the tag parses successfully, `markup` is a structured | ||
| // `RenderMarkup` object and filters are impossible. A raw string means | ||
| // the strict parse failed (e.g. because of a filter pipe). | ||
| if (typeof node.markup !== 'string') return; | ||
|
|
||
| const relativePipeIndex = indexOfFilterPipe(node.markup); | ||
| if (relativePipeIndex === -1) return; | ||
|
|
||
| const markupStart = node.source.indexOf(node.markup, node.position.start); | ||
| if (markupStart === -1) return; | ||
|
|
||
| const startIndex = markupStart + relativePipeIndex; | ||
| const endIndex = markupStart + node.markup.length; | ||
|
|
||
| context.report({ | ||
| message: | ||
| `Filters cannot be used on arguments passed to the '${node.name}' tag. ` + | ||
| `Apply the filter beforehand (e.g. with {% assign %}) and pass the result instead.`, | ||
| startIndex, | ||
| endIndex, | ||
| }); | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.