| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
- import { generateNodeId } 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' %})
- * - Snippet references ({% render 'name' %} and {% include 'name' %})
- * - Schema blocks ({% schema %}...{% endschema %})
- */
- export class LiquidExtractor {
- private filePath: string;
- private source: string;
- private nodes: Node[] = [];
- private edges: Edge[] = [];
- private unresolvedReferences: UnresolvedReference[] = [];
- private errors: ExtractionError[] = [];
- 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();
- // 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 statements 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;
- }
- /**
- * Extract {% render 'snippet' %} and {% include 'snippet' %} references
- */
- private extractSnippetReferences(fileNodeId: string): void {
- // Match {% render 'name' %} or {% include 'name' %} with optional parameters
- const renderRegex = /\{%[-]?\s*(render|include)\s+['"]([^'"]+)['"]/g;
- let match;
- while ((match = renderRegex.exec(this.source)) !== null) {
- const [fullMatch, tagType, snippetName] = match;
- const line = this.getLineNumber(match.index);
- // Create an import node for searchability
- const importNodeId = generateNodeId(this.filePath, 'import', snippetName!, 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 = generateNodeId(this.filePath, 'component', `${tagType}:${snippetName}`, 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' %} references
- */
- private extractSectionReferences(fileNodeId: string): void {
- // Match {% section 'name' %}
- const sectionRegex = /\{%[-]?\s*section\s+['"]([^'"]+)['"]/g;
- let match;
- while ((match = sectionRegex.exec(this.source)) !== null) {
- const [fullMatch, sectionName] = match;
- const line = this.getLineNumber(match.index);
- // Create an import node for searchability
- const importNodeId = generateNodeId(this.filePath, 'import', sectionName!, 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 = generateNodeId(this.filePath, 'component', `section:${sectionName}`, line);
- const node: Node = {
- id: nodeId,
- kind: 'component',
- name: sectionName!,
- qualifiedName: `${this.filePath}::section:${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
- this.unresolvedReferences.push({
- fromNodeId: fileNodeId,
- referenceName: `sections/${sectionName}.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) {
- 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) {
- schemaName = schemaJson.name;
- }
- } catch {
- // Schema isn't valid JSON, use default name
- }
- // Create a node for the schema
- const nodeId = generateNodeId(this.filePath, 'constant', `schema:${schemaName}`, 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,
- docstring: schemaContent?.trim().substring(0, 200), // Store first 200 chars as docstring
- 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 %} statements
- */
- private extractAssignments(fileNodeId: string): void {
- // Match {% assign variable_name = ... %}
- const assignRegex = /\{%[-]?\s*assign\s+(\w+)\s*=/g;
- let match;
- while ((match = assignRegex.exec(this.source)) !== null) {
- const [, variableName] = match;
- const line = this.getLineNumber(match.index);
- // Create a variable node
- const nodeId = generateNodeId(this.filePath, 'variable', variableName!, line);
- const node: Node = {
- id: nodeId,
- kind: 'variable',
- name: variableName!,
- qualifiedName: `${this.filePath}::${variableName}`,
- filePath: this.filePath,
- language: 'liquid',
- startLine: line,
- endLine: line,
- startColumn: match.index - this.getLineStart(line),
- endColumn: match.index - this.getLineStart(line) + match[0].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;
- }
- }
|