Skip to content

Commit 527558e

Browse files
authored
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
1 parent 41bc4c4 commit 527558e

File tree

17 files changed

+636
-146
lines changed

17 files changed

+636
-146
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

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

84+
/// Will be assigned during context initialization
85+
var snippetResolver: SnippetResolver!
86+
8487
/// User-provided global options for this documentation conversion.
8588
var options: Options?
8689

@@ -2037,6 +2040,8 @@ public class DocumentationContext {
20372040
knownDisambiguatedPathComponents: configuration.convertServiceConfiguration.knownDisambiguatedSymbolPathComponents
20382041
))
20392042
}
2043+
2044+
self.snippetResolver = SnippetResolver(symbolGraphLoader: symbolGraphLoader)
20402045
} catch {
20412046
// Pipe the error out of the dispatch queue.
20422047
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
private let dataProvider: any DataProvider
@@ -57,7 +58,7 @@ struct SymbolGraphLoader {
5758

5859
let loadingLock = Lock()
5960

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

6364
let loadGraphAtURL: (URL) -> Void = { [dataProvider] symbolGraphURL in
@@ -98,9 +99,13 @@ struct SymbolGraphLoader {
9899
usesExtensionSymbolFormat = symbolGraph.symbols.isEmpty ? nil : containsExtensionSymbols
99100
}
100101

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

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

@@ -151,7 +157,8 @@ struct SymbolGraphLoader {
151157
throw loadError
152158
}
153159

154-
self.symbolGraphs = loadedGraphs.mapValues(\.graph)
160+
self.symbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? nil : graph })
161+
self.snippetSymbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? graph : nil })
155162
(self.unifiedGraphs, self.graphLocations) = graphLoader.finishLoading(
156163
createOverloadGroups: FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled
157164
)
@@ -519,3 +526,9 @@ private extension SymbolGraph.Symbol.Availability.AvailabilityItem {
519526
domain?.rawValue.lowercased() == platform.rawValue.lowercased()
520527
}
521528
}
529+
530+
extension SymbolGraph.Symbol.KindIdentifier {
531+
var isSnippetKind: Bool {
532+
self == .snippet || self == .snippetGroup
533+
}
534+
}

Sources/SwiftDocC/Model/DocumentationNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ private extension BlockDirective {
903903
}
904904
}
905905

906-
extension [String] {
906+
extension Collection<String> {
907907

908908
/// Strip the minimum leading whitespace from all the strings in this array, as follows:
909909
/// - Find the line with least amount of leading whitespace. Ignore blank lines during this search.

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)