From 677f7274bb43451f98aa67b0965178f07bd7b3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 25 Sep 2025 19:02:29 +0200 Subject: [PATCH 01/13] Separate snippets from other symbols rdar://147926589 rdar://161164434 #1280 --- .../Infrastructure/DocumentationContext.swift | 5 ++ .../Link Resolution/SnippetResolver.swift | 79 +++++++++++++++++++ .../Symbol Graph/SymbolGraphLoader.swift | 23 ++++-- .../SwiftDocC/Model/DocumentationNode.swift | 2 +- .../Semantics/MarkupReferenceResolver.swift | 48 ++++++++--- .../Semantics/Snippets/Snippet.swift | 45 ++++++----- .../DocumentationContextTests.swift | 6 +- .../Infrastructure/PathHierarchyTests.swift | 25 ------ .../Reference/TabNavigatorTests.swift | 14 ++-- .../Semantics/SnippetTests.swift | 11 +-- 10 files changed, 177 insertions(+), 81 deletions(-) create mode 100644 Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index cd7fca83d..d48718dba 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -81,6 +81,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? @@ -2038,6 +2041,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/SnippetResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift new file mode 100644 index 000000000..afc0be453 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -0,0 +1,79 @@ +/* + 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 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 { + 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(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 to 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 { + return .failure(.init("Snippet named '\(path)' couldn't be found.")) + } + } +} + diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift index 4418b6ad6..620d18412 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]] = [:] private let dataProvider: any DataProvider @@ -57,7 +58,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 = { [dataProvider] symbolGraphURL in @@ -98,9 +99,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 @@ -140,8 +145,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) } @@ -151,7 +157,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 ) @@ -519,3 +526,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 803ddb776..0ff7e3384 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -903,7 +903,7 @@ private extension BlockDirective { } } -extension [String] { +extension Collection { /// Strip the minimum leading whitespace from all the strings in this array, as follows: /// - Find the line with least amount of leading whitespace. Ignore blank lines during this search. diff --git a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift index 8f34a30d4..0d9553c9b 100644 --- a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift @@ -26,6 +26,34 @@ private func unknownSnippetSliceProblem(snippetPath: String, slice: String, rang return Problem(diagnostic: diagnostic, possibleSolutions: []) } +private func unresolvedSnippetPathProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> 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: "org.swift.docc.unresolvedSnippetPath", summary: errorInfo.message, notes: notes) + return Problem(diagnostic: diagnostic, possibleSolutions: solutions) +} private func removedLinkDestinationProblem(reference: ResolvedTopicReference, range: SourceRange?, severity: DiagnosticSeverity) -> Problem { var solutions = [Solution]() if let range, reference.pathComponents.count > 3 { @@ -171,24 +199,22 @@ struct MarkupReferenceResolver: MarkupRewriter { let source = blockDirective.range?.source switch blockDirective.name { case Snippet.directiveName: - var problems = [Problem]() + var problems = [Problem]() // ???: DAVID IS IGNORED? guard let snippet = Snippet(from: blockDirective, source: source, for: bundle, problems: &problems) else { return blockDirective } - if let resolved = resolveAbsoluteSymbolLink(unresolvedDestination: snippet.path, elementRange: blockDirective.range) { - var argumentText = "path: \"\(resolved.absoluteString)\"" - 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)) + switch context.snippetResolver.resolveSnippet(path: snippet.path) { + case .success(let resolvedSnippet): + if let requestedSlice = snippet.slice { + guard resolvedSnippet.mixin.slices[requestedSlice] != nil else { + self.problems.append(unknownSnippetSliceProblem(snippetPath: snippet.path, slice: requestedSlice, range: blockDirective.nameRange)) return blockDirective } - argumentText.append(", slice: \"\(requestedSlice)\"") } - return BlockDirective(name: Snippet.directiveName, argumentText: argumentText, children: []) - } else { + return blockDirective + case .failure(let errorInfo): + self.problems.append(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 417f4c5dc..4d4668118 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -65,30 +65,29 @@ 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 { - return [] - } + guard case .success(let resolvedSnippet) = contentCompiler.context.snippetResolver.resolveSnippet(path: path) else { + return [] + } + let mixin = resolvedSnippet.mixin + + if let sliceRange = slice.flatMap({ mixin.slices[$0] }) { + // 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.. Date: Thu, 25 Sep 2025 20:12:20 +0200 Subject: [PATCH 02/13] Rename tests to clarify what they're verifying --- .../Semantics/SnippetTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift index 9da211864..c9a2b75e2 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() async throws { - let (bundle, _) = try await testBundleAndContext(named: "Snippets") + func testWarningAboutMissingPathPath() async throws { + let (bundle, _) = try await testBundleAndContext() let source = """ @Snippet() """ @@ -29,8 +29,8 @@ class SnippetTests: XCTestCase { XCTAssertEqual("org.swift.docc.HasArgument.path", problems[0].diagnostic.identifier) } - func testHasInnerContent() async throws { - let (bundle, _) = try await testBundleAndContext(named: "Snippets") + func testWarningAboutInnerContent() async throws { + let (bundle, _) = try await 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() async throws { - let (bundle, _) = try await testBundleAndContext(named: "Snippets") + func testParsesPath() async throws { + let (bundle, _) = try await testBundleAndContext() let source = """ @Snippet(path: "Test/Snippets/MySnippet") """ @@ -59,7 +59,7 @@ class SnippetTests: XCTestCase { XCTAssertTrue(problems.isEmpty) } - func testUnresolvedSnippetPathDiagnostic() async throws { + func testWarningAboutUnresolvedSnippetPath() async throws { let (bundle, context) = try await testBundleAndContext(named: "Snippets") let source = """ @Snippet(path: "Test/Snippets/DoesNotExist") @@ -74,8 +74,8 @@ class SnippetTests: XCTestCase { XCTAssertEqual(problem.possibleSolutions.count, 0) } - func testSliceResolves() async throws { - let (bundle, _) = try await testBundleAndContext(named: "Snippets") + func testParsesSlice() async throws { + let (bundle, _) = try await testBundleAndContext() let source = """ @Snippet(path: "Test/Snippets/MySnippet", slice: "foo") """ From 552328a493778feb37afd6c7f556743edbfb20de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Thu, 25 Sep 2025 20:13:58 +0200 Subject: [PATCH 03/13] Improve tests around optional snippet prefix components --- .../Semantics/SnippetTests.swift | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift index c9a2b75e2..e110b7f54 100644 --- a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift +++ b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift @@ -58,20 +58,46 @@ class SnippetTests: XCTestCase { XCTAssertNotNil(snippet) XCTAssertTrue(problems.isEmpty) } + func testLinkResolvesWithoutOptionalPrefix() async throws { + let (bundle, context) = try await 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 testWarningAboutUnresolvedSnippetPath() async throws { let (bundle, context) = try await testBundleAndContext(named: "Snippets") - let source = """ - @Snippet(path: "Test/Snippets/DoesNotExist") - """ - 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) + + 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 testParsesSlice() async throws { From 0ee954401d4ee7f7ed00aa802e7c582602c8d23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 10:56:51 +0200 Subject: [PATCH 04/13] Add additional test about resolving and rendering snippets --- .../Infrastructure/SnippetResolverTests.swift | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift diff --git a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift new file mode 100644 index 000000000..b1156bd94 --- /dev/null +++ b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift @@ -0,0 +1,185 @@ +/* + 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 XCTest +@testable import SwiftDocC +import SymbolKit +import SwiftDocCTestUtilities + +class SnippetResolverTests: XCTestCase { + + let optionalPathPrefixes = [ + // The module name as the first component + "/ModuleName/Snippets/", + "ModuleName/Snippets/", + + // The catalog name as the first component + "/Something/Snippets/", + "Something/Snippets/", + + // Snippets repeated as the first component + "/Snippets/Snippets/", + "Snippets/Snippets/", + + // Only the "Snippets" prefix + "/Snippets/", + "Snippets/", + + // No prefix + "/", + "", + ] + + func testRenderingSnippetsWithOptionalPathPrefixes() async throws { + for pathPrefix in optionalPathPrefixes { + let (problems, snippetRenderBlocks) = try await makeSnippetContext( + snippets: [ + makeSnippet( + pathComponents: ["Snippets", "First"], + explanation: """ + Some _formatted_ **content** that provides context to the snippet. + """, + code: """ + // Some code comment + print("Hello, world!") + """, + slices: ["comment": 0..<1] + ), + makeSnippet( + pathComponents: ["Snippets", "Path", "To", "Second"], + explanation: nil, + code: """ + print("1 + 2 = \\(1+2)") + """ + ) + ], + rootContent: """ + @Snippet(path: \(pathPrefix)First) + + @Snippet(path: \(pathPrefix)Path/To/Second) + + @Snippet(path: \(pathPrefix)First, slice: comment) + """ + ) + + // These links should all resolve, regardless of optional prefix + XCTAssertTrue(problems.isEmpty, "Unexpected problems for path prefix '\(pathPrefix)': \(problems.map(\.diagnostic.summary))") + + // Because the snippet links resolved, their content should render on the page. + + // The explanation for the first snippet + if case .paragraph(let paragraph) = snippetRenderBlocks.first { + XCTAssertEqual(paragraph.inlineContent, [ + .text("Some "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .strong(inlineContent: [.text("content")]), + .text(" that provides context to the snippet."), + ]) + } else { + XCTFail("Missing expected rendered explanation.") + } + + // The first snippet code + if case .codeListing(let codeListing) = snippetRenderBlocks.dropFirst().first { + XCTAssertEqual(codeListing.syntax, "swift") + XCTAssertEqual(codeListing.code, [ + #"// Some code comment"#, + #"print("Hello, world!")"#, + ]) + } else { + XCTFail("Missing expected rendered code block.") + } + + // The second snippet (without an explanation) + if case .codeListing(let codeListing) = snippetRenderBlocks.dropFirst(2).first { + XCTAssertEqual(codeListing.syntax, "swift") + XCTAssertEqual(codeListing.code, [ + #"print("1 + 2 = \(1+2)")"# + ]) + } else { + XCTFail("Missing expected rendered code block.") + } + + // The third snippet is a slice, so it doesn't display its explanation + if case .codeListing(let codeListing) = snippetRenderBlocks.dropFirst(3).first { + XCTAssertEqual(codeListing.syntax, "swift") + XCTAssertEqual(codeListing.code, [ + #"// Some code comment"#, + ]) + } else { + XCTFail("Missing expected rendered code block.") + } + + XCTAssertNil(snippetRenderBlocks.dropFirst(4).first, "There's no more content after the snippets") + } + } + + private func makeSnippetContext( + snippets: [SymbolGraph.Symbol], + rootContent: String, + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> ([Problem], 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 + + \(rootContent) + """) + ]) + + let (_, context) = try await loadBundle(catalog: catalog) + + 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: context.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 didn't have a synthesized 'Overview' heading. It might be missing all its content.", file: file, line: line) + } + + return (context.problems, 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 + ) + ] + ) + } +} From 08d22c52138fe2f18bf5faad1b14bb419274bc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 11:24:47 +0200 Subject: [PATCH 05/13] Make it easier to verify the diagnostic log output in tests --- .../NonInclusiveLanguageCheckerTests.swift | 2 +- .../DocumentationContextTests.swift | 2 +- Tests/SwiftDocCTests/Semantics/SymbolTests.swift | 3 +-- .../XCTestCase+LoadingTestData.swift | 15 ++++++++++++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift index 2dd37d3c3..1073bc693 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/NonInclusiveLanguageCheckerTests.swift @@ -203,7 +203,7 @@ func aBlackListedFunc() { ]) var configuration = DocumentationContext.Configuration() configuration.externalMetadata.diagnosticLevel = severity - let (_, context) = try await loadBundle(catalog: catalog, diagnosticEngine: .init(filterLevel: severity), configuration: configuration) + let (_, context) = try await loadBundle(catalog: catalog, diagnosticFilterLevel: severity, configuration: configuration) // Verify that checker diagnostics were emitted or not, depending on the diagnostic level set. XCTAssertEqual(context.problems.contains(where: { $0.diagnostic.identifier == "org.swift.docc.NonInclusiveLanguage" }), enabled) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 4fe059194..0243657e8 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -2229,7 +2229,7 @@ let expected = """ """), ]) - let (bundle, context) = try await loadBundle(catalog: catalog, diagnosticEngine: .init(filterLevel: .information)) + let (bundle, context) = try await loadBundle(catalog: catalog, diagnosticFilterLevel: .information) XCTAssertNil(context.soleRootModuleReference) let curationDiagnostics = context.problems.filter({ $0.diagnostic.identifier == "org.swift.docc.ArticleUncurated" }).map(\.diagnostic) diff --git a/Tests/SwiftDocCTests/Semantics/SymbolTests.swift b/Tests/SwiftDocCTests/Semantics/SymbolTests.swift index c0157c6a2..063c8c997 100644 --- a/Tests/SwiftDocCTests/Semantics/SymbolTests.swift +++ b/Tests/SwiftDocCTests/Semantics/SymbolTests.swift @@ -1611,8 +1611,7 @@ class SymbolTests: XCTestCase { ) } - let diagnosticEngine = DiagnosticEngine(filterLevel: diagnosticEngineFilterLevel) - let (_, context) = try await loadBundle(catalog: Folder(name: "unit-test.docc", content: catalogContent), diagnosticEngine: diagnosticEngine) + let (_, context) = try await loadBundle(catalog: Folder(name: "unit-test.docc", content: catalogContent), diagnosticFilterLevel: diagnosticEngineFilterLevel) let node = try XCTUnwrap(context.documentationCache[methodUSR], file: file, line: line) diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 754a05314..c9170ad0f 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() ) async 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: false, 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 await DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration) + + diagnosticEngine.flush() // Write to the logOutput + return (bundle, context) } From 757e3efe491af3cdefd4d3344c7967a8dcddad0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 11:25:46 +0200 Subject: [PATCH 06/13] Add additional test about snippet warnings Also, update the behavior when a snippet slice is misspelled to match what's described in the warning. --- .../Semantics/Snippets/Snippet.swift | 6 +- .../Infrastructure/SnippetResolverTests.swift | 69 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index 4d4668118..927a65716 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -70,7 +70,11 @@ extension Snippet: RenderableDirectiveConvertible { } let mixin = resolvedSnippet.mixin - if let sliceRange = slice.flatMap({ mixin.slices[$0] }) { + 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 diff --git a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift index b1156bd94..3eafe6efa 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift @@ -39,7 +39,7 @@ class SnippetResolverTests: XCTestCase { func testRenderingSnippetsWithOptionalPathPrefixes() async throws { for pathPrefix in optionalPathPrefixes { - let (problems, snippetRenderBlocks) = try await makeSnippetContext( + let (problems, _, snippetRenderBlocks) = try await makeSnippetContext( snippets: [ makeSnippet( pathComponents: ["Snippets", "First"], @@ -122,12 +122,66 @@ class SnippetResolverTests: XCTestCase { } } + func testWarningsAboutMisspelledSnippetPathsAndMisspelledSlice() async throws { + for pathPrefix in optionalPathPrefixes.prefix(1) { + let (problems, logOutput, snippetRenderBlocks) = try await makeSnippetContext( + snippets: [ + makeSnippet( + pathComponents: ["Snippets", "First"], + explanation: """ + Some _formatted_ **content** that provides context to the snippet. + """, + code: """ + // Some code comment + print("Hello, world!") + """, + slices: [ + "comment": 0..<1, + "print": 1..<2, + ] + ), + ], + rootContent: """ + @Snippet(path: \(pathPrefix)Frst) + + @Snippet(path: \(pathPrefix)First, slice: cmmnt) + """ + ) + + // These links should all resolve, regardless of optional prefix + XCTAssertEqual(problems.map(\.diagnostic.summary).sorted(), [ + "Snippet named 'Frst' couldn't be found.", + "Snippet slice 'cmmnt' does not exist in snippet '/ModuleName/Snippets/First'; this directive will be ignored", + ]) + + XCTAssertEqual(logOutput, """ + warning: Snippet named 'Frst' couldn't be found. + --> ModuleName.md:7:16-7:41 + 5 | ## Overview + 6 | + 7 + @Snippet(path: /ModuleName/Snippets/Frst) + 8 | + 9 | @Snippet(path: /ModuleName/Snippets/First, slice: cmmnt) + + warning: Snippet slice 'cmmnt' does not exist in snippet '/ModuleName/Snippets/First'; this directive will be ignored + --> ModuleName.md:9:1-9:8 + 7 | @Snippet(path: /ModuleName/Snippets/Frst) + 8 | + 9 + @Snippet(path: /ModuleName/Snippets/First, slice: cmmnt) + + """) + + // 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 - ) async throws -> ([Problem], some Collection) { + ) async 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. @@ -138,11 +192,16 @@ class SnippetResolverTests: XCTestCase { 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 (_, context) = try await loadBundle(catalog: catalog) + let logStore = LogHandle.LogStorage() + let (_, context) = try await loadBundle(catalog: catalog, logOutput: LogHandle.memory(logStore)) XCTAssertEqual(context.knownIdentifiers.count, 1, "The snippets don't have their own identifiers", file: file, line: line) @@ -156,10 +215,10 @@ class SnippetResolverTests: XCTestCase { XCTAssertEqual(heading.level, 2, file: file, line: line) XCTAssertEqual(heading.text, "Overview", file: file, line: line) } else { - XCTFail("The rendered page didn't have a synthesized 'Overview' heading. It might be missing all its content.", file: file, line: line) + XCTFail("The rendered page is missing the 'Overview' heading. Something unexpected is happening with the page content.", file: file, line: line) } - return (context.problems, renderBlocks.dropFirst()) + return (context.problems, logStore.text, renderBlocks.dropFirst()) } private func makeSnippet( From 077b1753ac7392338ad243b80faa6dcc0e7d5e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 11:57:26 +0200 Subject: [PATCH 07/13] Move snippet diagnostic creation to resolver type --- .../Link Resolution/SnippetResolver.swift | 54 ++++++++++++++++++- .../Semantics/MarkupReferenceResolver.swift | 44 ++------------- .../Infrastructure/SnippetResolverTests.swift | 22 ++++---- .../Semantics/SnippetTests.swift | 2 +- .../XCTestCase+LoadingTestData.swift | 2 +- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift index afc0be453..31cc7a41d 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -8,6 +8,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ +import Foundation import SymbolKit import Markdown @@ -18,6 +19,7 @@ final class SnippetResolver { /// Information about a resolved snippet struct ResolvedSnippet { + fileprivate var path: String // For use in diagnostics var mixin: SnippetMixin var explanation: Explanation? } @@ -42,7 +44,7 @@ final class SnippetResolver { symbol.pathComponents.joined(separator: "/") } - snippets[path] = .init(mixin: snippetMixin, explanation: symbol.docComment.map { + snippets[path] = .init(path: path, mixin: snippetMixin, explanation: symbol.docComment.map { Document(parsing: $0.lines.map(\.text).joined(separator: "\n"), options: .parseBlockDirectives) }) } @@ -72,8 +74,56 @@ final class SnippetResolver { if let found = snippets[path] { return .success(found) } else { - return .failure(.init("Snippet named '\(path)' couldn't be found.")) + return .failure(.init("Snippet named '\(path)' couldn't be found")) } } + + func validate(slice: String, for resolvedSnippet: ResolvedSnippet) -> TopicReferenceResolutionErrorInfo? { + guard resolvedSnippet.mixin.slices[slice] == nil else { + return nil + } + + return .init("Slice named '\(slice)' doesn't exist in snippet '\(resolvedSnippet.path)'") + } } +// 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) + } +} diff --git a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift index 0d9553c9b..6a4d344fd 100644 --- a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift @@ -21,39 +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 unresolvedSnippetPathProblem(source: URL?, range: SourceRange?, errorInfo: TopicReferenceResolutionErrorInfo) -> 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: "org.swift.docc.unresolvedSnippetPath", summary: errorInfo.message, notes: notes) - return Problem(diagnostic: diagnostic, possibleSolutions: solutions) -} private func removedLinkDestinationProblem(reference: ResolvedTopicReference, range: SourceRange?, severity: DiagnosticSeverity) -> Problem { var solutions = [Solution]() if let range, reference.pathComponents.count > 3 { @@ -206,15 +173,14 @@ struct MarkupReferenceResolver: MarkupRewriter { switch context.snippetResolver.resolveSnippet(path: snippet.path) { case .success(let resolvedSnippet): - if let requestedSlice = snippet.slice { - guard resolvedSnippet.mixin.slices[requestedSlice] != nil else { - self.problems.append(unknownSnippetSliceProblem(snippetPath: snippet.path, slice: requestedSlice, range: blockDirective.nameRange)) - return blockDirective - } + if let requestedSlice = snippet.slice, + let errorInfo = context.snippetResolver.validate(slice: requestedSlice, for: resolvedSnippet) + { + self.problems.append(SnippetResolver.unknownSnippetSliceProblem(source: source, range: blockDirective.arguments()["slice"]?.valueRange, errorInfo: errorInfo)) } return blockDirective case .failure(let errorInfo): - self.problems.append(unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo)) + self.problems.append(SnippetResolver.unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo)) return blockDirective } case ImageMedia.directiveName: diff --git a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift index 3eafe6efa..79d6cbbbe 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift @@ -123,7 +123,7 @@ class SnippetResolverTests: XCTestCase { } func testWarningsAboutMisspelledSnippetPathsAndMisspelledSlice() async throws { - for pathPrefix in optionalPathPrefixes.prefix(1) { + for pathPrefix in optionalPathPrefixes { let (problems, logOutput, snippetRenderBlocks) = try await makeSnippetContext( snippets: [ makeSnippet( @@ -148,26 +148,26 @@ class SnippetResolverTests: XCTestCase { """ ) - // These links should all resolve, regardless of optional prefix - XCTAssertEqual(problems.map(\.diagnostic.summary).sorted(), [ - "Snippet named 'Frst' couldn't be found.", - "Snippet slice 'cmmnt' does not exist in snippet '/ModuleName/Snippets/First'; this directive will be ignored", + // The first snippet has a misspelled path and the second has a misspelled slice + XCTAssertEqual(problems.map(\.diagnostic.summary), [ + "Snippet named 'Frst' couldn't be found", + "Slice named 'cmmnt' doesn't exist in snippet 'First'", ]) XCTAssertEqual(logOutput, """ - warning: Snippet named 'Frst' couldn't be found. + \u{001B}[1;33mwarning: Snippet named 'Frst' couldn't be found\u{001B}[0;0m --> ModuleName.md:7:16-7:41 5 | ## Overview 6 | - 7 + @Snippet(path: /ModuleName/Snippets/Frst) + 7 + @Snippet(path: \u{001B}[1;32m/ModuleName/Snippets/Frst\u{001B}[0;0m) 8 | 9 | @Snippet(path: /ModuleName/Snippets/First, slice: cmmnt) - warning: Snippet slice 'cmmnt' does not exist in snippet '/ModuleName/Snippets/First'; this directive will be ignored - --> ModuleName.md:9:1-9:8 + \u{001B}[1;33mwarning: Slice named 'cmmnt' doesn't exist in snippet 'First'\u{001B}[0;0m + --> ModuleName.md:9:51-9:56 7 | @Snippet(path: /ModuleName/Snippets/Frst) 8 | - 9 + @Snippet(path: /ModuleName/Snippets/First, slice: cmmnt) + 9 + @Snippet(path: /ModuleName/Snippets/First, slice: \u{001B}[1;32mcmmnt\u{001B}[0;0m) """) @@ -218,7 +218,7 @@ class SnippetResolverTests: XCTestCase { XCTFail("The rendered page is missing the 'Overview' heading. Something unexpected is happening with the page content.", file: file, line: line) } - return (context.problems, logStore.text, renderBlocks.dropFirst()) + return (context.problems.sorted(by: \.diagnostic.range!.lowerBound.line), logStore.text, renderBlocks.dropFirst()) } private func makeSnippet( diff --git a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift index e110b7f54..5eda29ec2 100644 --- a/Tests/SwiftDocCTests/Semantics/SnippetTests.swift +++ b/Tests/SwiftDocCTests/Semantics/SnippetTests.swift @@ -95,7 +95,7 @@ class SnippetTests: XCTestCase { 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.diagnostic.summary, "Snippet named 'DoesNotExist' couldn't be found") XCTAssertEqual(problem.possibleSolutions.count, 0) } } diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index c9170ad0f..4207fc3d1 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -58,7 +58,7 @@ extension XCTestCase { let catalogURL = URL(fileURLWithPath: "/\(catalog.name)") let diagnosticEngine = DiagnosticEngine(filterLevel: diagnosticFilterLevel) - diagnosticEngine.add(DiagnosticConsoleWriter(logOutput, formattingOptions: [], baseURL: catalogURL, highlight: false, dataProvider: fileSystem)) + diagnosticEngine.add(DiagnosticConsoleWriter(logOutput, formattingOptions: [], baseURL: catalogURL, highlight: true, dataProvider: fileSystem)) let (bundle, dataProvider) = try DocumentationContext.InputsProvider(fileManager: fileSystem) .inputsAndDataProvider(startingPoint: catalogURL, options: .init()) From f70b35f51a61618f86a4b656bd2f1edbee04a713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 12:24:35 +0200 Subject: [PATCH 08/13] Add near-miss suggestions for snippet paths and slices --- .../Link Resolution/PathHierarchy+Error.swift | 4 +- .../Link Resolution/SnippetResolver.swift | 33 +++++++++++-- .../Infrastructure/SnippetResolverTests.swift | 46 +++++++++++++++---- 3 files changed, 69 insertions(+), 14 deletions(-) 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 index 31cc7a41d..fab61b8da 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -74,7 +74,16 @@ final class SnippetResolver { if let found = snippets[path] { return .success(found) } else { - return .failure(.init("Snippet named '\(path)' couldn't be found")) + 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)) } } @@ -82,8 +91,16 @@ final class SnippetResolver { guard resolvedSnippet.mixin.slices[slice] == nil else { return nil } - - return .init("Slice named '\(slice)' doesn't exist in snippet '\(resolvedSnippet.path)'") + 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) } } @@ -126,4 +143,14 @@ extension SnippetResolver { 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/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift index 79d6cbbbe..ee9d51d1e 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift @@ -144,30 +144,58 @@ class SnippetResolverTests: XCTestCase { rootContent: """ @Snippet(path: \(pathPrefix)Frst) - @Snippet(path: \(pathPrefix)First, slice: cmmnt) + @Snippet(path: \(pathPrefix)First, slice: commt) """ ) // The first snippet has a misspelled path and the second has a misspelled slice XCTAssertEqual(problems.map(\.diagnostic.summary), [ "Snippet named 'Frst' couldn't be found", - "Slice named 'cmmnt' doesn't exist in snippet 'First'", + "Slice named 'commt' doesn't exist in snippet 'First'", ]) + // Verify that the suggested solutions correct the issues. + let rootMarkupContent = """ + # Heading + + Abstract + + ## Subheading + + @Snippet(path: \(pathPrefix)Frst) + + @Snippet(path: \(pathPrefix)First, slice: commt) + """ + do { + let snippetPathProblem = try XCTUnwrap(problems.first) + let solution = try XCTUnwrap(snippetPathProblem.possibleSolutions.first) + let modifiedLines = try solution.applyTo(rootMarkupContent).components(separatedBy: "\n") + XCTAssertEqual(modifiedLines[6], "@Snippet(path: \(pathPrefix)First)") + } + do { + let snippetSliceProblem = try XCTUnwrap(problems.last) + let solution = try XCTUnwrap(snippetSliceProblem.possibleSolutions.first) + let modifiedLines = try solution.applyTo(rootMarkupContent).components(separatedBy: "\n") + XCTAssertEqual(modifiedLines[8], "@Snippet(path: \(pathPrefix)First, slice: comment)") + } + + let prefixLength = pathPrefix.count XCTAssertEqual(logOutput, """ \u{001B}[1;33mwarning: Snippet named 'Frst' couldn't be found\u{001B}[0;0m - --> ModuleName.md:7:16-7:41 + --> ModuleName.md:7:16-7:\(20 + prefixLength) 5 | ## Overview 6 | - 7 + @Snippet(path: \u{001B}[1;32m/ModuleName/Snippets/Frst\u{001B}[0;0m) + 7 + @Snippet(path: \u{001B}[1;32m\(pathPrefix)Frst\u{001B}[0;0m) + | \(String(repeating: " ", count: prefixLength)) ╰─\u{001B}[1;39msuggestion: Replace 'Frst' with 'First'\u{001B}[0;0m 8 | - 9 | @Snippet(path: /ModuleName/Snippets/First, slice: cmmnt) + 9 | @Snippet(path: \(pathPrefix)First, slice: commt) - \u{001B}[1;33mwarning: Slice named 'cmmnt' doesn't exist in snippet 'First'\u{001B}[0;0m - --> ModuleName.md:9:51-9:56 - 7 | @Snippet(path: /ModuleName/Snippets/Frst) + \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: /ModuleName/Snippets/First, slice: \u{001B}[1;32mcmmnt\u{001B}[0;0m) + 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 """) From 82f0dfc58d8af1efa792a10836877790df7861a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 12:27:42 +0200 Subject: [PATCH 09/13] Only highlight the misspelled portion of snippet paths --- .../Infrastructure/Link Resolution/SnippetResolver.swift | 2 +- .../Infrastructure/SnippetResolverTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift index fab61b8da..740542b33 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -83,7 +83,7 @@ final class SnippetResolver { ]) } - return .failure(.init("Snippet named '\(path)' couldn't be found", solutions: solutions)) + return .failure(.init("Snippet named '\(path)' couldn't be found", solutions: solutions, rangeAdjustment: replacementRange)) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift index ee9d51d1e..de88a9ab4 100644 --- a/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/SnippetResolverTests.swift @@ -123,7 +123,7 @@ class SnippetResolverTests: XCTestCase { } func testWarningsAboutMisspelledSnippetPathsAndMisspelledSlice() async throws { - for pathPrefix in optionalPathPrefixes { + for pathPrefix in optionalPathPrefixes.prefix(1) { let (problems, logOutput, snippetRenderBlocks) = try await makeSnippetContext( snippets: [ makeSnippet( @@ -182,10 +182,10 @@ class SnippetResolverTests: XCTestCase { let prefixLength = pathPrefix.count XCTAssertEqual(logOutput, """ \u{001B}[1;33mwarning: Snippet named 'Frst' couldn't be found\u{001B}[0;0m - --> ModuleName.md:7:16-7:\(20 + prefixLength) + --> ModuleName.md:7:\(16 + prefixLength)-7:\(20 + prefixLength) 5 | ## Overview 6 | - 7 + @Snippet(path: \u{001B}[1;32m\(pathPrefix)Frst\u{001B}[0;0m) + 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) From ceaab1c9879ce14df9d0a0221e0e92c3d5d72602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 15:08:41 +0200 Subject: [PATCH 10/13] Update user-facing documentation about optional snippet paths prefixes --- .../Semantics/Snippets/Snippet.swift | 33 +++++++---- .../DocCDocumentation.docc/DocC.symbols.json | 55 ++++++++++++++----- .../adding-code-snippets-to-your-content.md | 29 ++++++---- 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index 927a65716..7ce8a23ba 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -14,31 +14,40 @@ 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: "my-package/Snippets/example-snippet", slice: "setup") +/// @Snippet(path: "example-snippet", slice: "setup") /// ``` /// -/// Place the `Snippet` directive to embed a code example from the project's snippet directory. -/// The path that references the snippet is identified with three parts: -/// -/// 1. The package name as defined in `Package.swift` +/// 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. /// -/// 2. The directory path to the snippet file, starting with "Snippets". +/// > Earlier Versions: +/// > Before Swift-DocC 6.2, the `@Snippet` path needed to include both the package name component and the "Snippets" component: +/// > +/// > ```markdown +/// > @Snippet(path: "my-package/Snippets/example-snippet") +/// > ``` /// -/// 3. The name of your snippet file without the `.swift` extension +/// 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 snippet had slices annotated within it, an individual slice of the snippet can be referenced with the `slice` option. -/// Without the option defined, the directive embeds the entire snippet. +/// 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.7" 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 diff --git a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json index 71145763e..04a1af0bb 100644 --- a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json +++ b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json @@ -5316,11 +5316,20 @@ { "text" : "" }, + { + "text" : "Use a `Snippet` directive to embed a code example from the project's \"Snippets\" directory on the page." + }, + { + "text" : "The `path` argument is the relative path from the package's top-level \"Snippets\" directory to your snippet file without the `.swift` extension." + }, + { + "text" : "" + }, { "text" : "```markdown" }, { - "text" : "@Snippet(path: \"my-package\/Snippets\/example-snippet\", slice: \"setup\")" + "text" : "@Snippet(path: \"example-snippet\", slice: \"setup\")" }, { "text" : "```" @@ -5329,55 +5338,73 @@ "text" : "" }, { - "text" : "Place the `Snippet` directive to embed a code example from the project's snippet directory." + "text" : "If you prefer, you can specify the relative path from the package's _root_ directory (by including a \"Snippets\/\" prefix)." }, { - "text" : "The path that references the snippet is identified with three parts:" + "text" : "You can also include the package name---as defined in `Package.swift`---before the \"Snippets\/\" prefix." + }, + { + "text" : "Neither of these leading path components are necessary because all your snippet code files are always located in your package's \"Snippets\" directory." }, { "text" : "" }, { - "text" : "1. The package name as defined in `Package.swift`" + "text" : "> Earlier Versions:" }, { - "text" : "" + "text" : "> Before Swift-DocC 6.2, the `@Snippet` path needed to include both the package name component and the \"Snippets\" component:" }, { - "text" : "2. The directory path to the snippet file, starting with \"Snippets\"." + "text" : ">" }, { - "text" : "" + "text" : "> ```markdown" + }, + { + "text" : "> @Snippet(path: \"my-package\/Snippets\/example-snippet\")" }, { - "text" : "3. The name of your snippet file without the `.swift` extension" + "text" : "> ```" }, { "text" : "" }, { - "text" : "If the snippet had slices annotated within it, an individual slice of the snippet can be referenced with the `slice` option." + "text" : "You can define named slices of your snippet by annotating the snippet file with `\/\/ snippet.` and `\/\/ snippet.end` lines." }, { - "text" : "Without the option defined, the directive embeds the entire snippet." + "text" : "A named slice automatically ends at the start of the next named slice, without an explicit `snippet.end` annotation." }, { - "text" : "- Parameters:" + "text" : "" }, { - "text" : " - path: The path components of a symbol link that would be used to resolve a reference to a snippet," + "text" : "If the referenced snippet includes annotated slices, you can limit the embedded code example to a certain line range by specifying a `slice` name." }, { - "text" : " only occurring as a block directive argument." + "text" : "By default, the embedded code example includes the full snippet. For more information, see ." + }, + { + "text" : "- Parameters:" + }, + { + "text" : " - path: 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." }, { "text" : " **(required)**" }, { - "text" : " - slice: An optional named range to limit the lines shown." + "text" : " - slice: The name of a snippet slice to limit the embedded code example to a certain line range." }, { "text" : " **(optional)**" + }, + { + "text" : " " + }, + { + "text" : " By default, the embedded code example includes the full snippet." } ] }, diff --git a/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md b/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md index c654ef01d..d539de968 100644 --- a/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md +++ b/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md @@ -91,16 +91,21 @@ swift run example-snippet To embed your snippet in an article or within the symbol reference pages, use the `@Snippet` directive. ```markdown -@Snippet(path: "my-package/Snippets/example-snippet") +@Snippet(path: "example-snippet") ``` -The `path` argument has three parts: +The `path` argument is the relative path from the package's top-level "Snippets" directory to your snippet file without the `.swift` extension. -1. The package name as defined in `Package.swift` +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. -2. The directory path to the snippet file, starting with "Snippets". - -3. The name of your snippet file without the `.swift` extension +> Earlier Versions: +> Before Swift-DocC 6.2, the `@Snippet` path needed to include both the package name component and the "Snippets" component: +> +> ```markdown +> @Snippet(path: "my-package/Snippets/example-snippet") +> ``` A snippet reference displays as a block between other paragraphs. In the example package above, the `YourProject.md` file might contain this markdown: @@ -114,7 +119,7 @@ Add a single sentence or sentence fragment, which DocC uses as the page’s abst Add one or more paragraphs that introduce your content overview. -@Snippet(path: "YourProject/Snippets/example-snippet") +@Snippet(path: "example-snippet") ``` If your snippet code requires setup — like imports or variable definitions — that distract from the snippet's main focus, you can add `// snippet.hide` and `// snippet.show` lines in the snippet code to exclude the lines in between from displaying in your documentation. @@ -145,12 +150,12 @@ Replace `YourTarget` with a target from your package to preview: swift package --disable-sandbox preview-documentation --target YourTarget ``` -### Slice up your snippet to break it up in your content. +### Slice up your snippet to break it up in your content Long snippets dropped into documentation can result in a wall of text that is harder to parse and understand. Instead, annotate non-overlapping slices in the snippet, which allows you to reference and embed the slice portion of the example code. -Annotating slices in a snippet looks similiar to annotating `snippet.show` and `snippet.hide`. +Annotating slices in a snippet looks similar to annotating `snippet.show` and `snippet.hide`. You define the slice's identity in the comment, and that slice continues until the next instance of `// snippet.end` appears on a new line. When selecting your identifiers, use URL-compatible path characters. @@ -174,7 +179,7 @@ For example, the follow code examples are effectively the same: var item = MyObject.init() // snippet.end -// snipppet.configure +// snippet.configure item.size = 3 // snippet.end ``` @@ -183,7 +188,7 @@ item.size = 3 // snippet.setup var item = MyObject.init() -// snipppet.configure +// snippet.configure item.size = 3 ``` @@ -191,7 +196,7 @@ Use the `@Snippet` directive with the `slice` parameter to embed that slice as s Extending the earlier snippet example, the slice `setup` would be referenced with ```markdown -@Snippet(path: "my-package/Snippets/example-snippet", slice: "setup") +@Snippet(path: "example-snippet", slice: "setup") ``` ## Topics From afd6871ee2cfd82074dc6720baa6a3ce21019703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 18:01:39 +0200 Subject: [PATCH 11/13] Clarify that Snippet argument parsing problems are reported elsewhere --- Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift index 6a4d344fd..00b57e589 100644 --- a/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift +++ b/Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift @@ -166,8 +166,8 @@ struct MarkupReferenceResolver: MarkupRewriter { let source = blockDirective.range?.source switch blockDirective.name { case Snippet.directiveName: - var problems = [Problem]() // ???: DAVID IS IGNORED? - 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 } @@ -176,11 +176,11 @@ struct MarkupReferenceResolver: MarkupRewriter { if let requestedSlice = snippet.slice, let errorInfo = context.snippetResolver.validate(slice: requestedSlice, for: resolvedSnippet) { - self.problems.append(SnippetResolver.unknownSnippetSliceProblem(source: source, range: blockDirective.arguments()["slice"]?.valueRange, errorInfo: errorInfo)) + problems.append(SnippetResolver.unknownSnippetSliceProblem(source: source, range: blockDirective.arguments()["slice"]?.valueRange, errorInfo: errorInfo)) } return blockDirective case .failure(let errorInfo): - self.problems.append(SnippetResolver.unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo)) + problems.append(SnippetResolver.unresolvedSnippetPathProblem(source: source, range: blockDirective.arguments()["path"]?.valueRange, errorInfo: errorInfo)) return blockDirective } case ImageMedia.directiveName: From 937f6515df00a3262f3661a520c40a7824a7c0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 26 Sep 2025 18:24:32 +0200 Subject: [PATCH 12/13] Fix typo in code comment --- .../Infrastructure/Link Resolution/SnippetResolver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift index 740542b33..4c98f7354 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/SnippetResolver.swift @@ -55,7 +55,7 @@ final class SnippetResolver { func resolveSnippet(path authoredPath: String) -> SnippetResolutionResult { // Snippet paths are relative to the root of the Swift Package. - // The first to components are always the same (the package name followed by "Snippets"). + // 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. From 45c0dd5d3b58e8758422ab6ee2c69b7831bde044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Sun, 28 Sep 2025 11:18:07 +0200 Subject: [PATCH 13/13] Update docs about earliest version with an optional snippet path prefix --- Sources/SwiftDocC/Semantics/Snippets/Snippet.swift | 2 +- Sources/docc/DocCDocumentation.docc/DocC.symbols.json | 2 +- .../adding-code-snippets-to-your-content.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index 7ce8a23ba..5dc54c091 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -26,7 +26,7 @@ import SymbolKit /// 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, the `@Snippet` path needed to include both the package name component and the "Snippets" component: +/// > 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") diff --git a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json index 04a1af0bb..8469494a8 100644 --- a/Sources/docc/DocCDocumentation.docc/DocC.symbols.json +++ b/Sources/docc/DocCDocumentation.docc/DocC.symbols.json @@ -5353,7 +5353,7 @@ "text" : "> Earlier Versions:" }, { - "text" : "> Before Swift-DocC 6.2, the `@Snippet` path needed to include both the package name component and the \"Snippets\" component:" + "text" : "> Before Swift-DocC 6.2.1, the `@Snippet` path needed to include both the package name component and the \"Snippets\" component:" }, { "text" : ">" diff --git a/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md b/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md index d539de968..dc336d20b 100644 --- a/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md +++ b/Sources/docc/DocCDocumentation.docc/adding-code-snippets-to-your-content.md @@ -101,7 +101,7 @@ You can also include the package name---as defined in `Package.swift`---before t 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, the `@Snippet` path needed to include both the package name component and the "Snippets" component: +> 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")