|
| 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 | +} |
0 commit comments