diff --git a/CHANGELOG.md b/CHANGELOG.md index f35a34378..766144819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Vue components written with the Options API (`export default { methods: {…}, computed: {…}, watch: {…}, mounted() {…} }`), as every Vue 2 app is, now have their methods, computed properties, watchers and lifecycle hooks in the graph. Each one has its own callers and calls, and a template's `@click="handleLogin"` links to the method it runs. On vue-element-admin that's over 800 new links, where a component's calls used to belong to the whole file. - In Java, Kotlin, Swift, C#, C and C++, Go, Scala, Ruby, Dart and the other case-sensitive languages, a name no longer links to a symbol whose name differs only in case. On jsoup, JUnit's `@Test` had linked every test to a `test` method; `new CookieManager()` had linked to a `cookieManager()` getter, and a `Method` type to a `method()`. PHP, Pascal, CFML, COBOL and VB.NET, whose names really do ignore case, are unchanged. - React Native events whose name is held in a constant now link native code to the JavaScript that listens for them, like NetInfo's `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)` or a Java `.emit(PROGRESS_EVENT, …)`. The constant is read the way the language scopes it, from the same file, an import or its owning class, so a parameter that happens to share a constant's name is never mistaken for it. - React Native views declared with `requireNativeComponent('X')`, the older Paper style that react-native-maps and segmented-control use, now link from JavaScript to their native iOS and Android implementations, as Codegen specs already did, including in plain `.js` files. A JSX tag written under a different name than its component, like `` for `import Autocomplete from './autocomplete.vue'`, now links to the component it renders. diff --git a/__tests__/vue-options-api.test.ts b/__tests__/vue-options-api.test.ts new file mode 100644 index 000000000..ba4f9f235 --- /dev/null +++ b/__tests__/vue-options-api.test.ts @@ -0,0 +1,115 @@ +/** + * A Vue Options API component's functions are symbols: `methods`, `computed`, + * `watch` entries and the lifecycle hooks, each owning the calls written in it. + * (`src/extraction/vue-options-api.ts`, wired in `vue-extractor.ts`.) + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { vueOptionsMembers } from '../src/extraction/vue-options-api'; + +describe('vueOptionsMembers', () => { + it('names every function an options object declares', () => { + const script = ` +import { mapActions } from 'vuex' +export default { + name: 'Login', + props: { redirect: String }, + data() { + return { loading: false } + }, + computed: { + title() { return 'Sign in' }, + fullName: { + get() { return this.first }, + set(v) { this.first = v } + }, + ...mapGetters(['user']) + }, + watch: { + $route: { handler(route) { this.redirect = route.query.redirect }, immediate: true }, + 'form.email'(value) { this.check(value) }, + loading: 'onLoading' + }, + created() { this.init() }, + mounted: function () { this.focus() }, + methods: { + ...mapActions(['login']), + async handleLogin() { await this.login() }, + validate: (value) => value.length > 0, + reset: function () {}, + [DYNAMIC]() {} + } +} +`; + expect(vueOptionsMembers(script).map((m) => m.name)).toEqual([ + 'data', 'title', 'fullName', '$route', 'form.email', 'created', 'mounted', 'handleLogin', 'validate', 'reset', + ]); + }); + + it('reads defineComponent, Vue.extend and Vue.component, and nothing without an options object', () => { + expect(vueOptionsMembers(`export default defineComponent({ setup() { return {} } })`).map((m) => m.name)).toEqual(['setup']); + expect(vueOptionsMembers(`export default Vue.extend({ methods: { go() {} } })`).map((m) => m.name)).toEqual(['go']); + expect(vueOptionsMembers(`export default Vue.component('x-btn', { methods: { tap() {} } })`).map((m) => m.name)).toEqual(['tap']); + expect(vueOptionsMembers(`const x = { methods: { go() {} } }\nexport default x`)).toEqual([]); + }); +}); + +describe('a Vue Options API component, indexed', () => { + let root = ''; + afterAll(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('gives each method its calls, and the template handler its method', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-options-')); + fs.mkdirSync(path.join(root, 'src/views/login'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'admin', dependencies: { vue: '^2.6.0' } })); + fs.writeFileSync( + path.join(root, 'src/views/login/index.vue'), + ` + + +` + ); + const cg = await CodeGraph.init(root, { index: true }); + try { + const nodes = cg.getNodesInFile('src/views/login/index.vue'); + const method = (name: string) => nodes.find((n) => n.kind === 'method' && n.name === name)!; + expect(nodes.filter((n) => n.kind === 'method').map((n) => n.name).sort()).toEqual(['data', 'handleLogin', 'validate']); + expect(method('handleLogin').qualifiedName).toBe('index::handleLogin'); + // The call inside handleLogin is handleLogin's, not the file's. + const calls = cg.getOutgoingEdges(method('handleLogin').id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.name); + expect(calls).toContain('validate'); + // `@click="handleLogin"` in the template runs the method. + const component = nodes.find((n) => n.kind === 'component')!; + const handlers = cg.getOutgoingEdges(component.id).filter((e) => e.target === method('handleLogin').id); + expect(handlers.length).toBeGreaterThan(0); + } finally { + cg.close(); + } + }); +}); diff --git a/src/extraction/vue-extractor.ts b/src/extraction/vue-extractor.ts index 862f17c79..5429a90de 100644 --- a/src/extraction/vue-extractor.ts +++ b/src/extraction/vue-extractor.ts @@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan import { generateNodeId } from './tree-sitter-helpers'; import { TreeSitterExtractor } from './tree-sitter'; import { isLanguageSupported } from './grammars'; +import { vueOptionsMembers } from './vue-options-api'; /** * Vue built-in components — skipped so a `` / `` in the @@ -114,6 +115,67 @@ export class VueExtractor { return node; } + /** + * Method nodes for an Options API component's members (see + * ./vue-options-api), and the references and edges written inside each — + * which the TS extractor attributed to the file — re-attributed to it. + * Lines are block-relative here; the caller offsets them with the rest. + */ + private addOptionsMembers( + block: { content: string; startLine: number }, + result: ExtractionResult, + componentNodeId: string + ): void { + const members = vueOptionsMembers(block.content); + if (members.length === 0) return; + const component = this.nodes.find((n) => n.id === componentNodeId); + const owner = component?.name ?? 'component'; + const lineAt = (offset: number) => block.content.slice(0, offset).split('\n').length; + const colAt = (offset: number) => offset - block.content.lastIndexOf('\n', offset - 1) - 1; + const now = Date.now(); + const created: Node[] = []; + for (const m of members) { + const startLine = lineAt(m.start); + const endLine = lineAt(m.end); + created.push({ + id: generateNodeId(this.filePath, 'method', `${owner}.${m.name}`, startLine + block.startLine), + kind: 'method', + name: m.name, + qualifiedName: `${owner}::${m.name}`, + filePath: this.filePath, + language: 'vue', + startLine, + endLine, + startColumn: colAt(m.start), + endColumn: colAt(m.end), + updatedAt: now, + }); + } + // Innermost member for a line: `computed: { x: { get() {…} } }` is one member. + const memberAt = (line: number): Node | undefined => { + let best: Node | undefined; + for (const n of created) { + if (n.startLine <= line && n.endLine >= line && (!best || n.startLine >= best.startLine)) best = n; + } + return best; + }; + // What the TS extractor attributed to the file (or to nothing narrower). + const fileNode = result.nodes.find((n) => n.kind === 'file'); + const narrower = new Set(result.nodes.filter((n) => n.kind !== 'file').map((n) => n.id)); + const isFileLevel = (id: string) => (fileNode ? id === fileNode.id : !narrower.has(id)); + for (const ref of result.unresolvedReferences) { + if (!isFileLevel(ref.fromNodeId)) continue; + const member = memberAt(ref.line); + if (member) ref.fromNodeId = member.id; + } + for (const edge of result.edges) { + if (edge.kind === 'contains' || !edge.line || !isFileLevel(edge.source)) continue; + const member = memberAt(edge.line); + if (member) edge.source = member.id; + } + result.nodes.push(...created); + } + /** * Extract