Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ public class DocumentationContext {
/// > Important: The topic graph has no awareness of source language specific edges.
var topicGraph = TopicGraph()

/// Will be assigned during context initialization
var snippetResolver: SnippetResolver!

/// User-provided global options for this documentation conversion.
var options: Options?

Expand Down Expand Up @@ -2235,6 +2238,8 @@ public class DocumentationContext {
knownDisambiguatedPathComponents: configuration.convertServiceConfiguration.knownDisambiguatedSymbolPathComponents
))
}

self.snippetResolver = SnippetResolver(symbolGraphLoader: symbolGraphLoader)
} catch {
// Pipe the error out of the dispatch queue.
discoveryError.sync({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2023-2024 Apple Inc. and the Swift project authors
Copyright (c) 2023-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
Expand Down Expand Up @@ -285,7 +285,7 @@ private extension PathHierarchy.Node {
}
}

private extension SourceRange {
extension SourceRange {
static func makeRelativeRange(startColumn: Int, endColumn: Int) -> SourceRange {
return SourceLocation(line: 0, column: startColumn, source: nil) ..< SourceLocation(line: 0, column: endColumn, source: nil)
}
Expand Down
156 changes: 156 additions & 0 deletions Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift
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)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import SymbolKit
/// which makes detecting symbol collisions and overloads easier.
struct SymbolGraphLoader {
private(set) var symbolGraphs: [URL: SymbolKit.SymbolGraph] = [:]
private(set) var snippetSymbolGraphs: [URL: SymbolKit.SymbolGraph] = [:]
private(set) var unifiedGraphs: [String: SymbolKit.UnifiedSymbolGraph] = [:]
private(set) var graphLocations: [String: [SymbolKit.GraphCollector.GraphKind]] = [:]
// FIXME: After 6.2, when we no longer have `DocumentationContextDataProvider` we can simply this code to not use a closure to read data.
Expand Down Expand Up @@ -58,7 +59,7 @@ struct SymbolGraphLoader {

let loadingLock = Lock()

var loadedGraphs = [URL: (usesExtensionSymbolFormat: Bool?, graph: SymbolKit.SymbolGraph)]()
var loadedGraphs = [URL: (usesExtensionSymbolFormat: Bool?, isSnippetGraph: Bool, graph: SymbolKit.SymbolGraph)]()
var loadError: (any Error)?

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

// If the graph doesn't have any symbols we treat it as a regular, but empty, graph.
// v
let isSnippetGraph = symbolGraph.symbols.values.first?.kind.identifier.isSnippetKind == true

// Store the decoded graph in `loadedGraphs`
loadingLock.sync {
loadedGraphs[symbolGraphURL] = (usesExtensionSymbolFormat, symbolGraph)
loadedGraphs[symbolGraphURL] = (usesExtensionSymbolFormat, isSnippetGraph, symbolGraph)
}
} catch {
// If the symbol graph was invalid, store the error
Expand Down Expand Up @@ -141,8 +146,9 @@ struct SymbolGraphLoader {
let mergeSignpostHandle = signposter.beginInterval("Build unified symbol graph", id: signposter.makeSignpostID())
let graphLoader = GraphCollector(extensionGraphAssociationStrategy: usingExtensionSymbolFormat ? .extendingGraph : .extendedGraph)

// feed the loaded graphs into the `graphLoader`
for (url, (_, graph)) in loadedGraphs {

// feed the loaded non-snippet graphs into the `graphLoader`
for (url, (_, isSnippets, graph)) in loadedGraphs where !isSnippets {
graphLoader.mergeSymbolGraph(graph, at: url)
}

Expand All @@ -152,7 +158,8 @@ struct SymbolGraphLoader {
throw loadError
}

self.symbolGraphs = loadedGraphs.mapValues(\.graph)
self.symbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? nil : graph })
self.snippetSymbolGraphs = loadedGraphs.compactMapValues({ _, isSnippets, graph in isSnippets ? graph : nil })
(self.unifiedGraphs, self.graphLocations) = graphLoader.finishLoading(
createOverloadGroups: FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled
)
Expand Down Expand Up @@ -546,3 +553,9 @@ private extension SymbolGraph.Symbol.Availability.AvailabilityItem {
domain?.rawValue.lowercased() == platform.rawValue.lowercased()
}
}

extension SymbolGraph.Symbol.KindIdentifier {
var isSnippetKind: Bool {
self == .snippet || self == .snippetGroup
}
}
2 changes: 1 addition & 1 deletion Sources/SwiftDocC/Model/DocumentationNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ private extension BlockDirective {
}
}

extension [String] {
extension Collection<String> {

/// Strips the minimum leading whitespace from all the strings in the array.
///
Expand Down
28 changes: 10 additions & 18 deletions Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ private func disabledLinkDestinationProblem(reference: ResolvedTopicReference, r
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: [])
}

private func unknownSnippetSliceProblem(snippetPath: String, slice: String, range: SourceRange?) -> Problem {
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")
return Problem(diagnostic: diagnostic, possibleSolutions: [])
}

private func removedLinkDestinationProblem(reference: ResolvedTopicReference, range: SourceRange?, severity: DiagnosticSeverity) -> Problem {
var solutions = [Solution]()
if let range, reference.pathComponents.count > 3 {
Expand Down Expand Up @@ -171,24 +166,21 @@ struct MarkupReferenceResolver: MarkupRewriter {
let source = blockDirective.range?.source
switch blockDirective.name {
case Snippet.directiveName:
var problems = [Problem]()
guard let snippet = Snippet(from: blockDirective, source: source, for: bundle, problems: &problems) else {
var ignoredParsingProblems = [Problem]() // Any argument parsing problems have already been reported elsewhere
guard let snippet = Snippet(from: blockDirective, source: source, for: bundle, problems: &ignoredParsingProblems) else {
return blockDirective
}

if let resolved = resolveAbsoluteSymbolLink(unresolvedDestination: snippet.path, elementRange: blockDirective.range) {
var argumentText = "path: \"\(resolved.absoluteString)\""
switch context.snippetResolver.resolveSnippet(path: snippet.path) {
case .success(let resolvedSnippet):
if let requestedSlice = snippet.slice,
let snippetMixin = try? context.entity(with: resolved).symbol?
.mixins[SymbolGraph.Symbol.Snippet.mixinKey] as? SymbolGraph.Symbol.Snippet {
guard snippetMixin.slices[requestedSlice] != nil else {
problems.append(unknownSnippetSliceProblem(snippetPath: snippet.path, slice: requestedSlice, range: blockDirective.nameRange))
return blockDirective
}
argumentText.append(", slice: \"\(requestedSlice)\"")
let errorInfo = context.snippetResolver.validate(slice: requestedSlice, for: resolvedSnippet)
{
problems.append(SnippetResolver.unknownSnippetSliceProblem(source: source, range: blockDirective.arguments()["slice"]?.valueRange, errorInfo: errorInfo))
}
return BlockDirective(name: Snippet.directiveName, argumentText: argumentText, children: [])
} else {
return blockDirective
case .failure(let errorInfo):
problems.append(SnippetResolver.unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo))
return blockDirective
}
case ImageMedia.directiveName:
Expand Down
Loading