Skip to content

Commit c1baea3

Browse files
DebugStevenJesse Haighd-ronnqvist
authored
Add experimental code block options: highlight, strikeout, wrap, showLineNumbers (#1287)
* update docs * copy by default * add feature flag 'enable-experimental-code-block-annotations' for copy-to-clipboard and other code block annotations * add more tests, remove docs, code cleanup * add code block options onto RenderBlockContent.CodeListing * remaining PR feedback * copy by default * add feature flag 'enable-experimental-code-block-annotations' for copy-to-clipboard and other code block annotations * WIP wrap and highlight * fix tests * WIP tests, WIP parsing for wrap and highlight * change parsing to handle values after = and arrays * add strikeout option * parse strikeout option, solution for language not as the first option on language line, tests * validate array values in code block options for highlight and strikeout * showLineNumbers option * remove trailing comma * test showLineNumbers * PR feedback * fix feature flag on new tests * remove optional return type * update JSON structure for extensibility * update RenderNode.spec to reflect using Range<Position> in LineAnnotation * update feature name * Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift Co-authored-by: David Rönnqvist <[email protected]> * Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift Co-authored-by: David Rönnqvist <[email protected]> * require LineAnnotation properties style and range * fix CodeListing initializers in Snippet * fix typo * add copy-to-clipboard button back to snippets when feature flag is present --------- Co-authored-by: Jesse Haigh <[email protected]> Co-authored-by: David Rönnqvist <[email protected]>
1 parent 82cc6c8 commit c1baea3

File tree

14 files changed

+839
-113
lines changed

14 files changed

+839
-113
lines changed

Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ internal struct InvalidCodeBlockOption: Checker {
1919
var problems = [Problem]()
2020

2121
/// Parsing options for code blocks
22-
private let knownOptions = RenderBlockContent.CodeListing.knownOptions
22+
private let knownOptions = RenderBlockContent.CodeBlockOptions.knownOptions
2323

2424
private var sourceFile: URL?
2525

@@ -31,32 +31,67 @@ internal struct InvalidCodeBlockOption: Checker {
3131
}
3232

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

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

42-
guard !tokens.isEmpty else { return }
43-
44-
for token in tokens {
45-
// if the token is an exact match, we don't need to do anything
46-
guard !knownOptions.contains(token) else { continue }
47-
48-
let matches = NearMiss.bestMatches(for: knownOptions, against: token)
39+
let matches = NearMiss.bestMatches(for: knownOptions, against: value)
4940

5041
if !matches.isEmpty {
51-
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(token.singleQuoted) in code block.")
42+
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.")
5243
let possibleSolutions = matches.map { candidate in
5344
Solution(
54-
summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).",
45+
summary: "Replace \(value.singleQuoted) with \(candidate.singleQuoted).",
5546
replacements: []
5647
)
5748
}
5849
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions))
50+
} else if lang == nil {
51+
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.")
52+
let possibleSolutions =
53+
Solution(
54+
summary: "If \(value.singleQuoted) is the language for this code block, then write \(value.singleQuoted) as the first option.",
55+
replacements: []
56+
)
57+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [possibleSolutions]))
58+
}
59+
}
60+
61+
func validateArrayIndices(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) {
62+
guard token == .highlight || token == .strikeout, let value = value else { return }
63+
// code property ends in a newline. this gives us a bogus extra line.
64+
let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1
65+
66+
let indices = RenderBlockContent.CodeBlockOptions.parseCodeBlockOptionsArray(value)
67+
68+
if !value.isEmpty, indices.isEmpty {
69+
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])")
70+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: []))
71+
return
5972
}
73+
74+
let invalid = indices.filter { $0 < 1 || $0 > lineCount }
75+
guard !invalid.isEmpty else { return }
76+
77+
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).")
78+
let solutions: [Solution] = {
79+
if invalid.contains(where: {$0 == lineCount + 1}) {
80+
return [Solution(
81+
summary: "If you intended the last line, change '\(lineCount + 1)' to \(lineCount).",
82+
replacements: []
83+
)]
84+
}
85+
return []
86+
}()
87+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: solutions))
88+
}
89+
90+
for (token, value) in tokens {
91+
matches(token: token, value: value)
92+
validateArrayIndices(token: token, value: value)
6093
}
94+
// check if first token (lang) might be a typo
95+
matches(token: .unknown, value: lang)
6196
}
6297
}

Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,60 @@ public enum RenderBlockContent: Equatable {
124124
public var code: [String]
125125
/// Additional metadata for this code block.
126126
public var metadata: RenderContentMetadata?
127+
/// Annotations for code blocks
128+
public var options: CodeBlockOptions?
129+
130+
/// Make a new `CodeListing` with the given data.
131+
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, options: CodeBlockOptions?) {
132+
self.syntax = syntax
133+
self.code = code
134+
self.metadata = metadata
135+
self.options = options
136+
}
137+
}
138+
139+
public struct CodeBlockOptions: Equatable {
140+
public var language: String?
127141
public var copyToClipboard: Bool
142+
public var showLineNumbers: Bool
143+
public var wrap: Int
144+
public var lineAnnotations: [LineAnnotation]
145+
146+
public struct Position: Equatable, Comparable, Codable {
147+
public static func < (lhs: RenderBlockContent.CodeBlockOptions.Position, rhs: RenderBlockContent.CodeBlockOptions.Position) -> Bool {
148+
if lhs.line == rhs.line, let lhsCharacter = lhs.character, let rhsCharacter = rhs.character {
149+
return lhsCharacter < rhsCharacter
150+
}
151+
return lhs.line < rhs.line
152+
}
153+
154+
public init(line: Int, character: Int? = nil) {
155+
self.line = line
156+
self.character = character
157+
}
158+
159+
public var line: Int
160+
public var character: Int?
161+
}
162+
163+
public struct LineAnnotation: Equatable, Codable {
164+
public var style: String
165+
public var range: Range<Position>
166+
167+
public init(style: String, range: Range<Position>) {
168+
self.style = style
169+
self.range = range
170+
}
171+
}
128172

129173
public enum OptionName: String, CaseIterable {
174+
case _nonFrozenEnum_useDefaultCase
130175
case nocopy
176+
case wrap
177+
case highlight
178+
case showLineNumbers
179+
case strikeout
180+
case unknown
131181

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

141-
/// Make a new `CodeListing` with the given data.
142-
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) {
143-
self.syntax = syntax
144-
self.code = code
145-
self.metadata = metadata
191+
// empty initializer with default values
192+
public init() {
193+
self.language = ""
194+
self.copyToClipboard = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled
195+
self.showLineNumbers = false
196+
self.wrap = 0
197+
self.lineAnnotations = []
198+
}
199+
200+
public init(parsingLanguageString language: String?) {
201+
let (lang, tokens) = Self.tokenizeLanguageString(language)
202+
203+
self.language = lang
204+
self.copyToClipboard = !tokens.contains { $0.name == .nocopy }
205+
self.showLineNumbers = tokens.contains { $0.name == .showLineNumbers }
206+
207+
if let wrapString = tokens.first(where: { $0.name == .wrap })?.value,
208+
let wrapValue = Int(wrapString) {
209+
self.wrap = wrapValue
210+
} else {
211+
self.wrap = 0
212+
}
213+
214+
var annotations: [LineAnnotation] = []
215+
216+
if let highlightString = tokens.first(where: { $0.name == .highlight })?.value {
217+
let highlightValue = Self.parseCodeBlockOptionsArray(highlightString)
218+
for line in highlightValue {
219+
let pos = Position(line: line, character: nil)
220+
let range = pos..<pos
221+
annotations.append(LineAnnotation(style: "highlight", range: range))
222+
}
223+
}
224+
225+
if let strikeoutString = tokens.first(where: { $0.name == .strikeout })?.value {
226+
let strikeoutValue = Self.parseCodeBlockOptionsArray(strikeoutString)
227+
for line in strikeoutValue {
228+
let pos = Position(line: line, character: nil)
229+
let range = pos..<pos
230+
annotations.append(LineAnnotation(style: "strikeout", range: range))
231+
}
232+
}
233+
234+
self.lineAnnotations = annotations
235+
}
236+
237+
public init(copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, showLineNumbers: Bool = false, wrap: Int, highlight: [Int], strikeout: [Int]) {
238+
self.copyToClipboard = copyToClipboard
239+
self.showLineNumbers = showLineNumbers
240+
self.wrap = wrap
241+
242+
var annotations: [LineAnnotation] = []
243+
for line in highlight {
244+
let pos = Position(line: line, character: nil)
245+
let range = pos..<pos
246+
annotations.append(LineAnnotation(style: "highlight", range: range))
247+
}
248+
for line in strikeout {
249+
let pos = Position(line: line, character: nil)
250+
let range = pos..<pos
251+
annotations.append(LineAnnotation(style: "strikeout", range: range))
252+
}
253+
self.lineAnnotations = annotations
254+
}
255+
256+
public init(copyToClipboard: Bool, showLineNumbers: Bool, wrap: Int, lineAnnotations: [LineAnnotation]) {
146257
self.copyToClipboard = copyToClipboard
258+
self.showLineNumbers = showLineNumbers
259+
self.wrap = wrap
260+
self.lineAnnotations = lineAnnotations
261+
}
262+
263+
/// A function that parses array values on code block options from the language line string
264+
static internal func parseCodeBlockOptionsArray(_ value: String?) -> [Int] {
265+
guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] }
266+
267+
if s.hasPrefix("[") && s.hasSuffix("]") {
268+
s.removeFirst()
269+
s.removeLast()
270+
}
271+
272+
return s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
273+
}
274+
275+
/// A function that parses the language line options on code blocks, returning the language and tokens, an array of OptionName and option values
276+
static internal func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: [(name: OptionName, value: String?)]) {
277+
guard let input else { return (lang: nil, tokens: []) }
278+
279+
let parts = parseLanguageString(input)
280+
var tokens: [(OptionName, String?)] = []
281+
var lang: String? = nil
282+
283+
for (index, part) in parts.enumerated() {
284+
if let eq = part.firstIndex(of: "=") {
285+
let key = part[..<eq].trimmingCharacters(in: .whitespaces).lowercased()
286+
let value = part[part.index(after: eq)...].trimmingCharacters(in: .whitespaces)
287+
if key == "wrap" {
288+
tokens.append((.wrap, value))
289+
} else if key == "highlight" {
290+
tokens.append((.highlight, value))
291+
} else if key == "strikeout" {
292+
tokens.append((.strikeout, value))
293+
} else {
294+
tokens.append((.unknown, key))
295+
}
296+
} else {
297+
let key = part.trimmingCharacters(in: .whitespaces).lowercased()
298+
if key == "nocopy" {
299+
tokens.append((.nocopy, nil as String?))
300+
} else if key == "showlinenumbers" {
301+
tokens.append((.showLineNumbers, nil as String?))
302+
} else if key == "wrap" {
303+
tokens.append((.wrap, nil as String?))
304+
} else if key == "highlight" {
305+
tokens.append((.highlight, nil as String?))
306+
} else if key == "strikeout" {
307+
tokens.append((.strikeout, nil as String?))
308+
} else if index == 0 && !key.contains("[") && !key.contains("]") {
309+
lang = key
310+
} else {
311+
tokens.append((.unknown, key))
312+
}
313+
}
314+
}
315+
return (lang, tokens)
316+
}
317+
318+
// helper function for tokenizeLanguageString to parse the language line
319+
static func parseLanguageString(_ input: String?) -> [Substring] {
320+
321+
guard let input else { return [] }
322+
var parts: [Substring] = []
323+
var start = input.startIndex
324+
var i = input.startIndex
325+
326+
var bracketDepth = 0
327+
328+
while i < input.endIndex {
329+
let c = input[i]
330+
331+
if c == "[" { bracketDepth += 1 }
332+
else if c == "]" { bracketDepth = max(0, bracketDepth - 1) }
333+
else if c == "," && bracketDepth == 0 {
334+
let seq = input[start..<i]
335+
if !seq.isEmpty {
336+
parts.append(seq)
337+
}
338+
input.formIndex(after: &i)
339+
start = i
340+
continue
341+
}
342+
input.formIndex(after: &i)
343+
}
344+
let tail = input[start..<input.endIndex]
345+
if !tail.isEmpty {
346+
parts.append(tail)
347+
}
348+
349+
return parts
147350
}
148351
}
149352

@@ -711,7 +914,7 @@ extension RenderBlockContent.Table: Codable {
711914
extension RenderBlockContent: Codable {
712915
private enum CodingKeys: CodingKey {
713916
case type
714-
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard
917+
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard, showLineNumbers, wrap, lineAnnotations
715918
case request, response
716919
case header, rows
717920
case numberOfColumns, columns
@@ -734,12 +937,23 @@ extension RenderBlockContent: Codable {
734937
self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content)))
735938
case .codeListing:
736939
let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled
940+
let options: CodeBlockOptions?
941+
if !Set(container.allKeys).isDisjoint(with: [.copyToClipboard, .showLineNumbers, .wrap, .lineAnnotations]) {
942+
options = try CodeBlockOptions(
943+
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy,
944+
showLineNumbers: container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? false,
945+
wrap: container.decodeIfPresent(Int.self, forKey: .wrap) ?? 0,
946+
lineAnnotations: container.decodeIfPresent([CodeBlockOptions.LineAnnotation].self, forKey: .lineAnnotations) ?? []
947+
)
948+
} else {
949+
options = nil
950+
}
737951
self = try .codeListing(.init(
738952
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
739953
code: container.decode([String].self, forKey: .code),
740954
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
741-
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy
742-
))
955+
options: options
956+
))
743957
case .heading:
744958
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)))
745959
case .orderedList:
@@ -842,7 +1056,10 @@ extension RenderBlockContent: Codable {
8421056
try container.encode(l.syntax, forKey: .syntax)
8431057
try container.encode(l.code, forKey: .code)
8441058
try container.encodeIfPresent(l.metadata, forKey: .metadata)
845-
try container.encode(l.copyToClipboard, forKey: .copyToClipboard)
1059+
try container.encodeIfPresent(l.options?.copyToClipboard, forKey: .copyToClipboard)
1060+
try container.encodeIfPresent(l.options?.showLineNumbers, forKey: .showLineNumbers)
1061+
try container.encodeIfPresent(l.options?.wrap, forKey: .wrap)
1062+
try container.encodeIfPresent(l.options?.lineAnnotations, forKey: .lineAnnotations)
8461063
case .heading(let h):
8471064
try container.encode(h.level, forKey: .level)
8481065
try container.encode(h.text, forKey: .text)

0 commit comments

Comments
 (0)