diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4ce4961d..b74c5d37ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading. +- `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - In C++, a call on a variable, parameter or member declared through a `typedef` or `using` alias now links to the method of the type the alias names, with the alias looked up the way C++ does it: in the calling function, its class and the classes that class inherits from, then the namespaces around it. In google/leveldb, `table_.Insert(buf)` on a `Table table_;` declared next to `typedef SkipList Table;` now reaches `SkipList::Insert` instead of the unrelated `HandleTable::Insert`, and `Table::Iterator iter(&table_);` reaches `SkipList::Iterator`'s constructor. A class that only shares the alias's name is no longer taken for it, and when the aliased type has no such method, as with an alias of `std::vector` or of a template parameter, no method is guessed from the receiver's name. Re-index C++ projects after upgrading. - In C++, a call on a variable, parameter or member declared with template arguments nested inside its template arguments, like `autovector>` or `Striped>`, now links to the method of the class it is declared as. Before, the nested arguments hid that class: in RocksDB, `files_marked_for_compaction_.clear()` on such an `autovector` linked to an unrelated `CompactionInputFiles::clear` that only shares words with the variable's name, and `mutex_.Get(key)` on a `Striped` linked to nothing. Re-index C++ projects after upgrading. diff --git a/__tests__/sync-module-importers.test.ts b/__tests__/sync-module-importers.test.ts index 57f6f311c7..2a8a524b17 100644 --- a/__tests__/sync-module-importers.test.ts +++ b/__tests__/sync-module-importers.test.ts @@ -133,9 +133,29 @@ const without = (files: Files, gone: string[]): Files => const only = (files: Files, kept: string[]): Files => Object.fromEntries(Object.entries(files).filter(([file]) => kept.includes(file))); +/** + * A file beside the one an extensionless import resolved to, which outranks + * it: a component's `./button` names `button.ts` when it is there, and + * `button.vue` when it is not — `sizeOf`, which both export, from the one or + * the other, and `onlyVue`, which only `button.vue` exports, through it or by + * its name. + */ +const OUTRANKING: Files = { + 'package.json': '{"name":"app"}', + 'src/widgets/button.vue': + '\n\n', + 'src/widgets/button.ts': + "export default function button() { return 'b'; }\nexport function buttonClass() { return 'c'; }\nexport function sizeOf() { return 2; }\n", + 'src/widgets/group.vue': + "\n\n", + 'src/widgets/panel.ts': "import button from './button';\nexport function panel() { return button(); }\n", +}; +const OUTRANKING_FILES = ['src/widgets/button.ts']; + describe.each([ ['Python package and module', PYTHON, PYTHON_BARRELS], ['ES module barrel', TS, TS_BARRELS], + ['file outranking the one an import resolved to', OUTRANKING, OUTRANKING_FILES], ])('sync re-resolves the imports through a re-exporting file: %s', (_label, files, barrels) => { it('when it is deleted', async () => { const root = tempRoot(); diff --git a/src/db/queries.ts b/src/db/queries.ts index 010d32fd36..f6b33befdd 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3189,6 +3189,48 @@ export class QueryBuilder { })); } + /** + * The module edges (`imports` edges into a file node) into the files an + * import of `dir/name` can find instead of a file added there: `dir/name.*` + * under another extension, and the folder's own entry, `dir/name/index.*` + * or `dir/name/__init__.py`. With the source file and language a + * resurrection needs. + */ + getModuleImportsNamed( + dir: string, + name: string + ): Array { + const base = dir ? `${dir}/${name}` : name; + // `x.` up to `x/` and `x/index.` up to `x/index/` ('/' is the byte after '.'). + const rows = this.db + .prepare( + `SELECT e.*, tgt.file_path AS target_file_path, src.file_path AS source_file_path, src.language AS source_language + FROM nodes tgt + JOIN edges e ON e.target = tgt.id AND e.kind = 'imports' + JOIN nodes src ON src.id = e.source + WHERE tgt.kind = 'file' + AND ((tgt.file_path >= ? AND tgt.file_path < ?) OR (tgt.file_path >= ? AND tgt.file_path < ?) OR tgt.file_path = ?)` + ) + .all(`${base}.`, `${base}/`, `${base}/index.`, `${base}/index/`, `${base}/__init__.py`) as Array< + EdgeRow & { target_file_path: string; source_file_path: string; source_language: Language } + >; + return rows + // Not a deeper path that only shares the prefix (`dir/name.d/x.ts`). + .filter((row) => { + const file = row.target_file_path; + if (file === `${base}/__init__.py`) return true; + const index = `${base}/index.`; + return !(file.startsWith(index) ? file.slice(index.length) : file.slice(base.length + 1)).includes('/'); + }) + .map((row) => ({ + ...rowToEdge(row), + edgeId: row.id, + targetFilePath: row.target_file_path, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + /** The files with a resolution edge into one of `targetIds`. */ getFilesWithEdgesInto(targetIds: string[]): string[] { const files = new Set(); diff --git a/src/index.ts b/src/index.ts index d98fac584e..bd9e11a55a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1118,7 +1118,10 @@ export class CodeGraph { const file = this.queries.getFileByPath(filePath); return file ? [{ path: filePath, language: file.language }] : []; }); - const reopened = reopenReferencesThroughAddedFiles(this.queries, createImportReader(this.projectRoot), added, edgesBeforeResolution); + const reopened = reopenReferencesThroughAddedFiles( + this.queries, createImportReader(this.projectRoot), added, edgesBeforeResolution, + (specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language) + ); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`); } } else { diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 705b62c85a..4dec64f3bb 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { QueryBuilder } from '../db/queries'; -import type { Language, UnresolvedReference } from '../types'; +import type { Edge, Language, UnresolvedReference } from '../types'; import type { ImportMapping, ReExport } from './types'; import { extractImportMappings, extractReExports, pythonModulePath, REEXPORT_MAX_DEPTH } from './import-resolver'; import { resurrectRefFromDroppedEdge } from './resurrect-ref'; @@ -123,6 +123,9 @@ export function reopenReferencesThroughRemovedFiles( return reopenEdges(queries, bindings, (e) => !gone.has(e.targetFilePath)); } +/** Resolves a module specifier written in `fromFile` as its import does: the project file it names, or null. */ +export type ModuleResolver = (specifier: string, fromFile: string, language: Language) => string | null; + /** * The other half: after a sync's retries, re-open the references the files * importing any of `added` make through those imports — the edges a name @@ -130,28 +133,96 @@ export function reopenReferencesThroughRemovedFiles( * `resolvedUpToEdgeId`: later ones were just resolved with the modules there) * and the references that failed, the import of the module itself among * them. (The failed-import retry looks those up by the module's name, and - * skips a name as common as `models` or `test`.) Returns the number re-opened. + * skips a name as common as `models` or `test`.) With `resolveModule`, also an + * import that resolved to a file beside an added one that now outranks it + * (`./button` to `button.vue` while `button.ts` was missing), and what that + * statement binds. Returns the number re-opened. */ export function reopenReferencesThroughAddedFiles( queries: QueryBuilder, reader: ImportReader, added: ReadonlyArray<{ path: string; language: Language }>, resolvedUpToEdgeId: number, + resolveModule?: ModuleResolver, ): number { - const bindings = bindingsOf(importersOf(queries, added, reader), reader); + const outranked = resolveModule ? outrankedImports(queries, added, resolveModule) : []; + const bindings = bindingsOf(importersOf(queries, added, reader, outranked), reader); const failed = [...bindings].flatMap(([file, { modules, names }]) => queries.getFailedReferencesThrough(file, modules, names)); - return reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed); + return reopenModuleEdges(queries, outranked) + + reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed); +} + +/** An import's module edge into a file that an added file now outranks. */ +interface OutrankedImport { + edge: Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }; + specifier: string; + added: string; +} + +/** + * The module edges an import resolved into a file beside one of `added` — + * another extension of its name, or its folder's own entry — when the + * import's specifier now names the added file: `./button` resolved to + * `button.vue` while `button.ts` was missing, and `button.ts` comes first. + */ +function outrankedImports( + queries: QueryBuilder, + added: ReadonlyArray<{ path: string; language: Language }>, + resolveModule: ModuleResolver, +): OutrankedImport[] { + const out: OutrankedImport[] = []; + for (const file of added) { + if (!BINDING_LANGUAGES.has(file.language)) continue; + const { dir, name } = importedName(file.path); + for (const edge of queries.getModuleImportsNamed(dir, name)) { + const specifier = edge.metadata?.refName; + if (edge.targetFilePath === file.path || typeof specifier !== 'string') continue; + if (resolveModule(specifier, edge.sourceFilePath, edge.sourceLanguage) === file.path) { + out.push({ edge, specifier, added: file.path }); + } + } + } + return out; +} + +/** The folder and name an import finds a file by: `a/b.ts` → `a`, `b`; a folder's own entry `a/b/index.ts` → `a`, `b`. */ +function importedName(filePath: string): { dir: string; name: string } { + const slash = filePath.lastIndexOf('/'); + const base = filePath.slice(slash + 1); + const dir = slash < 0 ? '' : filePath.slice(0, slash); + if (base.startsWith('index.') || base === '__init__.py') { + const up = dir.lastIndexOf('/'); + return { dir: up < 0 ? '' : dir.slice(0, up), name: dir.slice(up + 1) }; + } + const dot = base.indexOf('.', 1); + return { dir, name: dot < 0 ? base : base.slice(0, dot) }; +} + +/** Swap the outranked module edges for their references, for the sweep. Returns how many. */ +function reopenModuleEdges(queries: QueryBuilder, outranked: readonly OutrankedImport[]): number { + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + for (const { edge } of outranked) { + const ref = resurrectRefFromDroppedEdge(edge); + if (!ref || edgeIds.includes(edge.edgeId)) continue; + edgeIds.push(edge.edgeId); + refs.push(ref); + } + if (refs.length > 0) queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); + return refs.length; } /** * The files with an import statement naming one of `files`, or one of the * modules re-exporting from them, other than `files` themselves (going away, - * or just resolved with the others in place). + * or just resolved with the others in place); `outranked` imports name the + * added file they now resolve to. */ function importersOf( queries: QueryBuilder, files: ReadonlyArray<{ path: string; language: Language }>, reader: ImportReader, + outranked: readonly OutrankedImport[] = [], ): Importers { const importers: Importers = new Map(); const changed = new Set(files.map((file) => file.path)); @@ -177,6 +248,10 @@ function importersOf( for (const i of queries.getImportsInto(`file:${module.path}`, module.path)) { if (i.refName !== null) add(i.filePath, i.language, i.refName, module); } + if (depth > 1) continue; + for (const o of outranked) { + if (o.added === module.path) add(o.edge.sourceFilePath, o.edge.sourceLanguage, o.specifier, module); + } } const python = level.filter((module) => module.language === 'python'); if (python.length > 0) addPythonImporters(queries, python, reader, add);