Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Liquid capture variables, including quoted names, are now searchable in plain templates and multiline Liquid tags, and the documented coverage distinguishes standard Liquid declarations from Shopify dependencies and unsupported host features. (#2498)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused.
- In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ is written):
| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
| Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) |
| Astro | `.astro` | Full support (frontmatter + script extraction, template component/call references, `src/pages/` routes) |
| Liquid | `.liquid` | Full support |
| Liquid | `.liquid` | Partial support (assign/capture variables, literal Shopify snippet/section references and schema; [dialect boundaries](#liquid-coverage)) |
| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) |
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
Expand All @@ -843,6 +843,10 @@ is written):
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |

### Liquid coverage

Liquid coverage is partial. Indexed `.liquid` files expose `assign`/`capture` declarations, literal Shopify snippet/section paths and schema constants. Declarations do not require Shopify directories. Jekyll include lookup, Liquid in HTML/Markdown, embedded JS/CSS, asset/block dependencies and runtime settings/translations require separate host-aware support. Missing edges do not establish that these files or data are unused. See [Liquid dialects](https://shopify.github.io/liquid/basics/variations/).

## Measured cross-file coverage

Impact and blast-radius queries are only as good as the dependency graph behind them, so coverage is measured rather than asserted. **Fair coverage** = the share of symbol-bearing source files that have at least one *resolved cross-file dependent* — something that imports, calls, references, or (through a framework convention) routes to them — on a real-world benchmark repo per language. The residual is always a genuine static-analysis frontier (runtime dynamic dispatch, reflection / DI containers, framework-convention entry points, vendored third-party code), never hidden by gaming the denominator.
Expand Down
67 changes: 67 additions & 0 deletions __tests__/liquid-capture-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { LiquidExtractor } from '../src/extraction/liquid-extractor';

const extract = (source: string, file = 'plain/template.liquid') => new LiquidExtractor(file, source).extract();
describe('standard Liquid capture declarations', () => {
it.each([
['{% capture title %}Hello{% endcapture %}', 'title'],
['{%- capture title -%}Hello{%- endcapture -%}', 'title'],
['{% capture title_text %}Hello{% endcapture %}', 'title_text'],
['{% capture title-text %}Hello{% endcapture %}', 'title-text'],
["{% capture 'title' %}Hello{% endcapture %}", 'title'],
['{% capture "title" %}Hello{% endcapture %}', 'title'],
['{% capture title-%}Hello{% endcapture %}', 'title'],
['{% capture title extra %}Hello{% endcapture %}', 'title'],
['{% liquid\n capture "title"\n endcapture\n%}', 'title'],
['{% liquid\n capture title extra\n endcapture\n%}', 'title'],
['{% liquid\n capture title\n echo "Hello"\n endcapture\n%}', 'title'],
['{% liquid\r\n capture title\r\n echo "Hello"\r\n endcapture\r\n%}', 'title'],
['{% liquid\n capture title # a declaration\n echo "Hello"\n endcapture\n%}', 'title'],
])('indexes a variable and file containment: %s', (source, name) => {
const result = extract(source);
const variable = result.nodes.find(n => n.kind === 'variable');
expect(variable?.name).toBe(name);
expect(result.edges).toContainEqual(expect.objectContaining({ target: variable!.id, kind: 'contains' }));
expect(result.errors).toEqual([]);
});

it.each(['plain/template.liquid', '_includes/card.liquid', 'sections/card.liquid'])('does not require Shopify directories: %s', file => {
expect(extract('{% capture title %}Hello{% endcapture %}', file).nodes.some(n => n.name === 'title')).toBe(true);
});

it.each([
'{% comment %}{% capture fake %}x{% endcapture %}{% endcomment %}',
'{% raw %}{% capture fake %}x{% endcapture %}{% endraw %}',
'{% # capture fake %}',
'{% liquid\n # capture fake\n%}',
'{% liquid\n comment\n capture fake\n endcapture\n endcomment\n%}',
'{% liquid\n raw\n capture fake\n endcapture\n endraw\n%}',
'{% capture %}x{% endcapture %}',
'{% liquid\n capture\n%}',
"{% capture 'fake\" %}x{% endcapture %}",
'{% capture fake.extra %}x{% endcapture %}',
'{% assign text = "capture fake" %}',
])('ignores non-executing or invalid declarations: %s', source => {
expect(extract(source).nodes.some(n => n.name === 'fake')).toBe(false);
});

it('retains declaration order, positions and identity on one Unicode line', () => {
const source = 'é😀 {% assign title = 1 %}{% capture title %}one{% endcapture %}{% capture title %}two{% endcapture %}';
const variables = extract(source).nodes.filter(n => n.kind === 'variable');
expect(variables).toHaveLength(3);
expect(variables.map(n => n.startColumn)).toEqual([source.indexOf('{% assign'), source.indexOf('{% capture'), source.lastIndexOf('{% capture')]);
expect(new Set(variables.map(n => n.id)).size).toBe(3);
});

it('retains nested executable assigns and render references', () => {
const result = extract('{% capture title %}{% assign inner = 1 %}{% render "card" %}{% endcapture %}');
expect(result.nodes.filter(n => n.kind === 'variable').map(n => n.name)).toEqual(['title', 'inner']);
expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ referenceName: 'snippets/card.liquid' }));
});

it('does not pretend to extract embedded JavaScript or dynamic targets', () => {
const result = extract('{% capture title %}<script>function hello() {}</script>{% render target %}{% endcapture %}');
expect(result.nodes.some(n => n.kind === 'function' || n.name === 'hello')).toBe(false);
expect(result.unresolvedReferences).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion site/src/content/docs/reference/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Language support is automatic from the file extension — there's nothing to con
| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
| Vue | `.vue` | Full support (script + script-setup, Nuxt page/API/middleware routes) |
| Astro | `.astro` | Full support (frontmatter + script extraction, template component/call references, `src/pages/` routes) |
| Liquid | `.liquid` | Full support |
| Liquid | `.liquid` | Partial support (assign/capture declarations, literal Shopify snippet/section references and schema; host-specific paths, embedded code and runtime data remain limited) |
| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX forms) |
| Lua | `.lua` | Full support (functions, methods, locals, `require` imports, call edges) |
| R | `.R`, `.r` | Full support (functions, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
Expand Down
13 changes: 10 additions & 3 deletions src/extraction/liquid-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class LiquidExtractor {
// Extract schema block
this.extractSchema(fileNode.id);

// Extract assign statements as variables
// Extract assign and capture declarations as variables
this.extractAssignments(fileNode.id);
}
} catch (error) {
Expand Down Expand Up @@ -411,11 +411,18 @@ export class LiquidExtractor {
}

/**
* Extract {% assign var = value %} statements
* Extract assign and capture variable declarations in either tag spelling.
*/
private extractAssignments(fileNodeId: string): void {
// Both spellings. Most of a modern theme's assigns are the bare kind.
for (const match of this.findTagOccurrences('assign', `(\\w+)\\s*=`)) {
const declarations = [
...this.findTagOccurrences('assign', `(\\w+)\\s*=`),
// Liquid Ruby and LiquidJS accept quoted names and permissive trailing
// tokens. This is a declaration extractor, not a dialect validator.
...this.findTagOccurrences('capture', `(['"]?)([A-Za-z_][\\w-]*?)\\2(?=\\s|[-]?%\\}|$)`)
.map(match => ({ ...match, groups: [match.groups[0]!, match.groups[2]!] })),
].sort((a, b) => a.index - b.index);
for (const match of declarations) {
const variableName = match.groups[1];
const line = this.getLineNumber(match.index);

Expand Down