Skip to content

Commit 4be8229

Browse files
authored
[6.2] Improve handling of snippet symbol graph files (#1302) (#1306)
* Improve handling of snippet symbol graph files (#1302) * Separate snippets from other symbols rdar://147926589 rdar://161164434 #1280 * Rename tests to clarify what they're verifying * Improve tests around optional snippet prefix components * Add additional test about resolving and rendering snippets * Make it easier to verify the diagnostic log output in tests * Add additional test about snippet warnings Also, update the behavior when a snippet slice is misspelled to match what's described in the warning. * Move snippet diagnostic creation to resolver type * Add near-miss suggestions for snippet paths and slices * Only highlight the misspelled portion of snippet paths * Update user-facing documentation about optional snippet paths prefixes * Clarify that Snippet argument parsing problems are reported elsewhere * Fix typo in code comment * Update docs about earliest version with an optional snippet path prefix * Fix test that became flaky after recent snippets change (#1308) Depending on which symbol was _first_ in the dictionary of symbols, the entire symbol graph was categorized as either a snippet symbol graph or a regular symbol graph. However, real symbol graph files don't mix snippets and framework symbols like that. This fixes the test to don't mix snippets with real symbols. It also stops adding module nodes inside the symbol graph, because that also doesn't match real symbol graph files.
1 parent 558118d commit 4be8229

File tree

14 files changed

+590
-110
lines changed

14 files changed

+590
-110
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ public class DocumentationContext {
167167
/// > Important: The topic graph has no awareness of source language specific edges.
168168
var topicGraph = TopicGraph()
169169

170+
/// Will be assigned during context initialization
171+
var snippetResolver: SnippetResolver!
172+
170173
/// User-provided global options for this documentation conversion.
171174
var options: Options?
172175

@@ -2235,6 +2238,8 @@ public class DocumentationContext {
22352238
knownDisambiguatedPathComponents: configuration.convertServiceConfiguration.knownDisambiguatedSymbolPathComponents
22362239
))
22372240
}
2241+
2242+
self.snippetResolver = SnippetResolver(symbolGraphLoader: symbolGraphLoader)
22382243
} catch {
22392244
// Pipe the error out of the dispatch queue.
22402245
discoveryError.sync({

Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2023-2024 Apple Inc. and the Swift project authors
4+
Copyright (c) 2023-2025 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -285,7 +285,7 @@ private extension PathHierarchy.Node {
285285
}
286286
}
287287

288-
private extension SourceRange {
288+
extension SourceRange {
289289
static func makeRelativeRange(startColumn: Int, endColumn: Int) -> SourceRange {
290290
return SourceLocation(line: 0, column: startColumn, source: nil) ..< SourceLocation(line: 0, column: endColumn, source: nil)
291291
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import SymbolKit
13+
import Markdown
14+
15+
/// A type that resolves snippet paths.
16+
final class SnippetResolver {
17+
typealias SnippetMixin = SymbolKit.SymbolGraph.Symbol.Snippet
18+
typealias Explanation = Markdown.Document
19+
20+
/// Information about a resolved snippet
21+
struct ResolvedSnippet {
22+
fileprivate var path: String // For use in diagnostics
23+
var mixin: SnippetMixin
24+
var explanation: Explanation?
25+
}
26+
/// A snippet that has been resolved, either successfully or not.
27+
enum SnippetResolutionResult {
28+
case success(ResolvedSnippet)
29+
case failure(TopicReferenceResolutionErrorInfo)
30+
}
31+
32+
private var snippets: [String: ResolvedSnippet] = [:]
33+
34+
init(symbolGraphLoader: SymbolGraphLoader) {
35+
var snippets: [String: ResolvedSnippet] = [:]
36+
37+
for graph in symbolGraphLoader.snippetSymbolGraphs.values {
38+
for symbol in graph.symbols.values {
39+
guard let snippetMixin = symbol[mixin: SnippetMixin.self] else { continue }
40+
41+
let path: String = if symbol.pathComponents.first == "Snippets" {
42+
symbol.pathComponents.dropFirst().joined(separator: "/")
43+
} else {
44+
symbol.pathComponents.joined(separator: "/")
45+
}
46+
47+
snippets[path] = .init(path: path, mixin: snippetMixin, explanation: symbol.docComment.map {
48+
Document(parsing: $0.lines.map(\.text).joined(separator: "\n"), options: .parseBlockDirectives)
49+
})
50+
}
51+
}
52+
53+
self.snippets = snippets
54+
}
55+
56+
func resolveSnippet(path authoredPath: String) -> SnippetResolutionResult {
57+
// Snippet paths are relative to the root of the Swift Package.
58+
// The first two components are always the same (the package name followed by "Snippets").
59+
// The later components can either be subdirectories of the "Snippets" directory or the base name of a snippet '.swift' file (without the extension).
60+
61+
// Drop the common package name + "Snippets" prefix (that's always the same), if the authored path includes it.
62+
// This enables the author to omit this prefix (but include it for backwards compatibility with older DocC versions).
63+
var components = authoredPath.split(separator: "/", omittingEmptySubsequences: true)
64+
65+
// It's possible that the package name is "Snippets", resulting in two identical components. Skip until the last of those two.
66+
if let snippetsPrefixIndex = components.prefix(2).lastIndex(of: "Snippets"),
67+
// Don't search for an empty string if the snippet happens to be named "Snippets"
68+
let relativePathStart = components.index(snippetsPrefixIndex, offsetBy: 1, limitedBy: components.endIndex - 1)
69+
{
70+
components.removeFirst(relativePathStart)
71+
}
72+
73+
let path = components.joined(separator: "/")
74+
if let found = snippets[path] {
75+
return .success(found)
76+
} else {
77+
let replacementRange = SourceRange.makeRelativeRange(startColumn: authoredPath.utf8.count - path.utf8.count, length: path.utf8.count)
78+
79+
let nearMisses = NearMiss.bestMatches(for: snippets.keys, against: path)
80+
let solutions = nearMisses.map { candidate in
81+
Solution(summary: "\(Self.replacementOperationDescription(from: path, to: candidate))", replacements: [
82+
Replacement(range: replacementRange, replacement: candidate)
83+
])
84+
}
85+
86+
return .failure(.init("Snippet named '\(path)' couldn't be found", solutions: solutions, rangeAdjustment: replacementRange))
87+
}
88+
}
89+
90+
func validate(slice: String, for resolvedSnippet: ResolvedSnippet) -> TopicReferenceResolutionErrorInfo? {
91+
guard resolvedSnippet.mixin.slices[slice] == nil else {
92+
return nil
93+
}
94+
let replacementRange = SourceRange.makeRelativeRange(startColumn: 0, length: slice.utf8.count)
95+
96+
let nearMisses = NearMiss.bestMatches(for: resolvedSnippet.mixin.slices.keys, against: slice)
97+
let solutions = nearMisses.map { candidate in
98+
Solution(summary: "\(Self.replacementOperationDescription(from: slice, to: candidate))", replacements: [
99+
Replacement(range: replacementRange, replacement: candidate)
100+
])
101+
}
102+
103+
return .init("Slice named '\(slice)' doesn't exist in snippet '\(resolvedSnippet.path)'", solutions: solutions)
104+
}
105+
}
106+
107+
// MARK: Diagnostics
108+
109+
extension SnippetResolver {
110+
static func unknownSnippetSliceProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> Problem {
111+
_problem(source: source, range: range, errorInfo: errorInfo, id: "org.swift.docc.unknownSnippetPath")
112+
}
113+
114+
static func unresolvedSnippetPathProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> Problem {
115+
_problem(source: source, range: range, errorInfo: errorInfo, id: "org.swift.docc.unresolvedSnippetPath")
116+
}
117+
118+
private static func _problem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo, id: String) -> Problem {
119+
var solutions: [Solution] = []
120+
var notes: [DiagnosticNote] = []
121+
if let range {
122+
if let note = errorInfo.note, let source {
123+
notes.append(DiagnosticNote(source: source, range: range, message: note))
124+
}
125+
126+
solutions.append(contentsOf: errorInfo.solutions(referenceSourceRange: range))
127+
}
128+
129+
let diagnosticRange: SourceRange?
130+
if var rangeAdjustment = errorInfo.rangeAdjustment, let range {
131+
rangeAdjustment.offsetWithRange(range)
132+
assert(rangeAdjustment.lowerBound.column >= 0, """
133+
Unresolved snippet reference range adjustment created range with negative column.
134+
Source: \(source?.absoluteString ?? "nil")
135+
Range: \(rangeAdjustment.lowerBound.description):\(rangeAdjustment.upperBound.description)
136+
Summary: \(errorInfo.message)
137+
""")
138+
diagnosticRange = rangeAdjustment
139+
} else {
140+
diagnosticRange = range
141+
}
142+
143+
let diagnostic = Diagnostic(source: source, severity: .warning, range: diagnosticRange, identifier: id, summary: errorInfo.message, notes: notes)
144+
return Problem(diagnostic: diagnostic, possibleSolutions: solutions)
145+
}
146+
147+
private static func replacementOperationDescription(from: some StringProtocol, to: some StringProtocol) -> String {
148+
if from.isEmpty {
149+
return "Insert \(to.singleQuoted)"
150+
}
151+
if to.isEmpty {
152+
return "Remove \(from.singleQuoted)"
153+
}
154+
return "Replace \(from.singleQuoted) with \(to.singleQuoted)"
155+
}
156+
}

Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import SymbolKit
1717
/// which makes detecting symbol collisions and overloads easier.
1818
struct SymbolGraphLoader {
1919
private(set) var symbolGraphs: [URL: SymbolKit.SymbolGraph] = [:]
20+
private(set) var snippetSymbolGraphs: [URL: SymbolKit.SymbolGraph] = [:]
2021
private(set) var unifiedGraphs: [String: SymbolKit.UnifiedSymbolGraph] = [:]
2122
private(set) var graphLocations: [String: [SymbolKit.GraphCollector.GraphKind]] = [:]
2223
// FIXME: After 6.2, when we no longer have `DocumentationContextDataProvider` we can simply this code to not use a closure to read data.
@@ -58,7 +59,7 @@ struct SymbolGraphLoader {
5859

5960
let loadingLock = Lock()
6061

61-
var loadedGraphs = [URL: (usesExtensionSymbolFormat: Bool?, graph: SymbolKit.SymbolGraph)]()
62+
var loadedGraphs = [URL: (usesExtensionSymbolFormat: Bool?, isSnippetGraph: Bool, graph: SymbolKit.SymbolGraph)]()
6263
var loadError: (any Error)?
6364

6465
let loadGraphAtURL: (URL) -> Void = { [dataLoader, bundle] symbolGraphURL in
@@ -99,9 +100,13 @@ struct SymbolGraphLoader {
99100
usesExtensionSymbolFormat = symbolGraph.symbols.isEmpty ? nil : containsExtensionSymbols
100101
}
101102

103+
// If the graph doesn't have any symbols we treat it as a regular, but empty, graph.
104+
// v
105+
let isSnippetGraph = symbolGraph.symbols.values.first?.kind.identifier.isSnippetKind == true
106+
102107
// Store the decoded graph in `loadedGraphs`
103108
loadingLock.sync {
104-
loadedGraphs[symbolGraphURL] = (usesExtensionSymbolFormat, symbolGraph)
109+
loadedGraphs[symbolGraphURL] = (usesExtensionSymbolFormat, isSnippetGraph, symbolGraph)
105110
}
106111
} catch {
107112
// If the symbol graph was invalid, store the error
@@ -141,8 +146,9 @@ struct SymbolGraphLoader {
141146
let mergeSignpostHandle = signposter.beginInterval("Build unified symbol graph", id: signposter.makeSignpostID())
142147
let graphLoader = GraphCollector(extensionGraphAssociationStrategy: usingExtensionSymbolFormat ? .extendingGraph : .extendedGraph)
143148

144-
// feed the loaded graphs into the `graphLoader`
145-
for (url, (_, graph)) in loadedGraphs {
149+
150+
// feed the loaded non-snippet graphs into the `graphLoader`
151+
for (url, (_, isSnippets, graph)) in loadedGraphs where !isSnippets {
146152
graphLoader.mergeSymbolGraph(graph, at: url)
147153
}
148154

@@ -152,7 +158,8 @@ struct SymbolGraphLoader {
152158
throw loadError
153159
}
154160

155-
self.symbolGraphs = loadedGraphs.mapValues(\.graph)
161+
self.symbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? nil : graph })
162+
self.snippetSymbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? graph : nil })
156163
(self.unifiedGraphs, self.graphLocations) = graphLoader.finishLoading(
157164
createOverloadGroups: FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled
158165
)
@@ -546,3 +553,9 @@ private extension SymbolGraph.Symbol.Availability.AvailabilityItem {
546553
domain?.rawValue.lowercased() == platform.rawValue.lowercased()
547554
}
548555
}
556+
557+
extension SymbolGraph.Symbol.KindIdentifier {
558+
var isSnippetKind: Bool {
559+
self == .snippet || self == .snippetGroup
560+
}
561+
}

Sources/SwiftDocC/Model/DocumentationNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,7 @@ private extension BlockDirective {
853853
}
854854
}
855855

856-
extension [String] {
856+
extension Collection<String> {
857857

858858
/// Strips the minimum leading whitespace from all the strings in the array.
859859
///

Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ private func disabledLinkDestinationProblem(reference: ResolvedTopicReference, r
2121
return Problem(diagnostic: Diagnostic(source: range?.source, severity: severity, range: range, identifier: "org.swift.docc.disabledLinkDestination", summary: "The topic \(reference.path.singleQuoted) cannot be linked to."), possibleSolutions: [])
2222
}
2323

24-
private func unknownSnippetSliceProblem(snippetPath: String, slice: String, range: SourceRange?) -> Problem {
25-
let diagnostic = Diagnostic(source: range?.source, severity: .warning, range: range, identifier: "org.swift.docc.unknownSnippetSlice", summary: "Snippet slice \(slice.singleQuoted) does not exist in snippet \(snippetPath.singleQuoted); this directive will be ignored")
26-
return Problem(diagnostic: diagnostic, possibleSolutions: [])
27-
}
28-
2924
private func removedLinkDestinationProblem(reference: ResolvedTopicReference, range: SourceRange?, severity: DiagnosticSeverity) -> Problem {
3025
var solutions = [Solution]()
3126
if let range, reference.pathComponents.count > 3 {
@@ -171,24 +166,21 @@ struct MarkupReferenceResolver: MarkupRewriter {
171166
let source = blockDirective.range?.source
172167
switch blockDirective.name {
173168
case Snippet.directiveName:
174-
var problems = [Problem]()
175-
guard let snippet = Snippet(from: blockDirective, source: source, for: bundle, problems: &problems) else {
169+
var ignoredParsingProblems = [Problem]() // Any argument parsing problems have already been reported elsewhere
170+
guard let snippet = Snippet(from: blockDirective, source: source, for: bundle, problems: &ignoredParsingProblems) else {
176171
return blockDirective
177172
}
178173

179-
if let resolved = resolveAbsoluteSymbolLink(unresolvedDestination: snippet.path, elementRange: blockDirective.range) {
180-
var argumentText = "path: \"\(resolved.absoluteString)\""
174+
switch context.snippetResolver.resolveSnippet(path: snippet.path) {
175+
case .success(let resolvedSnippet):
181176
if let requestedSlice = snippet.slice,
182-
let snippetMixin = try? context.entity(with: resolved).symbol?
183-
.mixins[SymbolGraph.Symbol.Snippet.mixinKey] as? SymbolGraph.Symbol.Snippet {
184-
guard snippetMixin.slices[requestedSlice] != nil else {
185-
problems.append(unknownSnippetSliceProblem(snippetPath: snippet.path, slice: requestedSlice, range: blockDirective.nameRange))
186-
return blockDirective
187-
}
188-
argumentText.append(", slice: \"\(requestedSlice)\"")
177+
let errorInfo = context.snippetResolver.validate(slice: requestedSlice, for: resolvedSnippet)
178+
{
179+
problems.append(SnippetResolver.unknownSnippetSliceProblem(source: source, range: blockDirective.arguments()["slice"]?.valueRange, errorInfo: errorInfo))
189180
}
190-
return BlockDirective(name: Snippet.directiveName, argumentText: argumentText, children: [])
191-
} else {
181+
return blockDirective
182+
case .failure(let errorInfo):
183+
problems.append(SnippetResolver.unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo))
192184
return blockDirective
193185
}
194186
case ImageMedia.directiveName:

0 commit comments

Comments
 (0)