diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 224bd5f28..b0e25d87e 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -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? @@ -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({ diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift index 67851a212..e02a6f4c0 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Error.swift @@ -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 @@ -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) } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift new file mode 100644 index 000000000..4c98f7354 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -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)" + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift index 50baa2529..b422c614e 100644 --- a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift +++ b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift @@ -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. @@ -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 @@ -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 @@ -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) } @@ -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 ) @@ -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 + } +} diff --git a/Sources/SwiftDocC/Model/DocumentationNode.swift b/Sources/SwiftDocC/Model/DocumentationNode.swift index a9e313322..0035be6c3 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -853,7 +853,7 @@ private extension BlockDirective { } } -extension [String] { +extension Collection { /// Strips the minimum leading whitespace from all the strings in the array. /// diff --git a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift index 8f34a30d4..00b57e589 100644 --- a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift @@ -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 { @@ -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: diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index a2188a3c8..6984ff5a6 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -12,16 +12,42 @@ import Foundation public import Markdown import SymbolKit +/// Embeds a code example from the project's code snippets. +/// +/// Use a `Snippet` directive to embed a code example from the project's "Snippets" directory on the page. +/// The `path` argument is the relative path from the package's top-level "Snippets" directory to your snippet file without the `.swift` extension. +/// +/// ```markdown +/// @Snippet(path: "example-snippet", slice: "setup") +/// ``` +/// +/// If you prefer, you can specify the relative path from the package's _root_ directory (by including a "Snippets/" prefix). +/// You can also include the package name---as defined in `Package.swift`---before the "Snippets/" prefix. +/// Neither of these leading path components are necessary because all your snippet code files are always located in your package's "Snippets" directory. +/// +/// > Earlier Versions: +/// > Before Swift-DocC 6.2.1, the `@Snippet` path needed to include both the package name component and the "Snippets" component: +/// > +/// > ```markdown +/// > @Snippet(path: "my-package/Snippets/example-snippet") +/// > ``` +/// +/// You can define named slices of your snippet by annotating the snippet file with `// snippet.` and `// snippet.end` lines. +/// A named slice automatically ends at the start of the next named slice, without an explicit `snippet.end` annotation. +/// +/// If the referenced snippet includes annotated slices, you can limit the embedded code example to a certain line range by specifying a `slice` name. +/// By default, the embedded code example includes the full snippet. For more information, see . public final class Snippet: Semantic, AutomaticDirectiveConvertible { public static let introducedVersion = "5.6" public let originalMarkup: BlockDirective - /// The path components of a symbol link that would be used to resolve a reference to a snippet, - /// only occurring as a block directive argument. + /// The relative path from your package's top-level "Snippets" directory to the snippet file that you want to embed in the page, without the `.swift` file extension. @DirectiveArgumentWrapped public var path: String - /// An optional named range to limit the lines shown. + /// The name of a snippet slice to limit the embedded code example to a certain line range. + /// + /// By default, the embedded code example includes the full snippet. @DirectiveArgumentWrapped public var slice: String? = nil @@ -50,30 +76,33 @@ public final class Snippet: Semantic, AutomaticDirectiveConvertible { extension Snippet: RenderableDirectiveConvertible { func render(with contentCompiler: inout RenderContentCompiler) -> [any RenderContent] { - guard let snippet = Snippet(from: originalMarkup, for: contentCompiler.bundle) else { + guard case .success(let resolvedSnippet) = contentCompiler.context.snippetResolver.resolveSnippet(path: path) else { + return [] + } + let mixin = resolvedSnippet.mixin + + if let slice { + guard let sliceRange = mixin.slices[slice] else { + // The warning says that unrecognized snippet slices will ignore the entire snippet. return [] } + // Render only this slice without the explanatory content. + let lines = mixin.lines + // Trim the lines + .dropFirst(sliceRange.startIndex).prefix(sliceRange.endIndex - sliceRange.startIndex) + // Trim the whitespace + .linesWithoutLeadingWhitespace() + // Make dedicated copies of each line because the RenderBlockContent.codeListing requires it. + .map { String($0) } - guard let snippetReference = contentCompiler.resolveSymbolReference(destination: snippet.path), - let snippetEntity = try? contentCompiler.context.entity(with: snippetReference), - let snippetSymbol = snippetEntity.symbol, - let snippetMixin = snippetSymbol.mixins[SymbolGraph.Symbol.Snippet.mixinKey] as? SymbolGraph.Symbol.Snippet else { - return [] - } + return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil))] + } else { + // Render the full snippet and its explanatory content. + let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil)) - if let requestedSlice = snippet.slice, - let requestedLineRange = snippetMixin.slices[requestedSlice] { - // Render only the slice. - let lineRange = requestedLineRange.lowerBound.. ModuleName.md:7:\(16 + prefixLength)-7:\(20 + prefixLength) + 5 | ## Overview + 6 | + 7 + @Snippet(path: \(pathPrefix)\u{001B}[1;32mFrst\u{001B}[0;0m) + | \(String(repeating: " ", count: prefixLength)) ╰─\u{001B}[1;39msuggestion: Replace 'Frst' with 'First'\u{001B}[0;0m + 8 | + 9 | @Snippet(path: \(pathPrefix)First, slice: commt) + + \u{001B}[1;33mwarning: Slice named 'commt' doesn't exist in snippet 'First'\u{001B}[0;0m + --> ModuleName.md:9:\(30 + prefixLength)-9:\(35 + prefixLength) + 7 | @Snippet(path: \(pathPrefix)Frst) + 8 | + 9 + @Snippet(path: \(pathPrefix)First, slice: \u{001B}[1;32mcommt\u{001B}[0;0m) + | \(String(repeating: " ", count: prefixLength)) ╰─\u{001B}[1;39msuggestion: Replace 'commt' with 'comment'\u{001B}[0;0m + + """) + + // Because the snippet links failed to resolve, their content shouldn't render on the page. + XCTAssertTrue(snippetRenderBlocks.isEmpty, "There's no more content after the snippets") + } + } + + private func makeSnippetContext( + snippets: [SymbolGraph.Symbol], + rootContent: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> ([Problem], logOutput: String, some Collection) { + let catalog = Folder(name: "Something.docc", content: [ + JSONFile(name: "something-snippets.symbols.json", content: makeSymbolGraph(moduleName: "Snippets", symbols: snippets)), + // Include a "real" module that's separate from the snippet symbol graph. + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName")), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + Always include an abstract here before the custom markup + + ## Overview + + \(rootContent) + """) + ]) + // We make the "Overview" heading explicit above so that the rendered page will always have a `primaryContentSections`. + // This makes it easier for the test to then + + let logStore = LogHandle.LogStorage() + let (bundle, context) = try loadBundle(catalog: catalog, logOutput: LogHandle.memory(logStore)) + + XCTAssertEqual(context.knownIdentifiers.count, 1, "The snippets don't have their own identifiers", file: file, line: line) + + let reference = try XCTUnwrap(context.soleRootModuleReference, file: file, line: line) + let moduleNode = try context.entity(with: reference) + let renderNode = DocumentationNodeConverter(bundle: bundle, context: context).convert(moduleNode) + + let renderBlocks = try XCTUnwrap(renderNode.primaryContentSections.first as? ContentRenderSection, file: file, line: line).content + + if case .heading(let heading) = renderBlocks.first { + XCTAssertEqual(heading.level, 2, file: file, line: line) + XCTAssertEqual(heading.text, "Overview", file: file, line: line) + } else { + XCTFail("The rendered page is missing the 'Overview' heading. Something unexpected is happening with the page content.", file: file, line: line) + } + + return (context.problems.sorted(by: \.diagnostic.range!.lowerBound.line), logStore.text, renderBlocks.dropFirst()) + } + + private func makeSnippet( + pathComponents: [String], + explanation: String?, + code: String, + slices: [String: Range] = [:] + ) -> SymbolGraph.Symbol { + makeSymbol( + id: "$snippet__module-name.\(pathComponents.map { $0.lowercased() }.joined(separator: "."))", + kind: .snippet, + pathComponents: pathComponents, + docComment: explanation, + otherMixins: [ + SymbolGraph.Symbol.Snippet( + language: SourceLanguage.swift.id, + lines: code.components(separatedBy: "\n"), + slices: slices + ) + ] + ) + } +} diff --git a/Tests/SwiftDocCTests/Semantics/Reference/TabNavigatorTests.swift b/Tests/SwiftDocCTests/Semantics/Reference/TabNavigatorTests.swift index 73b8e928b..3df578b9e 100644 --- a/Tests/SwiftDocCTests/Semantics/Reference/TabNavigatorTests.swift +++ b/Tests/SwiftDocCTests/Semantics/Reference/TabNavigatorTests.swift @@ -160,15 +160,11 @@ class TabNavigatorTests: XCTestCase { XCTAssertNotNil(tabNavigator) - // UnresolvedTopicReference warning expected since the reference to the snippet "Snippets/Snippets/MySnippet" - // should fail to resolve here and then nothing would be added to the content. - XCTAssertEqual( - problems, - ["23: warning – org.swift.docc.unresolvedTopicReference"] - ) + // One warning is expected. This empty context has no snippets so the "Snippets/Snippets/MySnippet" path should fail to resolve. + XCTAssertEqual(problems, [ + "23: warning – org.swift.docc.unresolvedSnippetPath" + ]) - - XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( renderBlockContent.first, @@ -202,6 +198,8 @@ class TabNavigatorTests: XCTestCase { "Hey there.", .small(RenderBlockContent.Small(inlineContent: [.text("Hey but small.")])), + + // Because the the "Snippets/Snippets/MySnippet" snippet failed to resolve, we're not including any snippet content here. ] ), ] diff --git a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift index cc5750d3f..af5f678d4 100644 --- a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift +++ b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift @@ -15,8 +15,8 @@ import XCTest import Markdown class SnippetTests: XCTestCase { - func testNoPath() throws { - let (bundle, _) = try testBundleAndContext(named: "Snippets") + func testWarningAboutMissingPathPath() throws { + let (bundle, _) = try testBundleAndContext() let source = """ @Snippet() """ @@ -29,8 +29,8 @@ class SnippetTests: XCTestCase { XCTAssertEqual("org.swift.docc.HasArgument.path", problems[0].diagnostic.identifier) } - func testHasInnerContent() throws { - let (bundle, _) = try testBundleAndContext(named: "Snippets") + func testWarningAboutInnerContent() throws { + let (bundle, _) = try testBundleAndContext() let source = """ @Snippet(path: "path/to/snippet") { This content shouldn't be here. @@ -45,8 +45,8 @@ class SnippetTests: XCTestCase { XCTAssertEqual("org.swift.docc.Snippet.NoInnerContentAllowed", problems[0].diagnostic.identifier) } - func testLinkResolves() throws { - let (bundle, _) = try testBundleAndContext(named: "Snippets") + func testParsesPath() throws { + let (bundle, _) = try testBundleAndContext() let source = """ @Snippet(path: "Test/Snippets/MySnippet") """ @@ -58,23 +58,50 @@ class SnippetTests: XCTestCase { XCTAssertNotNil(snippet) XCTAssertTrue(problems.isEmpty) } + func testLinkResolvesWithoutOptionalPrefix() throws { + let (bundle, context) = try testBundleAndContext(named: "Snippets") + + for snippetPath in [ + "/Test/Snippets/MySnippet", + "Test/Snippets/MySnippet", + "Snippets/MySnippet", + "MySnippet", + ] { + let source = """ + @Snippet(path: "\(snippetPath)") + """ + let document = Document(parsing: source, options: .parseBlockDirectives) + var resolver = MarkupReferenceResolver(context: context, bundle: bundle, rootReference: try XCTUnwrap(context.soleRootModuleReference)) + _ = resolver.visit(document) + XCTAssertTrue(resolver.problems.isEmpty, "Unexpected problems: \(resolver.problems.map(\.diagnostic.summary))") + } + } - func testUnresolvedSnippetPathDiagnostic() throws { + func testWarningAboutUnresolvedSnippetPath() throws { let (bundle, context) = try testBundleAndContext(named: "Snippets") - let source = """ - @Snippet(path: "Test/Snippets/DoesntExist") - """ - let document = Document(parsing: source, options: .parseBlockDirectives) - var resolver = MarkupReferenceResolver(context: context, bundle: bundle, rootReference: context.rootModules[0]) - _ = resolver.visit(document) - XCTAssertEqual(1, resolver.problems.count) - resolver.problems.first.map { - XCTAssertEqual("org.swift.docc.unresolvedTopicReference", $0.diagnostic.identifier) + + for snippetPath in [ + "/Test/Snippets/DoesNotExist", + "Test/Snippets/DoesNotExist", + "Snippets/DoesNotExist", + "DoesNotExist", + ] { + let source = """ + @Snippet(path: "\(snippetPath)") + """ + let document = Document(parsing: source, options: .parseBlockDirectives) + var resolver = MarkupReferenceResolver(context: context, bundle: bundle, rootReference: try XCTUnwrap(context.soleRootModuleReference)) + _ = resolver.visit(document) + XCTAssertEqual(1, resolver.problems.count) + let problem = try XCTUnwrap(resolver.problems.first) + XCTAssertEqual(problem.diagnostic.identifier, "org.swift.docc.unresolvedSnippetPath") + XCTAssertEqual(problem.diagnostic.summary, "Snippet named 'DoesNotExist' couldn't be found") + XCTAssertEqual(problem.possibleSolutions.count, 0) } } - func testSliceResolves() throws { - let (bundle, _) = try testBundleAndContext(named: "Snippets") + func testParsesSlice() throws { + let (bundle, _) = try testBundleAndContext() let source = """ @Snippet(path: "Test/Snippets/MySnippet", slice: "foo") """ diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 6937a6c6e..a59afc8e8 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -43,21 +43,30 @@ extension XCTestCase { /// - Parameters: /// - catalog: The directory structure of the documentation catalog /// - otherFileSystemDirectories: Any other directories in the test file system. - /// - diagnosticEngine: The diagnostic engine for the created context. + /// - diagnosticFilterLevel: The minimum severity for diagnostics to emit. + /// - logOutput: An output stream to capture log output from creating the context. /// - configuration: Configuration for the created context. /// - Returns: The loaded documentation bundle and context for the given catalog input. func loadBundle( catalog: Folder, otherFileSystemDirectories: [Folder] = [], - diagnosticEngine: DiagnosticEngine = .init(), + diagnosticFilterLevel: DiagnosticSeverity = .warning, + logOutput: some TextOutputStream = LogHandle.none, configuration: DocumentationContext.Configuration = .init() ) throws -> (DocumentationBundle, DocumentationContext) { let fileSystem = try TestFileSystem(folders: [catalog] + otherFileSystemDirectories) + let catalogURL = URL(fileURLWithPath: "/\(catalog.name)") + + let diagnosticEngine = DiagnosticEngine(filterLevel: diagnosticFilterLevel) + diagnosticEngine.add(DiagnosticConsoleWriter(logOutput, formattingOptions: [], baseURL: catalogURL, highlight: true, dataProvider: fileSystem)) let (bundle, dataProvider) = try DocumentationContext.InputsProvider(fileManager: fileSystem) - .inputsAndDataProvider(startingPoint: URL(fileURLWithPath: "/\(catalog.name)"), options: .init()) + .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) let context = try DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) + + diagnosticEngine.flush() // Write to the logOutput + return (bundle, context) }