From 4c381aedbd43de846f547185237b0954bf029f02 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 21:03:23 +0000 Subject: [PATCH] fix(swift): a call on a constructed value is that type's member (#2203) A chained Swift call accepted any same-named member unless its name was a standard-library one: vapor's `JSONDecoder().decode(...)` and even its own `URLQueryDecoder().decode(...)` went to `ClientRequest._URLQueryContainer .decode`, IceCubes' `JSONEncoder().encode(self)` to a filter's `encode`, realm's `Realm().create(...)` to a test object's `create`. When the link before the call is a constructor - `TypeName(...)` with flat arguments - the member must belong to that type or a supertype / protocol the project gives it; an outside type (JSONDecoder) keeps only the project's extensions of it. A capitalized C function (`RLMObjectBaseObjectSchema(obj)!`) constructs nothing and is left alone. vapor -63 +62 (`URLQueryDecoder::decode`, `URLEncodedFormParser::parse`), IceCubesApp -23 +1, realm-swift -19 +19 (`Realm::create`), Alamofire -1; Kingfisher unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/swift-constructed-receiver.test.ts | 62 ++++++++++++++++++++ src/resolution/name-matcher.ts | 23 ++++++++ 3 files changed, 86 insertions(+) create mode 100644 __tests__/swift-constructed-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b607eb8b6..e5872b634 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 +- A Swift call on a value built on the spot, like `JSONDecoder().decode(…)` or `Realm().create(…)`, now resolves to that type's method, or to one it inherits. It no longer lands on some other type's method of the same name: Vapor's `JSONDecoder().decode` calls had gone to a request's private container. - A bare Lua call like `ipairs(t)`, `error("…")` or busted's `setup()` now means Lua's own function unless the file defines one. It no longer lands on a table method of the same name: telescope's `ipairs` calls had gone to a linked list's `ipairs`, and kong's `error` calls to its response module. A function's own alias of a host table member, like `local get_query = kong.request.get_query`, is now followed as well. - A C++ call into a macro-opened namespace, like `fmt::format("{}", x)`, now picks the overload its arguments fit. Before, the first overload indexed won: fmt's 1,400 `fmt::format` calls all reached the `text_style` overload in `color.h`. A narrow string literal now fits a narrow format string, a wide `L"…"` fits the wide overload, and the argument count has to fit too. - Calls inside an inline route handler now keep their receivers, so they resolve like any other method call. Before, Hono's `app.get('/', (c) => c.text('Hello'))` linked the route to the one other `text` method in the project, its client's `ClientResponse.text`; that happened in over 400 routes. A call like `userService.lookup(id)` reaches the service it names. diff --git a/__tests__/swift-constructed-receiver.test.ts b/__tests__/swift-constructed-receiver.test.ts new file mode 100644 index 000000000..a92ececa3 --- /dev/null +++ b/__tests__/swift-constructed-receiver.test.ts @@ -0,0 +1,62 @@ +/** + * A Swift call on a just-constructed value — `URLQueryDecoder().decode(…)`, + * `JSONDecoder().decode(…)` — is a member of the type constructed (or of what + * it inherits), never some other type's method of that name: vapor's + * `JSONDecoder().decode` went to a request's private + * `_URLQueryContainer.decode`, and so did its own decoders' calls. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-constructed-')); + const files: Record = { + 'Sources/App/URLQueryDecoder.swift': `struct URLQueryDecoder { + func decode(_ type: D.Type) -> Int { + return 0 + } +} +`, + 'Sources/App/Request.swift': `struct Request { + struct _Container { + func decode(_ type: D.Type) -> Int { + return 1 + } + } +} +`, + 'Sources/App/Use.swift': `import Foundation + +func run(data: Data) throws -> Int { + let a = URLQueryDecoder().decode(Int.self) + let b = try JSONDecoder().decode(Int.self, from: data) + return a + b +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Swift calls on a constructed value', () => { + it('reach the constructed type’s member, and no other type’s', () => { + const run = cg.getNodesInFile('Sources/App/Use.swift').find((n) => n.name === 'run')!; + const decodes = cg.getOutgoingEdges(run.id).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).filter((t) => t.name === 'decode').map((t) => t.qualifiedName); + expect(decodes).toEqual(['URLQueryDecoder::decode']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 44fe7894a..40d45e436 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2369,6 +2369,8 @@ interface SwiftCallShape { label: string; /** `name[…]`: a subscript of a value, not a call. */ subscript: boolean; + /** `URLEncodedFormDecoder().decode(…)`: the type the link before the call constructs. */ + constructed?: string; } function swiftCallShape(ref: UnresolvedRef, context: ResolutionContext): SwiftCallShape | null { @@ -2393,6 +2395,7 @@ function swiftCallShape(ref: UnresolvedRef, context: ResolutionContext): SwiftCa shape: 'chained', receiver: rustGoReceiverName(before.replace(/[?!]\s*\./g, '.')), label: /^\s*\(\s*([A-Za-z_]\w*)\s*:(?!:)/.exec(after)?.[1] ?? '', + constructed: /(?()]*>)?\s*\([^()]*\)\s*[?!]?\s*\.\s*$/.exec(before)?.[1], }; } @@ -2522,6 +2525,19 @@ function swiftHierarchyAt(ref: UnresolvedRef, context: ResolutionContext): Map { + const seen = new Set(); + const queue = [typeName]; + while (queue.length > 0 && seen.size < 40) { + const name = queue.shift()!; + if (seen.has(name)) continue; + seen.add(name); + queue.push(...swiftDeclOf(name, context).supers); + } + return seen; +} + /** * Of the in-scope members a bare / `self.` / `super.` Swift call could mean, * the nearest: the type's own, else its superclass's — Alamofire's @@ -2601,6 +2617,13 @@ function isSwiftCallTarget(n: Node, shape: SwiftCallShape | null, ref: Unresolve } if (owner === null) return true; if (shape.shape === 'chained') { + // A member of what the link before constructs, or of what that inherits: + // vapor's `URLEncodedFormDecoder().decode(…)` is not a request's private + // `_URLQueryContainer.decode`, `JSONDecoder().decode(…)` no project type's. + // (A capitalized C function — realm's `RLMObjectBaseObjectSchema(obj)!` — constructs nothing.) + if (shape.constructed && !context.getNodesByName(shape.constructed).some((f) => f.kind === 'function')) { + return swiftTypeClosure(shape.constructed, context).has(owner); + } if (shape.receiver === '' || !SWIFT_STD_METHODS.has(n.name)) return true; return sharesReceiverWord(shape.receiver.split('.').pop()!, n) || !swiftDeclOf(owner, context).projectType || swiftDeclaresLabel(n, shape.label, context);