mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
The Liquid extractor indexed a variable declared with {% assign %} as a
variable node, in both spellings (braced, and as a line of a
{% liquid %} tag) and skipping comment and raw regions, but it never
read {% capture %}, Liquid's other way to declare a variable. A
captured variable could not be found: search missed it, and
codegraph_explore reported its name as not in the index.
Fix:
- extractAssignments reads capture tags through the same
findTagOccurrences scanner as assign, so both spellings and the
comment/raw rule apply. The name is word characters, as for assign,
optionally in single or double quotes (Liquid reads 'title' as
title), and must end at whitespace or the tag's end (-%} included). A
name that goes on with "-" or ".", which the assign rule doesn't read
either, gets no node rather than a cut-short one.
- Captures are read after assigns. Node ids are keyed by file, kind,
name and line, so an assign keeps its id when a capture of the same
name shares its line.
- One helper now builds the variable node and its contains edge for
both. It computes the declaration's column once, where the assign
code computed it three times, each a split of the whole file.
Validation:
- 13 new tests in __tests__/extraction.test.ts. 11 fail with main's
extractor and pass with the fix: 8 declaration forms (braced,
whitespace-trimmed, tight -%}, single- and double-quoted, {% liquid %}
lines with LF and CRLF, a quoted {% liquid %} line), an assign and two
captures of one name on one line (distinct ids, the assign keeps
its), tags inside a capture body, and an index-level test that search
finds captured variables in a non-theme folder. 2 guards pass on
both: no capture from comment, raw, inline comments, prose, a
nameless tag or a tag inside a string, and no cut-short hyphenated
or dotted name. Red/green on the committed tree: 42 passed and 11
failed with main's extractor, 53 passed with the fix.
- A/B on efb091ec, each arm built from its own checkout: 8 Shopify
theme repos (Shopify/dawn, Shopify/horizon, Shopify/skeleton-theme,
ChumsInc/chums-impulse-dev, agencecinq/cinquante-et-un,
noclocks/shopify-devstore-theme, panoply/syncify, Shopify/theme-tools)
and 2 controls (express, requests). The controls and skeleton-theme,
which has no capture, are byte-identical. Every other theme gains
only variable nodes, each with one contains edge: dawn 36, horizon
192, chums-impulse-dev 90, cinquante-et-un 25, shopify-devstore-theme
27, syncify 25, theme-tools 49, 444 in all (383 braced, 61 in
{% liquid %} tags). That is exactly the count of capture tags a
separate scan finds outside comment and raw regions, and a script
checked each new node against the source text: it sits on a capture
tag naming it, with no edge but its contains. 444 right, 0 wrong.
Nothing is removed or moved, no unresolved ref changes, and no node
present on both arms changed its id. Every capture and assign name in
these themes is a plain word.
- Cost: the extractor is faster with the fix. A microbenchmark over
1,386 Liquid files (8 MB), 15 runs in each of 3 interleaved rounds,
gave per-round medians of 443-465 ms on main and 316-387 ms with the
fix, because whole-file line-start scans fall from 29,807 to 20,671.
Index wall time, median of 3 interleaved runs, main vs fix: horizon
2975 vs 2935 ms, chums-impulse-dev 2404 vs 2311, syncify 4329 vs
3867, theme-tools 8405 vs 8108, and the byte-identical controls
express 2427 vs 2417 and requests 2006 vs 1942, which is within the
noise of a shared machine.
- Rebased onto 68fcf2f1, which adds only PR #2813 (JS/TS static and
instance overrides, with a kernel change, so the kernel was rebuilt).
There the Liquid tests pass, and re-indexing the 8 theme repos
changes no Liquid or JSON line: 4 are byte-identical to the
pre-rebase fix arm, and the others differ only in PR #2813's
is_static flag on JavaScript methods (dawn 30, horizon 26,
shopify-devstore-theme 14) and one synthesized TypeScript call in
syncify.
- Kernel parity: not applicable, the kernel does not extract Liquid.
The issue also asks for README and docs-site wording about what Liquid
support covers. That is PR #2815, a docs-only change held for the
maintainer's review, so this change leaves the issue open.
Refs #2498
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
import { parseTree, findNodeAtLocation, type ParseError } from 'jsonc-parser';
|
|
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
|
|
import { generateNodeId, NodeIdAllocator } from './tree-sitter-helpers';
|
|
|
|
/**
|
|
* LiquidExtractor - Extracts relationships from Liquid template files
|
|
*
|
|
* Liquid is a templating language (used by Shopify, Jekyll, etc.) that doesn't
|
|
* have traditional functions or classes. Instead, we extract:
|
|
* - Section references ({% section 'name' %}) and section group references
|
|
* ({% sections 'header-group' %} → `sections/header-group.json`)
|
|
* - Snippet references ({% render 'name' %} and {% include 'name' %})
|
|
* - Schema blocks ({% schema %}...{% endschema %})
|
|
* - Variables declared with {% assign %} and {% capture %}
|
|
* - The sections a Shopify JSON template or section group lists
|
|
*/
|
|
export class LiquidExtractor {
|
|
private filePath: string;
|
|
private source: string;
|
|
private nodes: Node[] = [];
|
|
private nodeIds = new NodeIdAllocator();
|
|
private edges: Edge[] = [];
|
|
private unresolvedReferences: UnresolvedReference[] = [];
|
|
private errors: ExtractionError[] = [];
|
|
/** Where Liquid runs nothing — see `silentRegions`. Computed once per file. */
|
|
private silent?: Array<[start: number, end: number]>;
|
|
|
|
constructor(filePath: string, source: string) {
|
|
this.filePath = filePath;
|
|
this.source = source;
|
|
}
|
|
|
|
/**
|
|
* Extract from Liquid source
|
|
*/
|
|
extract(): ExtractionResult {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Create file node
|
|
const fileNode = this.createFileNode();
|
|
|
|
// Shopify OS 2.0 JSON template / section group: link each section `type`
|
|
// to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
|
|
// JSON file just carries the references — so it stays out of any
|
|
// symbol-bearing-file metric while its sections still get their dependents.)
|
|
if (this.filePath.endsWith('.json')) {
|
|
this.extractShopifyJsonSections(fileNode.id);
|
|
} else {
|
|
// Extract render/include statements (snippet references)
|
|
this.extractSnippetReferences(fileNode.id);
|
|
|
|
// Extract section references
|
|
this.extractSectionReferences(fileNode.id);
|
|
|
|
// Extract schema block
|
|
this.extractSchema(fileNode.id);
|
|
|
|
// Extract assign and capture declarations as variables
|
|
this.extractAssignments(fileNode.id);
|
|
}
|
|
} catch (error) {
|
|
this.errors.push({
|
|
message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
|
severity: 'error',
|
|
code: 'parse_error',
|
|
});
|
|
}
|
|
|
|
return {
|
|
nodes: this.nodes,
|
|
edges: this.edges,
|
|
unresolvedReferences: this.unresolvedReferences,
|
|
errors: this.errors,
|
|
durationMs: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a file node for the Liquid template
|
|
*/
|
|
private createFileNode(): Node {
|
|
const lines = this.source.split('\n');
|
|
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
|
|
|
|
const fileNode: Node = {
|
|
id,
|
|
kind: 'file',
|
|
name: this.filePath.split('/').pop() || this.filePath,
|
|
qualifiedName: this.filePath,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: 1,
|
|
endLine: lines.length,
|
|
startColumn: 0,
|
|
endColumn: lines[lines.length - 1]?.length || 0,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(fileNode);
|
|
return fileNode;
|
|
}
|
|
|
|
/**
|
|
* Shopify OS 2.0 JSON template / section group. Both have a `sections` object
|
|
* mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
|
|
* `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
|
|
* used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
|
|
*
|
|
* The theme editor writes a block comment ("IMPORTANT: The contents of this
|
|
* file are auto-generated…") at the top of every template and section group
|
|
* it saves, and Shopify's theme JSON allows comments and trailing commas.
|
|
* Anything else that is not JSON, like a file cut off partway, links nothing.
|
|
* Each reference sits where its `type` value is written.
|
|
*/
|
|
private extractShopifyJsonSections(fromNodeId: string): void {
|
|
const errors: ParseError[] = [];
|
|
const root = parseTree(this.source, errors, { allowTrailingComma: true });
|
|
if (errors.length > 0 || root?.type !== 'object') return;
|
|
const sections = findNodeAtLocation(root, ['sections']);
|
|
if (sections?.type !== 'object') return;
|
|
const seen = new Set<string>();
|
|
for (const section of sections.children ?? []) {
|
|
const value = section.children?.[1];
|
|
const type = value?.type === 'object' ? findNodeAtLocation(value, ['type']) : undefined;
|
|
if (type?.type !== 'string' || typeof type.value !== 'string' || seen.has(type.value)) continue;
|
|
seen.add(type.value);
|
|
const line = this.getLineNumber(type.offset);
|
|
this.unresolvedReferences.push({
|
|
fromNodeId,
|
|
referenceName: `sections/${type.value}.liquid`,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: type.offset - this.getLineStart(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The regions Liquid runs nothing in: `{% comment %}…{% endcomment %}` and
|
|
* `{% raw %}…{% endraw %}`, each from its opening tag to the end of its
|
|
* closing one (or to the end of the file, when it is never closed). The tags,
|
|
* schemas and declarations written inside one are examples, not code.
|
|
*/
|
|
private silentRegions(): Array<[start: number, end: number]> {
|
|
if (this.silent) return this.silent;
|
|
const regions: Array<[number, number]> = [];
|
|
const tags = /\{%[-]?\s*(\w+|#)([\s\S]*?)[-]?%\}/g;
|
|
let open: { name: string; start: number } | undefined;
|
|
let tag;
|
|
while ((tag = tags.exec(this.source)) !== null) {
|
|
const name = tag[1]!;
|
|
if (open) {
|
|
if (name === `end${open.name}`) {
|
|
regions.push([open.start, tags.lastIndex]);
|
|
open = undefined;
|
|
}
|
|
} else if (name === 'comment' || name === 'raw') {
|
|
open = { name, start: tag.index };
|
|
}
|
|
}
|
|
if (open) regions.push([open.start, this.source.length]);
|
|
return (this.silent = regions);
|
|
}
|
|
|
|
/** The silent region (see `silentRegions`) holding `index`, if any. */
|
|
private silentRegionAt(index: number): [start: number, end: number] | undefined {
|
|
return this.silentRegions().find(([start, end]) => index >= start && index < end);
|
|
}
|
|
|
|
/**
|
|
* Every occurrence of a Liquid tag, in BOTH spellings it can be written in.
|
|
*
|
|
* Inside a `{% liquid %}` tag, each line of the body is a tag
|
|
* WITHOUT braces of its own:
|
|
*
|
|
* {% liquid
|
|
* assign heading = section.settings.title
|
|
* render 'card', title: heading
|
|
* %}
|
|
*
|
|
* A pattern anchored on `{%` misses these references and assignments.
|
|
*
|
|
* Returns real offsets into `this.source`, so callers keep using
|
|
* getLineNumber/getLineStart unchanged.
|
|
*/
|
|
private findTagOccurrences(
|
|
tagPattern: string,
|
|
argPattern: string,
|
|
): Array<{ fullMatch: string; groups: string[]; index: number }> {
|
|
const found: Array<{ fullMatch: string; groups: string[]; index: number }> = [];
|
|
|
|
// Consume complete braced tags so strings and comments cannot start a
|
|
// second match inside the same tag. Skip non-executing regions entirely.
|
|
const blocks: Array<{ bodyStart: number; body: string }> = [];
|
|
const tags = /\{%[-]?\s*(\w+|#)([\s\S]*?)[-]?%\}/g;
|
|
const braced = new RegExp(`^\\{%[-]?\\s*(${tagPattern})\\s+${argPattern}`);
|
|
let tag;
|
|
while ((tag = tags.exec(this.source)) !== null) {
|
|
const silent = this.silentRegionAt(tag.index);
|
|
if (silent) {
|
|
tags.lastIndex = silent[1];
|
|
continue;
|
|
}
|
|
const name = tag[1]!;
|
|
if (name === 'liquid') {
|
|
blocks.push({
|
|
bodyStart: tag.index + tag[0].indexOf(name) + name.length,
|
|
body: tag[2]!,
|
|
});
|
|
continue;
|
|
}
|
|
const match = braced.exec(tag[0]);
|
|
if (match) {
|
|
found.push({ fullMatch: match[0], groups: match.slice(1) as string[], index: tag.index });
|
|
}
|
|
}
|
|
|
|
/* Inside a `{% liquid %}` body each tag starts its own line. Anchoring on
|
|
the line start keeps prose, filters and inline `#` comments out. */
|
|
const bare = new RegExp(`^[ \\t]*(${tagPattern})[ \\t]+${argPattern}`);
|
|
for (const b of blocks) {
|
|
let offset = b.bodyStart;
|
|
let suppressed: string | undefined;
|
|
for (const line of b.body.split('\n')) {
|
|
const name = /^[ \t]*(\w+)/.exec(line)?.[1];
|
|
if (suppressed) {
|
|
if (name === `end${suppressed}`) suppressed = undefined;
|
|
} else if (name === 'comment' || name === 'raw') {
|
|
suppressed = name;
|
|
} else {
|
|
const inner = bare.exec(line);
|
|
if (inner) {
|
|
found.push({
|
|
fullMatch: inner[0].trimStart(),
|
|
groups: inner.slice(1) as string[],
|
|
index: offset + (inner[0].length - inner[0].trimStart().length),
|
|
});
|
|
}
|
|
}
|
|
offset += line.length + 1;
|
|
}
|
|
}
|
|
|
|
return found.sort((a, b) => a.index - b.index);
|
|
}
|
|
|
|
/**
|
|
* Extract {% render 'snippet' %} and {% include 'snippet' %} references
|
|
*/
|
|
private extractSnippetReferences(fileNodeId: string): void {
|
|
// Both spellings: {% render 'name' %} and a bare `render 'name'` line
|
|
// inside a {% liquid %} block.
|
|
for (const match of this.findTagOccurrences('render|include', `['"]([^'"]+)['"]`)) {
|
|
const fullMatch = match.fullMatch;
|
|
const [tagType, snippetName] = match.groups;
|
|
const line = this.getLineNumber(match.index);
|
|
|
|
// Create an import node for searchability
|
|
const importNodeId = this.nodeIds.generate(this.filePath, 'import', snippetName!, line, match.index - this.getLineStart(line));
|
|
const importNode: Node = {
|
|
id: importNodeId,
|
|
kind: 'import',
|
|
name: snippetName!,
|
|
qualifiedName: `${this.filePath}::import:${snippetName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
signature: fullMatch,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.nodes.push(importNode);
|
|
|
|
// Add containment edge from file to import
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: importNodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Create a component node for the snippet reference
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'component', `${tagType}:${snippetName}`, line, match.index - this.getLineStart(line));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'component',
|
|
name: snippetName!,
|
|
qualifiedName: `${this.filePath}::${tagType}:${snippetName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Add unresolved reference to the snippet file
|
|
this.unresolvedReferences.push({
|
|
fromNodeId: fileNodeId,
|
|
referenceName: `snippets/${snippetName}.liquid`,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: match.index - this.getLineStart(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% section 'name' %} and {% sections 'group' %} references. A
|
|
* section is `sections/<name>.liquid`; a section group, which a layout renders
|
|
* with the plural tag, is the JSON file `sections/<group>.json` that lists its
|
|
* sections.
|
|
*/
|
|
private extractSectionReferences(fileNodeId: string): void {
|
|
// Both spellings, as for render/include above.
|
|
for (const match of this.findTagOccurrences('sections?', `['"]([^'"]+)['"]`)) {
|
|
const fullMatch = match.fullMatch;
|
|
const [tagType, sectionName] = match.groups;
|
|
const line = this.getLineNumber(match.index);
|
|
|
|
// Create an import node for searchability
|
|
const importNodeId = this.nodeIds.generate(this.filePath, 'import', sectionName!, line, match.index - this.getLineStart(line));
|
|
const importNode: Node = {
|
|
id: importNodeId,
|
|
kind: 'import',
|
|
name: sectionName!,
|
|
qualifiedName: `${this.filePath}::import:${sectionName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
signature: fullMatch,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
this.nodes.push(importNode);
|
|
|
|
// Add containment edge from file to import
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: importNodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Create a component node for the section reference
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'component', `${tagType}:${sectionName}`, line, match.index - this.getLineStart(line));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'component',
|
|
name: sectionName!,
|
|
qualifiedName: `${this.filePath}::${tagType}:${sectionName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: match.index - this.getLineStart(line),
|
|
endColumn: match.index - this.getLineStart(line) + fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
|
|
// Add unresolved reference to the section file, or the group's JSON
|
|
this.unresolvedReferences.push({
|
|
fromNodeId: fileNodeId,
|
|
referenceName: `sections/${sectionName}.${tagType === 'sections' ? 'json' : 'liquid'}`,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: match.index - this.getLineStart(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% schema %}...{% endschema %} blocks
|
|
*/
|
|
private extractSchema(fileNodeId: string): void {
|
|
// Match {% schema %}...{% endschema %}
|
|
const schemaRegex = /\{%[-]?\s*schema\s*[-]?%\}([\s\S]*?)\{%[-]?\s*endschema\s*[-]?%\}/g;
|
|
let match;
|
|
|
|
while ((match = schemaRegex.exec(this.source)) !== null) {
|
|
// A schema inside {% comment %} or {% raw %} is an example, not the
|
|
// section's: look again after that region ends.
|
|
const silent = this.silentRegionAt(match.index);
|
|
if (silent) {
|
|
schemaRegex.lastIndex = silent[1];
|
|
continue;
|
|
}
|
|
const [fullMatch, schemaContent] = match;
|
|
const startLine = this.getLineNumber(match.index);
|
|
const endLine = this.getLineNumber(match.index + fullMatch.length);
|
|
|
|
// Try to parse the schema JSON to get the name
|
|
let schemaName = 'schema';
|
|
try {
|
|
const schemaJson = JSON.parse(schemaContent!);
|
|
if (schemaJson.name) {
|
|
// Shopify schema names can be translation objects like {"en": "...", "fr": "..."}
|
|
schemaName = typeof schemaJson.name === 'string'
|
|
? schemaJson.name
|
|
: schemaJson.name.en || Object.values(schemaJson.name)[0] as string || 'schema';
|
|
}
|
|
} catch {
|
|
// Schema isn't valid JSON, use default name
|
|
}
|
|
|
|
// Create a node for the schema
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'constant', `schema:${schemaName}`, startLine, match.index - this.getLineStart(startLine));
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'constant',
|
|
name: schemaName,
|
|
qualifiedName: `${this.filePath}::schema:${schemaName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine,
|
|
endLine,
|
|
startColumn: match.index - this.getLineStart(startLine),
|
|
endColumn: 0,
|
|
// SECURITY (#383): don't dump the raw {% schema %} JSON (section settings
|
|
// + default values) into the docstring — the schema name is already in
|
|
// `name`, so the data block adds nothing but a potential leak of any
|
|
// IDs/endpoints/keys a developer placed in setting defaults.
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract {% assign var = value %} and {% capture var %}…{% endcapture %}
|
|
* declarations as variables
|
|
*/
|
|
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*=`)) {
|
|
this.addVariable(fileNodeId, match.groups[1]!, match);
|
|
}
|
|
// A capture declares its name as an assign does, and Liquid reads a quoted
|
|
// name (`{% capture 'title' %}`) as the name inside the quotes. A name that
|
|
// goes on with `-` or `.`, which the assign rule doesn't read either, gets
|
|
// no node rather than a cut-short one. Captures come after assigns, so an
|
|
// assign keeps its id when a capture of the same name shares its line.
|
|
for (const match of this.findTagOccurrences('capture', `(?:'(\\w+)'|"(\\w+)"|(\\w+))(?=\\s|-?%\\}|$)`)) {
|
|
const [, singleQuoted, doubleQuoted, bare] = match.groups;
|
|
this.addVariable(fileNodeId, (singleQuoted ?? doubleQuoted ?? bare)!, match);
|
|
}
|
|
}
|
|
|
|
/** A `variable` node, contained by the file, for a declaration that `findTagOccurrences` found. */
|
|
private addVariable(fileNodeId: string, variableName: string, match: { fullMatch: string; index: number }): void {
|
|
const line = this.getLineNumber(match.index);
|
|
const column = match.index - this.getLineStart(line);
|
|
const nodeId = this.nodeIds.generate(this.filePath, 'variable', variableName, line, column);
|
|
|
|
const node: Node = {
|
|
id: nodeId,
|
|
kind: 'variable',
|
|
name: variableName,
|
|
qualifiedName: `${this.filePath}::${variableName}`,
|
|
filePath: this.filePath,
|
|
language: 'liquid',
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: column,
|
|
endColumn: column + match.fullMatch.length,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
this.nodes.push(node);
|
|
|
|
// Add containment edge from file
|
|
this.edges.push({
|
|
source: fileNodeId,
|
|
target: nodeId,
|
|
kind: 'contains',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the line number for a character index
|
|
*/
|
|
private getLineNumber(index: number): number {
|
|
const substring = this.source.substring(0, index);
|
|
return (substring.match(/\n/g) || []).length + 1;
|
|
}
|
|
|
|
/**
|
|
* Get the character index of the start of a line
|
|
*/
|
|
private getLineStart(lineNumber: number): number {
|
|
const lines = this.source.split('\n');
|
|
let index = 0;
|
|
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
|
|
index += lines[i]!.length + 1; // +1 for newline
|
|
}
|
|
return index;
|
|
}
|
|
}
|