Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions graph/javascript/engine/call-edge-generation/jsx.dl
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<Child/>`, 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 `<script setup>` body runs once per instance, and the
// virtual script writes the component's template there too — so the tag reaches the
// module initializer. A `<script setup>` component has no default export at all, and
// an Options API one (`export default { components: {…}, data() {…} }`) exports an
// object no rule above renders. One whose default export is a definer that renders is
// reached through that export instead.
jsx_vue_module(mod) :- module_file("client", fp, mod), match(".*[.]vue", fp).
jsx_vue_module_renders(mod) :- jsx_export_holds(mod, "default", w), jsx_vue_definer_renders(w).
jsx_vue_tag_module(je, mod) :- jsx_element_tag(je, tag), expr_binding(_, v, tag), var_import(_, imp, v),
import_decl(_, _, _, "DEFAULT", _, _, _, _, imp), import_module(imp, mod), jsx_vue_module(mod).

// ── jsx_renders(Element, Component) — the callables the element runs ───────
// A function component (a constructor FUNCTION is callable_value's "ctor" half).
jsx_renders(je, m) :- jsx_tag_value(je, k, i), callable_value(k, i, m).
// A class component: constructed, then rendered.
jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_construct_target(t, m).
jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_member(t, "render", m).
// A Vue single-file component: its module initializer (see "Vue components" above).
jsx_renders(je, init) :- jsx_vue_tag_module(je, mod), !jsx_vue_module_renders(mod), module_init("client", init, mod).

// ── the props object ────────────────────────────────────────────────────────
// Keyed by the element, as an object literal is keyed by the literal.
Expand Down
5 changes: 5 additions & 0 deletions graph/javascript/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
.decl jsx_call_renders(c0:symbol, c1:symbol, c2:symbol)
.decl jsx_loader_settles(c0:symbol, c1:symbol, c2:symbol)
.decl jsx_component_kind(c0:symbol)
.decl jsx_vue_option(c0:symbol, c1:symbol)
.decl jsx_vue_definer_renders(c0:symbol)
.decl jsx_vue_module(c0:symbol)
.decl jsx_vue_module_renders(c0:symbol)
.decl jsx_vue_tag_module(c0:symbol, c1:symbol)
.decl jsx_connect_call(c0:symbol, c1:symbol)
.decl jsx_connect_props(c0:symbol, c1:symbol, c2:symbol)
.decl jsx_connect_class_props(c0:symbol, c1:symbol, c2:symbol)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script setup>
function aliasedLocal() { return "a"; }
</script>
<template><em>{{ aliasedLocal() }}</em></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script setup>
function childLocal() { return "c"; }
</script>
<template><b>{{ childLocal() }}</b></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
import { defineComponent } from "vue";
export default defineComponent({
setup() { return definedSetup(); },
});
function definedSetup() { return {}; }
</script>
<template><p>defined</p></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
import Child from "./Child.vue";
function legacyInit() { return 0; }
export default { name: "Legacy", components: { Child }, data() { return { n: legacyInit() }; } };
</script>
<template><i><Child /></i></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script setup>
defineProps({ count: Number });
function widgetLabel() { return "w"; }
</script>
<template><span>{{ widgetLabel() }}</span></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function Badge() { return "b"; }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default { items: [] };
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup>
import Child from "../components/Child.vue";
import MyWidget from "../components/MyWidget.vue";
import Legacy from "../components/Legacy.vue";
import Defined from "../components/Defined.vue";
import { Badge } from "../components/badge.js";
import Store from "../components/store.js";
import { defineComponent } from "vue";
const Local = defineComponent({ setup() { return localSetup(); } });
function localSetup() { return 1; }
function fmt(x) { return String(x); }
</script>
<template>
<div>
<Child />
<my-widget :count="1" />
<Legacy></Legacy>
<Defined />
<Local />
<Badge />
<Store />
<span>{{ fmt(Store) }}</span>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup>
import Aliased from "@/Aliased.vue";
import Gone from "@/Gone.vue";
import { Badge } from "@/badge.js";
</script>
<template>
<main>
<Aliased />
<Gone />
<Badge />
</main>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { fileURLToPath, URL } from "node:url";
export default {
resolve: { alias: { "@": fileURLToPath(new URL("./components", import.meta.url)) } },
};
4 changes: 2 additions & 2 deletions graph/test/javascript/expected/69-vue-sfc.edges
Original file line number Diff line number Diff line change
@@ -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(/*<tag>*/) -> known_edge child.js:1:1 Child
Comp.vue:11:6 JSX_ELEMENT MyBadge(/*<tag>*/) -> 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
12 changes: 12 additions & 0 deletions graph/test/javascript/expected/72-vue-component-tag.diag
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions graph/test/javascript/expected/72-vue-component-tag.edges
Original file line number Diff line number Diff line change
@@ -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(/*<tag>*/) -> known_edge components/Child.vue:1:1 <module>
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(/*<tag>*/) -> known_edge components/Child.vue:1:1 <module>
views/Parent.vue:16:6 JSX_ELEMENT MyWidget(/*<tag>*/) -> known_edge components/MyWidget.vue:1:1 <module>
views/Parent.vue:17:6 JSX_ELEMENT Legacy(/*<tag>*/) -> known_edge components/Legacy.vue:1:1 <module>
views/Parent.vue:18:6 JSX_ELEMENT Defined(/*<tag>*/) -> known_edge components/Defined.vue:4:3 setup
views/Parent.vue:19:6 JSX_ELEMENT Local(/*<tag>*/) -> known_edge views/Parent.vue:9:33 setup
views/Parent.vue:20:6 JSX_ELEMENT Badge(/*<tag>*/) -> known_edge components/badge.js:1:1 Badge
views/Parent.vue:21:6 JSX_ELEMENT Store(/*<tag>*/) -> 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(/*<tag>*/) -> known_edge components/badge.js:1:1 Badge
views/Shell.vue:8:6 JSX_ELEMENT Aliased(/*<tag>*/) -> known_edge components/Aliased.vue:1:1 <module>
views/Shell.vue:9:6 JSX_ELEMENT Gone(/*<tag>*/) -> ambiguous_unknown -
vite.config.js:3:28 FUNCTION_CALL fileURLToPath -> ambient_terminal -
vite.config.js:3:42 CONSTRUCTOR_CALL URL -> ambient_terminal -
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# defects: 0
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
rangeOf,
bindingPathOf,
} from '@/utils/javascript';
import { isVueTemplateTag } from '@/utils/vue-sfc';

/**
* `js_expression` and `js_call_site` — the spine.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1575,9 +1583,15 @@ function propertyKeyText(name: ts.PropertyName): string {
return '';
}

/**
* A JSX element, or a `.vue` template tag: `<Child/>` is written into the
* component's virtual script as the marked call `Child(/*<tag>*\/)`, 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);
}

/**
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
53 changes: 46 additions & 7 deletions parser/src/utils/vue-sfc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────
Expand Down
Loading