diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl index 308603127..67a6c8937 100644 --- a/graph/javascript/engine/call-edge-generation/jsx.dl +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -131,12 +131,37 @@ jsx_call_renders(w, "func", f) :- jsx_vue_definer(w), call_arg(w, 0, a), expr_va jsx_vue_has_setup(o) :- expr_kind(_, "OBJECT_LITERAL", _, o), expr_child(_, o, "PROPERTY_KEY", _, key), expr_name(_, "setup", key). jsx_vue_has_setup(o) :- literal_owns_method(o, m), method_decl(_, "setup", _, _, _, _, _, m). +// ── Vue single-file components ────────────────────────────────────────────── +// A `.vue` template tag, ``, reaches this file as a JSX_ELEMENT (the parser +// writes it into the component's virtual script), so everything above applies to it. +// A definer call renders when its argument is a function or names setup/render — read +// off the literal's own keys, as jsx_vue_has_setup is. +jsx_vue_option(a, n) :- literal_owns_method(a, m), method_decl(_, n, _, _, _, _, _, m). +jsx_vue_option(a, n) :- expr_child(_, a, "PROPERTY_KEY", _, key), expr_name(_, n, key). +jsx_vue_definer_renders(w) :- jsx_vue_definer(w), call_arg(w, 0, a), expr_kind(_, "FUNCTION_EXPRESSION", _, a). +jsx_vue_definer_renders(w) :- jsx_vue_definer(w), call_arg(w, 0, a), jsx_vue_option(a, "setup"). +jsx_vue_definer_renders(w) :- jsx_vue_definer(w), call_arg(w, 0, a), jsx_vue_option(a, "render"). + +// A single-file component, `import Child from "./Child.vue"`: its script is the +// module's top-level code — a ` + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue new file mode 100644 index 000000000..4649dc066 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Child.vue @@ -0,0 +1,4 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue new file mode 100644 index 000000000..64fda779c --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Defined.vue @@ -0,0 +1,8 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue new file mode 100644 index 000000000..0b8279d65 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/Legacy.vue @@ -0,0 +1,6 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue b/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue new file mode 100644 index 000000000..01e940472 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/MyWidget.vue @@ -0,0 +1,5 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js b/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js new file mode 100644 index 000000000..919862997 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/badge.js @@ -0,0 +1 @@ +export function Badge() { return "b"; } diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js b/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js new file mode 100644 index 000000000..7e5362e83 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/components/store.js @@ -0,0 +1 @@ +export default { items: [] }; diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue b/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue new file mode 100644 index 000000000..16cdfe434 --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/views/Parent.vue @@ -0,0 +1,24 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue b/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue new file mode 100644 index 000000000..d9c302fcc --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/views/Shell.vue @@ -0,0 +1,12 @@ + + diff --git a/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js b/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js new file mode 100644 index 000000000..798b97d7a --- /dev/null +++ b/graph/test/javascript/cases/72-vue-component-tag/src/vite.config.js @@ -0,0 +1,4 @@ +import { fileURLToPath, URL } from "node:url"; +export default { + resolve: { alias: { "@": fileURLToPath(new URL("./components", import.meta.url)) } }, +}; diff --git a/graph/test/javascript/expected/69-vue-sfc.edges b/graph/test/javascript/expected/69-vue-sfc.edges index d7efd225a..47623a7d1 100644 --- a/graph/test/javascript/expected/69-vue-sfc.edges +++ b/graph/test/javascript/expected/69-vue-sfc.edges @@ -1,5 +1,5 @@ Comp.vue:10:34 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper -Comp.vue:10:6 FUNCTION_CALL Child -> known_edge child.js:1:1 Child -Comp.vue:11:6 FUNCTION_CALL MyBadge -> known_edge child.js:5:1 MyBadge +Comp.vue:10:6 JSX_ELEMENT Child(/**/) -> known_edge child.js:1:1 Child +Comp.vue:11:6 JSX_ELEMENT MyBadge(/**/) -> known_edge child.js:5:1 MyBadge Comp.vue:12:21 FUNCTION_CALL onSave -> known_edge Comp.vue:4:1 onSave Comp.vue:12:40 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper diff --git a/graph/test/javascript/expected/72-vue-component-tag.diag b/graph/test/javascript/expected/72-vue-component-tag.diag new file mode 100644 index 000000000..ef755ad8f --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.diag @@ -0,0 +1,12 @@ +import_cause components/Defined.vue:2:10 vue not_staged +import_cause views/Parent.vue:8:10 vue not_staged +import_cause views/Shell.vue:3:1 @/Gone.vue not_staged +import_cause vite.config.js:1:10 node:url builtin +import_cause vite.config.js:1:25 node:url builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved components/Defined.vue:3:16 FUNCTION_CALL defineComponent callee_untyped +unresolved components/MyWidget.vue:2:1 FUNCTION_CALL defineProps callee_untyped +unresolved views/Parent.vue:11:26 FUNCTION_CALL String no_target +unresolved views/Parent.vue:9:15 FUNCTION_CALL defineComponent callee_untyped +unresolved vite.config.js:3:28 FUNCTION_CALL fileURLToPath no_target +unresolved vite.config.js:3:42 CONSTRUCTOR_CALL URL no_target diff --git a/graph/test/javascript/expected/72-vue-component-tag.edges b/graph/test/javascript/expected/72-vue-component-tag.edges new file mode 100644 index 000000000..91c94a209 --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.edges @@ -0,0 +1,26 @@ +components/Aliased.vue:4:19 FUNCTION_CALL aliasedLocal -> known_edge components/Aliased.vue:2:1 aliasedLocal +components/Child.vue:4:18 FUNCTION_CALL childLocal -> known_edge components/Child.vue:2:1 childLocal +components/Defined.vue:3:16 FUNCTION_CALL defineComponent -> ambiguous_unknown - +components/Defined.vue:3:16 FUNCTION_CALL defineComponent -> callback_registered components/Defined.vue:4:3 setup +components/Defined.vue:4:20 FUNCTION_CALL definedSetup -> known_edge components/Defined.vue:6:1 definedSetup +components/Legacy.vue:4:78 FUNCTION_CALL legacyInit -> known_edge components/Legacy.vue:3:1 legacyInit +components/Legacy.vue:6:15 JSX_ELEMENT Child(/**/) -> known_edge components/Child.vue:1:1 +components/MyWidget.vue:2:1 FUNCTION_CALL defineProps -> ambiguous_unknown - +components/MyWidget.vue:5:21 FUNCTION_CALL widgetLabel -> known_edge components/MyWidget.vue:3:1 widgetLabel +views/Parent.vue:11:26 FUNCTION_CALL String -> ambient_terminal - +views/Parent.vue:15:6 JSX_ELEMENT Child(/**/) -> known_edge components/Child.vue:1:1 +views/Parent.vue:16:6 JSX_ELEMENT MyWidget(/**/) -> known_edge components/MyWidget.vue:1:1 +views/Parent.vue:17:6 JSX_ELEMENT Legacy(/**/) -> known_edge components/Legacy.vue:1:1 +views/Parent.vue:18:6 JSX_ELEMENT Defined(/**/) -> known_edge components/Defined.vue:4:3 setup +views/Parent.vue:19:6 JSX_ELEMENT Local(/**/) -> known_edge views/Parent.vue:9:33 setup +views/Parent.vue:20:6 JSX_ELEMENT Badge(/**/) -> known_edge components/badge.js:1:1 Badge +views/Parent.vue:21:6 JSX_ELEMENT Store(/**/) -> ambiguous_unknown - +views/Parent.vue:22:15 FUNCTION_CALL fmt -> known_edge views/Parent.vue:11:1 fmt +views/Parent.vue:9:15 FUNCTION_CALL defineComponent -> ambiguous_unknown - +views/Parent.vue:9:15 FUNCTION_CALL defineComponent -> callback_registered views/Parent.vue:9:33 setup +views/Parent.vue:9:50 FUNCTION_CALL localSetup -> known_edge views/Parent.vue:10:1 localSetup +views/Shell.vue:10:6 JSX_ELEMENT Badge(/**/) -> known_edge components/badge.js:1:1 Badge +views/Shell.vue:8:6 JSX_ELEMENT Aliased(/**/) -> known_edge components/Aliased.vue:1:1 +views/Shell.vue:9:6 JSX_ELEMENT Gone(/**/) -> ambiguous_unknown - +vite.config.js:3:28 FUNCTION_CALL fileURLToPath -> ambient_terminal - +vite.config.js:3:42 CONSTRUCTOR_CALL URL -> ambient_terminal - diff --git a/graph/test/javascript/expected/72-vue-component-tag.oracle b/graph/test/javascript/expected/72-vue-component-tag.oracle new file mode 100644 index 000000000..8675b52d3 --- /dev/null +++ b/graph/test/javascript/expected/72-vue-component-tag.oracle @@ -0,0 +1 @@ +# defects: 0 diff --git a/parser/src/parsers/javascript/extractors/js-expression-extractor.ts b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts index 0429800dc..5ff2128c1 100644 --- a/parser/src/parsers/javascript/extractors/js-expression-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts @@ -33,6 +33,7 @@ import { rangeOf, bindingPathOf, } from '@/utils/javascript'; +import { isVueTemplateTag } from '@/utils/vue-sfc'; /** * `js_expression` and `js_call_site` — the spine. @@ -310,6 +311,10 @@ export class JsExpressionExtractor { child(node.right, JsEdgeRole.OPERAND, 1); return; } + if (isVueTemplateTag(node)) { + this.emitJsxChildren(node, row, next, ownerMethodHash); + return; + } if (ts.isCallExpression(node)) { this.emitCallChildren(node, row, next, rootContext, ownerMethodHash); return; @@ -1239,6 +1244,9 @@ function expressionKindOf(node: ts.Expression): JsExpressionKind | undefined { if (isModuleEdgeCall(node)) { return JsExpressionKind.MODULE_EDGE_CALL; } + if (isVueTemplateTag(node)) { + return JsExpressionKind.JSX_ELEMENT; + } if (ts.isCallExpression(node)) { return JsExpressionKind.CALL; } @@ -1323,7 +1331,7 @@ function expressionKindOf(node: ts.Expression): JsExpressionKind | undefined { } function isCallLike(node: ts.Expression): boolean { - return ts.isCallExpression(node) || ts.isNewExpression(node) + return (ts.isCallExpression(node) && !isVueTemplateTag(node)) || ts.isNewExpression(node) || ts.isTaggedTemplateExpression(node); } @@ -1575,9 +1583,15 @@ function propertyKeyText(name: ts.PropertyName): string { return ''; } +/** + * A JSX element, or a `.vue` template tag: `` is written into the + * component's virtual script as the marked call `Child(/**\/)`, and it is + * the same render a JSX tag is — a JSX_ELEMENT whose tag is the callee, not a + * call of `Child`. + */ function isJsxNode(node: ts.Node): boolean { return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) - || ts.isJsxFragment(node); + || ts.isJsxFragment(node) || isVueTemplateTag(node); } /** @@ -1603,6 +1617,9 @@ function isValidIdentifierText(text: string): boolean { } function jsxTagReference(node: ts.Node): ts.Expression | undefined { + if (isVueTemplateTag(node)) { + return (node as ts.CallExpression).expression; + } const tagName = ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index b3fcc452d..de97820eb 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -1213,7 +1213,7 @@ class JsModuleEdgeExtractor { // tsc resolves no `.vue` import itself (its extensions are fixed), so a // component in this program is looked up by path. const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) - ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath); + ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath, this.options.compilerOptions); if (resolved === undefined) { return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; } diff --git a/parser/src/utils/vue-sfc.ts b/parser/src/utils/vue-sfc.ts index 1d153ce52..35d533048 100644 --- a/parser/src/utils/vue-sfc.ts +++ b/parser/src/utils/vue-sfc.ts @@ -167,16 +167,55 @@ export function scriptTextOf( } /** - * The file a `.vue` specifier names, when it is relative. tsc resolves no `.vue` - * import itself (its extensions are fixed), so without this every - * `import Comp from './Comp.vue'` would read as unresolved. + * The file a `.vue` specifier names, when it is relative or goes through a path + * alias. tsc resolves no `.vue` import itself (its extensions are fixed), so + * without this every `import Comp from './Comp.vue'` would read as unresolved. + * + * `import Comp from '@/components/Comp.vue'` — the usual spelling in a Vite app — + * is mapped through `options.paths` the way tsc maps any other aliased import: the + * pattern with the longest prefix before its `*`, each substitution in order, the + * first that exists. */ -export function resolveVueSpecifier(specifier: string, fromFile: string): string | undefined { - if (!isVueFile(specifier) || !(specifier.startsWith('./') || specifier.startsWith('../'))) { +export function resolveVueSpecifier( + specifier: string, + fromFile: string, + options?: ts.CompilerOptions +): string | undefined { + if (!isVueFile(specifier)) { + return undefined; + } + if (specifier.startsWith('./') || specifier.startsWith('../')) { + const file = path.normalize(path.resolve(path.dirname(fromFile), specifier)); + return fs.existsSync(file) ? file : undefined; + } + const base = (options?.pathsBasePath as string | undefined) ?? options?.baseUrl; + if (options?.paths === undefined || base === undefined) { return undefined; } - const file = path.normalize(path.resolve(path.dirname(fromFile), specifier)); - return fs.existsSync(file) ? file : undefined; + let best: { prefix: string; star: string; targets: readonly string[] } | undefined; + for (const [pattern, targets] of Object.entries(options.paths)) { + const star = pattern.indexOf('*'); + if (star < 0) { + if (pattern === specifier) { + best = { prefix: pattern, star: '', targets }; + break; + } + continue; + } + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (specifier.length >= prefix.length + suffix.length && specifier.startsWith(prefix) + && specifier.endsWith(suffix) && (best === undefined || prefix.length > best.prefix.length)) { + best = { prefix, star: specifier.slice(prefix.length, specifier.length - suffix.length), targets }; + } + } + for (const target of best?.targets ?? []) { + const file = path.normalize(path.resolve(base, target.replace('*', best!.star))); + if (isVueFile(file) && fs.existsSync(file)) { + return file; + } + } + return undefined; } // ── the file's top-level blocks ────────────────────────────────────────────