mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 16:57:09 +08:00
fix(swift): a call through a type path lands on the type it names (#2118)
`API.PackageController.GetRoute.query(on:)` kept only `query` — the
receiver was a navigation_expression, not an identifier — and the bare
name exact-matched whichever type's `query` came first. On
SwiftPackageIndex-Server every route type has one, so health checks and
package-controller tests "called" the dependency controller (or a test's
own `query`).
Extraction (TS and the kernel's swift walker, byte-parity): a receiver that
is a path of capitalized type names, two segments or more, not led by
`Self.`, is kept: `API.PackageController.GetRoute.query`. Instance chains
(`self.store.load()`) and mixed paths (`URLSession.shared.data`) are
unchanged.
Resolution (swift-type-visibility.ts, called decisively from
matchReferenceInner so nothing falls through to name guessing): the member
on the type the path names — a method, an enum case with associated values,
or a nested type's initializer. A member's owner path is its qualified name
with the outermost extension's written path read back from its line
(`extension API.PackageController { enum ShowRoute … }` →
`API::PackageController::ShowRoute`), so twin `Feature::State`s declared in
`extension BasicsView.Feature` and `extension ObservableBasicsView.Feature`
are told apart. A path shortened inside its namespace or behind a module
qualifier fits; two owners, or none, leave the call unresolved. TCA's
`@Reducer enum Path { case detail }` generates `Path.State`/`Path.Action`,
so `Path.State.detail(…)` lands on the written case.
A/B vs main (edge diffs, every change classified): SPI 76 retargeted, all to
the type the line names, 7 dropped (Fluent `App.Version.query`, `Plot.Node.*`
— SDK members main pinned on project methods); TCA 61 retargeted, 1 dropped
(`State.StateReducer.scope` → an unrelated `Store::scope`); mastodon-ios 177
retargeted (`Mastodon.API.V2.Instance.instance`, `L10n.Plural.Count.vote`),
11 SDK drops; swift-nio 6 calls into a deprecated alias became instantiates
of `ChannelOptions.Types.*`; IceCubes 1/1, Alamofire 2 SDK drops.
Kernel/wasm parity 0 diffs over 2,752 files on six repos.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- A Swift call through a nested type, like `API.PackageController.GetRoute.query(on: db)`, now links to that type's method. Only `query` used to be kept, so the call linked to whichever type's `query` came first; in a Vapor app every route type has one, so callers, impact and `codegraph_explore` answers pointed at another endpoint's code. The same applies to nested types' initializers (`API.PackageController.Model(name:)`), to enum cases (`Gitlab.Error.requestFailed(status:)`), to types declared in an `extension API.PackageController { … }`, and to The Composable Architecture's `Path.State.detail(…)`. A call on a type path that names no type in the project, such as an SDK's `Plot.Node.footer(…)`, is left unlinked. Re-index Swift projects after upgrading.
|
||||
- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading.
|
||||
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
|
||||
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
|
||||
|
||||
@@ -86,6 +86,13 @@ func freeFn(a: Int, cb: @escaping (Int) -> Void) -> Session? {
|
||||
x?.optCall()
|
||||
y!.forced()
|
||||
Foo.make().draw()
|
||||
API.PackageController.GetRoute.query(on: db)
|
||||
API.PackageController
|
||||
.GetRoute.query(on: db)
|
||||
API.Model(name: "x")
|
||||
Self.Inner.make()
|
||||
App.lower.call()
|
||||
API.Ünicode.query()
|
||||
foo.bar().baz()
|
||||
"lit".upper()
|
||||
arr.map { $0.name }
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* A Swift call through a type path lands on the type the path names.
|
||||
*
|
||||
* `API.PackageController.GetRoute.query(on:)` kept only `query`, which
|
||||
* exact-matched whichever type's `query` came first — in a Vapor app every
|
||||
* route type has one, so a health check "called" the dependency controller's
|
||||
* query. The path is now kept and resolved on its type: declared nested,
|
||||
* in `extension API.PackageController { … }`, in `extension A.B.C { … }`,
|
||||
* shortened inside its namespace, or module-qualified. A path that names no
|
||||
* project type is left unresolved.
|
||||
*/
|
||||
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';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const FILES: Record<string, string> = {
|
||||
'Sources/App/API.swift': `enum API {}
|
||||
extension API {
|
||||
enum PackageController {
|
||||
enum GetRoute {
|
||||
static func query(on db: Int) -> Int { db }
|
||||
static func query(on db: Int, limit: Int) -> Int { db + limit }
|
||||
}
|
||||
struct Model { let name: String }
|
||||
}
|
||||
}
|
||||
extension API.PackageController {
|
||||
enum ShowRoute {
|
||||
static func query(on db: Int) -> Int { db }
|
||||
}
|
||||
}
|
||||
extension API.DependencyController.GetRoute {
|
||||
static func query(on db: Int) -> Int { db }
|
||||
}
|
||||
extension API {
|
||||
enum DependencyController {
|
||||
enum GetRoute {}
|
||||
static func inside() -> Int { PackageController.GetRoute.query(on: 0) }
|
||||
}
|
||||
}
|
||||
`,
|
||||
'Sources/App/Other.swift': `enum Other { static func query(on db: Int) -> Int { db } }
|
||||
enum Gitlab { enum Error: Swift.Error { case requestFailed(status: Int) } }
|
||||
enum Remote { enum Error: Swift.Error { case requestFailed(status: Int) } }
|
||||
struct Detail {}
|
||||
enum Screens {
|
||||
@Reducer
|
||||
enum Path { case detail(Detail) }
|
||||
}
|
||||
enum Zed { enum GetRoute { static func query(on db: Int) -> Int { db } } }
|
||||
enum Yon { enum GetRoute { static func query(on db: Int) -> Int { db } } }
|
||||
`,
|
||||
'Sources/App/Basics.swift': `struct BasicsView {}
|
||||
struct ObservableBasicsView {}
|
||||
extension BasicsView { struct Feature {} }
|
||||
extension ObservableBasicsView { struct Feature {} }
|
||||
extension BasicsView.Feature { struct State {} }
|
||||
extension ObservableBasicsView.Feature { struct State {} }
|
||||
`,
|
||||
'Sources/App/Health.swift': `import Foundation
|
||||
enum Health {
|
||||
static func one() -> Int { API.PackageController.GetRoute.query(on: 1) }
|
||||
static func two() -> Int {
|
||||
API.PackageController.GetRoute
|
||||
.query(on: 2)
|
||||
}
|
||||
static func three() -> Int { Other.query(on: 3) }
|
||||
static func four() -> Int { API.PackageController.ShowRoute.query(on: 4) }
|
||||
static func five() -> Int { API.DependencyController.GetRoute.query(on: 5) }
|
||||
static func six() -> API.PackageController.Model { API.PackageController.Model(name: "x") }
|
||||
static func seven() -> Int { App.Zed.GetRoute.query(on: 7) }
|
||||
static func eight() -> Int { Missing.Thing.query(on: 8) }
|
||||
static func nine() -> Int { API.PackageController.GetRoute.nothing(on: 9) }
|
||||
static func ten() throws { throw Gitlab.Error.requestFailed(status: 10) }
|
||||
static func eleven() { _ = Screens.Path.State.detail(Detail()) }
|
||||
static func twelve() { _ = ObservableBasicsView.Feature.State() }
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
describe('Swift: a call through a type path', () => {
|
||||
it('lands on the type the path names, or nowhere', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-type-path-'));
|
||||
roots.push(root);
|
||||
for (const [rel, content] of Object.entries(FILES)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
const cg = await CodeGraph.init(root, { index: true });
|
||||
try {
|
||||
const reached = (caller: string): string[] => {
|
||||
const from = cg.getNodesByName(caller).find((n) => n.kind === 'method');
|
||||
expect(from, caller).toBeDefined();
|
||||
return cg
|
||||
.getOutgoingEdgesFrom([from!.id], ['calls', 'instantiates'])
|
||||
.map((e) => cg.getNode(e.target))
|
||||
.filter((n) => n && n.language === 'swift')
|
||||
.map((n) => `${n!.qualifiedName}:${n!.startLine}`)
|
||||
.sort();
|
||||
};
|
||||
// The first declared of GetRoute's two overloads.
|
||||
expect(reached('one')).toEqual(['API::PackageController::GetRoute::query:5']);
|
||||
expect(reached('two')).toEqual(['API::PackageController::GetRoute::query:5']);
|
||||
expect(reached('three')).toEqual(['Other::query:1']);
|
||||
// Declared in `extension API.PackageController { enum ShowRoute … }`.
|
||||
expect(reached('four')).toEqual(['PackageController::ShowRoute::query:13']);
|
||||
// Declared in `extension API.DependencyController.GetRoute { … }`.
|
||||
expect(reached('five')).toEqual(['GetRoute::query:17']);
|
||||
// A nested type's initializer.
|
||||
expect(reached('six')).toEqual(['API::PackageController::Model:8']);
|
||||
// A module qualifier.
|
||||
expect(reached('seven')).toEqual(['Zed::GetRoute::query:9']);
|
||||
// No such type, and no such member: nothing, not some other type's `query`.
|
||||
expect(reached('eight')).toEqual([]);
|
||||
expect(reached('nine')).toEqual([]);
|
||||
// A case with associated values.
|
||||
expect(reached('ten')).toEqual(['Gitlab::Error::requestFailed:2']);
|
||||
// The case a TCA `@Reducer enum` generates its State from.
|
||||
expect(reached('eleven')).toContain('Screens::Path::detail:7');
|
||||
// Two types share the qualified name `Feature::State`; the extension each is declared in tells them apart.
|
||||
expect(reached('twelve')).toEqual(['Feature::State:6']);
|
||||
// Shortened inside its namespace.
|
||||
expect(reached('inside')).toEqual(['API::PackageController::GetRoute::query:5']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,24 @@ fn strip_js_ws(s: &str) -> String {
|
||||
s.chars().filter(|c| !is_js_space(*c)).collect()
|
||||
}
|
||||
|
||||
/// SWIFT_TYPE_PATH_RECEIVER (tree-sitter.ts): `API.PackageController.GetRoute` —
|
||||
/// two segments or more, each an ASCII capital then `[A-Za-z0-9_]*` (JS `\w`),
|
||||
/// not led by `Self.`.
|
||||
fn is_type_path(s: &str) -> bool {
|
||||
if s.starts_with("Self.") {
|
||||
return false;
|
||||
}
|
||||
let mut segments = 0;
|
||||
for seg in s.split('.') {
|
||||
let b = seg.as_bytes();
|
||||
if b.is_empty() || !b[0].is_ascii_uppercase() || !b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') {
|
||||
return false;
|
||||
}
|
||||
segments += 1;
|
||||
}
|
||||
segments >= 2
|
||||
}
|
||||
|
||||
struct Scope {
|
||||
row: u32,
|
||||
kind: &'static str,
|
||||
@@ -1061,8 +1079,16 @@ impl<'t> Walker<'t> {
|
||||
} else {
|
||||
method_name.to_string()
|
||||
};
|
||||
} else if let Some(path) = receiver
|
||||
.filter(|r| r.kind() == "navigation_expression")
|
||||
.map(|r| strip_js_ws(self.text(r)))
|
||||
.filter(|p| is_type_path(p))
|
||||
{
|
||||
// A type path, `API.PackageController.GetRoute.query(on:)`:
|
||||
// keep it for the resolver (tree-sitter.ts SWIFT_TYPE_PATH_RECEIVER).
|
||||
callee_name = format!("{path}.{method_name}");
|
||||
} else {
|
||||
// self_expression / super_expression / inner nav /
|
||||
// self_expression / super_expression / instance nav /
|
||||
// postfix / multi_line_string_literal → bare method name.
|
||||
callee_name = method_name.to_string();
|
||||
}
|
||||
|
||||
@@ -411,6 +411,8 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']
|
||||
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
|
||||
/** The field of a `this.<field>.<method>()` receiver: public or ES private (#1496, #1987). */
|
||||
const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']);
|
||||
/** A Swift receiver that is a path of types, `API.PackageController.GetRoute` — two segments or more, each capitalized. */
|
||||
const SWIFT_TYPE_PATH_RECEIVER = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+$/;
|
||||
|
||||
/**
|
||||
* Identifier-rooted member chains have no inferred property type (#1566),
|
||||
@@ -4902,6 +4904,21 @@ export class TreeSitterExtractor {
|
||||
else reencode = !!innerCallee;
|
||||
}
|
||||
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
|
||||
} else if (
|
||||
this.language === 'swift' &&
|
||||
receiver &&
|
||||
receiver.type === 'navigation_expression' &&
|
||||
SWIFT_TYPE_PATH_RECEIVER.test(getNodeText(receiver, this.source).replace(/\s+/g, ''))
|
||||
) {
|
||||
// Swift call through a type path — `API.PackageController.GetRoute.query(on:)`,
|
||||
// on one line or split before the `.query`. Keep the path: the
|
||||
// bare method name this used to emit exact-matched whichever
|
||||
// type's `query` came first (every route in a Vapor app has one).
|
||||
// The resolver finds the member on the type the path names, or
|
||||
// leaves the call unresolved. An instance chain (`self.store.load()`,
|
||||
// `viewModel.state.reset()`) is not a type path and stays bare.
|
||||
// Mirrored in the kernel's extract_call (swift.rs).
|
||||
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
|
||||
} else if (
|
||||
this.language === 'cfscript' &&
|
||||
receiver &&
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins';
|
||||
import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility';
|
||||
/**
|
||||
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
|
||||
* score. A name defined more times than this is "ubiquitous" — a method/symbol
|
||||
@@ -4383,6 +4384,13 @@ function matchReferenceInner(
|
||||
|
||||
if (isUnresolvedJsMemberCall(ref)) return null;
|
||||
|
||||
// A Swift call through a type path (`API.PackageController.GetRoute.query`)
|
||||
// resolves on the type the path names, or not at all: the strategies below
|
||||
// would bind it by the member's name alone.
|
||||
if (ref.language === 'swift' && ref.referenceKind === 'calls' && SWIFT_TYPE_PATH_CALL.test(ref.referenceName)) {
|
||||
return nmTimed('swiftTypePath', ref, () => resolveSwiftTypePathCall(ref, context));
|
||||
}
|
||||
|
||||
// Try strategies in order of confidence
|
||||
let result: ResolvedRef | null;
|
||||
|
||||
|
||||
@@ -39,13 +39,14 @@ interface Memo {
|
||||
conformances: Map<string, string[]>;
|
||||
clauses: Map<string, string[]>;
|
||||
inherited: Map<string, ReadonlySet<string>>;
|
||||
owners: Map<string, string | null>;
|
||||
}
|
||||
const memos = new WeakMap<ResolutionContext, Memo>();
|
||||
|
||||
function memoFor(context: ResolutionContext): Memo {
|
||||
let memo = memos.get(context);
|
||||
if (!memo) {
|
||||
memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map() };
|
||||
memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map(), owners: new Map() };
|
||||
memos.set(context, memo);
|
||||
}
|
||||
return memo;
|
||||
@@ -340,3 +341,120 @@ export function gateSwiftTypeTarget(result: ResolvedRef | null, ref: UnresolvedR
|
||||
if (!declared) return null;
|
||||
return declared.id === result.targetNodeId ? result : { ...result, targetNodeId: declared.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* A call through a type path, `API.PackageController.GetRoute.query` — at
|
||||
* least two capitalized segments before the member (the extractor keeps the
|
||||
* path only for those; see SWIFT_TYPE_PATH_RECEIVER in tree-sitter.ts).
|
||||
*/
|
||||
export const SWIFT_TYPE_PATH_CALL = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+\.[A-Za-z_]\w*$/;
|
||||
|
||||
/** What a type-path call can name: a method, or a case with associated values (`Gitlab.Error.requestFailed(status:)`). */
|
||||
const PATH_MEMBER_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>(['method', 'function', 'enum_member']);
|
||||
|
||||
/**
|
||||
* `API.PackageController.GetRoute.query(on:)` lands on the `query` of the
|
||||
* type the path names, and `API.PackageController.Model(name:)` on that
|
||||
* nested type — never on the member's name alone: in a Vapor app every
|
||||
* route's type has a `query`. A path the call's own namespace lets it
|
||||
* shorten (`PackageController.GetRoute` inside `extension API`) or a module
|
||||
* qualifier (`Vapor.HTTPStatus`) still fits. No type on the path, or two,
|
||||
* leaves the call unresolved.
|
||||
*/
|
||||
export function resolveSwiftTypePathCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
const name = ref.referenceName;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const member = name.slice(dot + 1);
|
||||
const path = name.slice(0, dot).split('.').join('::');
|
||||
const onPath = (kinds: ReadonlySet<Node['kind']>): Node[] => {
|
||||
const exact: Node[] = [];
|
||||
const shortened: Node[] = [];
|
||||
for (const n of context.getNodesByName(member)) {
|
||||
if (n.language !== 'swift' || !kinds.has(n.kind)) continue;
|
||||
if (TYPE_KINDS.has(n.kind) && isSwiftExtension(n, context)) continue;
|
||||
const owner = ownerPath(n, context);
|
||||
if (owner === null) continue;
|
||||
if (owner === path) exact.push(n);
|
||||
else if (owner.endsWith(`::${path}`) || moduleQualified(owner, path, context)) shortened.push(n);
|
||||
}
|
||||
return exact.length > 0 ? exact : shortened;
|
||||
};
|
||||
// `API.PackageController.Model(name:)` constructs a nested type.
|
||||
let fit = /^[A-Z]/.test(member) ? onPath(TYPE_KINDS) : [];
|
||||
if (fit.length === 0) fit = onPath(PATH_MEMBER_KINDS);
|
||||
// The Composable Architecture's `@Reducer enum Path { case detail(Detail) }`
|
||||
// generates `Path.State` and `Path.Action` with the same cases, so
|
||||
// `Path.State.detail(…)` constructs the case written in `Path`.
|
||||
if (fit.length === 0) {
|
||||
const reducer = /^(.+)::(?:State|Action)$/.exec(path)?.[1];
|
||||
if (reducer) {
|
||||
fit = context.getNodesByName(member).filter((n) => {
|
||||
if (n.language !== 'swift' || n.kind !== 'enum_member') return false;
|
||||
const owner = ownerPath(n, context);
|
||||
return owner !== null && (owner === reducer || owner.endsWith(`::${reducer}`)) && isReducerEnum(n, context);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fit.length === 0 || new Set(fit.map((n) => ownerPath(n, context))).size > 1) return null;
|
||||
// Of one type's overloads, the first declared.
|
||||
const target = fit.reduce((a, b) => (a.filePath < b.filePath || (a.filePath === b.filePath && a.startLine <= b.startLine) ? a : b));
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' };
|
||||
}
|
||||
|
||||
/** Is this case declared in a `@Reducer enum`? */
|
||||
function isReducerEnum(member: Node, context: ResolutionContext): boolean {
|
||||
const owner = context
|
||||
.getNodesInFile(member.filePath)
|
||||
.filter((t) => t.kind === 'enum' && t.startLine <= member.startLine && t.endLine >= member.endLine)
|
||||
.reduce<Node | null>((inner, t) => (!inner || t.startLine >= inner.startLine ? t : inner), null);
|
||||
if (!owner) return false;
|
||||
const lines = linesOf(owner.filePath, context) ?? [];
|
||||
// The node starts at its attributes; `@Reducer` may also sit on the line above.
|
||||
return /@Reducer\b/.test(lines.slice(Math.max(0, owner.startLine - 2), owner.startLine + 1).join(' '));
|
||||
}
|
||||
|
||||
/** `Vapor::HTTPStatus` for a type `HTTPStatus`: a qualifier that names no project type is a module. */
|
||||
function moduleQualified(owner: string, path: string, context: ResolutionContext): boolean {
|
||||
if (!path.endsWith(`::${owner}`)) return false;
|
||||
const head = path.slice(0, path.length - owner.length - 2);
|
||||
return !head.includes('::') && !context.getNodesByName(head).some((n) => n.language === 'swift' && TYPE_KINDS.has(n.kind));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full path of the type a member is declared on. Its qualified name
|
||||
* starts at the outermost declaration in its file, and an extension is named
|
||||
* by its last segment — the members of `extension API.PackageController {
|
||||
* enum GetRoute { … } }` are `PackageController::GetRoute::…` — so the
|
||||
* outermost extension's written path is read back from its line. Null for a
|
||||
* top-level function.
|
||||
*/
|
||||
function ownerPath(member: Node, context: ResolutionContext): string | null {
|
||||
const memo = memoFor(context).owners;
|
||||
const hit = memo.get(member.id);
|
||||
if (hit !== undefined) return hit;
|
||||
const cut = member.qualifiedName.lastIndexOf('::');
|
||||
let path: string | null = cut < 0 ? null : member.qualifiedName.slice(0, cut);
|
||||
if (path !== null) {
|
||||
const first = path.split('::')[0]!;
|
||||
const outer = context
|
||||
.getNodesInFile(member.filePath)
|
||||
.filter((t) => t.name === first && TYPE_KINDS.has(t.kind) && t.startLine <= member.startLine && t.endLine >= member.endLine)
|
||||
.reduce<Node | null>((out, t) => (!out || t.startLine < out.startLine ? t : out), null);
|
||||
const extended = outer ? extendedPath(outer, context) : null;
|
||||
if (extended) path = extended + path.slice(first.length);
|
||||
}
|
||||
memo.set(member.id, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** `extension API.PackageController {` → `API::PackageController`; null for anything but an extension of a nested type. */
|
||||
function extendedPath(node: Node, context: ResolutionContext): string | null {
|
||||
if (!isSwiftExtension(node, context)) return null;
|
||||
const lines = linesOf(node.filePath, context);
|
||||
if (!lines) return null;
|
||||
const head = [(lines[node.startLine - 1] ?? '').slice(node.startColumn), ...lines.slice(node.startLine, node.startLine + 3)]
|
||||
.join(' ')
|
||||
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
|
||||
const written = /\bextension\s+((?:[A-Za-z_]\w*\s*\.\s*)+[A-Za-z_]\w*)/.exec(head)?.[1];
|
||||
return written ? written.replace(/\s+/g, '').split('.').join('::') : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user