diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b2a46e2..1bbc06543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,6 +204,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, a Swift call written after `try!`, like `try! Message2(data: data)` or `try! Settings.Defaults.load()`, now links to what it calls, as it already did on macOS and Linux. Before, the `!` was read as a logical not, so the call linked to nothing, and a call made through a type lost the type it was made on. Re-index Swift projects on Windows after upgrading. - In JavaScript and TypeScript, a barrel's `export { A, B as C } from './x'` now links each name it passes on to what `./x` exports, following barrels that pass it on in turn. Before, each name was looked up by itself and could link to a same-named symbol elsewhere in the project, such as another folder's `Provider`, and a name passed on from a package, like `export { debounce } from 'lodash'`, could link to a project function of that name. Re-index JavaScript and TypeScript projects after upgrading. - In Swift, a call written after `!` or another prefix operator, or chained after `try` or `await`, now links to what it calls. `if !isReady()` links to `isReady`, `!store.contains(id)` and `try! JSONDecoder().decode(…)` link to the method of the type they're called on rather than to whichever `contains` or `decode` came first, and `!Settings.Defaults.isOn()` lands on the type it names. Re-index Swift projects after upgrading. +- In Swift, a call on a local created through `try`, `try!`, `try?` or `await`, or with a generic type's constructor, now links to that type's method: after `let storage = try! DiskStorage.Backend(config: config)`, `storage.store(…)` links to `Backend`'s `store`. Before, such a local had no known type, so the call was left unlinked or matched by name to another type's method. Re-index Swift projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/swift-try-initialized-locals.test.ts b/__tests__/swift-try-initialized-locals.test.ts new file mode 100644 index 000000000..ae97538ed --- /dev/null +++ b/__tests__/swift-try-initialized-locals.test.ts @@ -0,0 +1,138 @@ +/** + * A Swift local initialized through `try`, `try!`, `try?` or `await`, or with + * a generic type's constructor, has that type: a call on it is a method of + * the type constructed. + * + * `let x = try! Storage(path: p)` read as no type at all — the declaration + * patterns wanted `= Type(` — and so did `let v = Disk.Backend(…)`, so + * `x.store(1)` fell to name matching among every `store` in the project + * (Kingfisher's `let storage = try! DiskStorage.Backend(config:)` + * then `storage.store(…)`). Every target here has a namesake declared before + * it on another type, and no local is named like its type, so neither a + * first-declared nor a receiver-name guess can pass. + */ +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 = { + 'Sources/App/Decoys.swift': `final class Archive { + func store(_ v: Int) {} +} +final class Reloader { + func load() {} +} +final class Lookup { + func value(forKey k: String) -> Int? { nil } +} +final class Outbox { + func send() {} +} +`, + 'Sources/App/Types.swift': `final class Storage { + init(path: String) throws {} + func store(_ v: Int) {} +} +final class Loader { + init() async {} + func load() {} +} +enum Disk { + final class Backend { + init(config: Int) throws {} + func value(forKey k: String) throws -> T? { nil } + } +} +final class Pool { + init(capacity: Int) {} + func value(forKey k: String) -> T? { nil } +} +final class Connection { + func send() {} +} +final class Vault { + init() throws {} + func write(_ block: () -> T) -> T { block() } + func freeze() -> Vault { self } +} +final class Note { + func freeze() -> Note { self } +} +final class Builder { + init(host: String) throws {} + func connect() -> Connection { Connection() } +} +`, + 'Sources/App/Screen.swift': `final class Screen { + func a() { let x = try! Storage(path: "x"); x.store(1) } + func b() throws { let y = try Storage(path: "y"); y.store(2) } + func c() { let z = try? Storage(path: "z"); z?.store(3) } + func d() async { let w = await Loader(); w.load() } + func e() async throws { let r = try await Loader(); r.load() } + func f() throws { let v = try Disk.Backend(config: 1); _ = try v.value(forKey: "k") } + func g() { let t = Pool(capacity: 1); _ = t.value(forKey: "k") } + func h() { let u = Archive(); u.store(4) } + func i() throws { + var conn: Connection? = nil + conn = try Builder(host: "i") + .connect() + conn?.send() + } + func j() { + let obj = try! Vault().write { Note() } + _ = obj.freeze() + } +} +`, +}; + +describe('Swift: a local initialized through try, await or a generic constructor', () => { + it('has the type constructed', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-try-local-')); + 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 { + // The methods each function calls (constructions aside). + const methods = (caller: string): string[] => { + const from = cg.getNodesByName(caller).find((n) => n.kind === 'method'); + expect(from, caller).toBeDefined(); + return cg + .getOutgoingEdgesFrom([from!.id], ['calls']) + .map((e) => cg.getNode(e.target)) + .filter((n) => n && n.kind === 'method') + .map((n) => n!.qualifiedName) + .sort(); + }; + expect(methods('a')).toEqual(['Storage::store']); + expect(methods('b')).toEqual(['Storage::store']); + expect(methods('c')).toEqual(['Storage::store']); + expect(methods('d')).toEqual(['Loader::load']); + expect(methods('e')).toEqual(['Loader::load']); + expect(methods('f')).toEqual(['Disk::Backend::value']); + expect(methods('g')).toEqual(['Pool::value']); + // Already typed before: a plain constructor. + expect(methods('h')).toEqual(['Archive::store']); + // An assignment through `try` that goes on into a chain (`.connect()` on + // the next line) is no declaration of the chain head's type: the local + // keeps the type it was declared with. + expect(methods('i')).toEqual(['Builder::connect', 'Connection::send']); + // A member after the call makes the local a value of what it returns + // (realm-swift's `let obj = try! Realm().write { … }`): not a Vault. + expect(methods('j')).toContain('Vault::write'); + expect(methods('j')).not.toContain('Vault::freeze'); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b214d2de4..c24ce9579 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13101,6 +13101,13 @@ export function arrayElementType(type: string): string | undefined { */ const TYPE_ARGS = '(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*(?:\\[[\\s,]*\\]\\s*)*'; +/** + * A Swift constructor call's argument list that no member follows: closed on + * the line (two levels of nested parentheses) with no `.`/`?.` after it, or + * left open at the line's end — its arguments go on below. + */ +const SWIFT_UNCHAINED_CALL = '(?:\\((?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*\\)(?!\\s*\\??\\.)|\\([^)]*$)'; + /** * Per-language patterns that recover a local variable's (or typed parameter's) * type from its declaration/initializer. Each regex captures the type in group @@ -13375,7 +13382,16 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] ]; case 'swift': return [ - new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...) + // let lg = Logger(...), also with type arguments: `let p = Pool(…)` + new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*${TYPE_ARGS}\\(`), + // ...and through `try` / `try!` / `try?` / `await` when it declares the + // local: `let storage = try! DiskStorage.Backend(…)`. Not in a + // bare assignment — `channel = try ServerBootstrap(…)` goes on into + // `.bind(…).wait()` on the lines below, a value of another type, and + // must not hide what `var channel: Channel?` declared — nor when a + // member follows the call (`try! Realm().write { … }` is a value of + // what `write` returns). + new RegExp(`\\b(?:let|var)\\s+${r}\\b\\s*=\\s*(?:try[!?]\\s*|(?:try|await)\\s+)+([A-Z][\\w.]*)\\s*${TYPE_ARGS}${SWIFT_UNCHAINED_CALL}`), new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // let lg: Logger / param ]; case 'rust':