Skip to content

Commit 1a75898

Browse files
authored
TX-APP1C: add Package.Manifest.Clause and Package.Manifest.Redirection — the URL-clause -> .package(path:) rewrite with byte-for-byte restore (#8)
Extracted from the Institute Application's Composition area; org-layout resolution and the reversal ledger stay with the Application, which will consume this unit and delete its copy in a companion PR. Record: #7 (Goal swift-institute/.github#145).
1 parent 8ffdca3 commit 1a75898

6 files changed

Lines changed: 554 additions & 0 deletions
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
public import SPM_Standard
2+
3+
extension Package.Manifest {
4+
/// A located `.package(...)` clause within manifest source: the exact text
5+
/// it spans and the UTF-8 byte range it occupies.
6+
///
7+
/// Located by paren-matching over the **raw** manifest bytes, not by
8+
/// re-serialising an evaluated manifest. The reversal a redirection owes is
9+
/// *byte-identical* — the declared clause must be restored verbatim — so it
10+
/// has to be captured verbatim, and only the original bytes are verbatim.
11+
/// String-literal contents are skipped during the scan, so a parenthesis
12+
/// inside a quoted URL or path cannot close the clause early.
13+
///
14+
/// The range is expressed in bytes rather than `String.Index` because every
15+
/// delimiter this engine keys on — `.package(`, `(`, `)`, `"`, `\` — is
16+
/// ASCII, so byte boundaries at those points never fall inside a multi-byte
17+
/// UTF-8 scalar and slicing on them is exact. Working over `String.Index`
18+
/// would pull in Foundation's `range(of:)`, which the layer forbids.
19+
public struct Clause: Swift.Sendable, Swift.Equatable {
20+
/// The exact source text of the clause, from `.package(` to its
21+
/// matching `)`, inclusive.
22+
public let text: Swift.String
23+
24+
/// The half-open UTF-8 byte range the clause occupies in its source.
25+
public let range: Swift.Range<Swift.Int>
26+
}
27+
}
28+
29+
extension Package.Manifest.Clause {
30+
/// Every `.package(...)` clause in `source`, in source order.
31+
public static func all(in source: Swift.String) -> [Self] {
32+
let bytes = [Swift.UInt8](source.utf8)
33+
let marker = [Swift.UInt8](".package(".utf8)
34+
35+
var clauses: [Self] = []
36+
var index = 0
37+
while let start = firstIndex(of: marker, in: bytes, from: index) {
38+
let openParen = start + marker.count
39+
guard let close = closingParen(in: bytes, after: openParen) else {
40+
// Unbalanced from here on; nothing further is a well-formed
41+
// clause, so stop rather than ressynchronising on a guess.
42+
break
43+
}
44+
let range = start..<(close + 1)
45+
clauses.append(
46+
.init(
47+
text: Swift.String(decoding: bytes[range], as: Swift.UTF8.self),
48+
range: range
49+
)
50+
)
51+
index = close + 1
52+
}
53+
return clauses
54+
}
55+
56+
/// The url-form clause whose declared URL has package identity `identity`,
57+
/// or `nil` when the source declares no such dependency by URL.
58+
///
59+
/// Matched on derived identity, not on a literal URL comparison, so a
60+
/// declaration that omits a trailing `.git` or differs in case still
61+
/// matches — identity is exactly what SwiftPM keys a dependency on.
62+
public static func url(
63+
identity: Swift.String,
64+
in source: Swift.String
65+
) -> Self? {
66+
all(in: source).first { clause in
67+
guard let url = clause.declaredURL else { return false }
68+
return Self.identity(ofURL: url) == identity
69+
}
70+
}
71+
72+
/// The declared URL of a url-form clause (`.package(url: "…", …)`), or
73+
/// `nil` when the clause is not url-form.
74+
public var declaredURL: Swift.String? {
75+
Self.declaredURL(in: text)
76+
}
77+
78+
/// The declared path of a path-form clause (`.package(path: "…")`), or
79+
/// `nil` when the clause is not path-form.
80+
public var declaredPath: Swift.String? {
81+
Self.declaredPath(in: text)
82+
}
83+
84+
/// The declared URL of a standalone url-form clause text.
85+
///
86+
/// The standalone form lets a persisted clause be re-parsed without
87+
/// re-locating it in a manifest — a restore knows the exact clause text it
88+
/// stored and needs the identity it carries, not a position.
89+
public static func declaredURL(in text: Swift.String) -> Swift.String? {
90+
quoted(after: "url:", in: text)
91+
}
92+
93+
/// The declared path of a standalone path-form clause text.
94+
public static func declaredPath(in text: Swift.String) -> Swift.String? {
95+
quoted(after: "path:", in: text)
96+
}
97+
98+
/// `source` with this clause's byte span replaced by `replacement`.
99+
///
100+
/// The clause carries a byte range into the exact `source` it was located
101+
/// in; applying it to any other text is a programmer error the caller
102+
/// prevents by re-locating against the text it edits.
103+
public func replacing(
104+
with replacement: Swift.String,
105+
in source: Swift.String
106+
) -> Swift.String {
107+
let bytes = [Swift.UInt8](source.utf8)
108+
var result = [Swift.UInt8]()
109+
result.reserveCapacity(bytes.count)
110+
result.append(contentsOf: bytes[bytes.startIndex..<range.lowerBound])
111+
result.append(contentsOf: replacement.utf8)
112+
result.append(contentsOf: bytes[range.upperBound..<bytes.endIndex])
113+
return Swift.String(decoding: result, as: Swift.UTF8.self)
114+
}
115+
116+
/// SwiftPM's package identity for a source-control URL: the last path
117+
/// component, with a trailing slash and a trailing `.git` removed, folded
118+
/// to lower case. This mirrors how SwiftPM derives identity from a git URL.
119+
public static func identity(ofURL url: Swift.String) -> Swift.String {
120+
var value = url[...]
121+
while value.last == "/" { value = value.dropLast() }
122+
if value.hasSuffix(".git") { value = value.dropLast(4) }
123+
let component = value.split(separator: "/").last.map(Swift.String.init) ?? Swift.String(value)
124+
return component.lowercased()
125+
}
126+
}
127+
128+
extension Package.Manifest.Clause {
129+
/// The first quoted string literal that follows `label` in the clause text,
130+
/// or `nil` when the label or a following literal is absent.
131+
///
132+
/// Escaped quotes are not interpreted: a URL or filesystem path carrying a
133+
/// literal `"` is pathological here, and treating the first inner quote as
134+
/// the terminator fails loudly rather than smuggling a wrong value through.
135+
private static func quoted(after label: Swift.String, in text: Swift.String) -> Swift.String? {
136+
let bytes = [Swift.UInt8](text.utf8)
137+
let needle = [Swift.UInt8](label.utf8)
138+
let located = firstIndex(of: needle, in: bytes, from: 0)
139+
guard let labelStart = located else { return nil }
140+
141+
let quote = Swift.UInt8(ascii: "\"" as Swift.Unicode.Scalar)
142+
var index = labelStart + needle.count
143+
while index < bytes.count, bytes[index] != quote { index += 1 }
144+
guard index < bytes.count else { return nil }
145+
let open = index + 1
146+
var close = open
147+
while close < bytes.count, bytes[close] != quote { close += 1 }
148+
guard close < bytes.count else { return nil }
149+
return Swift.String(decoding: bytes[open..<close], as: Swift.UTF8.self)
150+
}
151+
152+
/// The index of the `)` that closes the `.package(` whose `(` sits at
153+
/// `start - 1`. Depth-counts parentheses and skips string-literal contents
154+
/// so a paren inside a quoted URL or path cannot close the clause early.
155+
/// `nil` when the parentheses never balance.
156+
private static func closingParen(in bytes: [Swift.UInt8], after start: Swift.Int) -> Swift.Int? {
157+
let quote = Swift.UInt8(ascii: "\"" as Swift.Unicode.Scalar)
158+
let backslash = Swift.UInt8(ascii: "\\" as Swift.Unicode.Scalar)
159+
let open = Swift.UInt8(ascii: "(" as Swift.Unicode.Scalar)
160+
let close = Swift.UInt8(ascii: ")" as Swift.Unicode.Scalar)
161+
162+
var depth = 1
163+
var index = start
164+
var insideString = false
165+
var escaped = false
166+
while index < bytes.count {
167+
let byte = bytes[index]
168+
if insideString {
169+
if escaped {
170+
escaped = false
171+
} else if byte == backslash {
172+
escaped = true
173+
} else if byte == quote {
174+
insideString = false
175+
}
176+
} else {
177+
switch byte {
178+
case quote: insideString = true
179+
180+
case open: depth += 1
181+
182+
case close:
183+
depth -= 1
184+
if depth == 0 { return index }
185+
186+
default: break
187+
}
188+
}
189+
index += 1
190+
}
191+
return nil
192+
}
193+
194+
/// The index in `haystack` at or after `from` where `needle` first occurs,
195+
/// or `nil`. A plain forward scan; manifests are small and this runs a
196+
/// handful of times per operation.
197+
private static func firstIndex(
198+
of needle: [Swift.UInt8],
199+
in haystack: [Swift.UInt8],
200+
from: Swift.Int
201+
) -> Swift.Int? {
202+
guard !needle.isEmpty, haystack.count >= needle.count else { return nil }
203+
var start = from
204+
let last = haystack.count - needle.count
205+
while start <= last {
206+
var offset = 0
207+
while offset < needle.count, haystack[start + offset] == needle[offset] {
208+
offset += 1
209+
}
210+
if offset == needle.count { return start }
211+
start += 1
212+
}
213+
return nil
214+
}
215+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
public import SPM_Standard
2+
3+
extension Package.Manifest.Redirection {
4+
/// Why a redirection or its restore refused to rewrite the manifest.
5+
public enum Error: Swift.Error, Swift.Sendable, Swift.Equatable {
6+
/// No url-form `.package(url:)` clause in the source declares a
7+
/// dependency with this SwiftPM package identity; there is nothing
8+
/// to redirect.
9+
case dependencyNotDeclaredByURL(identity: Swift.String)
10+
11+
/// The composed `.package(path:)` clause a prior redirect wrote is
12+
/// not present in the source — it may have been hand-edited or
13+
/// already restored. Refusing to guess beats a wrong rewrite.
14+
case composedClauseAbsent(planned: Swift.String)
15+
}
16+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
public import SPM_Standard
2+
3+
extension Package.Manifest.Redirection {
4+
/// The result of one ``Package/Manifest/Redirection/redirect(_:dependency:to:)``:
5+
/// the rewritten manifest source plus the exact clause texts a
6+
/// byte-for-byte restore needs.
7+
///
8+
/// `declared` and `planned` are the reversal record. A caller that
9+
/// persists them (in whatever ledger it owns) can restore the manifest
10+
/// verbatim later with ``Package/Manifest/Redirection/restore(_:planned:declared:)``.
11+
public struct Rewrite: Swift.Sendable, Swift.Equatable {
12+
/// The manifest source with the url-form clause replaced by `planned`.
13+
public let source: Swift.String
14+
15+
/// The url-form clause that was replaced, captured verbatim.
16+
public let declared: Swift.String
17+
18+
/// The path-form clause that replaced it.
19+
public let planned: Swift.String
20+
21+
public init(
22+
source: Swift.String,
23+
declared: Swift.String,
24+
planned: Swift.String
25+
) {
26+
self.source = source
27+
self.declared = declared
28+
self.planned = planned
29+
}
30+
}
31+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
public import SPM_Standard
2+
3+
extension Package.Manifest {
4+
/// Redirecting a URL-declared dependency to a **local mutable source**
5+
/// at an existing checkout, and undoing it byte-for-byte.
6+
///
7+
/// The mechanism is generated `.package(path:)` composition: the manifest's
8+
/// url-form clause is rewritten in place to a path-form clause, and the
9+
/// declared clause is captured **verbatim** so ``restore(_:planned:declared:)``
10+
/// can return it byte-for-byte. A composed manifest therefore carries a
11+
/// machine-local absolute path. That is a virtue, not a bug, as long as it
12+
/// stays local — off-machine it fails *loudly* at resolution rather than
13+
/// silently substituting a wrong source — but it means a composed manifest
14+
/// must never be committed; callers own surfacing that warning.
15+
///
16+
/// This is a pure source-to-source transformation. Deciding *which* local
17+
/// checkout a dependency redirects to — org layout, checkout discovery,
18+
/// ledgering the reversal — stays with the caller.
19+
public enum Redirection {}
20+
}
21+
22+
extension Package.Manifest.Redirection {
23+
/// Rewrites the url-form clause declaring `url` in `source` to a
24+
/// `.package(path:)` clause pointing at `path`.
25+
///
26+
/// The clause is located by SwiftPM package identity derived from `url`
27+
/// (see ``Package/Manifest/Clause/identity(ofURL:)``), so a declaration
28+
/// that omits a trailing `.git` or differs in case still matches.
29+
///
30+
/// - Parameters:
31+
/// - source: The consumer manifest source to rewrite.
32+
/// - url: The dependency's declared source-control URL.
33+
/// - path: The local checkout the dependency redirects to. Written into
34+
/// the manifest exactly as given; pass an absolute path so a leaked
35+
/// composed manifest fails loudly off-machine.
36+
/// - Returns: The rewritten source together with the declared clause
37+
/// captured verbatim and the planned clause that replaced it.
38+
/// - Throws: ``Error/dependencyNotDeclaredByURL(identity:)`` when no
39+
/// url-form clause in `source` declares the dependency.
40+
public static func redirect(
41+
_ source: Swift.String,
42+
dependency url: Swift.String,
43+
to path: Swift.String
44+
) throws(Error) -> Rewrite {
45+
let identity = Package.Manifest.Clause.identity(ofURL: url)
46+
guard let clause = Package.Manifest.Clause.url(identity: identity, in: source) else {
47+
throw .dependencyNotDeclaredByURL(identity: identity)
48+
}
49+
let planned = ".package(path: \"\(path)\")"
50+
return Rewrite(
51+
source: clause.replacing(with: planned, in: source),
52+
declared: clause.text,
53+
planned: planned
54+
)
55+
}
56+
57+
/// Restores `source` to its declared url-form clause **byte-for-byte**:
58+
/// locates the clause whose text equals `planned` and replaces it with
59+
/// `declared` verbatim.
60+
///
61+
/// - Parameters:
62+
/// - source: The composed manifest source to restore.
63+
/// - planned: The exact path-form clause a prior
64+
/// ``redirect(_:dependency:to:)`` wrote.
65+
/// - declared: The exact url-form clause it captured.
66+
/// - Returns: The restored manifest source.
67+
/// - Throws: ``Error/composedClauseAbsent(planned:)`` when `source` no
68+
/// longer contains `planned` — it may have been hand-edited or already
69+
/// restored, and guessing would be worse than refusing.
70+
public static func restore(
71+
_ source: Swift.String,
72+
planned: Swift.String,
73+
declared: Swift.String
74+
) throws(Error) -> Swift.String {
75+
let located = Package.Manifest.Clause.all(in: source).first { $0.text == planned }
76+
guard let clause = located else {
77+
throw .composedClauseAbsent(planned: planned)
78+
}
79+
return clause.replacing(with: declared, in: source)
80+
}
81+
}

0 commit comments

Comments
 (0)