astro-extractor.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. import { TreeSitterExtractor } from './tree-sitter';
  4. import { isLanguageSupported } from './grammars';
  5. /**
  6. * Astro built-in components — compiler-provided (`<Fragment>`) or shipped by
  7. * `astro:components` (`<Code>`, `<Debug>`), not user code.
  8. */
  9. const ASTRO_BUILTIN_COMPONENTS = new Set(['Fragment', 'Code', 'Debug']);
  10. /**
  11. * AstroExtractor - Extracts code relationships from Astro component files
  12. *
  13. * Astro files are multi-language: a TypeScript frontmatter block fenced by
  14. * `---` lines, a JSX-like HTML template, and optional <script>/<style> blocks.
  15. * Rather than parsing a full Astro grammar, we extract the frontmatter and
  16. * <script> contents and delegate them to the TypeScript TreeSitterExtractor
  17. * (Astro processes both as TypeScript by default — no `lang` attr needed).
  18. *
  19. * Also extracts function calls from template expressions (`{fn(...)}`) and
  20. * component usages (`<PascalCase>`) so cross-file edges are captured even
  21. * when the only reference lives in markup.
  22. *
  23. * Every .astro file produces a component node (Astro components are always
  24. * importable).
  25. */
  26. export class AstroExtractor {
  27. private filePath: string;
  28. private source: string;
  29. private nodes: Node[] = [];
  30. private edges: Edge[] = [];
  31. private unresolvedReferences: UnresolvedReference[] = [];
  32. private errors: ExtractionError[] = [];
  33. constructor(filePath: string, source: string) {
  34. this.filePath = filePath;
  35. this.source = source;
  36. }
  37. /**
  38. * Extract from Astro source
  39. */
  40. extract(): ExtractionResult {
  41. const startTime = Date.now();
  42. try {
  43. // Create component node for the .astro file itself
  44. const componentNode = this.createComponentNode();
  45. // Extract and process the frontmatter block (--- fenced, TypeScript)
  46. const frontmatter = this.extractFrontmatter();
  47. if (frontmatter) {
  48. this.processScriptContent(frontmatter, componentNode.id, 'frontmatter');
  49. }
  50. // Extract and process <script> blocks (client-side, TypeScript-capable)
  51. for (const block of this.extractScriptBlocks()) {
  52. this.processScriptContent(block, componentNode.id, 'script');
  53. }
  54. // Ranges the template scans must skip: frontmatter + <script>/<style>
  55. const coveredRanges = this.getCoveredRanges(frontmatter);
  56. // Extract function calls from template expressions ({fn(...)})
  57. this.extractTemplateCalls(componentNode.id, coveredRanges);
  58. // Extract component usages from template (<ComponentName>)
  59. this.extractTemplateComponents(componentNode.id, coveredRanges);
  60. } catch (error) {
  61. this.errors.push({
  62. message: `Astro extraction error: ${error instanceof Error ? error.message : String(error)}`,
  63. severity: 'error',
  64. code: 'parse_error',
  65. });
  66. }
  67. return {
  68. nodes: this.nodes,
  69. edges: this.edges,
  70. unresolvedReferences: this.unresolvedReferences,
  71. errors: this.errors,
  72. durationMs: Date.now() - startTime,
  73. };
  74. }
  75. /**
  76. * Create a component node for the .astro file
  77. */
  78. private createComponentNode(): Node {
  79. const lines = this.source.split('\n');
  80. const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
  81. const componentName = fileName.replace(/\.astro$/, '');
  82. const id = generateNodeId(this.filePath, 'component', componentName, 1);
  83. const node: Node = {
  84. id,
  85. kind: 'component',
  86. name: componentName,
  87. qualifiedName: `${this.filePath}::${componentName}`,
  88. filePath: this.filePath,
  89. language: 'astro',
  90. startLine: 1,
  91. endLine: lines.length,
  92. startColumn: 0,
  93. endColumn: lines[lines.length - 1]?.length || 0,
  94. isExported: true, // Astro components are always importable
  95. updatedAt: Date.now(),
  96. };
  97. this.nodes.push(node);
  98. return node;
  99. }
  100. /**
  101. * Extract the frontmatter block: the content between the opening `---`
  102. * fence (first non-blank line of the file) and the closing `---` fence.
  103. * An unclosed fence is treated as "no frontmatter" rather than swallowing
  104. * the whole template as TypeScript.
  105. *
  106. * Returns the content plus its 0-indexed start line, or null.
  107. */
  108. private extractFrontmatter(): { content: string; startLine: number; endLine: number } | null {
  109. const lines = this.source.split('\n');
  110. // Opening fence must be the first non-blank line
  111. let openIdx = -1;
  112. for (let i = 0; i < lines.length; i++) {
  113. const trimmed = lines[i]!.trim();
  114. if (trimmed === '') continue;
  115. if (trimmed === '---') openIdx = i;
  116. break;
  117. }
  118. if (openIdx === -1) return null;
  119. // Closing fence
  120. let closeIdx = -1;
  121. for (let i = openIdx + 1; i < lines.length; i++) {
  122. if (lines[i]!.trim() === '---') {
  123. closeIdx = i;
  124. break;
  125. }
  126. }
  127. if (closeIdx === -1) return null;
  128. return {
  129. content: lines.slice(openIdx + 1, closeIdx).join('\n'),
  130. startLine: openIdx + 1, // 0-indexed line where content starts
  131. endLine: closeIdx, // 0-indexed line of the closing fence
  132. };
  133. }
  134. /**
  135. * Extract <script> blocks from the template portion
  136. */
  137. private extractScriptBlocks(): Array<{ content: string; startLine: number }> {
  138. const blocks: Array<{ content: string; startLine: number }> = [];
  139. const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
  140. let match;
  141. while ((match = scriptRegex.exec(this.source)) !== null) {
  142. const content = match.groups?.content || match[2] || '';
  143. // Calculate the 0-indexed line where the content begins. The content
  144. // starts right after the opening tag's `>` — its leading `\n` is part
  145. // of the content, so relative line 1 sits ON the tag's closing line
  146. // (do not add 1 here; that double-counts the embedded newline).
  147. const beforeScript = this.source.substring(0, match.index);
  148. const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
  149. const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
  150. const openingTagLines = (openingTag.match(/\n/g) || []).length;
  151. const contentStartLine = scriptTagLine + openingTagLines; // 0-indexed
  152. blocks.push({ content, startLine: contentStartLine });
  153. }
  154. return blocks;
  155. }
  156. /**
  157. * Process frontmatter / script content by delegating to TreeSitterExtractor.
  158. * Astro treats both as TypeScript by default.
  159. */
  160. private processScriptContent(
  161. block: { content: string; startLine: number },
  162. componentNodeId: string,
  163. label: 'frontmatter' | 'script'
  164. ): void {
  165. if (!isLanguageSupported('typescript')) {
  166. this.errors.push({
  167. message: `Parser for typescript not available, cannot parse Astro ${label} block`,
  168. severity: 'warning',
  169. });
  170. return;
  171. }
  172. // Delegate to TreeSitterExtractor
  173. const extractor = new TreeSitterExtractor(this.filePath, block.content, 'typescript');
  174. const result = extractor.extract();
  175. // Offset line numbers from the block back to .astro file positions
  176. for (const node of result.nodes) {
  177. node.startLine += block.startLine;
  178. node.endLine += block.startLine;
  179. node.language = 'astro'; // Mark as astro, not TS
  180. this.nodes.push(node);
  181. // Add containment edge from component to this node
  182. this.edges.push({
  183. source: componentNodeId,
  184. target: node.id,
  185. kind: 'contains',
  186. });
  187. }
  188. // Offset edges (they reference line numbers)
  189. for (const edge of result.edges) {
  190. if (edge.line) {
  191. edge.line += block.startLine;
  192. }
  193. this.edges.push(edge);
  194. }
  195. // Offset unresolved references
  196. for (const ref of result.unresolvedReferences) {
  197. ref.line += block.startLine;
  198. ref.filePath = this.filePath;
  199. ref.language = 'astro';
  200. this.unresolvedReferences.push(ref);
  201. }
  202. // Carry over errors
  203. for (const error of result.errors) {
  204. if (error.line) {
  205. error.line += block.startLine;
  206. }
  207. this.errors.push(error);
  208. }
  209. }
  210. /**
  211. * Line ranges (0-indexed, inclusive) the template scans must skip:
  212. * the frontmatter block and <script>/<style> blocks.
  213. */
  214. private getCoveredRanges(
  215. frontmatter: { startLine: number; endLine: number } | null
  216. ): Array<[number, number]> {
  217. const coveredRanges: Array<[number, number]> = [];
  218. if (frontmatter) {
  219. // Cover from the opening fence line through the closing fence line
  220. coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]);
  221. }
  222. const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
  223. let tagMatch;
  224. while ((tagMatch = tagRegex.exec(this.source)) !== null) {
  225. const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
  226. const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
  227. coveredRanges.push([startLine, endLine]);
  228. }
  229. return coveredRanges;
  230. }
  231. /**
  232. * Extract function calls from Astro template expressions.
  233. *
  234. * Astro templates embed JSX-like expressions (`{formatDate(post.date)}`,
  235. * `class:list={cn(...)}`), so calls frequently live in markup rather than
  236. * the frontmatter. We scan template lines for `{expression}` groups and
  237. * extract call patterns from them. A `{` group left open at end-of-line
  238. * (the pervasive `{posts.map((post) => (` pattern) contributes the calls
  239. * on its opening line.
  240. */
  241. private extractTemplateCalls(
  242. componentNodeId: string,
  243. coveredRanges: Array<[number, number]>
  244. ): void {
  245. const lines = this.source.split('\n');
  246. // Complete groups: {...} — excluding JSX comments ({/* ... */})
  247. const exprRegex = /\{([^}/][^}]*)\}/g;
  248. // A group opened but not closed on this line
  249. const openExprRegex = /\{([^}/][^}]*)$/;
  250. for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
  251. if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
  252. const line = lines[lineIdx]!;
  253. const exprs: Array<{ text: string; offset: number }> = [];
  254. let exprMatch;
  255. while ((exprMatch = exprRegex.exec(line)) !== null) {
  256. exprs.push({ text: exprMatch[1]!, offset: exprMatch.index });
  257. }
  258. const openMatch = openExprRegex.exec(line.replace(exprRegex, ''));
  259. if (openMatch) {
  260. exprs.push({ text: openMatch[1]!, offset: line.lastIndexOf('{') });
  261. }
  262. for (const expr of exprs) {
  263. // Extract function calls: identifiers followed by (
  264. // Matches: cn(...), formatDate(...), obj.method(...)
  265. const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g;
  266. let callMatch;
  267. while ((callMatch = callRegex.exec(expr.text)) !== null) {
  268. const calleeName = callMatch[1]!;
  269. // Skip control-flow keywords valid inside expressions
  270. if (calleeName === 'if' || calleeName === 'await' || calleeName === 'function') continue;
  271. this.unresolvedReferences.push({
  272. fromNodeId: componentNodeId,
  273. referenceName: calleeName,
  274. referenceKind: 'calls',
  275. line: lineIdx + 1, // 1-indexed
  276. column: expr.offset + callMatch.index,
  277. filePath: this.filePath,
  278. language: 'astro',
  279. });
  280. }
  281. }
  282. }
  283. }
  284. /**
  285. * Extract component usages from the Astro template.
  286. *
  287. * PascalCase tags like <Layout>, <PostCard /> represent component
  288. * instantiations — analogous to function calls in imperative code.
  289. * Lowercase tags are native HTML (Astro does not register kebab-case
  290. * components the way Vue does, so those are real custom elements and
  291. * are skipped).
  292. */
  293. private extractTemplateComponents(
  294. componentNodeId: string,
  295. coveredRanges: Array<[number, number]>
  296. ): void {
  297. const lines = this.source.split('\n');
  298. // Opening/self-closing tags (closing tags </Foo> start with </ so won't match)
  299. const componentTagRegex = /<([A-Z][a-zA-Z0-9_$]*)\b/g;
  300. for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
  301. if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
  302. const line = lines[lineIdx]!;
  303. let match;
  304. while ((match = componentTagRegex.exec(line)) !== null) {
  305. const componentName = match[1]!;
  306. if (ASTRO_BUILTIN_COMPONENTS.has(componentName)) continue;
  307. this.unresolvedReferences.push({
  308. fromNodeId: componentNodeId,
  309. referenceName: componentName,
  310. referenceKind: 'references',
  311. line: lineIdx + 1, // 1-indexed
  312. column: match.index + 1,
  313. filePath: this.filePath,
  314. language: 'astro',
  315. });
  316. }
  317. }
  318. }
  319. }