Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d579066
WIP add copy button to CodeListing
Jul 28, 2025
f2aa602
update OpenAPI spec with copyToClipboard on CodeListing
Jul 30, 2025
11ce8e9
Add option to copy a code block parsed from the language string, deli…
Jul 31, 2025
3943457
fix unit tests
Jul 31, 2025
4d3ae46
add unit test for copyToClipboard
Aug 1, 2025
6935f87
fixup missing async on test
Aug 5, 2025
59dc989
update docs
Aug 6, 2025
d7b1268
copy by default
Aug 11, 2025
5d85dfa
add feature flag 'enable-experimental-code-block' for copy-to-clipboa…
Aug 11, 2025
4d427da
add more tests, remove docs, code cleanup
Aug 15, 2025
f6ad87b
WIP diagnostics
Aug 18, 2025
fe35cdc
WIP diagnostics: tests
Aug 20, 2025
c9b8a52
add code block options onto RenderBlockContent.CodeListing
Aug 21, 2025
775fd68
rename feature flag
Sep 17, 2025
b8712b5
remaining PR feedback
Sep 17, 2025
00049e3
copyToClipboard should be false when nocopy is present
Sep 19, 2025
7977adc
init copyToClipboard based on feature flag presence
DebugSteven Oct 1, 2025
da78af6
copy by default
Aug 11, 2025
159ffce
add feature flag 'enable-experimental-code-block-annotations' for cop…
Aug 11, 2025
33fa136
WIP wrap and highlight
Aug 8, 2025
0d7bfa7
fix tests
Aug 13, 2025
c9a92e6
WIP tests, WIP parsing for wrap and highlight
Aug 13, 2025
a0e47dd
change parsing to handle values after = and arrays
Aug 27, 2025
76051f7
add strikeout option
Aug 28, 2025
d787165
parse strikeout option, solution for language not as the first option…
Aug 29, 2025
7ea301d
validate array values in code block options for highlight and strikeout
Aug 30, 2025
543e61d
showLineNumbers option
Sep 5, 2025
00f4fc5
remove trailing comma
Sep 5, 2025
d1243d4
test showLineNumbers
Sep 8, 2025
65e5404
PR feedback
Sep 19, 2025
e442144
fix feature flag on new tests
Sep 19, 2025
f3014a3
remove optional return type
Sep 19, 2025
d2cc4b4
update JSON structure for extensibility
Sep 24, 2025
6414d38
update RenderNode.spec to reflect using Range<Position> in LineAnnota…
Sep 26, 2025
7be0085
update feature name
Sep 26, 2025
2be0322
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
971df64
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
de416ec
require LineAnnotation properties style and range
DebugSteven Oct 1, 2025
35a24b8
fix CodeListing initializers in Snippet
DebugSteven Oct 1, 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 @@ -273,6 +273,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 @@ -2457,7 +2458,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