Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1f90d7a
update docs
Aug 6, 2025
2e570f9
copy by default
Aug 11, 2025
33d929c
add feature flag 'enable-experimental-code-block-annotations' for cop…
Aug 11, 2025
9315bdb
add more tests, remove docs, code cleanup
Aug 15, 2025
20b0c08
add code block options onto RenderBlockContent.CodeListing
Aug 21, 2025
73c7416
remaining PR feedback
Sep 17, 2025
0a7f55f
copy by default
Aug 11, 2025
01195f9
add feature flag 'enable-experimental-code-block-annotations' for cop…
Aug 11, 2025
f3c9328
WIP wrap and highlight
Aug 8, 2025
5a6b443
fix tests
Aug 13, 2025
1d25949
WIP tests, WIP parsing for wrap and highlight
Aug 13, 2025
81f26eb
change parsing to handle values after = and arrays
Aug 27, 2025
fccebb7
add strikeout option
Aug 28, 2025
456ba3a
parse strikeout option, solution for language not as the first option…
Aug 29, 2025
d2f3b06
validate array values in code block options for highlight and strikeout
Aug 30, 2025
52212eb
showLineNumbers option
Sep 5, 2025
0e010ac
remove trailing comma
Sep 5, 2025
8be4df3
test showLineNumbers
Sep 8, 2025
83737dd
PR feedback
Sep 19, 2025
12960a0
fix feature flag on new tests
Sep 19, 2025
9649d27
remove optional return type
Sep 19, 2025
38f4ef3
update JSON structure for extensibility
Sep 24, 2025
6b0956c
update RenderNode.spec to reflect using Range<Position> in LineAnnota…
Sep 26, 2025
0c57de0
update feature name
Sep 26, 2025
8028002
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
074081a
Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.s…
DebugSteven Sep 30, 2025
48d1c46
require LineAnnotation properties style and range
DebugSteven Oct 1, 2025
0d80729
fix CodeListing initializers in Snippet
DebugSteven Oct 1, 2025
45c6592
fix typo
DebugSteven Oct 6, 2025
4cb5248
add copy-to-clipboard button back to snippets when feature flag is pr…
DebugSteven Oct 6, 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
67 changes: 51 additions & 16 deletions Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal struct InvalidCodeBlockOption: Checker {
var problems = [Problem]()

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

private var sourceFile: URL?

Expand All @@ -31,32 +31,67 @@ internal struct InvalidCodeBlockOption: Checker {
}

mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !info.isEmpty else { return }
let (lang, tokens) = RenderBlockContent.CodeBlockOptions.tokenizeLanguageString(codeBlock.language)

let tokens = info
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
func matches(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) {
guard token == .unknown, let value = value else { return }

guard !tokens.isEmpty else { return }

for token in tokens {
// if the token is an exact match, we don't need to do anything
guard !knownOptions.contains(token) else { continue }

let matches = NearMiss.bestMatches(for: knownOptions, against: token)
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 \(token.singleQuoted) in code block.")
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 \(token.singleQuoted) with \(candidate.singleQuoted).",
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: this expression can be simplified by using count(where:) in combination with passing a key path for the closure

Suggested change
let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1
let lineCount = codeBlock.code.count(where: \.isNewline) - 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)
}
}
235 changes: 226 additions & 9 deletions Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,60 @@ public enum RenderBlockContent: Equatable {
public var code: [String]
/// Additional metadata for this code block.
public var metadata: RenderContentMetadata?
/// Annotations for code blocks
public var options: CodeBlockOptions?

/// Make a new `CodeListing` with the given data.
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, options: CodeBlockOptions?) {
self.syntax = syntax
self.code = code
self.metadata = metadata
self.options = options
}
}

public struct CodeBlockOptions: Equatable {
public var language: String?
public var copyToClipboard: Bool
public var showLineNumbers: Bool
public var wrap: Int
public var lineAnnotations: [LineAnnotation]

public struct Position: Equatable, Comparable, Codable {
public static func < (lhs: RenderBlockContent.CodeBlockOptions.Position, rhs: RenderBlockContent.CodeBlockOptions.Position) -> Bool {
if lhs.line == rhs.line, let lhsCharacter = lhs.character, let rhsCharacter = rhs.character {
return lhsCharacter < rhsCharacter
}
return lhs.line < rhs.line
}

public init(line: Int, character: Int? = nil) {
self.line = line
self.character = character
}

public var line: Int
public var character: Int?
}

public struct LineAnnotation: Equatable, Codable {
public var style: String
public var range: Range<Position>

public init(style: String, range: Range<Position>) {
self.style = style
self.range = range
}
}

public enum OptionName: String, CaseIterable {
case _nonFrozenEnum_useDefaultCase
case nocopy
case wrap
case highlight
case showLineNumbers
case strikeout
case unknown

init?(caseInsensitive raw: some StringProtocol) {
self.init(rawValue: raw.lowercased())
Expand All @@ -138,12 +188,165 @@ public enum RenderBlockContent: Equatable {
Set(OptionName.allCases.map(\.rawValue))
}

/// Make a new `CodeListing` with the given data.
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) {
self.syntax = syntax
self.code = code
self.metadata = metadata
// empty initializer with default values
public init() {
self.language = ""
self.copyToClipboard = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled
self.showLineNumbers = false
self.wrap = 0
self.lineAnnotations = []
}

public init(parsingLanguageString language: String?) {
let (lang, tokens) = Self.tokenizeLanguageString(language)

self.language = lang
self.copyToClipboard = !tokens.contains { $0.name == .nocopy }
self.showLineNumbers = tokens.contains { $0.name == .showLineNumbers }

if let wrapString = tokens.first(where: { $0.name == .wrap })?.value,
let wrapValue = Int(wrapString) {
self.wrap = wrapValue
} else {
self.wrap = 0
}

var annotations: [LineAnnotation] = []

if let highlightString = tokens.first(where: { $0.name == .highlight })?.value {
let highlightValue = Self.parseCodeBlockOptionsArray(highlightString)
for line in highlightValue {
let pos = Position(line: line, character: nil)
let range = pos..<pos
annotations.append(LineAnnotation(style: "highlight", range: range))
}
}

if let strikeoutString = tokens.first(where: { $0.name == .strikeout })?.value {
let strikeoutValue = Self.parseCodeBlockOptionsArray(strikeoutString)
for line in strikeoutValue {
let pos = Position(line: line, character: nil)
let range = pos..<pos
annotations.append(LineAnnotation(style: "strikeout", range: range))
}
}

self.lineAnnotations = annotations
}

public init(copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, showLineNumbers: Bool = false, wrap: Int, highlight: [Int], strikeout: [Int]) {
self.copyToClipboard = copyToClipboard
self.showLineNumbers = showLineNumbers
self.wrap = wrap

var annotations: [LineAnnotation] = []
for line in highlight {
let pos = Position(line: line, character: nil)
let range = pos..<pos
annotations.append(LineAnnotation(style: "highlight", range: range))
}
for line in strikeout {
let pos = Position(line: line, character: nil)
let range = pos..<pos
annotations.append(LineAnnotation(style: "strikeout", range: range))
}
self.lineAnnotations = annotations
}

public init(copyToClipboard: Bool, showLineNumbers: Bool, wrap: Int, lineAnnotations: [LineAnnotation]) {
self.copyToClipboard = copyToClipboard
self.showLineNumbers = showLineNumbers
self.wrap = wrap
self.lineAnnotations = lineAnnotations
}

/// A function that parses array values on code block options from the language line string
static internal func parseCodeBlockOptionsArray(_ value: String?) -> [Int] {
guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] }

if s.hasPrefix("[") && s.hasSuffix("]") {
s.removeFirst()
s.removeLast()
}

return s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}

/// A function that parses the language line options on code blocks, returning the language and tokens, an array of OptionName and option values
static internal func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: [(name: OptionName, value: String?)]) {
guard let input else { return (lang: nil, tokens: []) }

let parts = parseLanguageString(input)
var tokens: [(OptionName, String?)] = []
var lang: String? = nil

for (index, part) in parts.enumerated() {
if let eq = part.firstIndex(of: "=") {
let key = part[..<eq].trimmingCharacters(in: .whitespaces).lowercased()
let value = part[part.index(after: eq)...].trimmingCharacters(in: .whitespaces)
if key == "wrap" {
tokens.append((.wrap, value))
} else if key == "highlight" {
tokens.append((.highlight, value))
} else if key == "strikeout" {
tokens.append((.strikeout, value))
} else {
tokens.append((.unknown, key))
}
} else {
let key = part.trimmingCharacters(in: .whitespaces).lowercased()
if key == "nocopy" {
tokens.append((.nocopy, nil as String?))
} else if key == "showlinenumbers" {
tokens.append((.showLineNumbers, nil as String?))
} else if key == "wrap" {
tokens.append((.wrap, nil as String?))
} else if key == "highlight" {
tokens.append((.highlight, nil as String?))
} else if key == "strikeout" {
tokens.append((.strikeout, nil as String?))
} else if index == 0 && !key.contains("[") && !key.contains("]") {
lang = key
} else {
tokens.append((.unknown, key))
}
}
}
return (lang, tokens)
}

// helper function for tokenizeLanguageString to parse the language line
static func parseLanguageString(_ input: String?) -> [Substring] {

guard let input else { return [] }
var parts: [Substring] = []
var start = input.startIndex
var i = input.startIndex

var bracketDepth = 0

while i < input.endIndex {
let c = input[i]

if c == "[" { bracketDepth += 1 }
else if c == "]" { bracketDepth = max(0, bracketDepth - 1) }
else if c == "," && bracketDepth == 0 {
let seq = input[start..<i]
if !seq.isEmpty {
parts.append(seq)
}
input.formIndex(after: &i)
start = i
continue
}
input.formIndex(after: &i)
}
let tail = input[start..<input.endIndex]
if !tail.isEmpty {
parts.append(tail)
}
Comment on lines +328 to +347
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIY: the part that increments the index and reads the characters can be simplified by iterating over the input zipped with its indices—we have an indexed() helper that does just that (zip(indices, self))—and the character switching can be a tiny amount more readable with a switch statement. Together it would look something like this:

for (i, c) in input.indexed() {
    switch c {
    case "[": bracketDepth += 1
    case "]": bracketDepth -= 1
    case "," where bracketDepth == 0:
        if start < i {
            parts.append(input[start..<i])
        }
        start = input.index(after: i) // Don't include the "," in the next token
        
    default: continue
    }
}
if start < input.endIndex {
    parts.append(input[start...])
}


return parts
}
}

Expand Down Expand Up @@ -711,7 +914,7 @@ extension RenderBlockContent.Table: Codable {
extension RenderBlockContent: Codable {
private enum CodingKeys: CodingKey {
case type
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard, showLineNumbers, wrap, lineAnnotations
case request, response
case header, rows
case numberOfColumns, columns
Expand All @@ -734,12 +937,23 @@ extension RenderBlockContent: Codable {
self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content)))
case .codeListing:
let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled
let options: CodeBlockOptions?
if !Set(container.allKeys).isDisjoint(with: [.copyToClipboard, .showLineNumbers, .wrap, .lineAnnotations]) {
options = try CodeBlockOptions(
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy,
showLineNumbers: container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? false,
wrap: container.decodeIfPresent(Int.self, forKey: .wrap) ?? 0,
lineAnnotations: container.decodeIfPresent([CodeBlockOptions.LineAnnotation].self, forKey: .lineAnnotations) ?? []
)
} else {
options = nil
}
self = try .codeListing(.init(
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
code: container.decode([String].self, forKey: .code),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy
))
options: options
))
case .heading:
self = try .heading(.init(level: container.decode(Int.self, forKey: .level), text: container.decode(String.self, forKey: .text), anchor: container.decodeIfPresent(String.self, forKey: .anchor)))
case .orderedList:
Expand Down Expand Up @@ -842,7 +1056,10 @@ extension RenderBlockContent: Codable {
try container.encode(l.syntax, forKey: .syntax)
try container.encode(l.code, forKey: .code)
try container.encodeIfPresent(l.metadata, forKey: .metadata)
try container.encode(l.copyToClipboard, forKey: .copyToClipboard)
try container.encodeIfPresent(l.options?.copyToClipboard, forKey: .copyToClipboard)
try container.encodeIfPresent(l.options?.showLineNumbers, forKey: .showLineNumbers)
try container.encodeIfPresent(l.options?.wrap, forKey: .wrap)
try container.encodeIfPresent(l.options?.lineAnnotations, forKey: .lineAnnotations)
case .heading(let h):
try container.encode(h.level, forKey: .level)
try container.encode(h.text, forKey: .text)
Expand Down
Loading