vue-extractor.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. import { TreeSitterExtractor } from './tree-sitter';
  4. import { isLanguageSupported } from './grammars';
  5. /**
  6. * Vue built-in components — skipped so a `<Transition>` / `<KeepAlive>` in the
  7. * template doesn't become a phantom reference to a user component. Checked
  8. * AFTER kebab→Pascal conversion, so `<keep-alive>` is caught here too.
  9. */
  10. const VUE_BUILTIN_COMPONENTS = new Set([
  11. 'Transition',
  12. 'TransitionGroup',
  13. 'KeepAlive',
  14. 'Suspense',
  15. 'Teleport',
  16. 'Component',
  17. 'Slot',
  18. ]);
  19. /** `my-component` → `MyComponent` (Vue allows either form in templates). */
  20. function kebabToPascal(name: string): string {
  21. return name
  22. .split('-')
  23. .map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : ''))
  24. .join('');
  25. }
  26. /**
  27. * VueExtractor - Extracts code relationships from Vue Single-File Component files
  28. *
  29. * Vue SFCs are multi-language (script + template + style). Rather than
  30. * parsing the full Vue grammar, we extract the <script> block content
  31. * and delegate it to the TypeScript/JavaScript TreeSitterExtractor.
  32. *
  33. * Every .vue file produces a component node (Vue components are always importable).
  34. */
  35. export class VueExtractor {
  36. private filePath: string;
  37. private source: string;
  38. private nodes: Node[] = [];
  39. private edges: Edge[] = [];
  40. private unresolvedReferences: UnresolvedReference[] = [];
  41. private errors: ExtractionError[] = [];
  42. constructor(filePath: string, source: string) {
  43. this.filePath = filePath;
  44. this.source = source;
  45. }
  46. /**
  47. * Extract from Vue source
  48. */
  49. extract(): ExtractionResult {
  50. const startTime = Date.now();
  51. try {
  52. // Create component node for the .vue file itself
  53. const componentNode = this.createComponentNode();
  54. // Extract and process script blocks
  55. const scriptBlocks = this.extractScriptBlocks();
  56. for (const block of scriptBlocks) {
  57. this.processScriptBlock(block, componentNode.id);
  58. }
  59. // Extract component usages from the <template> (<ComponentName>).
  60. // Without this, a Vue component used only in another component's
  61. // markup (incl. through a barrel import) is invisible to callers /
  62. // impact (#629 follow-up).
  63. this.extractTemplateComponents(componentNode.id);
  64. } catch (error) {
  65. this.errors.push({
  66. message: `Vue extraction error: ${error instanceof Error ? error.message : String(error)}`,
  67. severity: 'error',
  68. });
  69. }
  70. return {
  71. nodes: this.nodes,
  72. edges: this.edges,
  73. unresolvedReferences: this.unresolvedReferences,
  74. errors: this.errors,
  75. durationMs: Date.now() - startTime,
  76. };
  77. }
  78. /**
  79. * Create a component node for the .vue file
  80. */
  81. private createComponentNode(): Node {
  82. const lines = this.source.split('\n');
  83. const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
  84. const componentName = fileName.replace(/\.vue$/, '');
  85. const id = generateNodeId(this.filePath, 'component', componentName, 1);
  86. const node: Node = {
  87. id,
  88. kind: 'component',
  89. name: componentName,
  90. qualifiedName: `${this.filePath}::${componentName}`,
  91. filePath: this.filePath,
  92. language: 'vue',
  93. startLine: 1,
  94. endLine: lines.length,
  95. startColumn: 0,
  96. endColumn: lines[lines.length - 1]?.length || 0,
  97. isExported: true, // Vue components are always importable
  98. updatedAt: Date.now(),
  99. };
  100. this.nodes.push(node);
  101. return node;
  102. }
  103. /**
  104. * Extract <script> and <script setup> blocks from the Vue source
  105. */
  106. private extractScriptBlocks(): Array<{
  107. content: string;
  108. startLine: number;
  109. isSetup: boolean;
  110. isTypeScript: boolean;
  111. }> {
  112. const blocks: Array<{
  113. content: string;
  114. startLine: number;
  115. isSetup: boolean;
  116. isTypeScript: boolean;
  117. }> = [];
  118. const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
  119. let match;
  120. while ((match = scriptRegex.exec(this.source)) !== null) {
  121. const attrs = match[1] || '';
  122. const content = match.groups?.content || match[2] || '';
  123. // Detect TypeScript from lang attribute
  124. const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
  125. // Detect <script setup>
  126. const isSetup = /\bsetup\b/.test(attrs);
  127. // Calculate start line of the script content (line after <script>)
  128. const beforeScript = this.source.substring(0, match.index);
  129. const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
  130. // The content starts on the line after the opening <script> tag
  131. const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
  132. const openingTagLines = (openingTag.match(/\n/g) || []).length;
  133. const contentStartLine = scriptTagLine + openingTagLines + 1; // 0-indexed line
  134. blocks.push({
  135. content,
  136. startLine: contentStartLine,
  137. isSetup,
  138. isTypeScript,
  139. });
  140. }
  141. return blocks;
  142. }
  143. /**
  144. * Process a script block by delegating to TreeSitterExtractor
  145. */
  146. private processScriptBlock(
  147. block: { content: string; startLine: number; isSetup: boolean; isTypeScript: boolean },
  148. componentNodeId: string
  149. ): void {
  150. const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
  151. // Check if the script language parser is available
  152. if (!isLanguageSupported(scriptLanguage)) {
  153. this.errors.push({
  154. message: `Parser for ${scriptLanguage} not available, cannot parse Vue script block`,
  155. severity: 'warning',
  156. });
  157. return;
  158. }
  159. // Delegate to TreeSitterExtractor
  160. const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
  161. const result = extractor.extract();
  162. // Offset line numbers from script block back to .vue file positions
  163. for (const node of result.nodes) {
  164. node.startLine += block.startLine;
  165. node.endLine += block.startLine;
  166. node.language = 'vue'; // Mark as vue, not TS/JS
  167. this.nodes.push(node);
  168. // Add containment edge from component to this node
  169. this.edges.push({
  170. source: componentNodeId,
  171. target: node.id,
  172. kind: 'contains',
  173. });
  174. }
  175. // Offset edges (they reference line numbers)
  176. for (const edge of result.edges) {
  177. if (edge.line) {
  178. edge.line += block.startLine;
  179. }
  180. this.edges.push(edge);
  181. }
  182. // Offset unresolved references
  183. for (const ref of result.unresolvedReferences) {
  184. ref.line += block.startLine;
  185. ref.filePath = this.filePath;
  186. ref.language = 'vue';
  187. this.unresolvedReferences.push(ref);
  188. }
  189. // Carry over errors
  190. for (const error of result.errors) {
  191. if (error.line) {
  192. error.line += block.startLine;
  193. }
  194. this.errors.push(error);
  195. }
  196. }
  197. /**
  198. * Extract component usages from the Vue `<template>`.
  199. *
  200. * PascalCase tags (`<Modal>`, `<Button />`) and kebab-case tags
  201. * (`<my-button>`) both represent component instantiations — analogous to
  202. * function calls in imperative code. Capturing them creates parent→child
  203. * component edges and lets `callers` / `impact` see a component that is
  204. * only ever used in markup. Vue's extractor previously parsed only the
  205. * `<script>` block, so these usages produced no edge at all (#629).
  206. *
  207. * HTML elements (lowercase, no hyphen) and Vue built-ins are skipped.
  208. * Unmatched names create no edge during resolution, so converting
  209. * kebab-case is safe even for native custom elements.
  210. */
  211. private extractTemplateComponents(componentNodeId: string): void {
  212. // Ranges covered by <script> / <style> blocks — skip them so script
  213. // identifiers and CSS selectors aren't mistaken for template tags. This
  214. // also correctly handles nested <template> tags (v-if / slots), which a
  215. // single non-greedy <template>…</template> match would mis-bound.
  216. const coveredRanges: Array<[number, number]> = [];
  217. const blockRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
  218. let blockMatch;
  219. while ((blockMatch = blockRegex.exec(this.source)) !== null) {
  220. const startLine = (this.source.substring(0, blockMatch.index).match(/\n/g) || []).length;
  221. const endLine = startLine + (blockMatch[0].match(/\n/g) || []).length;
  222. coveredRanges.push([startLine, endLine]);
  223. }
  224. const lines = this.source.split('\n');
  225. // Opening / self-closing tags (closing `</Foo>` starts with `</`, so the
  226. // leading `<` followed by a name letter won't match it).
  227. const tagRegex = /<([A-Za-z][A-Za-z0-9_-]*)\b/g;
  228. for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
  229. if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
  230. const line = lines[lineIdx]!;
  231. let match;
  232. while ((match = tagRegex.exec(line)) !== null) {
  233. const raw = match[1]!;
  234. let componentName: string;
  235. if (/^[A-Z]/.test(raw)) {
  236. componentName = raw; // PascalCase component
  237. } else if (raw.includes('-')) {
  238. componentName = kebabToPascal(raw); // kebab-case component
  239. } else {
  240. continue; // lowercase, no hyphen → native HTML element
  241. }
  242. if (VUE_BUILTIN_COMPONENTS.has(componentName)) continue;
  243. this.unresolvedReferences.push({
  244. fromNodeId: componentNodeId,
  245. referenceName: componentName,
  246. referenceKind: 'references',
  247. line: lineIdx + 1, // 1-indexed
  248. column: match.index + 1,
  249. filePath: this.filePath,
  250. language: 'vue',
  251. });
  252. }
  253. }
  254. }
  255. }