liquid-extractor.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. /**
  4. * LiquidExtractor - Extracts relationships from Liquid template files
  5. *
  6. * Liquid is a templating language (used by Shopify, Jekyll, etc.) that doesn't
  7. * have traditional functions or classes. Instead, we extract:
  8. * - Section references ({% section 'name' %})
  9. * - Snippet references ({% render 'name' %} and {% include 'name' %})
  10. * - Schema blocks ({% schema %}...{% endschema %})
  11. */
  12. export class LiquidExtractor {
  13. private filePath: string;
  14. private source: string;
  15. private nodes: Node[] = [];
  16. private edges: Edge[] = [];
  17. private unresolvedReferences: UnresolvedReference[] = [];
  18. private errors: ExtractionError[] = [];
  19. constructor(filePath: string, source: string) {
  20. this.filePath = filePath;
  21. this.source = source;
  22. }
  23. /**
  24. * Extract from Liquid source
  25. */
  26. extract(): ExtractionResult {
  27. const startTime = Date.now();
  28. try {
  29. // Create file node
  30. const fileNode = this.createFileNode();
  31. // Extract render/include statements (snippet references)
  32. this.extractSnippetReferences(fileNode.id);
  33. // Extract section references
  34. this.extractSectionReferences(fileNode.id);
  35. // Extract schema block
  36. this.extractSchema(fileNode.id);
  37. // Extract assign statements as variables
  38. this.extractAssignments(fileNode.id);
  39. } catch (error) {
  40. this.errors.push({
  41. message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
  42. severity: 'error',
  43. code: 'parse_error',
  44. });
  45. }
  46. return {
  47. nodes: this.nodes,
  48. edges: this.edges,
  49. unresolvedReferences: this.unresolvedReferences,
  50. errors: this.errors,
  51. durationMs: Date.now() - startTime,
  52. };
  53. }
  54. /**
  55. * Create a file node for the Liquid template
  56. */
  57. private createFileNode(): Node {
  58. const lines = this.source.split('\n');
  59. const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
  60. const fileNode: Node = {
  61. id,
  62. kind: 'file',
  63. name: this.filePath.split('/').pop() || this.filePath,
  64. qualifiedName: this.filePath,
  65. filePath: this.filePath,
  66. language: 'liquid',
  67. startLine: 1,
  68. endLine: lines.length,
  69. startColumn: 0,
  70. endColumn: lines[lines.length - 1]?.length || 0,
  71. updatedAt: Date.now(),
  72. };
  73. this.nodes.push(fileNode);
  74. return fileNode;
  75. }
  76. /**
  77. * Extract {% render 'snippet' %} and {% include 'snippet' %} references
  78. */
  79. private extractSnippetReferences(fileNodeId: string): void {
  80. // Match {% render 'name' %} or {% include 'name' %} with optional parameters
  81. const renderRegex = /\{%[-]?\s*(render|include)\s+['"]([^'"]+)['"]/g;
  82. let match;
  83. while ((match = renderRegex.exec(this.source)) !== null) {
  84. const [fullMatch, tagType, snippetName] = match;
  85. const line = this.getLineNumber(match.index);
  86. // Create an import node for searchability
  87. const importNodeId = generateNodeId(this.filePath, 'import', snippetName!, line);
  88. const importNode: Node = {
  89. id: importNodeId,
  90. kind: 'import',
  91. name: snippetName!,
  92. qualifiedName: `${this.filePath}::import:${snippetName}`,
  93. filePath: this.filePath,
  94. language: 'liquid',
  95. signature: fullMatch,
  96. startLine: line,
  97. endLine: line,
  98. startColumn: match.index - this.getLineStart(line),
  99. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  100. updatedAt: Date.now(),
  101. };
  102. this.nodes.push(importNode);
  103. // Add containment edge from file to import
  104. this.edges.push({
  105. source: fileNodeId,
  106. target: importNodeId,
  107. kind: 'contains',
  108. });
  109. // Create a component node for the snippet reference
  110. const nodeId = generateNodeId(this.filePath, 'component', `${tagType}:${snippetName}`, line);
  111. const node: Node = {
  112. id: nodeId,
  113. kind: 'component',
  114. name: snippetName!,
  115. qualifiedName: `${this.filePath}::${tagType}:${snippetName}`,
  116. filePath: this.filePath,
  117. language: 'liquid',
  118. startLine: line,
  119. endLine: line,
  120. startColumn: match.index - this.getLineStart(line),
  121. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  122. updatedAt: Date.now(),
  123. };
  124. this.nodes.push(node);
  125. // Add containment edge from file
  126. this.edges.push({
  127. source: fileNodeId,
  128. target: nodeId,
  129. kind: 'contains',
  130. });
  131. // Add unresolved reference to the snippet file
  132. this.unresolvedReferences.push({
  133. fromNodeId: fileNodeId,
  134. referenceName: `snippets/${snippetName}.liquid`,
  135. referenceKind: 'references',
  136. line,
  137. column: match.index - this.getLineStart(line),
  138. });
  139. }
  140. }
  141. /**
  142. * Extract {% section 'name' %} references
  143. */
  144. private extractSectionReferences(fileNodeId: string): void {
  145. // Match {% section 'name' %}
  146. const sectionRegex = /\{%[-]?\s*section\s+['"]([^'"]+)['"]/g;
  147. let match;
  148. while ((match = sectionRegex.exec(this.source)) !== null) {
  149. const [fullMatch, sectionName] = match;
  150. const line = this.getLineNumber(match.index);
  151. // Create an import node for searchability
  152. const importNodeId = generateNodeId(this.filePath, 'import', sectionName!, line);
  153. const importNode: Node = {
  154. id: importNodeId,
  155. kind: 'import',
  156. name: sectionName!,
  157. qualifiedName: `${this.filePath}::import:${sectionName}`,
  158. filePath: this.filePath,
  159. language: 'liquid',
  160. signature: fullMatch,
  161. startLine: line,
  162. endLine: line,
  163. startColumn: match.index - this.getLineStart(line),
  164. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  165. updatedAt: Date.now(),
  166. };
  167. this.nodes.push(importNode);
  168. // Add containment edge from file to import
  169. this.edges.push({
  170. source: fileNodeId,
  171. target: importNodeId,
  172. kind: 'contains',
  173. });
  174. // Create a component node for the section reference
  175. const nodeId = generateNodeId(this.filePath, 'component', `section:${sectionName}`, line);
  176. const node: Node = {
  177. id: nodeId,
  178. kind: 'component',
  179. name: sectionName!,
  180. qualifiedName: `${this.filePath}::section:${sectionName}`,
  181. filePath: this.filePath,
  182. language: 'liquid',
  183. startLine: line,
  184. endLine: line,
  185. startColumn: match.index - this.getLineStart(line),
  186. endColumn: match.index - this.getLineStart(line) + fullMatch.length,
  187. updatedAt: Date.now(),
  188. };
  189. this.nodes.push(node);
  190. // Add containment edge from file
  191. this.edges.push({
  192. source: fileNodeId,
  193. target: nodeId,
  194. kind: 'contains',
  195. });
  196. // Add unresolved reference to the section file
  197. this.unresolvedReferences.push({
  198. fromNodeId: fileNodeId,
  199. referenceName: `sections/${sectionName}.liquid`,
  200. referenceKind: 'references',
  201. line,
  202. column: match.index - this.getLineStart(line),
  203. });
  204. }
  205. }
  206. /**
  207. * Extract {% schema %}...{% endschema %} blocks
  208. */
  209. private extractSchema(fileNodeId: string): void {
  210. // Match {% schema %}...{% endschema %}
  211. const schemaRegex = /\{%[-]?\s*schema\s*[-]?%\}([\s\S]*?)\{%[-]?\s*endschema\s*[-]?%\}/g;
  212. let match;
  213. while ((match = schemaRegex.exec(this.source)) !== null) {
  214. const [fullMatch, schemaContent] = match;
  215. const startLine = this.getLineNumber(match.index);
  216. const endLine = this.getLineNumber(match.index + fullMatch.length);
  217. // Try to parse the schema JSON to get the name
  218. let schemaName = 'schema';
  219. try {
  220. const schemaJson = JSON.parse(schemaContent!);
  221. if (schemaJson.name) {
  222. schemaName = schemaJson.name;
  223. }
  224. } catch {
  225. // Schema isn't valid JSON, use default name
  226. }
  227. // Create a node for the schema
  228. const nodeId = generateNodeId(this.filePath, 'constant', `schema:${schemaName}`, startLine);
  229. const node: Node = {
  230. id: nodeId,
  231. kind: 'constant',
  232. name: schemaName,
  233. qualifiedName: `${this.filePath}::schema:${schemaName}`,
  234. filePath: this.filePath,
  235. language: 'liquid',
  236. startLine,
  237. endLine,
  238. startColumn: match.index - this.getLineStart(startLine),
  239. endColumn: 0,
  240. docstring: schemaContent?.trim().substring(0, 200), // Store first 200 chars as docstring
  241. updatedAt: Date.now(),
  242. };
  243. this.nodes.push(node);
  244. // Add containment edge from file
  245. this.edges.push({
  246. source: fileNodeId,
  247. target: nodeId,
  248. kind: 'contains',
  249. });
  250. }
  251. }
  252. /**
  253. * Extract {% assign var = value %} statements
  254. */
  255. private extractAssignments(fileNodeId: string): void {
  256. // Match {% assign variable_name = ... %}
  257. const assignRegex = /\{%[-]?\s*assign\s+(\w+)\s*=/g;
  258. let match;
  259. while ((match = assignRegex.exec(this.source)) !== null) {
  260. const [, variableName] = match;
  261. const line = this.getLineNumber(match.index);
  262. // Create a variable node
  263. const nodeId = generateNodeId(this.filePath, 'variable', variableName!, line);
  264. const node: Node = {
  265. id: nodeId,
  266. kind: 'variable',
  267. name: variableName!,
  268. qualifiedName: `${this.filePath}::${variableName}`,
  269. filePath: this.filePath,
  270. language: 'liquid',
  271. startLine: line,
  272. endLine: line,
  273. startColumn: match.index - this.getLineStart(line),
  274. endColumn: match.index - this.getLineStart(line) + match[0].length,
  275. updatedAt: Date.now(),
  276. };
  277. this.nodes.push(node);
  278. // Add containment edge from file
  279. this.edges.push({
  280. source: fileNodeId,
  281. target: nodeId,
  282. kind: 'contains',
  283. });
  284. }
  285. }
  286. /**
  287. * Get the line number for a character index
  288. */
  289. private getLineNumber(index: number): number {
  290. const substring = this.source.substring(0, index);
  291. return (substring.match(/\n/g) || []).length + 1;
  292. }
  293. /**
  294. * Get the character index of the start of a line
  295. */
  296. private getLineStart(lineNumber: number): number {
  297. const lines = this.source.split('\n');
  298. let index = 0;
  299. for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
  300. index += lines[i]!.length + 1; // +1 for newline
  301. }
  302. return index;
  303. }
  304. }