mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
fix(swift): resolve imports from Package.swift targets, not path segments (#3105)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(swift): match repeated SPM target prefixes * test(swift): cover repeated SPM target prefixes * test(swift): cover valid prefix before later partial match * docs(swift): clarify target grouping parity scope * docs(swift): clarify target grouping parity scope * docs(swift): clarify target grouping parity scope * fix(swift): resolve imports from Package.swift targets, not path segments Stop fabricating IMPORTS from import Foundation onto a same-named folder. Declare modules from Package.swift when the manifest is usable; keep Sources/* for grouping and fail-open folder resolve minus SDK names. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(swift): keep empty Package.swift declarations and nested .target() deps external An inferred Sources/* folder is grouping-only. A dependency .target(name:) is not a module. Treat both as unresolved so import Foundation cannot bind to a decoy folder. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(swift): keep implicit IMPORTS intra-group and gate linear Package.swift resolve @_exported must not paint sibling files as implicit imports. A dedicated bench pins declaration-only resolve and (t_4n/t_n)/4 linearity. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Honor member-only @_exported imports, skip comments while scanning Package.swift factories, fail-open mixed helper-built target lists, and block CoreData/CoreGraphics decoy folders on the inferred path. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Match path: "." as the package root, skip block-commented Package.swift factories, read import kind from the clause only, and skip capture tests when the optional Swift grammar is missing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): scan Swift import-kind without nested regex backtracking CodeQL js/redos flagged the comment-skipping IMPORT_KIND_RE; a linear walk keeps the same kind tokens. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): linear Package.swift factory scan and gate Swift context parseSwiftPackageManifest re-walked every prefix for comments (O(n²) in factory count). Resume the scan and cover nested factories in one pass. Wire Swift into the import-target context arm now that resolveImportTarget is 5-arg. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Tighten Package.swift and import-text scanners: skip comments/strings, reject escapes, treat ident + [ as incomplete, and drop the unused factory-comment wrapper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): prettier the @_exported availability fixture Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Judge Package.swift completeness from Package(...)'s own targets: argument instead of raw-text regexes, nest block comments when reading an import kind, and strip leading ./ from declared target paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Collect Package.swift factories only from Package(targets: [...]), fail-open on computed array elements, and treat // after a label colon as a comment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Ignore stray factories when Package() exists but omits targets:. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3105) Require the Package-scan seen box so the always-true undefined guard goes away. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a578747455
commit
dcb2eb5cb4
24 changed files with 2959 additions and 174 deletions
11
.github/workflows/ci-tests.yml
vendored
11
.github/workflows/ci-tests.yml
vendored
|
|
@ -618,6 +618,17 @@ jobs:
|
|||
run: node --import tsx bench/rust-cargo-targets/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Swift Package.swift import-resolve guards (#2964, #2931)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: same baseline approach as parse-dispatch-rounds —
|
||||
# exact declared/SDK/undeclared floors plus a fingerprint, then
|
||||
# ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy,
|
||||
# parseSwiftPackageManifest 4n/n). Pins declaration-only resolve,
|
||||
# https:// factory survival, and #2931 segment-boundary membership.
|
||||
# See bench/swift-package-imports/measure.mjs.
|
||||
run: node --import tsx bench/swift-package-imports/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: MCP tools/list countRepos vs listRepos guards (#3259, #3184)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: exact registry cardinality + tool-roster + schema-flag
|
||||
|
|
|
|||
|
|
@ -659,6 +659,11 @@
|
|||
"path_segments": 13,
|
||||
"probe": "ExternalPkg0"
|
||||
},
|
||||
"context": {
|
||||
"target": "App",
|
||||
"with_context": "Sources/App/Lib.swift,Sources/Models/User.swift",
|
||||
"without_context": "Sources/App/Lib.swift"
|
||||
},
|
||||
"_measured": {
|
||||
"collide_ms": 0.819,
|
||||
"collide_scaling_ratio": 3.454,
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ const HEAP_BUDGETED = [
|
|||
* their hooks declare three or four parameters — so their numbers stay exactly
|
||||
* where they were.
|
||||
*/
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python'];
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift'];
|
||||
|
||||
/**
|
||||
* Needs `node --expose-gc` to force collection for a clean delta; without it
|
||||
|
|
@ -1869,7 +1869,7 @@ function resolveOne(lang, from, target, pass) {
|
|||
if (lang === 'swift') {
|
||||
return resolveSwiftImportTarget(
|
||||
{ kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target },
|
||||
{ fromFile: from, allFilePaths },
|
||||
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
|
||||
);
|
||||
}
|
||||
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
|
||||
|
|
@ -2326,6 +2326,26 @@ const CONTEXT_PROBE = {
|
|||
probeFile('app/main.py', [['Function', 'app.main.run']]),
|
||||
],
|
||||
},
|
||||
/**
|
||||
* `import App` where App `@_exported import`s Models. With parsedFiles the
|
||||
* re-export closure unions Models' files; without it the adapter returns
|
||||
* only App's own file. Two distinct non-null answers, so a dropped
|
||||
* `parsedFiles` cannot look like a miss.
|
||||
*/
|
||||
swift: {
|
||||
from: 'Sources/Client/Main.swift',
|
||||
target: 'App',
|
||||
parsedFiles: [
|
||||
{
|
||||
...probeFile('Sources/App/Lib.swift', [['Class', 'App.Lib']]),
|
||||
parsedImports: [
|
||||
{ kind: 'reexport', localName: 'Models', importedName: 'Models', targetRaw: 'Models' },
|
||||
],
|
||||
},
|
||||
probeFile('Sources/Models/User.swift', [['Class', 'Models.User']]),
|
||||
probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
|
||||
|
|
@ -3065,7 +3085,7 @@ expectNoOrphanKeys(
|
|||
// against a claim in a comment. `run.ts` passes the fifth argument to every
|
||||
// provider; which ones can OBSERVE it is decided by how many parameters each
|
||||
// hook declares, and that is a number the registry can be asked for. Today
|
||||
// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 —
|
||||
// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 —
|
||||
// which is why thirteen arms can ignore this whole question and their numbers
|
||||
// did not move when it was fixed.
|
||||
//
|
||||
|
|
|
|||
52
gitnexus/bench/swift-package-imports/baselines.json
Normal file
52
gitnexus/bench/swift-package-imports/baselines.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"_what": "Baselines for bench/swift-package-imports/measure.mjs --check. Guards Swift Package.swift declaration resolve after #2964 / #2931: declared-target hits, SDK and undeclared-folder misses, empty declaredTargets fail-closed, @_exported union, #2931 nested-prefix membership, first-wins grouping, and https:// factory survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.",
|
||||
|
||||
"_triage": "READ THIS BEFORE RE-RUNNING. targets, files, imports, declared_resolved, sdk_external, undeclared_external, empty_declared_external, reexport_extra, nested_repeat_resolved, first_wins, parse_targets, parse_binary_skipped, url_comment_targets, parse_complete and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. resolve_scaling_ratio, strategy_scaling_ratio and parse_scaling_ratio are the only timing arms; runner contention dominates them, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.",
|
||||
|
||||
"targets": 16,
|
||||
"files": 260,
|
||||
"imports": 12288,
|
||||
"_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. declared_resolved only asserts something while the corpus still walks many files and imports. Shrink it to one happy-path module and declared_resolved still reads 32 and still passes, asserting a property the corpus no longer has. Files-per-target is 16 and does not scale — 4x grows target count so a hit returns a constant-size file list. Scaling files-per-target instead makes the return-list copy look quadratic even when the index is linear (the same trap bench/import-target documents for Swift collide_scaling).",
|
||||
|
||||
"declared_resolved": 32,
|
||||
"sdk_external": 32,
|
||||
"undeclared_external": 16,
|
||||
"empty_declared_external": 1,
|
||||
"reexport_extra": 1,
|
||||
"nested_repeat_resolved": 1,
|
||||
"first_wins": 1,
|
||||
"_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only.",
|
||||
|
||||
"parse_targets": 3,
|
||||
"parse_binary_skipped": 1,
|
||||
"url_comment_targets": 1,
|
||||
"parse_complete": 1,
|
||||
"_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is Models+App+AppTests. parse_binary_skipped=1 means Lib/Gen/CFoo never entered the map. url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.",
|
||||
|
||||
"layout_fingerprint": "2b220baa1df63778489949f9596879ba1c89a49e62c57bd9d2336fd8d74c17bd",
|
||||
"_layout_fingerprint_note": "sha256 over sorted from|target->files rows on the unique query set. A change here is a BEHAVIOUR change — the resolver returned a different target set. Explain it, never re-baseline it alone.",
|
||||
|
||||
"resolve_scaling_budget": 1.9,
|
||||
"strategy_scaling_budget": 1.6,
|
||||
"parse_scaling_budget": 1.9,
|
||||
"_resolve_scaling_note": "(t_4n / t_n) / 4 for resolveSwiftImportTarget over the declared corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. A per-import allFilePaths scan scores ~4. Budget is 1.9, i.e. 1.51x the measured maximum 1.261 — this file's siblings use ~1.5x on ratios. min-of-15 estimator.",
|
||||
"_strategy_scaling_note": "(t_4n / t_n) / 4 for swiftPackageStrategy (the import-config WeakMap index). Measured maximum 1.030; budget 1.6 is 1.55x, matching parse-dispatch-rounds' pack_scaling_budget.",
|
||||
"_parse_scaling_note": "(t_4n / t_n) / 4 for parseSwiftPackageManifest over 256 vs 1024 factories. Measured maximum 1.276; budget 1.9 is 1.49x. The small cell is still sub-millisecond, so this budget is looser than strategy on purpose.",
|
||||
|
||||
"_measured": {
|
||||
"resolve_scaling_ratio": 1.261,
|
||||
"resolve_scaling_ratio_samples": [1.261, 1.201, 1.182, 1.241, 1.212],
|
||||
"strategy_scaling_ratio": 1.03,
|
||||
"strategy_scaling_ratio_samples": [1.019, 0.994, 0.97, 1.015, 1.03],
|
||||
"parse_scaling_ratio": 1.276,
|
||||
"parse_scaling_ratio_samples": [1.25, 1.276, 1.215, 1.224, 1.227],
|
||||
"resolve_ms": 3.5,
|
||||
"resolve_ms_4x": 17.16,
|
||||
"strategy_ms": 0.67,
|
||||
"strategy_ms_4x": 2.65,
|
||||
"parse_ms": 0.41,
|
||||
"parse_ms_4x": 2.01,
|
||||
"reps": 15
|
||||
},
|
||||
"_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them."
|
||||
}
|
||||
475
gitnexus/bench/swift-package-imports/measure.mjs
Normal file
475
gitnexus/bench/swift-package-imports/measure.mjs
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
/**
|
||||
* Build-free bench for Swift Package.swift declaration resolve (#2964 / #2931).
|
||||
*
|
||||
* WHY THIS EXISTS. `bench/import-target`'s Swift arm never threads a
|
||||
* `resolutionConfig`. It times the no-manifest directory-segment index
|
||||
* (`getSwiftModuleIndex`) and cannot see a revert that starts scanning
|
||||
* `allFilePaths` per `import`, drops declaration-only resolve, treats
|
||||
* `https://` as a comment, or matches a target dir inside another path
|
||||
* segment (#2931). Graph output on a tiny fixture is identical either way.
|
||||
* This file is the same shape as `bench/parse-dispatch-rounds`: exact floors
|
||||
* first, ratio timing only, never a millisecond ceiling.
|
||||
*
|
||||
* ARMS:
|
||||
*
|
||||
* - `targets` / `files` / `imports` — EXACT, and they are the FLOOR.
|
||||
* `declared_resolved` only asserts something while the corpus still has
|
||||
* many files and imports to walk. Shrink it to one happy-path module and
|
||||
* the resolve arm still passes, gating a property the corpus no longer has.
|
||||
*
|
||||
* - `declared_resolved` / `sdk_external` / `undeclared_external` /
|
||||
* `empty_declared_external` / `reexport_extra` / `nested_repeat_resolved` /
|
||||
* `first_wins` — EXACT. Declaration map hits stay hits. Foundation / UIKit
|
||||
* and an in-repo `CoreUI` folder that is NOT in Package.swift stay
|
||||
* external. An empty `declaredTargets` map fails every name closed. Import
|
||||
* of a module that `@_exported import`s another unions that module's files.
|
||||
* A `#2931` nested `Sources/Mod0` path still belongs to Mod0. Grouping
|
||||
* assigns a two-prefix file to the FIRST target only.
|
||||
*
|
||||
* - `parse_targets` / `parse_binary_skipped` / `url_comment_targets` /
|
||||
* `parse_complete` — EXACT. Source factories are kept; binary/plugin
|
||||
* factories are not modules; `https://` on the same line does not hide a
|
||||
* later `.target`.
|
||||
*
|
||||
* - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files`
|
||||
* rows on the unique query set. Catches a target-set change that leaves
|
||||
* the counts intact.
|
||||
*
|
||||
* - `resolve_scaling_ratio` / `strategy_scaling_ratio` / `parse_scaling_ratio`
|
||||
* — the only timing arms, RATIOS not millisecond ceilings.
|
||||
* `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear.
|
||||
* Files-per-target is FIXED while target count (and therefore files and
|
||||
* imports) grow 4x, so a hit returns a constant-size file list. A correct
|
||||
* once-per-pass index is then linear in imports; a per-import scan of
|
||||
* allFilePaths scores ~4. Parse scales factory count 4x on a fixed-shape
|
||||
* manifest.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/swift-package-imports/measure.mjs
|
||||
* node --import tsx bench/swift-package-imports/measure.mjs --check
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { parseSwiftPackageManifest } from '../../src/core/ingestion/language-config.ts';
|
||||
import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.ts';
|
||||
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
|
||||
import { groupSwiftFilesBySpmTarget } from '../../src/core/ingestion/languages/swift/target-grouping.ts';
|
||||
|
||||
const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8'));
|
||||
|
||||
const REPS = 15;
|
||||
const TARGETS_SMALL = 16;
|
||||
const FILES_PER_TARGET = 16;
|
||||
const QUERY_REPEATS = 8;
|
||||
const PARSE_TARGETS = 256;
|
||||
const SCALE = 4;
|
||||
|
||||
function queryKinds(targetCount) {
|
||||
return [
|
||||
(t) => ({ raw: `Mod${(t + 1) % targetCount}`, expect: 'declared' }),
|
||||
() => ({ raw: 'Foundation', expect: 'sdk' }),
|
||||
() => ({ raw: 'CoreUI', expect: 'undeclared' }),
|
||||
(t) => ({ raw: `Mod${(t + 1) % targetCount}.Model`, expect: 'declared' }),
|
||||
() => ({ raw: 'UIKit', expect: 'sdk' }),
|
||||
() => ({ raw: 'Ghost', expect: 'miss' }),
|
||||
];
|
||||
}
|
||||
|
||||
function parsedImport(targetRaw) {
|
||||
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
|
||||
}
|
||||
|
||||
function stubFile(filePath, parsedImports = []) {
|
||||
return {
|
||||
filePath,
|
||||
moduleScope: `module:${filePath}`,
|
||||
scopes: [],
|
||||
parsedImports,
|
||||
localDefs: [],
|
||||
referenceSites: [],
|
||||
};
|
||||
}
|
||||
|
||||
function targetMap(targetCount) {
|
||||
const targets = new Map();
|
||||
for (let t = 0; t < targetCount; t++) targets.set(`Mod${t}`, `Sources/Mod${t}`);
|
||||
return targets;
|
||||
}
|
||||
|
||||
function declaredConfig(targets) {
|
||||
return { origin: 'package.swift', targets, declaredTargets: targets };
|
||||
}
|
||||
|
||||
function buildCorpus(targetCount) {
|
||||
const targets = targetMap(targetCount);
|
||||
const files = [];
|
||||
const parsedFiles = [];
|
||||
for (let t = 0; t < targetCount; t++) {
|
||||
for (let i = 0; i < FILES_PER_TARGET; i++) {
|
||||
const filePath = `Sources/Mod${t}/File${i}.swift`;
|
||||
files.push(filePath);
|
||||
const imports =
|
||||
t === 0 && i === 0
|
||||
? [{ kind: 'reexport', localName: 'Mod1', importedName: 'Mod1', targetRaw: 'Mod1' }]
|
||||
: [];
|
||||
parsedFiles.push(stubFile(filePath, imports));
|
||||
}
|
||||
}
|
||||
|
||||
const foundation = 'Sources/Foundation/Thing.swift';
|
||||
const coreUi = 'Sources/CoreUI/View.swift';
|
||||
const nested = 'vendor/Sources/Mod0/Sources/Mod0/Nested.swift';
|
||||
const clash = 'Sources/Mod0/Vendor/Sources/Mod1/Clash.swift';
|
||||
for (const extra of [foundation, coreUi, nested, clash]) {
|
||||
files.push(extra);
|
||||
parsedFiles.push(stubFile(extra));
|
||||
}
|
||||
|
||||
const kinds = queryKinds(targetCount);
|
||||
const queries = [];
|
||||
for (let t = 0; t < targetCount; t++) {
|
||||
for (let i = 0; i < FILES_PER_TARGET; i++) {
|
||||
const from = `Sources/Mod${t}/File${i}.swift`;
|
||||
for (let r = 0; r < QUERY_REPEATS; r++) {
|
||||
for (const kind of kinds) {
|
||||
const { raw, expect } = kind(t);
|
||||
queries.push({ from, raw, expect, unique: r === 0 && i === 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
parsedFiles,
|
||||
queries,
|
||||
targets,
|
||||
config: declaredConfig(targets),
|
||||
extras: { foundation, coreUi, nested, clash },
|
||||
targetCount,
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeKey(from, raw, result) {
|
||||
if (result == null) return `${from}|${raw}-><null>`;
|
||||
const list = typeof result === 'string' ? [result] : [...result];
|
||||
return `${from}|${raw}->${list.sort().join(',')}`;
|
||||
}
|
||||
|
||||
function resultFiles(result) {
|
||||
if (result == null) return [];
|
||||
return typeof result === 'string' ? [result] : [...result];
|
||||
}
|
||||
|
||||
function resolveOne(query, allFilePaths, config, parsedFiles) {
|
||||
return resolveSwiftImportTarget(parsedImport(query.raw), {
|
||||
fromFile: query.from,
|
||||
allFilePaths,
|
||||
resolutionConfig: config,
|
||||
parsedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
function strategyCtx(files, config) {
|
||||
return {
|
||||
allFilePaths: new Set(files),
|
||||
allFileList: files,
|
||||
normalizedFileList: files.map((p) => p.replace(/\\/g, '/')),
|
||||
index: {},
|
||||
resolveCache: new Map(),
|
||||
configs: {
|
||||
tsconfigPaths: null,
|
||||
goModule: null,
|
||||
composerConfig: null,
|
||||
swiftPackageConfig: config,
|
||||
csharpConfigs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePass(corpus) {
|
||||
const allFilePaths = new Set(corpus.files);
|
||||
let resolved = 0;
|
||||
for (const query of corpus.queries) {
|
||||
if (resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles) != null) resolved++;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function strategyPass(corpus) {
|
||||
const ctx = strategyCtx(corpus.files, corpus.config);
|
||||
let resolved = 0;
|
||||
for (const query of corpus.queries) {
|
||||
if (swiftPackageStrategy(query.raw, query.from, ctx) != null) resolved++;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function manifestFor(targetCount) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
rows.push(
|
||||
` .target(name: "Mod${i}", dependencies: [.product(name: "X", package: "https://example.com/x")]),`,
|
||||
);
|
||||
if (i % 8 === 0) {
|
||||
rows.push(
|
||||
` .binaryTarget(name: "Bin${i}", url: "https://example.com/b${i}.xcframework"),`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return `let package = Package(\n name: "Demo",\n targets: [\n${rows.join('\n')}\n ]\n)\n`;
|
||||
}
|
||||
|
||||
function fastest(fn, reps) {
|
||||
fn();
|
||||
let best = Infinity;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
fn();
|
||||
best = Math.min(best, performance.now() - t0);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function correctness(corpus) {
|
||||
const allFilePaths = new Set(corpus.files);
|
||||
const records = [];
|
||||
let declaredResolved = 0;
|
||||
let sdkExternal = 0;
|
||||
let undeclaredExternal = 0;
|
||||
|
||||
const unique = corpus.queries.filter((q) => q.unique);
|
||||
for (const query of unique) {
|
||||
const result = resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles);
|
||||
records.push(outcomeKey(query.from, query.raw, result));
|
||||
if (query.expect === 'declared' && result != null) declaredResolved++;
|
||||
if (query.expect === 'sdk' && result == null) sdkExternal++;
|
||||
if (query.expect === 'undeclared' && result == null) undeclaredExternal++;
|
||||
}
|
||||
|
||||
const fromOther = 'Sources/Mod2/File0.swift';
|
||||
const reexport = resultFiles(
|
||||
resolveOne({ from: fromOther, raw: 'Mod0' }, allFilePaths, corpus.config, corpus.parsedFiles),
|
||||
);
|
||||
const reexportExtra =
|
||||
reexport.includes('Sources/Mod0/File0.swift') && reexport.includes('Sources/Mod1/File0.swift')
|
||||
? 1
|
||||
: 0;
|
||||
const nestedRepeatResolved = reexport.includes(corpus.extras.nested) ? 1 : 0;
|
||||
|
||||
const empty = resolveOne(
|
||||
{ from: fromOther, raw: 'Mod0' },
|
||||
allFilePaths,
|
||||
{ origin: 'package.swift', targets: corpus.targets, declaredTargets: new Map() },
|
||||
corpus.parsedFiles,
|
||||
);
|
||||
const emptyDeclaredExternal = empty == null ? 1 : 0;
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget(corpus.files, (p) => p, corpus.targets);
|
||||
const firstWins =
|
||||
groups.get('Mod0')?.includes(corpus.extras.clash) === true &&
|
||||
groups.get('Mod1')?.includes(corpus.extras.clash) !== true
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const parseFixed = parseSwiftPackageManifest(`
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")],
|
||||
targets: [
|
||||
.target(name: "Models"),
|
||||
.target(name: "App"),
|
||||
.binaryTarget(name: "Lib", url: "https://example.com/Lib.xcframework", checksum: "abc"),
|
||||
.testTarget(name: "AppTests"),
|
||||
.plugin(name: "Gen"),
|
||||
.systemLibrary(name: "CFoo"),
|
||||
]
|
||||
)
|
||||
`);
|
||||
const urlComment = parseSwiftPackageManifest(
|
||||
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
|
||||
);
|
||||
|
||||
return {
|
||||
declaredResolved,
|
||||
sdkExternal,
|
||||
undeclaredExternal,
|
||||
emptyDeclaredExternal,
|
||||
reexportExtra,
|
||||
nestedRepeatResolved,
|
||||
firstWins,
|
||||
parseTargets: parseFixed.targets.size,
|
||||
parseBinarySkipped:
|
||||
parseFixed.targets.has('Lib') ||
|
||||
parseFixed.targets.has('Gen') ||
|
||||
parseFixed.targets.has('CFoo')
|
||||
? 0
|
||||
: 1,
|
||||
urlCommentTargets: urlComment.targets.get('T') === 'Sources/T' ? 1 : 0,
|
||||
parseComplete: parseFixed.complete && urlComment.complete ? 1 : 0,
|
||||
fingerprint: createHash('sha256').update(records.sort().join('\n')).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
const small = buildCorpus(TARGETS_SMALL);
|
||||
const large = buildCorpus(TARGETS_SMALL * SCALE);
|
||||
const shape = correctness(small);
|
||||
|
||||
const smallResolveMs = fastest(() => resolvePass(small), REPS);
|
||||
const largeResolveMs = fastest(() => resolvePass(large), REPS);
|
||||
const resolveScaling = largeResolveMs / smallResolveMs / SCALE;
|
||||
|
||||
const smallStrategyMs = fastest(() => strategyPass(small), REPS);
|
||||
const largeStrategyMs = fastest(() => strategyPass(large), REPS);
|
||||
const strategyScaling = largeStrategyMs / smallStrategyMs / SCALE;
|
||||
|
||||
const parseSmallSrc = manifestFor(PARSE_TARGETS);
|
||||
const parseLargeSrc = manifestFor(PARSE_TARGETS * SCALE);
|
||||
const parseSmall = parseSwiftPackageManifest(parseSmallSrc);
|
||||
const parseLarge = parseSwiftPackageManifest(parseLargeSrc);
|
||||
const smallParseMs = fastest(() => parseSwiftPackageManifest(parseSmallSrc), REPS);
|
||||
const largeParseMs = fastest(() => parseSwiftPackageManifest(parseLargeSrc), REPS);
|
||||
const parseScaling = largeParseMs / smallParseMs / SCALE;
|
||||
|
||||
const files = small.files.length;
|
||||
const imports = small.queries.length;
|
||||
|
||||
console.log(`targets : ${small.targetCount} (expect ${baselines.targets})`);
|
||||
console.log(`files : ${files} (expect ${baselines.files})`);
|
||||
console.log(`imports : ${imports} (expect ${baselines.imports})`);
|
||||
console.log(
|
||||
`declared_resolved : ${shape.declaredResolved} (expect ${baselines.declared_resolved})`,
|
||||
);
|
||||
console.log(`sdk_external : ${shape.sdkExternal} (expect ${baselines.sdk_external})`);
|
||||
console.log(
|
||||
`undeclared_external : ${shape.undeclaredExternal} (expect ${baselines.undeclared_external})`,
|
||||
);
|
||||
console.log(
|
||||
`empty_declared_external: ${shape.emptyDeclaredExternal} (expect ${baselines.empty_declared_external})`,
|
||||
);
|
||||
console.log(
|
||||
`reexport_extra : ${shape.reexportExtra} (expect ${baselines.reexport_extra})`,
|
||||
);
|
||||
console.log(
|
||||
`nested_repeat_resolved : ${shape.nestedRepeatResolved} (expect ${baselines.nested_repeat_resolved})`,
|
||||
);
|
||||
console.log(`first_wins : ${shape.firstWins} (expect ${baselines.first_wins})`);
|
||||
console.log(`parse_targets : ${shape.parseTargets} (expect ${baselines.parse_targets})`);
|
||||
console.log(
|
||||
`parse_binary_skipped : ${shape.parseBinarySkipped} (expect ${baselines.parse_binary_skipped})`,
|
||||
);
|
||||
console.log(
|
||||
`url_comment_targets : ${shape.urlCommentTargets} (expect ${baselines.url_comment_targets})`,
|
||||
);
|
||||
console.log(
|
||||
`parse_complete : ${shape.parseComplete} (expect ${baselines.parse_complete})`,
|
||||
);
|
||||
console.log(`layout_fingerprint : ${shape.fingerprint}`);
|
||||
console.log(
|
||||
`resolve_scaling_ratio : ${resolveScaling.toFixed(3)} (budget <= ${baselines.resolve_scaling_budget}; ~1.0 is linear)`,
|
||||
);
|
||||
console.log(
|
||||
`strategy_scaling_ratio : ${strategyScaling.toFixed(3)} (budget <= ${baselines.strategy_scaling_budget}; ~1.0 is linear)`,
|
||||
);
|
||||
console.log(
|
||||
`parse_scaling_ratio : ${parseScaling.toFixed(3)} (budget <= ${baselines.parse_scaling_budget}; ~1.0 is linear)`,
|
||||
);
|
||||
console.log(
|
||||
`reps : ${REPS} resolve ${smallResolveMs.toFixed(2)}ms / 4x ${largeResolveMs.toFixed(2)}ms strategy ${smallStrategyMs.toFixed(2)}ms / 4x ${largeStrategyMs.toFixed(2)}ms parse ${smallParseMs.toFixed(2)}ms / 4x ${largeParseMs.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`parse_scale_shape : ${parseSmall.targets.size} -> ${parseLarge.targets.size} targets (expect ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE})`,
|
||||
);
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let failed = false;
|
||||
|
||||
if (shape.fingerprint !== baselines.layout_fingerprint) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL layout_fingerprint: ${shape.fingerprint}\n` +
|
||||
` expected ${baselines.layout_fingerprint}\n` +
|
||||
` Unique-query target set moved. Explain it; do not re-baseline alone.`,
|
||||
);
|
||||
}
|
||||
|
||||
const exact = [
|
||||
['targets', small.targetCount],
|
||||
['files', files],
|
||||
['imports', imports],
|
||||
['declared_resolved', shape.declaredResolved],
|
||||
['sdk_external', shape.sdkExternal],
|
||||
['undeclared_external', shape.undeclaredExternal],
|
||||
['empty_declared_external', shape.emptyDeclaredExternal],
|
||||
['reexport_extra', shape.reexportExtra],
|
||||
['nested_repeat_resolved', shape.nestedRepeatResolved],
|
||||
['first_wins', shape.firstWins],
|
||||
['parse_targets', shape.parseTargets],
|
||||
['parse_binary_skipped', shape.parseBinarySkipped],
|
||||
['url_comment_targets', shape.urlCommentTargets],
|
||||
['parse_complete', shape.parseComplete],
|
||||
];
|
||||
for (const [name, value] of exact) {
|
||||
if (value !== baselines[name]) {
|
||||
failed = true;
|
||||
console.error(`\nFAIL ${name}: ${value}, expected exactly ${baselines[name]}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
small.targetCount !== baselines.targets ||
|
||||
files !== baselines.files ||
|
||||
imports !== baselines.imports
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL shape: the corpus must stay large enough that declared_resolved still measures a walk.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
parseSmall.targets.size !== PARSE_TARGETS ||
|
||||
parseLarge.targets.size !== PARSE_TARGETS * SCALE
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL parse scale shape: ${parseSmall.targets.size} -> ${parseLarge.targets.size}, ` +
|
||||
`expected ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolveScaling > baselines.resolve_scaling_budget) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL resolve_scaling_ratio: ${resolveScaling.toFixed(3)} exceeds ` +
|
||||
`${baselines.resolve_scaling_budget} (~1.0 is linear).\n` +
|
||||
` resolveSwiftImportTarget grew superlinearly in file+import count — a\n` +
|
||||
` per-import scan of allFilePaths scores ~4 here. Re-run on an idle\n` +
|
||||
` machine before investigating, and check \`reps\` first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (strategyScaling > baselines.strategy_scaling_budget) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL strategy_scaling_ratio: ${strategyScaling.toFixed(3)} exceeds ` +
|
||||
`${baselines.strategy_scaling_budget} (~1.0 is linear).\n` +
|
||||
` swiftPackageStrategy grew superlinearly — usually the WeakMap target\n` +
|
||||
` index falling back to O(imports × files). Re-run idle; check \`reps\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parseScaling > baselines.parse_scaling_budget) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL parse_scaling_ratio: ${parseScaling.toFixed(3)} exceeds ` +
|
||||
`${baselines.parse_scaling_budget} (~1.0 is linear).\n` +
|
||||
` parseSwiftPackageManifest grew superlinearly in factory count.\n` +
|
||||
` Re-run on an idle machine before investigating, and check \`reps\` first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log('\nOK — within budget.');
|
||||
}
|
||||
|
|
@ -13,18 +13,22 @@
|
|||
* every strategy invocation, per `import-processor`'s build-once
|
||||
* context). Lookup per import is then O(1).
|
||||
*
|
||||
* Behavior is preserved bit-for-bit: a file is attributed to a target
|
||||
* iff its **forward-slash (backslash-normalized), case-sensitive** path
|
||||
* starts with `<targetDir>/`, matching the old
|
||||
* `normalizedFileList[i].startsWith(targetDir + '/')` comparison
|
||||
* (`normalizedFileList` is only backslash→forward-slash normalized — NOT
|
||||
* lowercased — so the match is case-sensitive); the returned paths are
|
||||
* the original-case `allFileList` entries; and the per-target file ORDER
|
||||
* follows `allFileList`, so the emitted `{ kind: 'files', files }` set and
|
||||
* ordering are identical to the old scan.
|
||||
* A file is attributed to a target iff its **forward-slash
|
||||
* (backslash-normalized), case-sensitive** path matches at a
|
||||
* **segment boundary**: it starts with `<targetDir>/` or contains
|
||||
* `/<targetDir>/`. `normalizedFileList` is
|
||||
* only backslash→forward-slash normalized — NOT lowercased — so the
|
||||
* match is case-sensitive. The returned paths are the original-case
|
||||
* `allFileList` entries, and the per-target file ORDER follows
|
||||
* `allFileList`.
|
||||
*
|
||||
* Import-config fans a file to every matching declared target. Grouping
|
||||
* (`groupSwiftFilesBySpmTarget`) is first-target-wins. That divergence
|
||||
* is intentional.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js';
|
||||
import type { ImportResolutionConfig, ImportResolverStrategy, ResolveCtx } from '../types.js';
|
||||
|
||||
interface SwiftTargetIndex {
|
||||
|
|
@ -66,10 +70,10 @@ function getSwiftTargetIndex(
|
|||
// Pre-compute each target's directory prefix once (original case, to
|
||||
// match the legacy comparison against the forward-slash-normalized,
|
||||
// case-sensitive file list — see module docstring).
|
||||
const targetPrefixes: { name: string; prefix: string }[] = [];
|
||||
const targetDirs: { name: string; prefix: string }[] = [];
|
||||
const byTarget = new Map<string, string[]>();
|
||||
for (const [name, dir] of targets) {
|
||||
targetPrefixes.push({ name, prefix: dir + '/' });
|
||||
targetDirs.push({ name, prefix: swiftDeclaredTargetPrefix(dir) });
|
||||
byTarget.set(name, []);
|
||||
}
|
||||
|
||||
|
|
@ -82,10 +86,10 @@ function getSwiftTargetIndex(
|
|||
for (let i = 0; i < ctx.allFileList.length; i++) {
|
||||
const norm = ctx.normalizedFileList[i];
|
||||
if (!norm.endsWith('.swift')) continue;
|
||||
for (const { name, prefix } of targetPrefixes) {
|
||||
if (norm.startsWith(prefix)) {
|
||||
byTarget.get(name)!.push(ctx.allFileList[i]);
|
||||
}
|
||||
for (const { name, prefix } of targetDirs) {
|
||||
if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue;
|
||||
const bucket = byTarget.get(name);
|
||||
if (bucket !== undefined) bucket.push(ctx.allFileList[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,19 +101,19 @@ function getSwiftTargetIndex(
|
|||
/** Swift Package.swift target map resolution strategy. */
|
||||
export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
|
||||
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
|
||||
if (swiftPackageConfig) {
|
||||
// Only the targets map is needed; build the index lazily so repos
|
||||
// without a Package.swift config pay nothing.
|
||||
if (swiftPackageConfig.targets.has(rawImportPath)) {
|
||||
const index = getSwiftTargetIndex(ctx, swiftPackageConfig.targets);
|
||||
const files = index.byTarget.get(rawImportPath);
|
||||
if (files !== undefined && files.length > 0) {
|
||||
// Copy so callers can't mutate the cached index bucket.
|
||||
return { kind: 'files', files: [...files] };
|
||||
}
|
||||
}
|
||||
if (swiftPackageConfig == null) return null;
|
||||
const declared = coerceDeclaredSwiftTargets(swiftPackageConfig);
|
||||
if (declared == null) return null;
|
||||
const moduleName = rawImportPath.split('.')[0];
|
||||
if (moduleName === '' || !declared.has(moduleName)) {
|
||||
return null;
|
||||
}
|
||||
return null; // External framework (Foundation, UIKit, etc.)
|
||||
const index = getSwiftTargetIndex(ctx, declared);
|
||||
const files = index.byTarget.get(moduleName);
|
||||
if (files !== undefined && files.length > 0) {
|
||||
return { kind: 'files', files: [...files] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const swiftImportConfig: ImportResolutionConfig = {
|
||||
|
|
|
|||
|
|
@ -175,9 +175,55 @@ export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEv
|
|||
}
|
||||
|
||||
/** Swift Package Manager module config */
|
||||
export type SwiftPackageConfigOrigin = 'package.swift' | 'directories';
|
||||
|
||||
export interface SwiftPackageConfig {
|
||||
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
|
||||
targets: Map<string, string>;
|
||||
/**
|
||||
* `package.swift` — extracted from a readable Package.swift with no
|
||||
* completeness hazards. Explicit import resolve may treat this as a
|
||||
* declaration map (empty means every name is external).
|
||||
* `directories` — inferred from `Sources/*` (or Package/Sources / src)
|
||||
* when no usable declaration exists. Grouping uses this; import resolve
|
||||
* must not.
|
||||
* Omitted on hand-built test configs: treated as a declaration map so
|
||||
* existing `{ targets }` fixtures stay valid.
|
||||
*/
|
||||
origin?: SwiftPackageConfigOrigin;
|
||||
/**
|
||||
* Declaration map when `origin` is `package.swift` (may be empty).
|
||||
* Grouping uses `targets`, which is this map when it is non-empty and the
|
||||
* inferred `Sources/*` map when the declaration is empty — so a
|
||||
* binary-only Package.swift does not collapse every file into `__default__`.
|
||||
*/
|
||||
declaredTargets?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaration view for explicit import resolve. `origin: 'directories'`
|
||||
* is grouping-only. A hand-built `{ targets }` with no origin stays a
|
||||
* declaration so existing fixtures keep working.
|
||||
*/
|
||||
export function coerceDeclaredSwiftTargets(
|
||||
resolutionConfig: unknown,
|
||||
): ReadonlyMap<string, string> | null {
|
||||
const config = resolutionConfig as Partial<SwiftPackageConfig> | null | undefined;
|
||||
if (config == null) return null;
|
||||
if (config.origin === 'directories') return null;
|
||||
if (config.declaredTargets instanceof Map) return config.declaredTargets;
|
||||
if (config.targets instanceof Map) return config.targets;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Segment-boundary prefix for a Package.swift `path:`. `"."` / `"./"` is the package root. */
|
||||
export function swiftDeclaredTargetPrefix(dir: string): string {
|
||||
let norm = dir.replace(/\\/g, '/');
|
||||
while (norm.startsWith('./')) {
|
||||
norm = norm.slice(2);
|
||||
}
|
||||
norm = norm.replace(/\/+$/, '');
|
||||
return norm === '' || norm === '.' ? '' : `${norm}/`;
|
||||
}
|
||||
|
||||
/** Zig package config parsed from build.zig.zon and the root build.zig */
|
||||
|
|
@ -616,12 +662,768 @@ async function collectDeclaredNamespaces(
|
|||
return structure.incomplete ? 'truncated' : 'ok';
|
||||
}
|
||||
|
||||
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
// Swift imports are module-name based (e.g., `import SiuperModel`)
|
||||
// SPM convention: Sources/<TargetName>/ or Package/Sources/<TargetName>/
|
||||
// We scan for these directories to build a target map
|
||||
const targets = new Map<string, string>();
|
||||
const SWIFT_SOURCE_FACTORY_NAMES = ['target', 'executableTarget', 'testTarget', 'macro'] as const;
|
||||
const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'plugin', 'systemLibrary'] as const;
|
||||
const SWIFT_SKIP_FACTORIES = new Set<string>(SWIFT_SKIP_FACTORY_NAMES);
|
||||
const SWIFT_FACTORY_RE = new RegExp(
|
||||
`\\.(${[...SWIFT_SOURCE_FACTORY_NAMES, ...SWIFT_SKIP_FACTORY_NAMES].join('|')})\\s*\\(`,
|
||||
'g',
|
||||
);
|
||||
function extractBalancedParen(source: string, openIndex: number): string | null {
|
||||
let depth = 0;
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = openIndex; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
// `https://` lives inside a string, already excluded above.
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(openIndex + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSwiftIdentCont(ch: string | undefined): boolean {
|
||||
return ch !== undefined && /[A-Za-z0-9_]/.test(ch);
|
||||
}
|
||||
|
||||
function skipSwiftWsAndComments(source: string, start: number): number | null {
|
||||
let i = start;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
const nl = source.indexOf('\n', i + 2);
|
||||
if (nl === -1) return null;
|
||||
i = nl + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
let depth = 1;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
continue;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First `name:` / `path:` string outside comments. Escapes and interpolations are unreadable. */
|
||||
function readSwiftFactoryField(
|
||||
block: string,
|
||||
field: 'name' | 'path',
|
||||
): { value: string | undefined; keyPresent: boolean } {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = 0; i < block.length; i++) {
|
||||
const ch = block[i];
|
||||
const next = block[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!/[A-Za-z_]/.test(ch)) continue;
|
||||
let j = i + 1;
|
||||
while (j < block.length && isSwiftIdentCont(block[j])) j++;
|
||||
if (block.slice(i, j) !== field) {
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
const colonAt = skipSwiftWsAndComments(block, j);
|
||||
if (colonAt === null || block[colonAt] !== ':') {
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
const valueAt = skipSwiftWsAndComments(block, colonAt + 1);
|
||||
if (valueAt === null) return { value: undefined, keyPresent: true };
|
||||
const quote = block[valueAt];
|
||||
if (quote !== '"' && quote !== "'") return { value: undefined, keyPresent: true };
|
||||
const parsed = readSwiftSimpleQuotedString(block, valueAt);
|
||||
if (parsed === null) return { value: undefined, keyPresent: true };
|
||||
return { value: parsed, keyPresent: true };
|
||||
}
|
||||
return { value: undefined, keyPresent: false };
|
||||
}
|
||||
|
||||
/** Quoted literal with no escapes. Any `\` (including `\u{…}` and `\(`) is unreadable. */
|
||||
function readSwiftSimpleQuotedString(source: string, openIndex: number): string | null {
|
||||
const quote = source[openIndex];
|
||||
let i = openIndex + 1;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\') return null;
|
||||
if (ch === quote) return source.slice(openIndex + 1, i);
|
||||
if (ch === '\n') return null;
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function swiftManifestHasCompletenessHazard(source: string): boolean {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
let atLineStart = true;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') {
|
||||
inLineComment = false;
|
||||
atLineStart = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
} else if (ch === '\n') {
|
||||
atLineStart = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
else if (ch === '\n') atLineStart = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
atLineStart = true;
|
||||
continue;
|
||||
}
|
||||
if (atLineStart && /\s/.test(ch)) continue;
|
||||
if (atLineStart && ch === '#') {
|
||||
if (source.startsWith('if', i + 1) && !isSwiftIdentCont(source[i + 3])) return true;
|
||||
if (source.startsWith('elseif', i + 1) && !isSwiftIdentCont(source[i + 7])) return true;
|
||||
}
|
||||
atLineStart = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface SwiftCommentScan {
|
||||
i: number;
|
||||
inString: '"' | "'" | null;
|
||||
escape: boolean;
|
||||
inLineComment: boolean;
|
||||
blockCommentDepth: number;
|
||||
}
|
||||
|
||||
function newSwiftCommentScan(): SwiftCommentScan {
|
||||
return { i: 0, inString: null, escape: false, inLineComment: false, blockCommentDepth: 0 };
|
||||
}
|
||||
|
||||
/** Resume the comment/string walk up to `upTo`. Matches are left-to-right, so this is O(n) over the file. */
|
||||
function advanceSwiftCommentScan(source: string, state: SwiftCommentScan, upTo: number): void {
|
||||
let { i, inString, escape, inLineComment, blockCommentDepth } = state;
|
||||
for (; i < upTo; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
state.i = i;
|
||||
state.inString = inString;
|
||||
state.escape = escape;
|
||||
state.inLineComment = inLineComment;
|
||||
state.blockCommentDepth = blockCommentDepth;
|
||||
}
|
||||
|
||||
function swiftPathIsUnreadable(customPath: string | undefined, hasPathKey: boolean): boolean {
|
||||
if (!hasPathKey) return false;
|
||||
return customPath === undefined || customPath === '' || customPath.includes('\\(');
|
||||
}
|
||||
|
||||
/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */
|
||||
export function parseSwiftPackageManifest(source: string): {
|
||||
targets: Map<string, string>;
|
||||
complete: boolean;
|
||||
} {
|
||||
const targets = new Map<string, string>();
|
||||
if (swiftManifestHasCompletenessHazard(source)) {
|
||||
return { targets, complete: false };
|
||||
}
|
||||
|
||||
const packageTargets = inspectSwiftPackageTargets(source);
|
||||
|
||||
SWIFT_FACTORY_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let sawUnreadableFactory = false;
|
||||
const commentScan = newSwiftCommentScan();
|
||||
let coveredEnd = -1;
|
||||
while ((match = SWIFT_FACTORY_RE.exec(source)) !== null) {
|
||||
advanceSwiftCommentScan(source, commentScan, match.index);
|
||||
if (
|
||||
commentScan.inLineComment ||
|
||||
commentScan.blockCommentDepth > 0 ||
|
||||
commentScan.inString !== null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packageTargets.sawPackage &&
|
||||
!packageTargets.arraySpans.some(([lo, hi]) => match.index >= lo && match.index <= hi)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (match.index > 0 && match.index < coveredEnd) continue;
|
||||
const kind = match[1];
|
||||
const paren = source.indexOf('(', match.index);
|
||||
const block = extractBalancedParen(source, paren);
|
||||
if (block === null) {
|
||||
sawUnreadableFactory = true;
|
||||
continue;
|
||||
}
|
||||
coveredEnd = Math.max(coveredEnd, paren + 1 + block.length + 1);
|
||||
if (SWIFT_SKIP_FACTORIES.has(kind)) continue;
|
||||
const nameField = readSwiftFactoryField(block, 'name');
|
||||
if (nameField.value === undefined || nameField.value === '') {
|
||||
sawUnreadableFactory = true;
|
||||
continue;
|
||||
}
|
||||
const name = nameField.value;
|
||||
const pathField = readSwiftFactoryField(block, 'path');
|
||||
const customPath = pathField.value;
|
||||
if (swiftPathIsUnreadable(customPath, pathField.keyPresent)) {
|
||||
sawUnreadableFactory = true;
|
||||
continue;
|
||||
}
|
||||
const dir = customPath ?? (kind === 'testTarget' ? `Tests/${name}` : `Sources/${name}`);
|
||||
const existing = targets.get(name);
|
||||
if (existing === undefined) {
|
||||
targets.set(name, dir);
|
||||
} else if (customPath !== undefined && existing === `Sources/${name}`) {
|
||||
// A later `.target(name:path:)` wins over an earlier same-name
|
||||
// factory that only implied the default path.
|
||||
targets.set(name, customPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targets,
|
||||
complete: !sawUnreadableFactory && !packageTargets.helperBuilt,
|
||||
};
|
||||
}
|
||||
|
||||
interface SwiftPackageTargetsInspection {
|
||||
helperBuilt: boolean;
|
||||
sawPackage: boolean;
|
||||
arraySpans: Array<[number, number]>;
|
||||
}
|
||||
|
||||
const SWIFT_ALL_FACTORY_NAMES = new Set<string>([
|
||||
...SWIFT_SOURCE_FACTORY_NAMES,
|
||||
...SWIFT_SKIP_FACTORY_NAMES,
|
||||
]);
|
||||
|
||||
/** Locate `Package(...)`'s `targets:` argument. Product `targets:` stay nested. */
|
||||
function inspectSwiftPackageTargets(source: string): SwiftPackageTargetsInspection {
|
||||
const arraySpans: Array<[number, number]> = [];
|
||||
let helperBuilt = false;
|
||||
const seen = { package: false };
|
||||
const unreadable = forEachSwiftPackageArgs(
|
||||
source,
|
||||
(args, argsStart) => {
|
||||
const found = inspectPackageTargetsArg(args);
|
||||
if (found.helperBuilt) {
|
||||
helperBuilt = true;
|
||||
return true;
|
||||
}
|
||||
if (found.arrayStart !== null && found.arrayEnd !== null) {
|
||||
arraySpans.push([argsStart + found.arrayStart, argsStart + found.arrayEnd]);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
seen,
|
||||
);
|
||||
return { helperBuilt: helperBuilt || unreadable, sawPackage: seen.package, arraySpans };
|
||||
}
|
||||
|
||||
/** Walk `Package(` calls outside comments/strings. Unclosed `Package(` is incomplete. */
|
||||
function forEachSwiftPackageArgs(
|
||||
source: string,
|
||||
visit: (args: string, argsStart: number) => boolean,
|
||||
seen: { package: boolean },
|
||||
): boolean {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!source.startsWith('Package', i) ||
|
||||
isSwiftIdentCont(source[i + 7]) ||
|
||||
(i > 0 && isSwiftIdentCont(source[i - 1]))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parenAt = skipSwiftWsAndComments(source, i + 7);
|
||||
if (parenAt === null || source[parenAt] !== '(') continue;
|
||||
seen.package = true;
|
||||
const args = extractBalancedParen(source, parenAt);
|
||||
if (args === null) return true;
|
||||
if (visit(args, parenAt + 1)) return true;
|
||||
i = parenAt + args.length + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inspectPackageTargetsArg(args: string): {
|
||||
helperBuilt: boolean;
|
||||
arrayStart: number | null;
|
||||
arrayEnd: number | null;
|
||||
} {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
let paren = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
const next = args[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
paren++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
paren--;
|
||||
continue;
|
||||
}
|
||||
if (paren !== 0) continue;
|
||||
if (
|
||||
!args.startsWith('targets', i) ||
|
||||
isSwiftIdentCont(args[i + 7]) ||
|
||||
(i > 0 && isSwiftIdentCont(args[i - 1]))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const colonAt = skipSwiftWsAndComments(args, i + 7);
|
||||
if (colonAt === null || args[colonAt] !== ':') {
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return classifyPackageTargetsValue(args, colonAt + 1);
|
||||
}
|
||||
return { helperBuilt: false, arrayStart: null, arrayEnd: null };
|
||||
}
|
||||
|
||||
function classifyPackageTargetsValue(
|
||||
args: string,
|
||||
afterColon: number,
|
||||
): {
|
||||
helperBuilt: boolean;
|
||||
arrayStart: number | null;
|
||||
arrayEnd: number | null;
|
||||
} {
|
||||
const start = skipSwiftWsAndComments(args, afterColon);
|
||||
if (start === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null };
|
||||
if (args[start] === '[') {
|
||||
const close = matchSwiftSquare(args, start);
|
||||
if (close === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null };
|
||||
const next = skipSwiftWsAndComments(args, close + 1);
|
||||
if (next !== null && args[next] === '+') {
|
||||
return { helperBuilt: true, arrayStart: start, arrayEnd: close };
|
||||
}
|
||||
if (packageTargetsArrayHasComputed(args, start, close)) {
|
||||
return { helperBuilt: true, arrayStart: start, arrayEnd: close };
|
||||
}
|
||||
return { helperBuilt: false, arrayStart: start, arrayEnd: close };
|
||||
}
|
||||
return { helperBuilt: true, arrayStart: null, arrayEnd: null };
|
||||
}
|
||||
|
||||
function packageTargetsArrayHasComputed(source: string, open: number, close: number): boolean {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
let paren = 0;
|
||||
let bracket = 0;
|
||||
for (let i = open; i < close; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
bracket++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ']') {
|
||||
bracket--;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
paren++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
paren--;
|
||||
continue;
|
||||
}
|
||||
if (bracket !== 1 || paren !== 0) continue;
|
||||
if (ch === ',' || /\s/.test(ch)) continue;
|
||||
if (ch === '.') {
|
||||
let j = i + 1;
|
||||
while (j < close && isSwiftIdentCont(source[j])) j++;
|
||||
const name = source.slice(i + 1, j);
|
||||
const after = skipSwiftWsAndComments(source, j);
|
||||
if (after !== null && source[after] === '(' && SWIFT_ALL_FACTORY_NAMES.has(name)) {
|
||||
const block = extractBalancedParen(source, after);
|
||||
if (block === null) return true;
|
||||
i = after + block.length + 1;
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchSwiftSquare(source: string, openIndex: number): number | null {
|
||||
let depth = 0;
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = openIndex; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') depth++;
|
||||
else if (ch === ']') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function inferSwiftDirectoryTargets(repoRoot: string): Promise<Map<string, string>> {
|
||||
const targets = new Map<string, string>();
|
||||
const sourceDirs = ['Sources', 'Package/Sources', 'src'];
|
||||
for (const sourceDir of sourceDirs) {
|
||||
try {
|
||||
|
|
@ -636,12 +1438,41 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
|||
// Directory doesn't exist
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
if (targets.size > 0) {
|
||||
if (isDev) {
|
||||
logger.info(`📦 Loaded ${targets.size} Swift package targets`);
|
||||
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
try {
|
||||
const source = await fs.readFile(path.join(repoRoot, 'Package.swift'), 'utf-8');
|
||||
const parsed = parseSwiftPackageManifest(source);
|
||||
if (parsed.complete) {
|
||||
if (isDev) {
|
||||
logger.info(`📦 Loaded ${parsed.targets.size} Swift package targets from Package.swift`);
|
||||
}
|
||||
if (parsed.targets.size > 0) {
|
||||
return {
|
||||
targets: parsed.targets,
|
||||
origin: 'package.swift',
|
||||
declaredTargets: parsed.targets,
|
||||
};
|
||||
}
|
||||
const inferred = await inferSwiftDirectoryTargets(repoRoot);
|
||||
return {
|
||||
targets: inferred,
|
||||
origin: 'package.swift',
|
||||
declaredTargets: parsed.targets,
|
||||
};
|
||||
}
|
||||
return { targets };
|
||||
} catch {
|
||||
// Missing or unreadable — fall through to inferred folders.
|
||||
}
|
||||
|
||||
const inferred = await inferSwiftDirectoryTargets(repoRoot);
|
||||
if (inferred.size > 0) {
|
||||
if (isDev) {
|
||||
logger.info(`📦 Inferred ${inferred.size} Swift source folders`);
|
||||
}
|
||||
return { targets: inferred, origin: 'directories' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@
|
|||
* Module identity: Swift has no in-source `package X` marker. Module
|
||||
* membership is the SPM target *subtree* (`Sources/<Target>/…`), threaded
|
||||
* in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`)
|
||||
* and grouped by `groupSwiftFilesBySpmTarget` — replicating legacy
|
||||
* `groupSwiftFilesByTarget`. With no scanned source dir the map is null
|
||||
* and grouped by `groupSwiftFilesBySpmTarget`. The helper preserves the
|
||||
* legacy `groupSwiftFilesByTarget` bucketing contract for ordinary layouts
|
||||
* while intentionally fixing #2931's repeated-prefix edge case. With no
|
||||
* target map (no Package.swift and no scanned `Sources/*`) the map is null
|
||||
* and all files form one `__default__` module (single-Xcode-project
|
||||
* assumption). Every pair of distinct `.swift` files in the same module
|
||||
* gets a directed IMPORTS edge in both directions (whole-module
|
||||
* visibility is symmetric).
|
||||
* assumption). An inferred folder map must keep sibling folders isolated.
|
||||
* Every pair of distinct `.swift`
|
||||
* files in the same module gets a directed IMPORTS edge in both directions
|
||||
* (whole-module visibility is symmetric).
|
||||
*
|
||||
* Node identity + edge construction mirror the generic `emitImportEdges`
|
||||
* convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)`
|
||||
|
|
@ -41,8 +44,6 @@ export function emitSwiftImplicitImportEdges(
|
|||
_nodeLookup: GraphNodeLookup,
|
||||
resolutionConfig?: unknown,
|
||||
): void {
|
||||
// Group files by SPM target subtree (the module). No-source-dir → all
|
||||
// files in one `__default__` bucket.
|
||||
const targets = coerceSwiftTargets(resolutionConfig);
|
||||
const filesByTarget = groupSwiftFilesBySpmTarget(
|
||||
parsedFiles,
|
||||
|
|
@ -51,16 +52,15 @@ export function emitSwiftImplicitImportEdges(
|
|||
);
|
||||
|
||||
for (const [, group] of filesByTarget) {
|
||||
if (group.length < 2) continue; // no siblings to import
|
||||
if (group.length < 2) continue;
|
||||
for (const source of group) {
|
||||
for (const target of group) {
|
||||
if (source.filePath === target.filePath) continue; // no self-import
|
||||
const dedupKey = `${source.filePath}->${target.filePath}`;
|
||||
|
||||
for (const dest of group) {
|
||||
if (source.filePath === dest.filePath) continue;
|
||||
const dedupKey = `${source.filePath}->${dest.filePath}`;
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPORTS', dedupKey),
|
||||
sourceId: generateId('File', source.filePath),
|
||||
targetId: generateId('File', target.filePath),
|
||||
targetId: generateId('File', dest.filePath),
|
||||
type: 'IMPORTS',
|
||||
confidence: 1.0,
|
||||
reason: 'swift-scope: implicit module visibility',
|
||||
|
|
|
|||
|
|
@ -4,32 +4,30 @@
|
|||
* `@import.name` / `@import.testable` that `interpretSwiftImport`
|
||||
* consumes.
|
||||
*
|
||||
* Swift imports are whole-module (no named members), so this is 1:1 —
|
||||
* one `import` produces exactly one import. The split layer exposes the
|
||||
* module name and the `@testable` flag without pushing raw-text parsing
|
||||
* into `interpret.ts`.
|
||||
* import Foundation → kind=namespace, source=Foundation
|
||||
* import Foo.Bar → kind=namespace, source=Foo
|
||||
* import struct Foo.Bar → kind=named, source=Foo, name=Bar
|
||||
* @testable import MyApp → kind=namespace, source=MyApp, testable=1
|
||||
* @_exported import Foo → kind=reexport, source=Foo, name=Foo
|
||||
* @_exported import struct Foo.Bar → kind=reexport, source=Foo, name=Bar
|
||||
*
|
||||
* import Foundation → kind=namespace, source=Foundation
|
||||
* import Foo.Bar → kind=namespace, source=Foo (SPM target),
|
||||
* name=Foo.Bar (full path, for reference)
|
||||
* @testable import MyApp → kind=namespace, source=MyApp, testable=1
|
||||
*
|
||||
* Verified against tree-sitter-swift 0.7.1:
|
||||
* (import_declaration
|
||||
* (modifiers (attribute (user_type (type_identifier))))? ; @testable / @_exported
|
||||
* (identifier (simple_identifier)+)) ; one per dotted segment
|
||||
* Import-kind (`struct`/`class`/…) is not a named tree-sitter child
|
||||
* (hidden `_import_kind` in 0.7.1). Read it from the statement text.
|
||||
* `@_exported` / `@testable` live on `modifiers`.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const IMPORT_KIND_TOKEN_RE = /^(struct|class|enum|protocol|func|let|var|typealias)\b/;
|
||||
|
||||
interface SwiftImportSpec {
|
||||
/** SPM target name — the first dotted segment (`Foo` in `import Foo.Bar`). */
|
||||
readonly source: string;
|
||||
/** Full dotted module path (`Foo.Bar`). */
|
||||
readonly memberName: string;
|
||||
readonly fullPath: string;
|
||||
/** True for `@testable import` (test-scope visibility; resolves identically). */
|
||||
readonly testable: boolean;
|
||||
readonly exported: boolean;
|
||||
readonly importKind: string | null;
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
|
|
@ -42,14 +40,15 @@ export function splitSwiftImport(stmtNode: SyntaxNode): CaptureMatch | null {
|
|||
|
||||
function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null {
|
||||
let testable = false;
|
||||
let exported = false;
|
||||
let identifierNode: SyntaxNode | null = null;
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'modifiers') {
|
||||
// Any attribute whose text mentions `testable` flips the flag.
|
||||
if (/\btestable\b/.test(child.text)) testable = true;
|
||||
if (swiftModifiersHaveAttribute(child.text, 'testable')) testable = true;
|
||||
if (swiftModifiersHaveAttribute(child.text, '_exported')) exported = true;
|
||||
} else if (child.type === 'identifier') {
|
||||
identifierNode = child;
|
||||
}
|
||||
|
|
@ -57,36 +56,219 @@ function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null {
|
|||
|
||||
if (identifierNode === null) return null;
|
||||
|
||||
// The module path is one or more simple_identifier children, one per
|
||||
// dotted segment. The SPM target is the FIRST segment.
|
||||
const importKind = importKindFromClause(node, identifierNode);
|
||||
|
||||
const segments: string[] = [];
|
||||
for (let i = 0; i < identifierNode.namedChildCount; i++) {
|
||||
const seg = identifierNode.namedChild(i);
|
||||
if (seg !== null && seg.type === 'simple_identifier') segments.push(seg.text);
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
// Fall back to the raw identifier text (e.g. a grammar shape we didn't
|
||||
// anticipate). Split on `.` to recover the target segment.
|
||||
const raw = identifierNode.text.trim();
|
||||
if (raw === '') return null;
|
||||
const parts = raw.split('.');
|
||||
return { source: parts[0], fullPath: raw, testable, atNode: node };
|
||||
segments.push(...raw.split('.'));
|
||||
}
|
||||
|
||||
return {
|
||||
source: segments[0],
|
||||
memberName: segments.length > 1 ? segments[segments.length - 1] : segments[0],
|
||||
fullPath: segments.join('.'),
|
||||
testable,
|
||||
exported,
|
||||
importKind,
|
||||
atNode: node,
|
||||
};
|
||||
}
|
||||
|
||||
function isSwiftIdentCont(ch: string | undefined): boolean {
|
||||
return ch !== undefined && /[A-Za-z0-9_]/.test(ch);
|
||||
}
|
||||
|
||||
/** First `word` outside comments and strings. Linear scan. */
|
||||
function indexOfBareWord(text: string, word: string): number {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
text.startsWith(word, i) &&
|
||||
!isSwiftIdentCont(text[i + word.length]) &&
|
||||
(i === 0 || !isSwiftIdentCont(text[i - 1]))
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** `@name` token in modifier text — not `_exported` / `testable` inside a string. */
|
||||
function swiftModifiersHaveAttribute(text: string, name: 'testable' | '_exported'): boolean {
|
||||
let inString: '"' | "'" | null = null;
|
||||
let escape = false;
|
||||
let inLineComment = false;
|
||||
let blockCommentDepth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockCommentDepth > 0) {
|
||||
if (ch === '*' && next === '/') {
|
||||
blockCommentDepth--;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
blockCommentDepth++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString !== null) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
blockCommentDepth = 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch !== '@') continue;
|
||||
let j = i + 1;
|
||||
while (j < text.length && /\s/.test(text[j])) j++;
|
||||
if (text.startsWith(name, j) && !isSwiftIdentCont(text[j + name.length])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Kind token from the import clause only — skip `@available(..., message: "import struct")`. */
|
||||
function importKindFromClause(node: SyntaxNode, identifierNode: SyntaxNode): string | null {
|
||||
const identRel = identifierNode.startIndex - node.startIndex;
|
||||
const before = identRel >= 0 ? node.text.slice(0, identRel) : node.text;
|
||||
const importAt = indexOfBareWord(before, 'import');
|
||||
const clause = importAt === -1 ? before : before.slice(importAt);
|
||||
return kindAfterImportKeyword(clause);
|
||||
}
|
||||
|
||||
/** After `import`, skip whitespace and comments, then read a kind token. Linear: no nested-quantifier backtracking. */
|
||||
function kindAfterImportKeyword(clause: string): string | null {
|
||||
const start = indexOfBareWord(clause, 'import');
|
||||
if (start === -1) return null;
|
||||
let i = start + 'import'.length;
|
||||
let skipped = false;
|
||||
while (i < clause.length) {
|
||||
const ch = clause[i];
|
||||
const next = clause[i + 1];
|
||||
if (/\s/.test(ch)) {
|
||||
skipped = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
const nl = clause.indexOf('\n', i + 2);
|
||||
if (nl === -1) return null;
|
||||
skipped = true;
|
||||
i = nl + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
let depth = 1;
|
||||
i += 2;
|
||||
while (i < clause.length && depth > 0) {
|
||||
if (clause[i] === '/' && clause[i + 1] === '*') {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (clause[i] === '*' && clause[i + 1] === '/') {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!skipped) return null;
|
||||
return IMPORT_KIND_TOKEN_RE.exec(clause.slice(i))?.[1] ?? null;
|
||||
}
|
||||
|
||||
function bindingKind(spec: SwiftImportSpec): 'namespace' | 'named' | 'reexport' {
|
||||
if (spec.exported) return 'reexport';
|
||||
if (spec.importKind !== null && spec.fullPath.includes('.')) return 'named';
|
||||
return 'namespace';
|
||||
}
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: SwiftImportSpec): CaptureMatch {
|
||||
const kind = bindingKind(spec);
|
||||
const nameText = kind === 'namespace' ? spec.fullPath : spec.memberName;
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, 'namespace'),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.fullPath),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, nameText),
|
||||
};
|
||||
if (spec.testable) {
|
||||
m['@import.testable'] = syntheticCapture('@import.testable', spec.atNode, '1');
|
||||
|
|
|
|||
|
|
@ -1,37 +1,27 @@
|
|||
/**
|
||||
* `resolveImportTarget` adapter for the Swift `ScopeResolver`.
|
||||
*
|
||||
* Swift's `import ModuleName` brings in a whole SPM target / framework
|
||||
* module. The scope-resolution contract passes only `allFilePaths` (no
|
||||
* `SwiftPackageConfig`), so we resolve a module name to the `.swift`
|
||||
* files under a directory segment named after the module — the SPM
|
||||
* convention `Sources/<Module>/*.swift` (and the common
|
||||
* `<Module>/*.swift` layout). This needs no manifest parsing.
|
||||
* A Package.swift declaration map (`origin: 'package.swift'`) resolves
|
||||
* only declared target names. Otherwise refuse well-known SDK module
|
||||
* names and fall back to the memoized directory-segment index so local
|
||||
* folder modules still resolve without a manifest.
|
||||
*
|
||||
* Same-module (intra-target) visibility — the bulk of Swift cross-file
|
||||
* resolution, which needs NO `import` statement — is handled separately
|
||||
* by `populateSwiftTargetSiblings` (see `target-siblings.ts`). This
|
||||
* adapter only resolves EXPLICIT `import` statements (cross-module).
|
||||
*
|
||||
* Returns all matching files (one ImportEdge per file, like Go's
|
||||
* package resolver) so every exported symbol in the module materializes
|
||||
* a binding. Returns `null` for external frameworks (Foundation, UIKit,
|
||||
* …) that have no in-repo directory.
|
||||
*
|
||||
* Performance: the directory→files grouping is memoized on the stable
|
||||
* `allFilePaths` Set identity (the same Set is threaded to every import
|
||||
* in a run), so it is built once per run — NOT once per import. Mirrors
|
||||
* Python's `getPythonFileIndex` WeakMap pattern (PR #1918).
|
||||
* Same-module visibility without `import` is `populateSwiftTargetSiblings`.
|
||||
* This adapter only resolves EXPLICIT cross-module `import`s.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js';
|
||||
import { isSwiftSdkModule } from './sdk-modules.js';
|
||||
|
||||
export interface SwiftResolveContext {
|
||||
readonly fromFile: string;
|
||||
/** `ReadonlySet` so the orchestrator's stable run-level set flows
|
||||
* straight through to the memoized index key. */
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
readonly resolutionConfig?: unknown;
|
||||
readonly parsedFiles?: readonly ParsedFile[];
|
||||
}
|
||||
|
||||
interface SwiftModuleIndex {
|
||||
|
|
@ -40,16 +30,17 @@ interface SwiftModuleIndex {
|
|||
readonly byModule: Map<string, string[]>;
|
||||
}
|
||||
|
||||
interface SwiftDeclaredFileIndex {
|
||||
readonly declared: ReadonlyMap<string, string>;
|
||||
readonly byName: ReadonlyMap<string, string[]>;
|
||||
}
|
||||
|
||||
const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet<string>): SwiftModuleIndex => {
|
||||
const byModule = new Map<string, string[]>();
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (!norm.endsWith('.swift')) continue;
|
||||
// Each interior directory segment is a candidate module name. A file
|
||||
// `Sources/Models/User.swift` is attributed to module `Sources` and
|
||||
// module `Models`; an `import Models` then resolves to it.
|
||||
const segments = norm.split('/');
|
||||
// Drop the filename (last segment); the rest are directory segments.
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
if (seg === '') continue;
|
||||
|
|
@ -65,12 +56,70 @@ const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet<string>): Swif
|
|||
return { byModule };
|
||||
});
|
||||
|
||||
export function resolveSwiftImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | readonly string[] | null {
|
||||
const SWIFT_DECLARED_INDEX = new WeakMap<ReadonlySet<string>, SwiftDeclaredFileIndex>();
|
||||
|
||||
function getDeclaredFilesByName(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
declared: ReadonlyMap<string, string>,
|
||||
): ReadonlyMap<string, string[]> {
|
||||
const hit = SWIFT_DECLARED_INDEX.get(allFilePaths);
|
||||
if (hit !== undefined && hit.declared === declared) return hit.byName;
|
||||
|
||||
const dirs = [...declared.entries()].map(([name, dir]) => ({
|
||||
name,
|
||||
prefix: swiftDeclaredTargetPrefix(dir),
|
||||
}));
|
||||
const byName = new Map<string, string[]>();
|
||||
for (const { name } of dirs) byName.set(name, []);
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
if (!norm.endsWith('.swift')) continue;
|
||||
for (const { name, prefix } of dirs) {
|
||||
if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue;
|
||||
const bucket = byName.get(name);
|
||||
if (bucket !== undefined) bucket.push(raw);
|
||||
}
|
||||
}
|
||||
|
||||
const index = { declared, byName };
|
||||
SWIFT_DECLARED_INDEX.set(allFilePaths, index);
|
||||
return byName;
|
||||
}
|
||||
|
||||
const getSwiftReexportFlag = perFileSet(
|
||||
(parsedFiles: readonly ParsedFile[]): { hasReexport: boolean } => {
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (imp.kind === 'reexport') return { hasReexport: true };
|
||||
}
|
||||
}
|
||||
return { hasReexport: false };
|
||||
},
|
||||
);
|
||||
|
||||
const getSwiftParsedByPath = perFileSet(
|
||||
(parsedFiles: readonly ParsedFile[]): ReadonlyMap<string, ParsedFile> => {
|
||||
const byPath = new Map<string, ParsedFile>();
|
||||
for (const parsed of parsedFiles) {
|
||||
byPath.set(parsed.filePath, parsed);
|
||||
}
|
||||
return byPath;
|
||||
},
|
||||
);
|
||||
|
||||
function excludeImporter(files: readonly string[], fromFile: string): string[] {
|
||||
return files.filter((f) => f !== fromFile);
|
||||
}
|
||||
|
||||
function firstSwiftModuleSegment(targetRaw: string): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
const moduleName = targetRaw.split('.')[0];
|
||||
return moduleName === '' ? null : moduleName;
|
||||
}
|
||||
|
||||
function narrowContext(workspaceIndex: WorkspaceIndex): SwiftResolveContext | null {
|
||||
const ctx = workspaceIndex as SwiftResolveContext | undefined;
|
||||
// Duck-type the set (PR #1918 P2: don't `instanceof Set`).
|
||||
const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
|
|
@ -80,18 +129,84 @@ export function resolveSwiftImportTarget(
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Swift import target is the SPM module name (first dotted segment).
|
||||
const targetRaw = parsedImport.targetRaw;
|
||||
if (targetRaw === null || targetRaw === '') return null;
|
||||
const moduleName = targetRaw.split('.')[0];
|
||||
/** Module files only — no @_exported closure. Null means external / unknown. */
|
||||
function resolveSwiftModuleFiles(moduleName: string, ctx: SwiftResolveContext): string[] | null {
|
||||
if (moduleName === '') return null;
|
||||
|
||||
const declared = coerceDeclaredSwiftTargets(ctx.resolutionConfig);
|
||||
if (declared !== null) {
|
||||
if (!declared.has(moduleName)) return null;
|
||||
const files = getDeclaredFilesByName(ctx.allFilePaths, declared).get(moduleName);
|
||||
if (files === undefined) return null;
|
||||
const out = excludeImporter(files, ctx.fromFile);
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
if (isSwiftSdkModule(moduleName)) return null;
|
||||
|
||||
const index = getSwiftModuleIndex(ctx.allFilePaths);
|
||||
const files = index.byModule.get(moduleName);
|
||||
if (files === undefined || files.length === 0) return null; // external framework
|
||||
|
||||
// Exclude the importer itself (a file under `Foo/` importing `Foo`).
|
||||
const out = files.filter((f) => f !== ctx.fromFile);
|
||||
if (files === undefined || files.length === 0) return null;
|
||||
const out = excludeImporter(files, ctx.fromFile);
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function expandSwiftReexportFiles(seed: readonly string[], ctx: SwiftResolveContext): string[] {
|
||||
const parsedFiles = ctx.parsedFiles;
|
||||
if (parsedFiles === undefined || parsedFiles.length === 0) return [...seed];
|
||||
if (!getSwiftReexportFlag(parsedFiles).hasReexport) return [...seed];
|
||||
const byPath = getSwiftParsedByPath(parsedFiles);
|
||||
|
||||
const seenModules = new Set<string>();
|
||||
const out = new Set(seed);
|
||||
const queue = [...seed];
|
||||
let head = 0;
|
||||
|
||||
while (head < queue.length) {
|
||||
const file = queue[head++];
|
||||
const parsed = byPath.get(file);
|
||||
if (parsed === undefined) continue;
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (imp.kind !== 'reexport') continue;
|
||||
const targetRaw = imp.targetRaw;
|
||||
if (targetRaw === null) continue;
|
||||
const moduleName = firstSwiftModuleSegment(targetRaw);
|
||||
if (moduleName === null || seenModules.has(moduleName)) continue;
|
||||
// `@_exported import struct Models.User` re-exports User, not Models.
|
||||
// File-level resolve cannot attribute a member to a file, so skip
|
||||
// the whole-module enqueue rather than painting every Models file.
|
||||
if (imp.importedName !== moduleName) continue;
|
||||
seenModules.add(moduleName);
|
||||
const more = resolveSwiftModuleFiles(moduleName, ctx);
|
||||
if (more === null) continue;
|
||||
for (const next of more) {
|
||||
if (out.has(next)) continue;
|
||||
out.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function resolveSwiftImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | readonly string[] | null {
|
||||
const ctx = narrowContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
|
||||
const targetRaw = parsedImport.targetRaw;
|
||||
if (targetRaw === null) return null;
|
||||
const moduleName = firstSwiftModuleSegment(targetRaw);
|
||||
if (moduleName === null) return null;
|
||||
|
||||
const files = resolveSwiftModuleFiles(moduleName, ctx);
|
||||
if (files === null) return null;
|
||||
const expanded = expandSwiftReexportFiles(files, ctx);
|
||||
return expanded.length > 0 ? expanded : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,17 +19,23 @@ export function interpretSwiftImport(captures: CaptureMatch): ParsedImport | nul
|
|||
const sourceCap = captures['@import.source'];
|
||||
if (sourceCap === undefined) return null;
|
||||
|
||||
// Swift imports are whole-module (wildcard semantics): `import Foundation`
|
||||
// brings the entire module into scope, no named members. The SPM target
|
||||
// (first dotted segment) is the resolution target; the full path is kept
|
||||
// as importedName for reference. `@testable` resolves identically to a
|
||||
// plain import (same module is visible in test scope).
|
||||
const source = sourceCap.text;
|
||||
const fullPath = captures['@import.name']?.text ?? source;
|
||||
const name = captures['@import.name']?.text ?? source;
|
||||
const kindText = captures['@import.kind']?.text;
|
||||
|
||||
if (kindText === 'reexport' || kindText === 'named') {
|
||||
return {
|
||||
kind: kindText,
|
||||
localName: name,
|
||||
importedName: name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: source,
|
||||
importedName: fullPath,
|
||||
importedName: name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,14 +37,15 @@
|
|||
* Array where Element: Equatable`) are not narrowed — the `Self`
|
||||
* type of a protocol method resolves to the protocol, not the
|
||||
* conforming type.
|
||||
* 2. **Cross-module `import` resolution** is still directory-segment
|
||||
* based (`import Foo` → files under a `Foo/` dir); explicit imports do
|
||||
* not yet consult the SPM target map (follow-up, tracked under #1935).
|
||||
* Same-target visibility (the common case) IS SPM-target-subtree
|
||||
* accurate — handled by sibling augmentation grouped via
|
||||
* `groupSwiftFilesBySpmTarget`, not by explicit imports.
|
||||
* 2. **Cross-module `import` resolution** uses a Package.swift
|
||||
* declaration map when one is present, otherwise the directory-segment
|
||||
* index minus well-known SDK module names (#2964). Same-target
|
||||
* visibility (the common case) is SPM-target-subtree grouping via
|
||||
* `groupSwiftFilesBySpmTarget`, not explicit imports.
|
||||
* 3. **Operator / subscript overloads** dispatch by name only.
|
||||
* 4. **`@_exported import` re-exports** are treated as plain imports.
|
||||
* 4. **`@_exported import`** is `ParsedImport` `kind: 'reexport'` and
|
||||
* in-repo modules are closed transitively at resolve time. `public
|
||||
* import` is not a re-export.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
|
@ -87,12 +88,18 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
// `goScopeResolver`'s `loadGoModulePath`.
|
||||
loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath),
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: SwiftResolveContext = { fromFile, allFilePaths };
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => {
|
||||
const ws: SwiftResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
resolutionConfig,
|
||||
parsedFiles: context?.parsedFiles,
|
||||
};
|
||||
return resolveSwiftImportTarget(
|
||||
interpretSwiftImport({
|
||||
'@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE },
|
||||
}) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw },
|
||||
context?.parsedImport ??
|
||||
interpretSwiftImport({
|
||||
'@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE },
|
||||
}) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
29
gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts
Normal file
29
gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Well-known Apple / Swift SDK module names that must not bind to a
|
||||
* same-named in-repo folder on the no-manifest path (#2964).
|
||||
*
|
||||
* Modeled on `CSHARP_EXTERNAL_ROOTS`. A Package.swift target that uses
|
||||
* one of these names still wins — this set is only the inferred/null path.
|
||||
*/
|
||||
export const SWIFT_SDK_MODULES: ReadonlySet<string> = new Set([
|
||||
'Foundation',
|
||||
'UIKit',
|
||||
'SwiftUI',
|
||||
'AppKit',
|
||||
'Combine',
|
||||
'Dispatch',
|
||||
'Darwin',
|
||||
'ObjectiveC',
|
||||
'Swift',
|
||||
'CoreFoundation',
|
||||
'CoreData',
|
||||
'CoreGraphics',
|
||||
'XCTest',
|
||||
'Testing',
|
||||
'Observation',
|
||||
'os',
|
||||
]);
|
||||
|
||||
export function isSwiftSdkModule(name: string): boolean {
|
||||
return SWIFT_SDK_MODULES.has(name);
|
||||
}
|
||||
|
|
@ -9,36 +9,31 @@
|
|||
* drops cross-directory same-module edges and can mis-resolve a
|
||||
* constructor call to a wrong same-simple-named type in another target.
|
||||
*
|
||||
* This module duplicates the legacy `groupSwiftFilesByTarget`
|
||||
* (`languages/swift.ts`) semantics **verbatim** so the registry-primary
|
||||
* path matches legacy SPM-subtree grouping without touching the legacy
|
||||
* pipeline (hard constraint: legacy stays byte-identical). The SPM target
|
||||
* map is threaded in via the `resolutionConfig` channel
|
||||
* The SPM target map is threaded in via the `resolutionConfig` channel
|
||||
* (`loadSwiftPackageConfig` → `resolutionConfig` → these hooks); see
|
||||
* `scope-resolver.ts` and `scope-resolution/pipeline/run.ts`.
|
||||
*
|
||||
* NOTE: This intentionally differs from the import-config module's
|
||||
* leading-`startsWith` (`import-resolvers/configs/swift.ts`): that module
|
||||
* fans a file out to EVERY matching target (a nested file can belong to
|
||||
* multiple configured target dirs there), whereas legacy module grouping
|
||||
* assigns each file to the FIRST matching target only (legacy `break`s) —
|
||||
* one bucket per file. Do not copy the import-config behavior here.
|
||||
* Path matching is the same segment-boundary rule as import-config
|
||||
* (`fileMatchesSwiftTargetDir`). Grouping assigns each file to the FIRST
|
||||
* matching target; import-config fans a file out to every matching target.
|
||||
*/
|
||||
|
||||
import type { SwiftPackageConfig } from '../../language-config.js';
|
||||
import { swiftDeclaredTargetPrefix, type SwiftPackageConfig } from '../../language-config.js';
|
||||
export { coerceDeclaredSwiftTargets } from '../../language-config.js';
|
||||
|
||||
const DEFAULT_TARGET = '__default__';
|
||||
|
||||
/**
|
||||
* Group `items` by SPM target subtree, replicating legacy
|
||||
* `groupSwiftFilesByTarget` semantics exactly:
|
||||
* Group `items` by SPM target subtree:
|
||||
*
|
||||
* - `targets` null/empty (no scanned source dir found) → ALL items go to
|
||||
* a single `__default__` bucket (single-Xcode-project assumption).
|
||||
* - Otherwise: a file matches a target when its normalized path either
|
||||
* starts with `<targetDir>/` (`indexOf === 0`) OR contains it at a `/`
|
||||
* boundary (`norm[idx - 1] === '/'`). Each file is assigned to the
|
||||
* FIRST matching target only (one bucket per file, no fan-out).
|
||||
* starts with `<targetDir>/` or contains `/<targetDir>/` at a segment
|
||||
* boundary. Using a segment-aware suffix search matters when an earlier,
|
||||
* non-boundary occurrence of the same text appears in the path (#2931).
|
||||
* - Each file is assigned to the FIRST matching target only (one bucket per
|
||||
* file, no fan-out).
|
||||
* - Files matching no target fall into the `__default__` bucket.
|
||||
*
|
||||
* `targets` is `name → directory` (the `SwiftPackageConfig.targets` map).
|
||||
|
|
@ -53,10 +48,9 @@ export function groupSwiftFilesBySpmTarget<T>(
|
|||
return new Map([[DEFAULT_TARGET, [...items]]]);
|
||||
}
|
||||
|
||||
// Pre-convert target dirs to normalized prefix format once.
|
||||
const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({
|
||||
name,
|
||||
prefix: dir.replace(/\\/g, '/') + '/',
|
||||
prefix: swiftDeclaredTargetPrefix(dir),
|
||||
}));
|
||||
|
||||
const groups = new Map<string, T[]>();
|
||||
|
|
@ -67,8 +61,7 @@ export function groupSwiftFilesBySpmTarget<T>(
|
|||
const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath;
|
||||
let assigned = false;
|
||||
for (const { name, prefix } of targetPrefixes) {
|
||||
const idx = normalized.indexOf(prefix);
|
||||
if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) {
|
||||
if (pathMatchesTargetPrefix(normalized, prefix)) {
|
||||
let group = groups.get(name);
|
||||
if (group === undefined) {
|
||||
group = [];
|
||||
|
|
@ -102,3 +95,13 @@ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap<strin
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathMatchesTargetPrefix(normalizedPath: string, prefix: string): boolean {
|
||||
if (prefix === '') return true;
|
||||
return normalizedPath.startsWith(prefix) || normalizedPath.includes(`/${prefix}`);
|
||||
}
|
||||
|
||||
/** Segment-boundary membership used by grouping and declared import resolve. */
|
||||
export function fileMatchesSwiftTargetDir(normalizedPath: string, targetDir: string): boolean {
|
||||
return pathMatchesTargetPrefix(normalizedPath, swiftDeclaredTargetPrefix(targetDir));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@
|
|||
* Module identity: Swift has no in-source `package X` marker. The SPM
|
||||
* target is a directory subtree (`Sources/<Target>/…`). Module membership
|
||||
* is threaded in via the SPM target map (`ctx.resolutionConfig` →
|
||||
* `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`,
|
||||
* replicating legacy `wireSwiftImplicitImports`'s `groupSwiftFilesByTarget`:
|
||||
* files are grouped by SPM target subtree when a package config is present,
|
||||
* else ALL Swift files form one module (`__default__`,
|
||||
* `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`.
|
||||
* The helper preserves the legacy `wireSwiftImplicitImports` bucketing
|
||||
* contract for ordinary layouts (first target wins and unmatched files use
|
||||
* `__default__`) but intentionally fixes #2931's repeated-prefix edge case.
|
||||
* When a target map is present (declared Package.swift or inferred
|
||||
* `Sources/*` folders), files are grouped by SPM target subtree;
|
||||
* otherwise ALL Swift files form one module (`__default__`,
|
||||
* single-Xcode-project assumption). Every `.swift` file in the same target
|
||||
* sees its siblings' top-level defs.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -485,6 +485,31 @@ describe('swiftPackageStrategy', () => {
|
|||
const result = swiftPackageStrategy('Foundation', 'App.swift', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve inferred grouping folders when the declaration is empty', () => {
|
||||
const ctx = makeCtx(['Sources/Foundation/Thing.swift', 'Sources/App/main.swift'], {
|
||||
swiftPackageConfig: {
|
||||
origin: 'package.swift',
|
||||
targets: new Map([
|
||||
['Foundation', 'Sources/Foundation'],
|
||||
['App', 'Sources/App'],
|
||||
]),
|
||||
declaredTargets: new Map(),
|
||||
},
|
||||
});
|
||||
expect(swiftPackageStrategy('Foundation', 'Sources/App/main.swift', ctx)).toBeNull();
|
||||
expect(swiftPackageStrategy('App', 'Sources/App/main.swift', ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an inferred directories origin', () => {
|
||||
const ctx = makeCtx(['Sources/Models/User.swift'], {
|
||||
swiftPackageConfig: {
|
||||
origin: 'directories',
|
||||
targets: new Map([['Models', 'Sources/Models']]),
|
||||
},
|
||||
});
|
||||
expect(swiftPackageStrategy('Models', 'Sources/App/main.swift', ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rubyRequireStrategy', () => {
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
resolutionConfig: undefined,
|
||||
external: 'Foundation',
|
||||
decoy: 'Sources/Foundation/Thing.swift',
|
||||
reachesDecoy: 'Models',
|
||||
reachesDecoy: 'Sources',
|
||||
},
|
||||
],
|
||||
[
|
||||
|
|
@ -417,7 +417,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
*/
|
||||
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
|
||||
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
|
||||
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
|
||||
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
|
||||
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { groupSwiftFilesBySpmTarget } from '../../../src/core/ingestion/languages/swift/target-grouping.js';
|
||||
|
||||
describe('groupSwiftFilesBySpmTarget', () => {
|
||||
it('matches a later segment-aligned target prefix after an earlier partial occurrence', () => {
|
||||
const targets = new Map([['Core', 'Modules/Core']]);
|
||||
const items = ['vendor/SubModules/Core/shim/Modules/Core/Thing.swift'];
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets);
|
||||
|
||||
expect(groups.get('Core')).toEqual(items);
|
||||
expect(groups.get('__default__')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an earlier segment-aligned match when a later occurrence is partial', () => {
|
||||
const targets = new Map([['Core', 'Modules/Core']]);
|
||||
const items = ['Modules/Core/Thing/SubModules/Core/Thing.swift'];
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets);
|
||||
|
||||
expect(groups.get('Core')).toEqual(items);
|
||||
expect(groups.get('__default__')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat a partial path segment as a target match', () => {
|
||||
const targets = new Map([['Core', 'Modules/Core']]);
|
||||
const item = 'vendor/SubModules/Core/Thing.swift';
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets);
|
||||
|
||||
expect(groups.get('Core')).toBeUndefined();
|
||||
expect(groups.get('__default__')).toEqual([item]);
|
||||
});
|
||||
|
||||
it('keeps first-target-wins behavior when target paths overlap', () => {
|
||||
const targets = new Map([
|
||||
['Outer', 'Sources'],
|
||||
['Inner', 'Sources/Feature'],
|
||||
]);
|
||||
const item = 'Sources/Feature/Thing.swift';
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets);
|
||||
|
||||
expect(groups.get('Outer')).toEqual([item]);
|
||||
expect(groups.get('Inner')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Intra-group implicit IMPORTS. @_exported is client-facing
|
||||
* (`resolveSwiftImportTarget`); siblings in the exporting target
|
||||
* must not gain edges to the reexported module.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../../../src/core/graph/graph.js';
|
||||
import { generateId } from '../../../../src/lib/utils.js';
|
||||
import { emitSwiftImplicitImportEdges } from '../../../../src/core/ingestion/languages/swift/implicit-imports.js';
|
||||
import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
|
||||
|
||||
const DECLARED = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([
|
||||
['A', 'Sources/A'],
|
||||
['B', 'Sources/B'],
|
||||
]),
|
||||
};
|
||||
|
||||
function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile {
|
||||
return {
|
||||
filePath,
|
||||
moduleScope: `module:${filePath}` as ScopeId,
|
||||
scopes: [],
|
||||
parsedImports,
|
||||
localDefs: [],
|
||||
referenceSites: [],
|
||||
};
|
||||
}
|
||||
|
||||
function reexport(targetRaw: string): ParsedImport {
|
||||
return { kind: 'reexport', localName: targetRaw, importedName: targetRaw, targetRaw };
|
||||
}
|
||||
|
||||
function ns(targetRaw: string): ParsedImport {
|
||||
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
|
||||
}
|
||||
|
||||
function importPair(rels: readonly { sourceId: string; targetId: string; type: string }[]) {
|
||||
return rels
|
||||
.filter((rel) => rel.type === 'IMPORTS')
|
||||
.map((rel) => `${rel.sourceId}->${rel.targetId}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('emitSwiftImplicitImportEdges', () => {
|
||||
it('does not paint sibling files into a @_exported module', () => {
|
||||
const a = 'Sources/A/A.swift';
|
||||
const other = 'Sources/A/Other.swift';
|
||||
const b = 'Sources/B/B.swift';
|
||||
const parsed = [stubFile(a, [reexport('B')]), stubFile(other), stubFile(b)];
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
emitSwiftImplicitImportEdges(graph, parsed, new Map(), DECLARED);
|
||||
|
||||
const pairs = importPair(graph.relationships);
|
||||
expect(pairs).toEqual(
|
||||
[
|
||||
`${generateId('File', a)}->${generateId('File', other)}`,
|
||||
`${generateId('File', other)}->${generateId('File', a)}`,
|
||||
].sort(),
|
||||
);
|
||||
expect(pairs.some((pair) => pair.includes(generateId('File', b)))).toBe(false);
|
||||
|
||||
const fromApp = resolveSwiftImportTarget(ns('A'), {
|
||||
fromFile: 'Sources/App/main.swift',
|
||||
allFilePaths: new Set([a, other, b, 'Sources/App/main.swift']),
|
||||
resolutionConfig: DECLARED,
|
||||
parsedFiles: parsed,
|
||||
});
|
||||
expect(fromApp).toEqual(expect.arrayContaining([a, other, b]));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Swift import capture: import-kind, @_exported, and module path (R5, R6).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SupportedLanguages } from '../../../../src/config/supported-languages.js';
|
||||
import { emitSwiftScopeCaptures } from '../../../../src/core/ingestion/languages/swift/index.js';
|
||||
import { interpretSwiftImport } from '../../../../src/core/ingestion/languages/swift/interpret.js';
|
||||
import { isLanguageAvailable } from '../../../../src/core/tree-sitter/parser-loader.js';
|
||||
|
||||
function importsOf(src: string) {
|
||||
return emitSwiftScopeCaptures(src, 'Probe.swift')
|
||||
.map((match) => interpretSwiftImport(match))
|
||||
.filter((imp): imp is NonNullable<typeof imp> => imp !== null);
|
||||
}
|
||||
|
||||
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
|
||||
|
||||
describe.skipIf(!swiftAvailable)('interpretSwiftImport via emitSwiftScopeCaptures', () => {
|
||||
it('import Foundation is a namespace, not exported', () => {
|
||||
expect(importsOf('import Foundation')).toEqual([
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'Foundation',
|
||||
importedName: 'Foundation',
|
||||
targetRaw: 'Foundation',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('import struct Models.User is a named binding of User', () => {
|
||||
expect(importsOf('import struct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows a block comment between import and its kind', () => {
|
||||
expect(importsOf('import /* selected API */ struct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows a line comment between import and its kind', () => {
|
||||
expect(importsOf('import // selected API\nstruct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the kind after a long block comment that repeats star-slash-slash-star', () => {
|
||||
const noise = '*//*'.repeat(80);
|
||||
expect(importsOf(`import /* ${noise} */ struct Models.User`)).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not take import kind from an @available message string', () => {
|
||||
expect(
|
||||
importsOf('@available(*, deprecated, message: "import struct") import Foo.Bar'),
|
||||
).toMatchObject([{ kind: 'namespace', targetRaw: 'Foo' }]);
|
||||
});
|
||||
|
||||
it('preserves @testable as the same module', () => {
|
||||
const [imp] = importsOf('@testable import App');
|
||||
expect(imp).toMatchObject({
|
||||
kind: 'namespace',
|
||||
targetRaw: 'App',
|
||||
});
|
||||
});
|
||||
|
||||
it('@_exported import Models is a reexport of the module handle', () => {
|
||||
expect(importsOf('@_exported import Models')).toEqual([
|
||||
{
|
||||
kind: 'reexport',
|
||||
localName: 'Models',
|
||||
importedName: 'Models',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('@_exported import struct Models.User is a reexport of User', () => {
|
||||
expect(importsOf('@_exported import struct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'reexport',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('public import Models is not a reexport', () => {
|
||||
expect(importsOf('public import Models')).toEqual([
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'Models',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat _exported inside an @available message as @_exported', () => {
|
||||
expect(importsOf('@available(*, deprecated, message: "_exported") import Models')).toEqual([
|
||||
{
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'Models',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the kind after a comment that itself contains import', () => {
|
||||
expect(importsOf('import /* import */ struct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the kind after nested block comments', () => {
|
||||
expect(importsOf('import /* outer /* inner */ */ struct Models.User')).toEqual([
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'User',
|
||||
importedName: 'User',
|
||||
targetRaw: 'Models',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
129
gitnexus/test/unit/scope-resolution/swift/import-target.test.ts
Normal file
129
gitnexus/test/unit/scope-resolution/swift/import-target.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Registry-primary Swift import resolve (PR 3105 / #2964).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
|
||||
|
||||
const AE1_FILES = [
|
||||
'Sources/Foundation/Thing.swift',
|
||||
'Sources/Models/User.swift',
|
||||
'Sources/App/main.swift',
|
||||
] as const;
|
||||
|
||||
function ns(targetRaw: string): ParsedImport {
|
||||
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
|
||||
}
|
||||
|
||||
function resolve(
|
||||
targetRaw: string,
|
||||
files: readonly string[] = AE1_FILES,
|
||||
fromFile = 'Sources/App/main.swift',
|
||||
resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
return resolveSwiftImportTarget(ns(targetRaw), {
|
||||
fromFile,
|
||||
allFilePaths: new Set(files),
|
||||
resolutionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveSwiftImportTarget — inferred / no config (R7)', () => {
|
||||
it('AE1: Foundation is null; Models reaches User.swift', () => {
|
||||
expect(resolve('Foundation')).toBeNull();
|
||||
expect(resolve('Models')).toEqual(['Sources/Models/User.swift']);
|
||||
});
|
||||
|
||||
it('AE6: UIKit / CoreData / CoreGraphics are null on inferred decoys', () => {
|
||||
const files = [
|
||||
...AE1_FILES,
|
||||
'Sources/UIKit/Thing.swift',
|
||||
'Sources/CoreData/Thing.swift',
|
||||
'Sources/CoreGraphics/Thing.swift',
|
||||
];
|
||||
const inferred = {
|
||||
origin: 'directories' as const,
|
||||
targets: new Map([
|
||||
['App', 'Sources/App'],
|
||||
['Models', 'Sources/Models'],
|
||||
['Foundation', 'Sources/Foundation'],
|
||||
['UIKit', 'Sources/UIKit'],
|
||||
['CoreData', 'Sources/CoreData'],
|
||||
['CoreGraphics', 'Sources/CoreGraphics'],
|
||||
]),
|
||||
};
|
||||
expect(resolve('UIKit', files, 'Sources/App/main.swift', inferred)).toBeNull();
|
||||
expect(resolve('CoreData', files, 'Sources/App/main.swift', inferred)).toBeNull();
|
||||
expect(resolve('CoreGraphics', files, 'Sources/App/main.swift', inferred)).toBeNull();
|
||||
expect(resolve('Models', files, 'Sources/App/main.swift', inferred)).toEqual([
|
||||
'Sources/Models/User.swift',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSwiftImportTarget — declared Package.swift (R3, R4)', () => {
|
||||
const declared = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([
|
||||
['Models', 'Sources/Models'],
|
||||
['App', 'Sources/App'],
|
||||
]),
|
||||
};
|
||||
|
||||
it('AE2: Models reaches files; Foundation is null', () => {
|
||||
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', declared)).toEqual([
|
||||
'Sources/Models/User.swift',
|
||||
]);
|
||||
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', declared)).toBeNull();
|
||||
});
|
||||
|
||||
it('empty declared map makes every name external, including Foundation', () => {
|
||||
const empty = { origin: 'package.swift' as const, targets: new Map<string, string>() };
|
||||
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull();
|
||||
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull();
|
||||
});
|
||||
|
||||
it('empty declaredTargets with inferred grouping folders stays external', () => {
|
||||
const loadedShape = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([
|
||||
['Foundation', 'Sources/Foundation'],
|
||||
['App', 'Sources/App'],
|
||||
['Models', 'Sources/Models'],
|
||||
]),
|
||||
declaredTargets: new Map<string, string>(),
|
||||
};
|
||||
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull();
|
||||
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull();
|
||||
});
|
||||
|
||||
it('a declared target literally named Foundation still resolves', () => {
|
||||
const namedFoundation = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([['Foundation', 'Sources/Foundation']]),
|
||||
};
|
||||
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', namedFoundation)).toEqual([
|
||||
'Sources/Foundation/Thing.swift',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a declared path: "." target reaches root-level Swift files', () => {
|
||||
const files = ['Lib.swift', 'Sources/Other/X.swift'];
|
||||
expect(
|
||||
resolve('Lib', files, 'Sources/App/main.swift', {
|
||||
origin: 'package.swift',
|
||||
targets: new Map([['Lib', '.']]),
|
||||
}),
|
||||
).toEqual(['Lib.swift', 'Sources/Other/X.swift']);
|
||||
});
|
||||
|
||||
it('excludes the importer from its own module file list', () => {
|
||||
const files = ['Sources/Models/User.swift', 'Sources/Models/Other.swift'];
|
||||
expect(
|
||||
resolve('Models', files, 'Sources/Models/User.swift', {
|
||||
origin: 'package.swift',
|
||||
targets: new Map([['Models', 'Sources/Models']]),
|
||||
}),
|
||||
).toEqual(['Sources/Models/Other.swift']);
|
||||
});
|
||||
});
|
||||
452
gitnexus/test/unit/scope-resolution/swift/package-config.test.ts
Normal file
452
gitnexus/test/unit/scope-resolution/swift/package-config.test.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/**
|
||||
* Package.swift target loading (PR 3105 / #2964).
|
||||
*
|
||||
* `loadSwiftPackageConfig` must distinguish a declaration map
|
||||
* (`origin: 'package.swift'`) from an inferred `Sources/*` folder map
|
||||
* (`origin: 'directories'`). Grouping uses either; explicit import
|
||||
* resolve uses only the declared origin.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadSwiftPackageConfig,
|
||||
parseSwiftPackageManifest,
|
||||
swiftDeclaredTargetPrefix,
|
||||
} from '../../../../src/core/ingestion/language-config.js';
|
||||
import { coerceDeclaredSwiftTargets } from '../../../../src/core/ingestion/languages/swift/target-grouping.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function repo(files: Readonly<Record<string, string | null>>): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-pkg-'));
|
||||
roots.push(root);
|
||||
for (const [rel, contents] of Object.entries(files)) {
|
||||
const full = path.join(root, rel);
|
||||
if (contents === null) {
|
||||
fs.mkdirSync(full, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, contents);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const MODELS_APP = `
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
targets: [
|
||||
.target(name: "Models"),
|
||||
.target(name: "App"),
|
||||
]
|
||||
)
|
||||
`;
|
||||
|
||||
describe('parseSwiftPackageManifest', () => {
|
||||
it('maps .target(name:) with no path to Sources/<name>', () => {
|
||||
const parsed = parseSwiftPackageManifest(MODELS_APP);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
expect(parsed.targets.get('App')).toBe('Sources/App');
|
||||
});
|
||||
|
||||
it('honors an explicit path:', () => {
|
||||
const src = `
|
||||
.target(name: "Core", path: "Modules/Core")
|
||||
`;
|
||||
const parsed = parseSwiftPackageManifest(src);
|
||||
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
|
||||
complete: true,
|
||||
entries: [['Core', 'Modules/Core']],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps .testTarget to Tests/<name>', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.testTarget(name: "AppTests")`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('AppTests')).toBe('Tests/AppTests');
|
||||
});
|
||||
|
||||
it('skips binary / plugin / systemLibrary targets', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
.binaryTarget(name: "Lib", path: "Lib.xcframework")
|
||||
.plugin(name: "Gen")
|
||||
.systemLibrary(name: "CFoo")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.size).toBe(0);
|
||||
});
|
||||
|
||||
it('treats #if as a completeness hazard', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
#if os(macOS)
|
||||
.target(name: "MacOnly")
|
||||
#endif
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a helper-built targets: list as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(name: "Demo", targets: makeTargets())
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a computed name: as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: targetName)`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a computed path: as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: corePath)`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a block-commented factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
/* .target(name: "Ghost") */
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('records path: "." as the package root', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "Lib", path: ".")`);
|
||||
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
|
||||
complete: true,
|
||||
entries: [['Lib', '.']],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a // commented factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
// .target(name: "Ghost")
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('does not treat a dependency .target(name:) as a declared target', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
.target(name: "App", dependencies: [.target(name: "Core")])
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('App')).toBe('Sources/App');
|
||||
expect(parsed.targets.has('Core')).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers an explicit path over an earlier same-name factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
.target(name: "Core")
|
||||
.target(name: "Core", path: "Modules/Core")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Core')).toBe('Modules/Core');
|
||||
});
|
||||
|
||||
it('treats a string-interpolated path as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: "Modules/\\(name)")`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a parenthesized name string as a complete factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "Foo (experimental)")`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Foo (experimental)')).toBe('Sources/Foo (experimental)');
|
||||
});
|
||||
|
||||
it('ignores a commented parenthesis while balancing a factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
.target(name: "Core", // )
|
||||
path: "Modules/Core")
|
||||
`);
|
||||
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
|
||||
complete: true,
|
||||
entries: [['Core', 'Modules/Core']],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a block-comment parenthesis while balancing a factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "Core", /* ) */ path: "Modules/Core")`);
|
||||
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
|
||||
complete: true,
|
||||
entries: [['Core', 'Modules/Core']],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a mixed literal + helper-built targets: list as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(name: "Demo", targets: [.target(name: "Core")] + makeTargets())
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a helper-built list concatenated before literals as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(name: "Demo", targets: makeTargets() + [.target(name: "Core")])
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat .library(..., targets: names) as a helper-built list', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
.library(name: "Demo", targets: libTargets)
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('ignores a factory-like spelling inside a string literal', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let example = ".target(name: 'Ghost')"
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('treats a variable-prefix targets: concatenation as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(name: "Demo", targets: extraTargets + [.target(name: "Core")])
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('skips a commented name: field and uses the real one', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(/* name: "Ghost" */ name: "Models")`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('does not treat a commented #if as a completeness hazard', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
// #if os(macOS)
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('does not treat a block-commented #if as a completeness hazard', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
/*
|
||||
#if os(macOS)
|
||||
.target(name: "MacOnly")
|
||||
#endif
|
||||
*/
|
||||
.target(name: "Models")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('MacOnly')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('rejects a name string with a Swift escape', () => {
|
||||
const parsed = parseSwiftPackageManifest(`.target(name: "\\u{43}ore")`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
expect(parsed.targets.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not treat https:// on the same line as a commented factory', () => {
|
||||
const parsed = parseSwiftPackageManifest(
|
||||
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
|
||||
);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('T')).toBe('Sources/T');
|
||||
});
|
||||
|
||||
it('ignores a commented helper-built targets: list', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
targets: [
|
||||
.target(name: "Models"),
|
||||
]
|
||||
)
|
||||
// targets: makeTargets()
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('ignores a helper-built targets: spelling inside a string', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let note = "targets: makeTargets()"
|
||||
let package = Package(name: "Demo", targets: [.target(name: "Models")])
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('treats Package(targets: variable) as incomplete even when a factory was collected', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
func unused() { _ = Target.target(name: "Ghost") }
|
||||
let package = Package(name: "Demo", targets: actualTargets)
|
||||
.target(name: "Incidental")
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
expect(parsed.targets.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not collect a factory outside Package(targets: [...])', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
func unused() { _ = Target.target(name: "Ghost") }
|
||||
let package = Package(name: "Demo", targets: [.target(name: "Incidental")])
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Incidental')).toBe('Sources/Incidental');
|
||||
});
|
||||
|
||||
it('treats a computed element inside the targets: array as incomplete', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(name: "Demo", targets: [makeTargets()])
|
||||
`);
|
||||
expect(parsed.complete).toBe(false);
|
||||
expect(parsed.targets.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not collect factories when Package omits targets:', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let unused = Target.target(name: "Ghost")
|
||||
let package = Package(name: "Empty")
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not treat a line comment after a label colon as live source', () => {
|
||||
const parsed = parseSwiftPackageManifest(`
|
||||
let package = Package(
|
||||
name: "Demo",
|
||||
targets: [
|
||||
.target(name: // .target(name: "Ghost")
|
||||
"Models"),
|
||||
]
|
||||
)
|
||||
`);
|
||||
expect(parsed.complete).toBe(true);
|
||||
expect(parsed.targets.has('Ghost')).toBe(false);
|
||||
expect(parsed.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
});
|
||||
|
||||
describe('swiftDeclaredTargetPrefix', () => {
|
||||
it('treats . and ./ as the package root', () => {
|
||||
expect(swiftDeclaredTargetPrefix('.')).toBe('');
|
||||
expect(swiftDeclaredTargetPrefix('./')).toBe('');
|
||||
expect(swiftDeclaredTargetPrefix('./.')).toBe('');
|
||||
});
|
||||
|
||||
it('strips a leading ./ from a relative target path', () => {
|
||||
expect(swiftDeclaredTargetPrefix('./Sources/Core')).toBe('Sources/Core/');
|
||||
expect(swiftDeclaredTargetPrefix('Sources/Core')).toBe('Sources/Core/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSwiftPackageConfig', () => {
|
||||
it('returns a declared map from Package.swift and ignores undeclared Sources/* folders', async () => {
|
||||
const root = repo({
|
||||
'Package.swift': MODELS_APP,
|
||||
'Sources/Models/User.swift': '',
|
||||
'Sources/App/main.swift': '',
|
||||
'Sources/Foundation/Thing.swift': '',
|
||||
});
|
||||
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('package.swift');
|
||||
expect([...cfg!.targets.keys()].sort()).toEqual(['App', 'Models']);
|
||||
expect(cfg!.targets.has('Foundation')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an empty declared map when the manifest only has skipped target kinds', async () => {
|
||||
const root = repo({
|
||||
'Package.swift': `
|
||||
let package = Package(
|
||||
name: "OnlyBinary",
|
||||
targets: [.binaryTarget(name: "Lib", path: "Lib.xcframework")]
|
||||
)
|
||||
`,
|
||||
'Sources/Foundation/Thing.swift': '',
|
||||
});
|
||||
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('package.swift');
|
||||
expect(cfg!.declaredTargets?.size).toBe(0);
|
||||
// Grouping still uses inferred folders so App/Foundation stay isolated.
|
||||
expect(cfg!.targets.get('Foundation')).toBe('Sources/Foundation');
|
||||
expect(coerceDeclaredSwiftTargets(cfg)?.size).toBe(0);
|
||||
});
|
||||
|
||||
it('infers Sources/* folders when Package.swift is missing', async () => {
|
||||
const root = repo({
|
||||
'Sources/App/main.swift': '',
|
||||
'Sources/Models/User.swift': '',
|
||||
});
|
||||
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('directories');
|
||||
expect(cfg!.targets.get('App')).toBe('Sources/App');
|
||||
expect(cfg!.targets.get('Models')).toBe('Sources/Models');
|
||||
});
|
||||
|
||||
it('infers directories when Package.swift is unreadable (is a directory)', async () => {
|
||||
const root = repo({
|
||||
'Package.swift': null,
|
||||
'Sources/App/main.swift': '',
|
||||
});
|
||||
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('directories');
|
||||
expect(cfg!.targets.get('App')).toBe('Sources/App');
|
||||
});
|
||||
|
||||
it('infers directories when the manifest has completeness hazards', async () => {
|
||||
const root = repo({
|
||||
'Package.swift': `
|
||||
#if os(Linux)
|
||||
.target(name: "LinuxOnly")
|
||||
#endif
|
||||
`,
|
||||
'Sources/App/main.swift': '',
|
||||
});
|
||||
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('directories');
|
||||
expect(cfg!.targets.get('App')).toBe('Sources/App');
|
||||
expect(cfg!.targets.has('LinuxOnly')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when there is no manifest and no source folders', async () => {
|
||||
const root = repo({ 'README.md': '' });
|
||||
expect(await loadSwiftPackageConfig(root)).toBeNull();
|
||||
});
|
||||
|
||||
it('declares a one-line manifest that includes an https:// dependency URL', async () => {
|
||||
const root = repo({
|
||||
'Package.swift':
|
||||
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
|
||||
'Sources/T/T.swift': '',
|
||||
});
|
||||
const cfg = await loadSwiftPackageConfig(root);
|
||||
expect(cfg?.origin).toBe('package.swift');
|
||||
expect(cfg!.declaredTargets?.get('T')).toBe('Sources/T');
|
||||
expect(cfg!.targets.get('T')).toBe('Sources/T');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* In-repo @_exported visibility (R5 / AE4).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared';
|
||||
import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
|
||||
|
||||
const DECLARED = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([
|
||||
['A', 'Sources/A'],
|
||||
['B', 'Sources/B'],
|
||||
['C', 'Sources/C'],
|
||||
]),
|
||||
};
|
||||
|
||||
function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile {
|
||||
return {
|
||||
filePath,
|
||||
moduleScope: `module:${filePath}` as ScopeId,
|
||||
scopes: [],
|
||||
parsedImports,
|
||||
localDefs: [],
|
||||
referenceSites: [],
|
||||
};
|
||||
}
|
||||
|
||||
function reexport(targetRaw: string, name = targetRaw): ParsedImport {
|
||||
return { kind: 'reexport', localName: name, importedName: name, targetRaw };
|
||||
}
|
||||
|
||||
function ns(targetRaw: string): ParsedImport {
|
||||
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
|
||||
}
|
||||
|
||||
function resolve(
|
||||
targetRaw: string,
|
||||
files: readonly string[],
|
||||
parsedFiles: ParsedFile[],
|
||||
fromFile = 'Sources/App/main.swift',
|
||||
resolutionConfig: unknown = DECLARED,
|
||||
): string | readonly string[] | null {
|
||||
return resolveSwiftImportTarget(ns(targetRaw), {
|
||||
fromFile,
|
||||
allFilePaths: new Set(files),
|
||||
resolutionConfig,
|
||||
parsedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Swift @_exported visibility', () => {
|
||||
it('AE4: import A includes B when A @_exported import B', () => {
|
||||
const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/App/main.swift'];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('B')]),
|
||||
stubFile('Sources/B/B.swift'),
|
||||
stubFile('Sources/App/main.swift'),
|
||||
];
|
||||
expect(resolve('A', files, parsed)).toEqual(
|
||||
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']),
|
||||
);
|
||||
});
|
||||
|
||||
it('transitive A → B → C unions C', () => {
|
||||
const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift'];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('B')]),
|
||||
stubFile('Sources/B/B.swift', [reexport('C')]),
|
||||
stubFile('Sources/C/C.swift'),
|
||||
];
|
||||
expect(resolve('A', files, parsed, 'Sources/App/main.swift')).toEqual(
|
||||
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']),
|
||||
);
|
||||
});
|
||||
|
||||
it('cycle A → B → A terminates', () => {
|
||||
const files = ['Sources/A/A.swift', 'Sources/B/B.swift'];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('B')]),
|
||||
stubFile('Sources/B/B.swift', [reexport('A')]),
|
||||
];
|
||||
expect(resolve('A', files, parsed)).toEqual(
|
||||
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']),
|
||||
);
|
||||
});
|
||||
|
||||
it('@_exported import Foundation adds no files', () => {
|
||||
const files = ['Sources/A/A.swift', 'Sources/Foundation/Thing.swift', 'Sources/App/main.swift'];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('Foundation')]),
|
||||
stubFile('Sources/Foundation/Thing.swift'),
|
||||
];
|
||||
expect(resolve('A', files, parsed)).toEqual(['Sources/A/A.swift']);
|
||||
});
|
||||
|
||||
it('member-only @_exported import does not paint the rest of the module', () => {
|
||||
const files = [
|
||||
'Sources/A/A.swift',
|
||||
'Sources/Models/User.swift',
|
||||
'Sources/Models/Other.swift',
|
||||
'Sources/App/main.swift',
|
||||
];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('Models', 'User')]),
|
||||
stubFile('Sources/Models/User.swift'),
|
||||
stubFile('Sources/Models/Other.swift'),
|
||||
];
|
||||
const declared = {
|
||||
origin: 'package.swift' as const,
|
||||
targets: new Map([
|
||||
['A', 'Sources/A'],
|
||||
['Models', 'Sources/Models'],
|
||||
]),
|
||||
};
|
||||
expect(resolve('A', files, parsed, 'Sources/App/main.swift', declared)).toEqual([
|
||||
'Sources/A/A.swift',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a sibling file that does not write @_exported still contributes the reexport', () => {
|
||||
const files = ['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift'];
|
||||
const parsed = [
|
||||
stubFile('Sources/A/A.swift', [reexport('B')]),
|
||||
stubFile('Sources/A/Other.swift'),
|
||||
stubFile('Sources/B/B.swift'),
|
||||
];
|
||||
expect(resolve('A', files, parsed)).toEqual(
|
||||
expect.arrayContaining(['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,18 +3,19 @@
|
|||
* KTD2).
|
||||
*
|
||||
* `groupSwiftFilesBySpmTarget` (`languages/swift/target-grouping.ts`)
|
||||
* DUPLICATES the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`)
|
||||
* SPM-subtree semantics so the registry-primary same-module hooks group by
|
||||
* the SPM target subtree without touching the legacy pipeline (hard
|
||||
* constraint: legacy stays byte-identical). Because the duplication can
|
||||
* silently drift if legacy is later changed, this test pins the exact
|
||||
* bucketing for representative inputs so a future divergence surfaces
|
||||
* loudly:
|
||||
* preserves the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`)
|
||||
* bucketing contract for ordinary SPM layouts: one target bucket per file,
|
||||
* first-target-wins ordering, and the same `__default__` fallback. It now
|
||||
* intentionally differs for issue #2931's repeated-prefix edge case by
|
||||
* accepting a later segment-boundary occurrence when an earlier textual
|
||||
* occurrence is embedded inside a longer path segment. These tests pin the
|
||||
* shared ordinary-layout contract plus that documented #2931 fix.
|
||||
*
|
||||
* 1. A multi-subdir single target buckets into ONE group.
|
||||
* 2. A file matching two overlapping same-named target prefixes is
|
||||
* assigned to the FIRST target only (legacy `break`s — no fan-out).
|
||||
* 3. Unmatched files AND the no-targets case route to `__default__` = all.
|
||||
* 3. Target dirs match only at path-segment boundaries.
|
||||
* 4. Unmatched files AND the no-targets case route to `__default__` = all.
|
||||
*
|
||||
* `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }`
|
||||
* (no `instanceof` on the config object) and returns `null` otherwise.
|
||||
|
|
@ -27,7 +28,7 @@ import {
|
|||
|
||||
const id = (s: string) => s;
|
||||
|
||||
describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)', () => {
|
||||
describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => {
|
||||
it('buckets a multi-subdir single target into ONE group', () => {
|
||||
const files = [
|
||||
'Sources/Alpha/Core/User.swift',
|
||||
|
|
@ -45,7 +46,7 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
|
|||
|
||||
it('assigns a file matching two overlapping same-named prefixes to the FIRST target only', () => {
|
||||
// Both targets are prefixes of the file's path (Beta dir nested under
|
||||
// Alpha). Legacy `break`s on the first match → one bucket per file.
|
||||
// Alpha). The first configured match wins → one bucket per file.
|
||||
const files = ['Sources/Alpha/Beta/User.swift'];
|
||||
const targets = new Map([
|
||||
['Alpha', 'Sources/Alpha'],
|
||||
|
|
@ -58,9 +59,19 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
|
|||
expect(groups.has('Beta')).toBe(false);
|
||||
});
|
||||
|
||||
it('assigns root-level files to a path: "." target', () => {
|
||||
const files = ['Lib.swift', 'Sources/Other/X.swift'];
|
||||
const targets = new Map([['Lib', '.']]);
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
|
||||
|
||||
expect(groups.get('Lib')).toEqual(files);
|
||||
expect(groups.has('__default__')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a target dir only at a `/` boundary, not a substring', () => {
|
||||
// "Sources/Alpha" must NOT match "Sources/AlphaBeta/..." — the legacy
|
||||
// predicate requires idx===0 or a preceding `/`.
|
||||
// "Sources/Alpha" must NOT match "Sources/AlphaBeta/...". The matcher
|
||||
// accepts only a path-start or slash-delimited target occurrence.
|
||||
const files = ['Sources/AlphaBeta/User.swift'];
|
||||
const targets = new Map([['Alpha', 'Sources/Alpha']]);
|
||||
|
||||
|
|
@ -114,6 +125,26 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
|
|||
expect(groups.get('Beta')).toEqual([items[1]]);
|
||||
});
|
||||
|
||||
it('keeps inferred Sources/* folders in separate buckets', () => {
|
||||
const targets = new Map([
|
||||
['App', 'Sources/App'],
|
||||
['Models', 'Sources/Models'],
|
||||
['Foundation', 'Sources/Foundation'],
|
||||
]);
|
||||
const items = [
|
||||
'Sources/App/main.swift',
|
||||
'Sources/Models/User.swift',
|
||||
'Sources/Foundation/Thing.swift',
|
||||
];
|
||||
|
||||
const groups = groupSwiftFilesBySpmTarget(items, id, targets);
|
||||
|
||||
expect(groups.get('App')).toEqual(['Sources/App/main.swift']);
|
||||
expect(groups.get('Models')).toEqual(['Sources/Models/User.swift']);
|
||||
expect(groups.get('Foundation')).toEqual(['Sources/Foundation/Thing.swift']);
|
||||
expect(groups.get('__default__')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes backslash paths to forward-slash before matching', () => {
|
||||
const files = ['Sources\\Alpha\\Core\\User.swift'];
|
||||
const targets = new Map([['Alpha', 'Sources/Alpha']]);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue