Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5eac2fa
WIP add copy button to CodeListing
Jul 28, 2025
af05461
update OpenAPI spec with copyToClipboard on CodeListing
Jul 30, 2025
2318b84
Add option to copy a code block parsed from the language string, deli…
Jul 31, 2025
5721030
fix unit tests
Jul 31, 2025
2d6131d
add unit test for copyToClipboard
Aug 1, 2025
cb321ea
fixup missing async on test
Aug 5, 2025
056b0dc
update docs
Aug 6, 2025
d26471c
copy by default
Aug 11, 2025
e2cde6e
add feature flag 'enable-experimental-code-block' for copy-to-clipboa…
Aug 11, 2025
7bc5467
add more tests, remove docs, code cleanup
Aug 15, 2025
7374b5a
WIP diagnostics
Aug 18, 2025
15dbba5
WIP diagnostics: tests
Aug 20, 2025
2987eac
add code block options onto RenderBlockContent.CodeListing
Aug 21, 2025
155ef45
rename feature flag
Sep 17, 2025
4740cae
remaining PR feedback
Sep 17, 2025
48ed2d8
copyToClipboard should be false when nocopy is present
Sep 19, 2025
16b2f80
WIP wrap and highlight
Aug 8, 2025
4fcc438
fix tests
Aug 13, 2025
9046ed2
WIP tests, WIP parsing for wrap and highlight
Aug 13, 2025
9ed89fe
change parsing to handle values after = and arrays
Aug 27, 2025
b2e66ee
add strikeout option
Aug 28, 2025
821a73b
parse strikeout option, solution for language not as the first option…
Aug 29, 2025
7ddf123
validate array values in code block options for highlight and strikeout
Aug 30, 2025
299eee6
showLineNumbers option
Sep 5, 2025
6b87c1b
remove trailing comma
Sep 5, 2025
5f9f80a
test showLineNumbers
Sep 8, 2025
8e8e438
PR feedback
Sep 19, 2025
b7b9d35
fix feature flag on new tests
Sep 19, 2025
f18f93c
remove optional return type
Sep 19, 2025
96fe662
update JSON structure for extensibility
Sep 24, 2025
5b3f31c
update RenderNode.spec to reflect using Range<Position> in LineAnnota…
Sep 26, 2025
fe69739
update feature name
Sep 26, 2025
49a76b5
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
81f406c
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
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
*/

internal import Foundation
internal import Markdown

/**
Code blocks can have a `nocopy` option after the \`\`\`, in the language line.
`nocopy` can be immediately after the \`\`\` or after a specified language and a comma (`,`).
*/
internal struct InvalidCodeBlockOption: Checker {
var problems = [Problem]()

/// Parsing options for code blocks
private let knownOptions = RenderBlockContent.CodeBlockOptions.knownOptions

private var sourceFile: URL?

/// Creates a new checker that detects documents with multiple titles.
///
/// - Parameter sourceFile: The URL to the documentation file that the checker checks.
init(sourceFile: URL?) {
self.sourceFile = sourceFile
}

mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
let (lang, tokens) = RenderBlockContent.CodeBlockOptions.tokenizeLanguageString(codeBlock.language)

func matches(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) {
guard token == .unknown, let value = value else { return }

let matches = NearMiss.bestMatches(for: knownOptions, against: value)

if !matches.isEmpty {
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.")
let possibleSolutions = matches.map { candidate in
Solution(
summary: "Replace \(value.singleQuoted) with \(candidate.singleQuoted).",
replacements: []
)
}
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions))
} else if lang == nil {
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.")
let possibleSolutions =
Solution(
summary: "If \(value.singleQuoted) is the language for this code block, then write \(value.singleQuoted) as the first option.",
replacements: []
)
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [possibleSolutions]))
}
}

func validateArrayIndices(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) {
guard token == .highlight || token == .strikeout, let value = value else { return }
// code property ends in a newline. this gives us a bogus extra line.
let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1

let indices = RenderBlockContent.CodeBlockOptions.parseCodeBlockOptionsArray(value)

if !value.isEmpty, indices.isEmpty {
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Could not parse \(token.rawValue.singleQuoted) indices from \(value.singleQuoted). Expected an integer (e.g. 3) or an array (e.g. [1, 3, 5])")
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: []))
return
}

let invalid = indices.filter { $0 < 1 || $0 > lineCount }
guard !invalid.isEmpty else { return }

let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Invalid \(token.rawValue.singleQuoted) index\(invalid.count == 1 ? "" : "es") in \(value.singleQuoted) for a code block with \(lineCount) line\(lineCount == 1 ? "" : "s"). Valid range is 1...\(lineCount).")
let solutions: [Solution] = {
if invalid.contains(where: {$0 == lineCount + 1}) {
return [Solution(
summary: "If you intended the last line, change '\(lineCount + 1)' to \(lineCount).",
replacements: []
)]
}
return []
}()
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: solutions))
}

for (token, value) in tokens {
matches(token: token, value: value)
validateArrayIndices(token: token, value: value)
}
// check if first token (lang) might be a typo
matches(token: .unknown, value: lang)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ public class DocumentationContext {
MissingAbstract(sourceFile: source).any(),
NonOverviewHeadingChecker(sourceFile: source).any(),
SeeAlsoInTopicsHeadingChecker(sourceFile: source).any(),
InvalidCodeBlockOption(sourceFile: source).any(),
])
checker.visit(document)
diagnosticEngine.emit(checker.problems)
Expand Down Expand Up @@ -2453,7 +2454,6 @@ public class DocumentationContext {
}
}
}

/// A closure type getting the information about a reference in a context and returns any possible problems with it.
public typealias ReferenceCheck = (DocumentationContext, ResolvedTopicReference) -> [Problem]

Expand Down
13 changes: 13 additions & 0 deletions Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ extension DocumentationBundle.Info {
self.unknownFeatureFlags = []
}

/// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockAnnotationsEnabled``.
public var experimentalCodeBlockAnnotations: Bool?

public init(experimentalCodeBlockAnnotations: Bool? = nil) {
self.experimentalCodeBlockAnnotations = experimentalCodeBlockAnnotations
self.unknownFeatureFlags = []
}

/// A list of decoded feature flag keys that didn't match a known feature flag.
public let unknownFeatureFlags: [String]

enum CodingKeys: String, CodingKey, CaseIterable {
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
case experimentalCodeBlockAnnotations = "ExperimentalCodeBlockAnnotations"
}

struct AnyCodingKeys: CodingKey {
Expand All @@ -66,6 +75,9 @@ extension DocumentationBundle.Info {
switch codingKey {
case .experimentalOverloadedSymbolPresentation:
self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName)

case .experimentalCodeBlockAnnotations:
self.experimentalCodeBlockAnnotations = try values.decode(Bool.self, forKey: flagName)
}
} else {
unknownFeatureFlags.append(flagName.stringValue)
Expand All @@ -79,6 +91,7 @@ extension DocumentationBundle.Info {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation)
try container.encode(experimentalCodeBlockAnnotations, forKey: .experimentalCodeBlockAnnotations)
}
}
}
Loading