Skip to content

Commit 23d6a2c

Browse files
authored
Client-side translation (#314)
* add a case-folded resolution table to the linker * translate apple.developer.com links on the client side * we seem to have accidentally fixed another issue
1 parent 1062dd4 commit 23d6a2c

14 files changed

+197
-39
lines changed

Sources/LinkResolution/UCF.ResolutionPath.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ extension UCF
1818
}
1919
}
2020
extension UCF.ResolutionPath
21+
{
22+
func lowercased() -> Self { .init(string: self.string.lowercased()) }
23+
}
24+
extension UCF.ResolutionPath
2125
{
2226
@inlinable
2327
init(_ namespace:Symbol.Module)

Sources/LinkResolution/UCF.ResolutionTable.swift

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ extension UCF
88
@frozen public
99
struct ResolutionTable<Overload> where Overload:ResolvableOverload
1010
{
11-
public
12-
var modules:[Symbol.Module]
1311
@usableFromInline
1412
var entries:[UCF.ResolutionPath: InlineArray<Overload>]
13+
@usableFromInline
14+
var modules:[UCF.ResolutionPath: Symbol.Module]
1515

1616
@inlinable public
1717
init()
1818
{
19-
self.modules = []
2019
self.entries = [:]
20+
self.modules = [:]
2121
}
2222
}
2323
}
@@ -27,6 +27,46 @@ extension UCF.ResolutionTable:ExpressibleByDictionaryLiteral
2727
init(dictionaryLiteral:(Never, Never)...) { self.init() }
2828
}
2929
extension UCF.ResolutionTable
30+
{
31+
/// Lowercases all paths in the table, merging overloads with the same case-folded path.
32+
/// Some modules may become **unresolvable** if they have names that differ only in case.
33+
public
34+
func caseFolded() -> Self
35+
{
36+
var copy:Self = self
37+
38+
copy.entries.removeAll(keepingCapacity: true)
39+
copy.modules.removeAll(keepingCapacity: true)
40+
41+
for (path, overloads):(UCF.ResolutionPath, InlineArray<Overload>) in self.entries
42+
{
43+
{
44+
for overload:Overload in overloads
45+
{
46+
$0.append(overload)
47+
}
48+
} (&copy.entries[path.lowercased(), default: .some([])])
49+
}
50+
// We need to sort this one because it can suffer from path collisions, and the
51+
// “winning” module would otherwise be non-deterministic.
52+
for (path, module):(UCF.ResolutionPath, Symbol.Module) in self.modules.sorted(
53+
by: { $0.value < $1.value })
54+
{
55+
copy.modules[path.lowercased()] = module
56+
}
57+
58+
return copy
59+
}
60+
}
61+
extension UCF.ResolutionTable
62+
{
63+
@inlinable public mutating
64+
func register(_ module:Symbol.Module)
65+
{
66+
self.modules[.init(module)] = module
67+
}
68+
}
69+
extension UCF.ResolutionTable
3070
{
3171
@inlinable public
3272
subscript(namespace:Symbol.Module,
@@ -58,6 +98,14 @@ extension UCF.ResolutionTable
5898
}
5999
extension UCF.ResolutionTable
60100
{
101+
public
102+
func resolve(qualified path:UCF.Selector.Path,
103+
matching suffix:UCF.Selector.Suffix?) -> UCF.Resolution<Overload>
104+
{
105+
var search:Search = .init(matching: suffix)
106+
return self.resolve(qualified: path, with: &search)
107+
}
108+
61109
func resolve(_ selector:UCF.Selector,
62110
in scope:UCF.ResolutionScope) -> UCF.Resolution<Overload>
63111
{
@@ -82,7 +130,7 @@ extension UCF.ResolutionTable
82130
}
83131
}
84132

85-
for namespace:Symbol.Module in self.modules.reversed() where
133+
for namespace:Symbol.Module in self.modules.values where
86134
namespace != scope.namespace
87135
{
88136
let path:UCF.ResolutionPath = .join(["\(namespace)"] + selector.path.components)
@@ -98,17 +146,24 @@ extension UCF.ResolutionTable
98146
}
99147
}
100148

149+
return self.resolve(qualified: selector.path, with: &search)
150+
}
151+
152+
private
153+
func resolve(qualified path:UCF.Selector.Path,
154+
with search:inout Search) -> UCF.Resolution<Overload>
155+
{
101156
// If we got this far, assume the first path component is a module name.
102-
if selector.path.components.count == 1
157+
if path.components.count == 1
103158
{
104-
let last:Symbol.Module = .init(selector.path.components[0])
105-
if self.modules.contains(last)
159+
let path:UCF.ResolutionPath = .init(string: path.components[0])
160+
if let module:Symbol.Module = self.modules[path]
106161
{
107-
return .module(last)
162+
return .module(module)
108163
}
109164
}
110165

111-
let path:UCF.ResolutionPath = .join(selector.path.components)
166+
let path:UCF.ResolutionPath = .join(path.components)
112167
if let list:InlineArray<Overload> = self.entries[path]
113168
{
114169
search.add(list)

Sources/UnidocLinker/Resolver/UCF.Selector (ext).swift renamed to Sources/LinkResolution/UCF.Selector (ext).swift

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import FNV1
2-
import SymbolGraphs
32
import UCF
43

4+
// TODO: this does not belong in this module
55
extension UCF.Selector
66
{
7+
public
78
static func translate(domain:Substring, path:Substring) -> Self?
89
{
910
// Does this look like a link to Swift documentation? If so, we probably already have a
@@ -16,33 +17,25 @@ extension UCF.Selector
1617
if let c:Int = path.firstIndex(of: "documentation")
1718
{
1819
let d:Int = path.index(after: c)
19-
let suffix:Suffix?
20-
2120
if let last:Int = path.indices.last
2221
{
23-
suffix =
2422
{
2523
if let hyphen:String.Index = $0.firstIndex(of: "-"),
26-
let hash:FNV24 = .init($0[..<hyphen], radix: 10)
24+
let _:Int = .init($0[..<hyphen], radix: 10)
2725
{
26+
// These numeric prefixes are not FNV-1 hashes, they seem to be
27+
// ordinal numbers generated opaquely by Apple. We can strip them
28+
// in the hopes that the bare path is resolvable, but we can’t use
29+
// them to help disambiguate anything.
2830
$0 = $0[$0.index(after: hyphen)...]
29-
return .hash(hash)
30-
}
31-
else
32-
{
33-
return nil
3431
}
3532
} (&path[last])
3633
}
37-
else
38-
{
39-
suffix = nil
40-
}
4134

4235
return .init(
4336
base: .qualified,
4437
path: .init(components: path[d...].map { $0.lowercased() }),
45-
suffix: suffix)
38+
suffix: nil)
4639
}
4740
else
4841
{

Sources/SymbolGraphCompiler/SSGC.ModuleIndex.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ extension SSGC
1010
public
1111
let id:Symbol.Module
1212

13+
public
14+
let resolvableModules:[Symbol.Module]
1315
public
1416
let resolvableLinks:UCF.ResolutionTable<UCF.CausalOverload>
1517
public
@@ -28,6 +30,7 @@ extension SSGC
2830

2931

3032
init(id:Symbol.Module,
33+
resolvableModules:[Symbol.Module],
3134
resolvableLinks:UCF.ResolutionTable<UCF.CausalOverload>,
3235
declarations:[(id:Symbol.Module, decls:[Decl])],
3336
extensions:[SSGC.Extension],
@@ -37,6 +40,7 @@ extension SSGC
3740
resources:[any SSGC.ResourceFile] = [])
3841
{
3942
self.id = id
43+
self.resolvableModules = resolvableModules
4044
self.resolvableLinks = resolvableLinks
4145
self.declarations = declarations
4246
self.extensions = extensions

Sources/SymbolGraphCompiler/SSGC.TypeChecker.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ extension SSGC
1919

2020
private
2121
var resolvableLinks:UCF.ResolutionTable<UCF.CausalOverload>
22+
private
23+
var resolvableModules:[Symbol.Module]
2224

2325
public
2426
init(ignoreExportedInterfaces:Bool = true, threshold:Symbol.ACL = .public)
2527
{
2628
self.ignoreExportedInterfaces = ignoreExportedInterfaces
2729
self.declarations = .init(threshold: threshold)
2830
self.extensions = .init()
29-
self.resolvableLinks = .init()
31+
32+
self.resolvableLinks = [:]
33+
self.resolvableModules = []
3034
}
3135
}
3236
}
@@ -66,7 +70,8 @@ extension SSGC.TypeChecker
6670
private mutating
6771
func add(symbols culture:SSGC.SymbolCulture, from id:Symbol.Module) throws
6872
{
69-
self.resolvableLinks.modules.append(id)
73+
self.resolvableModules.append(id)
74+
self.resolvableLinks.register(id)
7075

7176
/// We use this to look up protocols by name instead of symbol. This is needed in order
7277
/// to work around some bizarre lib/SymbolGraphGen bugs.
@@ -550,6 +555,7 @@ extension SSGC.TypeChecker
550555
}
551556

552557
return .init(id: culture,
558+
resolvableModules: self.resolvableModules,
553559
resolvableLinks: self.resolvableLinks,
554560
declarations: self.declarations.load(culture: culture),
555561
extensions: extensions,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import MarkdownAST
2+
import UCF
3+
4+
extension Markdown.SourceURL
5+
{
6+
var translatableSelector:UCF.Selector?
7+
{
8+
// Skip the two slashes.
9+
guard
10+
let start:String.Index = self.suffix.string.index(self.suffix.string.startIndex,
11+
offsetBy: 2,
12+
limitedBy: self.suffix.string.endIndex),
13+
case "//" = self.suffix.string[..<start]
14+
else
15+
{
16+
return nil
17+
}
18+
19+
guard
20+
let slash:String.Index = self.suffix.string[start...].firstIndex(of: "/")
21+
else
22+
{
23+
return nil
24+
}
25+
26+
return .translate(
27+
domain: self.suffix.string[start ..< slash],
28+
path: self.suffix.string[slash...])
29+
}
30+
}

Sources/SymbolGraphLinker/Resolution/SSGC.OutlineResolver.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,44 @@ extension SSGC.OutlineResolver
135135
}
136136
}
137137

138+
mutating
139+
func translate(url:Markdown.SourceURL) -> SymbolGraph.Outline?
140+
{
141+
guard
142+
let translatable:UCF.Selector = url.translatableSelector
143+
else
144+
{
145+
return nil
146+
}
147+
148+
switch self.scopes.causalURLs.resolve(
149+
qualified: translatable.path,
150+
matching: translatable.suffix)
151+
{
152+
case .module(let module):
153+
// Unidoc linker doesn’t currently support `symbol` outlines that are not
154+
// declarations, so for now we just synthesize a normal vertex outline.
155+
let text:SymbolGraph.OutlineText = .init(path: "\(module)", fragment: nil)
156+
return .vertex(self.tables.intern(module) * .module, text: text)
157+
158+
case .overload(let overload):
159+
return .symbol(self.tables.intern(overload.id))
160+
161+
case .ambiguous(let overloads, rejected: let rejected):
162+
if overloads.isEmpty
163+
{
164+
return nil
165+
}
166+
167+
self.diagnostics[url.suffix.source] = UCF.ResolutionError<SSGC.Symbolicator>.init(
168+
overloads: overloads,
169+
rejected: rejected,
170+
selector: translatable)
171+
172+
return nil
173+
}
174+
}
175+
138176
mutating
139177
func outline(_ codelink:UCF.Selector,
140178
at source:SourceReference<Markdown.Source>) -> SymbolGraph.Outline?

Sources/SymbolGraphLinker/Resolution/SSGC.OutlineResolverEnvironment.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ extension SSGC
1010
let origin:Int32?
1111

1212
let causalLinks:UCF.ResolutionTable<UCF.CausalOverload>
13+
let causalURLs:UCF.ResolutionTable<UCF.CausalOverload>
1314
let resources:[String: SSGC.Resource]
1415
let codelink:UCF.ResolutionScope
1516
/// The scope to use when resolving `doc:` links. The namespace may be different from
@@ -21,12 +22,14 @@ extension SSGC
2122
private
2223
init(origin:Int32?,
2324
causalLinks:UCF.ResolutionTable<UCF.CausalOverload>,
25+
causalURLs:UCF.ResolutionTable<UCF.CausalOverload>,
2426
resources:[String: SSGC.Resource],
2527
codelink:UCF.ResolutionScope,
2628
doclink:UCF.ArticleScope)
2729
{
2830
self.origin = origin
2931
self.causalLinks = causalLinks
32+
self.causalURLs = causalURLs
3033
self.resources = resources
3134
self.codelink = codelink
3235
self.doclink = doclink
@@ -57,6 +60,7 @@ extension SSGC.OutlineResolverEnvironment
5760
{
5861
self.init(origin: origin,
5962
causalLinks: context.causalLinks,
63+
causalURLs: context.causalURLs,
6064
resources: context.resources,
6165
codelink: .init(namespace: namespace ?? context.id,
6266
imports: [],

Sources/SymbolGraphLinker/Resolution/SSGC.Outliner.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ extension SSGC.Outliner
6666
return self.outline(doc: url.suffix, as: url.provenance)
6767

6868
case let scheme?:
69-
return self.cache.add(outline: .url("\(scheme):\(url.suffix)",
70-
location: url.suffix.source.start))
69+
return self.outline(url: url, scheme: scheme)
7170
}
7271

7372
case .file(let link):
@@ -103,6 +102,24 @@ extension SSGC.Outliner
103102
return nil
104103
}
105104

105+
private mutating
106+
func outline(url:Markdown.SourceURL, scheme:String) -> Int?
107+
{
108+
let translated:SymbolGraph.Outline?
109+
110+
switch scheme
111+
{
112+
case "http": translated = self.resolver.translate(url: url)
113+
case "https": translated = self.resolver.translate(url: url)
114+
default: translated = nil
115+
}
116+
117+
// TODO: log translations?
118+
119+
return self.cache.add(outline: translated ?? .url("\(scheme):\(url.suffix)",
120+
location: url.suffix.source.start))
121+
}
122+
106123
private mutating
107124
func outline(ucf link:Markdown.SourceString) -> Int?
108125
{

0 commit comments

Comments
 (0)