-
Notifications
You must be signed in to change notification settings - Fork 160
Improve handling of snippet symbol graph files #1302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
d-ronnqvist
merged 16 commits into
swiftlang:main
from
d-ronnqvist:separate-snippets-resolver
Sep 29, 2025
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
677f727
Separate snippets from other symbols
d-ronnqvist 64fac3c
Rename tests to clarify what they're verifying
d-ronnqvist 552328a
Improve tests around optional snippet prefix components
d-ronnqvist 0ee9544
Add additional test about resolving and rendering snippets
d-ronnqvist 08d22c5
Make it easier to verify the diagnostic log output in tests
d-ronnqvist 757e3ef
Add additional test about snippet warnings
d-ronnqvist 077b175
Move snippet diagnostic creation to resolver type
d-ronnqvist f70b35f
Add near-miss suggestions for snippet paths and slices
d-ronnqvist 82f0dfc
Only highlight the misspelled portion of snippet paths
d-ronnqvist ceaab1c
Update user-facing documentation about optional snippet paths prefixes
d-ronnqvist a50433d
Merge branch 'main' into separate-snippets-resolver
d-ronnqvist afd6871
Clarify that Snippet argument parsing problems are reported elsewhere
d-ronnqvist 474d5c0
Merge branch 'main' into separate-snippets-resolver
d-ronnqvist 6fbe109
Merge branch 'main' into separate-snippets-resolver
d-ronnqvist 937f651
Fix typo in code comment
d-ronnqvist 45c0dd5
Update docs about earliest version with an optional snippet path prefix
d-ronnqvist File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 156 additions & 0 deletions
156
Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| /* | ||
| This source file is part of the Swift.org open source project | ||
|
|
||
| Copyright (c) 2025 Apple Inc. and the Swift project authors | ||
| Licensed under Apache License v2.0 with Runtime Library Exception | ||
|
|
||
| See https://swift.org/LICENSE.txt for license information | ||
| See https://swift.org/CONTRIBUTORS.txt for Swift project authors | ||
| */ | ||
|
|
||
| import Foundation | ||
| import SymbolKit | ||
| import Markdown | ||
|
|
||
| /// A type that resolves snippet paths. | ||
| final class SnippetResolver { | ||
| typealias SnippetMixin = SymbolKit.SymbolGraph.Symbol.Snippet | ||
| typealias Explanation = Markdown.Document | ||
|
|
||
| /// Information about a resolved snippet | ||
| struct ResolvedSnippet { | ||
| fileprivate var path: String // For use in diagnostics | ||
| var mixin: SnippetMixin | ||
| var explanation: Explanation? | ||
| } | ||
| /// A snippet that has been resolved, either successfully or not. | ||
| enum SnippetResolutionResult { | ||
| case success(ResolvedSnippet) | ||
| case failure(TopicReferenceResolutionErrorInfo) | ||
| } | ||
|
|
||
| private var snippets: [String: ResolvedSnippet] = [:] | ||
|
|
||
| init(symbolGraphLoader: SymbolGraphLoader) { | ||
| var snippets: [String: ResolvedSnippet] = [:] | ||
|
|
||
| for graph in symbolGraphLoader.snippetSymbolGraphs.values { | ||
| for symbol in graph.symbols.values { | ||
| guard let snippetMixin = symbol[mixin: SnippetMixin.self] else { continue } | ||
|
|
||
| let path: String = if symbol.pathComponents.first == "Snippets" { | ||
| symbol.pathComponents.dropFirst().joined(separator: "/") | ||
| } else { | ||
| symbol.pathComponents.joined(separator: "/") | ||
| } | ||
|
|
||
| snippets[path] = .init(path: path, mixin: snippetMixin, explanation: symbol.docComment.map { | ||
| Document(parsing: $0.lines.map(\.text).joined(separator: "\n"), options: .parseBlockDirectives) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| self.snippets = snippets | ||
| } | ||
|
|
||
| func resolveSnippet(path authoredPath: String) -> SnippetResolutionResult { | ||
| // Snippet paths are relative to the root of the Swift Package. | ||
| // The first two components are always the same (the package name followed by "Snippets"). | ||
| // The later components can either be subdirectories of the "Snippets" directory or the base name of a snippet '.swift' file (without the extension). | ||
|
|
||
| // Drop the common package name + "Snippets" prefix (that's always the same), if the authored path includes it. | ||
| // This enables the author to omit this prefix (but include it for backwards compatibility with older DocC versions). | ||
| var components = authoredPath.split(separator: "/", omittingEmptySubsequences: true) | ||
|
|
||
| // It's possible that the package name is "Snippets", resulting in two identical components. Skip until the last of those two. | ||
| if let snippetsPrefixIndex = components.prefix(2).lastIndex(of: "Snippets"), | ||
| // Don't search for an empty string if the snippet happens to be named "Snippets" | ||
| let relativePathStart = components.index(snippetsPrefixIndex, offsetBy: 1, limitedBy: components.endIndex - 1) | ||
| { | ||
| components.removeFirst(relativePathStart) | ||
| } | ||
|
|
||
| let path = components.joined(separator: "/") | ||
| if let found = snippets[path] { | ||
| return .success(found) | ||
| } else { | ||
| let replacementRange = SourceRange.makeRelativeRange(startColumn: authoredPath.utf8.count - path.utf8.count, length: path.utf8.count) | ||
|
|
||
| let nearMisses = NearMiss.bestMatches(for: snippets.keys, against: path) | ||
| let solutions = nearMisses.map { candidate in | ||
| Solution(summary: "\(Self.replacementOperationDescription(from: path, to: candidate))", replacements: [ | ||
| Replacement(range: replacementRange, replacement: candidate) | ||
| ]) | ||
| } | ||
|
|
||
| return .failure(.init("Snippet named '\(path)' couldn't be found", solutions: solutions, rangeAdjustment: replacementRange)) | ||
| } | ||
| } | ||
|
|
||
| func validate(slice: String, for resolvedSnippet: ResolvedSnippet) -> TopicReferenceResolutionErrorInfo? { | ||
| guard resolvedSnippet.mixin.slices[slice] == nil else { | ||
| return nil | ||
| } | ||
| let replacementRange = SourceRange.makeRelativeRange(startColumn: 0, length: slice.utf8.count) | ||
|
|
||
| let nearMisses = NearMiss.bestMatches(for: resolvedSnippet.mixin.slices.keys, against: slice) | ||
| let solutions = nearMisses.map { candidate in | ||
| Solution(summary: "\(Self.replacementOperationDescription(from: slice, to: candidate))", replacements: [ | ||
| Replacement(range: replacementRange, replacement: candidate) | ||
| ]) | ||
| } | ||
|
|
||
| return .init("Slice named '\(slice)' doesn't exist in snippet '\(resolvedSnippet.path)'", solutions: solutions) | ||
| } | ||
| } | ||
|
|
||
| // MARK: Diagnostics | ||
|
|
||
| extension SnippetResolver { | ||
| static func unknownSnippetSliceProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> Problem { | ||
| _problem(source: source, range: range, errorInfo: errorInfo, id: "org.swift.docc.unknownSnippetPath") | ||
| } | ||
|
|
||
| static func unresolvedSnippetPathProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> Problem { | ||
| _problem(source: source, range: range, errorInfo: errorInfo, id: "org.swift.docc.unresolvedSnippetPath") | ||
| } | ||
|
|
||
| private static func _problem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo, id: String) -> Problem { | ||
| var solutions: [Solution] = [] | ||
| var notes: [DiagnosticNote] = [] | ||
| if let range { | ||
| if let note = errorInfo.note, let source { | ||
| notes.append(DiagnosticNote(source: source, range: range, message: note)) | ||
| } | ||
|
|
||
| solutions.append(contentsOf: errorInfo.solutions(referenceSourceRange: range)) | ||
| } | ||
|
|
||
| let diagnosticRange: SourceRange? | ||
| if var rangeAdjustment = errorInfo.rangeAdjustment, let range { | ||
| rangeAdjustment.offsetWithRange(range) | ||
| assert(rangeAdjustment.lowerBound.column >= 0, """ | ||
| Unresolved snippet reference range adjustment created range with negative column. | ||
| Source: \(source?.absoluteString ?? "nil") | ||
| Range: \(rangeAdjustment.lowerBound.description):\(rangeAdjustment.upperBound.description) | ||
| Summary: \(errorInfo.message) | ||
| """) | ||
| diagnosticRange = rangeAdjustment | ||
| } else { | ||
| diagnosticRange = range | ||
| } | ||
|
|
||
| let diagnostic = Diagnostic(source: source, severity: .warning, range: diagnosticRange, identifier: id, summary: errorInfo.message, notes: notes) | ||
| return Problem(diagnostic: diagnostic, possibleSolutions: solutions) | ||
| } | ||
|
|
||
| private static func replacementOperationDescription(from: some StringProtocol, to: some StringProtocol) -> String { | ||
| if from.isEmpty { | ||
| return "Insert \(to.singleQuoted)" | ||
| } | ||
| if to.isEmpty { | ||
| return "Remove \(from.singleQuoted)" | ||
| } | ||
| return "Replace \(from.singleQuoted) with \(to.singleQuoted)" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI: there are some nice "Good first issue" improvements to be had here. For example, if the snippet file is nested in a deeper subdirectory but there's only one snippet with that base name, we could have a custom solution an/or note that says that a snippet with that name exist in a deeper path. After this is merged I can open an issue suggesting that.