|
@@ -1216,6 +1216,302 @@ export class TreeSitterExtractor {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 [, tagType, snippetName] = match;
|
|
|
|
|
+ const line = this.getLineNumber(match.index);
|
|
|
|
|
+
|
|
|
|
|
+ // 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) + match[0].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 [, sectionName] = match;
|
|
|
|
|
+ const line = this.getLineNumber(match.index);
|
|
|
|
|
+
|
|
|
|
|
+ // 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) + match[0].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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Extract nodes and edges from source code
|
|
* Extract nodes and edges from source code
|
|
|
*/
|
|
*/
|
|
@@ -1224,6 +1520,14 @@ export function extractFromSource(
|
|
|
source: string,
|
|
source: string,
|
|
|
language?: Language
|
|
language?: Language
|
|
|
): ExtractionResult {
|
|
): ExtractionResult {
|
|
|
- const extractor = new TreeSitterExtractor(filePath, source, language);
|
|
|
|
|
|
|
+ const detectedLanguage = language || detectLanguage(filePath);
|
|
|
|
|
+
|
|
|
|
|
+ // Use custom extractor for Liquid
|
|
|
|
|
+ if (detectedLanguage === 'liquid') {
|
|
|
|
|
+ const extractor = new LiquidExtractor(filePath, source);
|
|
|
|
|
+ return extractor.extract();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage);
|
|
|
return extractor.extract();
|
|
return extractor.extract();
|
|
|
}
|
|
}
|