diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift new file mode 100644 index 000000000..e483fa76c --- /dev/null +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -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) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index b4200ae07..87fd41cd1 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -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) @@ -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] diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift index dd62465dd..4f95feb9b 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift @@ -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 { @@ -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) @@ -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) } } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 7c4695f2a..7490add17 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -124,12 +124,229 @@ 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?) { + 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 + + public init(style: String, range: Range) { + 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()) + } + } + + public static var knownOptions: Set { + Set(OptionName.allCases.map(\.rawValue)) + } + + // empty initializer with default values + public init() { + self.language = "" + self.copyToClipboard = true + 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.. [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[.. [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.. [any RenderContent] { // Default to the bundle's code listing syntax if one is not explicitly declared in the code block. - return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil))] + + if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled { + let codeBlockOptions = RenderBlockContent.CodeBlockOptions(parsingLanguageString: codeBlock.language) + let listing = RenderBlockContent.CodeListing( + syntax: codeBlockOptions.language ?? bundle.info.defaultCodeListingLanguage, + code: codeBlock.code.splitByNewlines, + metadata: nil, + options: codeBlockOptions + ) + + return [RenderBlockContent.codeListing(listing)] + + } else { + return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, options: nil))] + } } mutating func visitHeading(_ heading: Heading) -> [any RenderContent] { diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index 5dc54c091..f156e5ac0 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -93,10 +93,10 @@ extension Snippet: RenderableDirectiveConvertible { // Make dedicated copies of each line because the RenderBlockContent.codeListing requires it. .map { String($0) } - return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil))] + return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil, options: nil))] } else { // Render the full snippet and its explanatory content. - let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil)) + let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil, options: nil)) var content: [any RenderContent] = resolvedSnippet.explanation?.children.flatMap { contentCompiler.visit($0) } ?? [] content.append(fullCode) diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 4ced31500..4f25303ac 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -781,6 +781,39 @@ } } }, + "LineAnnotation": { + "type": "object", + "properties": { + "style": { + "type": "string", + "enum": ["highlight", "strikeout"] + }, + "range": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + } + }, + "required": [ + "style", + "range" + ] + }, + "Position": { + "type": "object", + "properties": { + "line": { + "type": "integer" + }, + "character": { + "type": "integer" + } + }, + "required": [ + "line" + ] + }, "CodeListing": { "type": "object", "required": [ @@ -805,6 +838,21 @@ }, "metadata": { "$ref": "#/components/schemas/RenderContentMetadata" + }, + "copyToClipboard": { + "type": "boolean" + }, + "showLineNumbers": { + "type": "boolean" + }, + "wrap": { + "type": "integer" + }, + "lineAnnotations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LineAnnotation" + } } } }, diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index def7e642d..538e55781 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -13,7 +13,10 @@ public struct FeatureFlags: Codable { /// The current feature flags that Swift-DocC uses to conditionally enable /// (usually experimental) behavior in Swift-DocC. public static var current = FeatureFlags() - + + /// Whether or not experimental annotation of code blocks is enabled. + public var isExperimentalCodeBlockAnnotationsEnabled = false + /// Whether or not experimental support for device frames on images and video is enabled. public var isExperimentalDeviceFrameSupportEnabled = false diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index e8c8a31b4..c23c8c55b 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -19,7 +19,7 @@ extension ConvertAction { public init(fromConvertCommand convert: Docc.Convert, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws { var standardError = LogHandle.standardError let outOfProcessResolver: OutOfProcessReferenceResolver? - + FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled = convert.featureFlags.enableExperimentalCodeBlockAnnotations FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 95b0d098c..aa427bdb1 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -475,7 +475,13 @@ extension Docc { struct FeatureFlagOptions: ParsableArguments { @Flag(help: "Allows for custom templates, like `header.html`.") var experimentalEnableCustomTemplates = false - + + @Flag( + name: .customLong("enable-experimental-code-block-annotations"), + help: "Support annotations for code blocks." + ) + var enableExperimentalCodeBlockAnnotations = false + @Flag(help: .hidden) var enableExperimentalDeviceFrameSupport = false @@ -558,6 +564,14 @@ extension Docc { } + /// A user-provided value that is true if the user enables experimental support for code block annotation. + /// + /// Defaults to false. + public var enableExperimentalCodeBlocAnnotations: Bool { + get { featureFlags.enableExperimentalCodeBlockAnnotations } + set { featureFlags.enableExperimentalCodeBlockAnnotations = newValue} + } + /// A user-provided value that is true if the user enables experimental support for device frames. /// /// Defaults to false. diff --git a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md index 07d44ee0c..9793f9717 100644 --- a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md +++ b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md @@ -4,35 +4,35 @@ Enhance your content's presentation with special formatting and styling for text ## Overview -Use [Markdown](https://daringfireball.net/projects/markdown/syntax), a -lightweight markup language, to give structure and style to your documentation. -DocC includes a custom dialect of Markdown, documentation markup, which -extends Markdown's syntax to include features like symbol linking, improved +Use [Markdown](https://daringfireball.net/projects/markdown/syntax), a +lightweight markup language, to give structure and style to your documentation. +DocC includes a custom dialect of Markdown, documentation markup, which +extends Markdown's syntax to include features like symbol linking, improved image support, term lists, and asides. -To ensure consistent structure and styling, use DocC's documentation markup for +To ensure consistent structure and styling, use DocC's documentation markup for all of the documentation you write. ### Add a Page Title and Section Headers -To add a page title, precede the text you want to use with a hash (`#`) and a +To add a page title, precede the text you want to use with a hash (`#`) and a space. For the page title of an article or API collection, use plain text only. ```markdown # Getting Started with Sloths ``` -> Important: Page titles must be the first line of content in a documentation +> Important: Page titles must be the first line of content in a documentation file. One or more empty lines can precede the page title. -For the page title of a landing page, enter a symbol link by wrapping the framework's +For the page title of a landing page, enter a symbol link by wrapping the framework's module name within a set of double backticks (\`\`). ```markdown # ``SlothCreator`` ``` -For a documentation extension file, enter a symbol link by wrapping the path to the symbol +For a documentation extension file, enter a symbol link by wrapping the path to the symbol within double backticks (\`\`). The path may start with the framework's module name or with the name of a top-level symbol in the module. @@ -48,41 +48,41 @@ The following example shows a documentation extension link to the same symbol st # ``CareSchedule/Event`` ``` -Augment every page title with a short and concise single-sentence abstract or -summary that provides additional information about the content. Add the summary +Augment every page title with a short and concise single-sentence abstract or +summary that provides additional information about the content. Add the summary using a new paragraph directly below the page title. ```markdown # Getting Started with Sloths Create a sloth and assign personality traits and abilities. -``` +``` -To add a header for an Overview or a Discussion section, use a double hash +To add a header for an Overview or a Discussion section, use a double hash (`##`) and a space, and then include either term in plain text. ```markdown ## Overview ``` -For all other section headers, use a triple hash (`###`) and a space, and then +For all other section headers, use a triple hash (`###`) and a space, and then add the title of the header in plain text. ```markdown ### Create a Sloth ``` -Use this type of section header in framework landing pages, top-level pages, -articles, and occasionally in symbol reference pages where you need to +Use this type of section header in framework landing pages, top-level pages, +articles, and occasionally in symbol reference pages where you need to provide more detail. ### Format Text in Bold, Italics, and Code Voice -DocC provides three ways to format the text in your documentation. You can -apply bold or italic styling, or you can use code voice, which renders the +DocC provides three ways to format the text in your documentation. You can +apply bold or italic styling, or you can use code voice, which renders the specified text in a monospace font. -To add bold styling, wrap the text in a pair of double asterisks (`**`). +To add bold styling, wrap the text in a pair of double asterisks (`**`). Alternatively, use double underscores (`__`). The following example uses bold styling for the names of the sloths: @@ -92,42 +92,42 @@ The following example uses bold styling for the names of the sloths: __Silly Sloth__: Prefers twigs for breakfast. ``` -Use italicized text to introduce new or alternative terms to the reader. To add -italic styling, wrap the text in a set of single underscores (`_`) or single +Use italicized text to introduce new or alternative terms to the reader. To add +italic styling, wrap the text in a set of single underscores (`_`) or single asterisks (`*`). -The following example uses italics for the words _metabolism_ and _habitat_: +The following example uses italics for the words _metabolism_ and _habitat_: ```markdown A sloth's _metabolism_ is highly dependent on its *habitat*. ``` -Use code voice to refer to symbols inline, or to include short code fragments, -such as class names or method signatures. To add code voice, wrap the text in +Use code voice to refer to symbols inline, or to include short code fragments, +such as class names or method signatures. To add code voice, wrap the text in a set of backticks (\`). -In the following example, DocC renders the words _ice_, _fire_, _wind_, and +In the following example, DocC renders the words _ice_, _fire_, _wind_, and _lightning_ in a monospace font: ```markdown -If your sloth possesses one of the special powers: `ice`, `fire`, +If your sloth possesses one of the special powers: `ice`, `fire`, `wind`, or `lightning`. ``` -> Note: To include multiple lines of code, use a code listing instead. For more +> Note: To include multiple lines of code, use a code listing instead. For more information, see . ### Add Code Listings -DocC includes support for code listings, or fenced code blocks, which allow you -to go beyond the basic declaration sections you find in symbol reference pages, -and to provide more complete code examples for adopters of your framework. You can -include code listings in your in-source symbol documentation, in extension +DocC includes support for code listings, or fenced code blocks, which allow you +to go beyond the basic declaration sections you find in symbol reference pages, +and to provide more complete code examples for adopters of your framework. You can +include code listings in your in-source symbol documentation, in extension files, and in articles and tutorials. -To create a code listing, start a new paragraph and add three backticks -(\`\`\`). Then, directly following the backticks, add the name of the -programming language in lowercase text. Add one or more lines of code, and then +To create a code listing, start a new paragraph and add three backticks +(\`\`\`). Then, directly following the backticks, add the name of the +programming language in lowercase text. Add one or more lines of code, and then add a new line and terminate the code listing by adding another three backticks: ```swift @@ -139,11 +139,11 @@ add a new line and terminate the code listing by adding another three backticks: } ``` -> Important: When formatting your code listing, use spaces to indent lines -instead of tabs so that DocC preserves the indentation when compiling your +> Important: When formatting your code listing, use spaces to indent lines +instead of tabs so that DocC preserves the indentation when compiling your documentation. -DocC uses the programming language you specify to apply the correct syntax +DocC uses the programming language you specify to apply the correct syntax color formatting. For the example above, DocC generates the following: ```swift @@ -191,12 +191,12 @@ DocC supports the following list types: | ------------- | ------------------------------------------------------ | | Bulleted list | Groups items that can appear in any order. | | Numbered list | Delineates a sequence of events in a particular order. | -| Term list | Defines a series of term-definition pairs. | +| Term list | Defines a series of term-definition pairs. | -> Important: Don't add images or code listings between list items. Bulleted and +> Important: Don't add images or code listings between list items. Bulleted and numbered lists must contain two or more items. -To create a bulleted list, precede each of the list's items with an asterisk (`*`) and a +To create a bulleted list, precede each of the list's items with an asterisk (`*`) and a space. Alternatively, use a dash (`-`) or a plus sign (`+`) instead of an asterisk (`*`); the list markers are interchangeable. ```markdown @@ -206,7 +206,7 @@ space. Alternatively, use a dash (`-`) or a plus sign (`+`) instead of an asteri + Lightning ``` -To create a numbered list, precede each of the list's items with the number of the step, then a period (`.`) and a space. +To create a numbered list, precede each of the list's items with the number of the step, then a period (`.`) and a space. ```markdown 1. Give the sloth some food. @@ -215,8 +215,8 @@ To create a numbered list, precede each of the list's items with the number of t 4. Put the sloth to bed. ``` -To create a term list, precede each term with a dash (`-`) and a -space, the `term` keyword, and another space. Then add a colon (`:`), a space, and the definition after the term. +To create a term list, precede each term with a dash (`-`) and a +space, the `term` keyword, and another space. Then add a colon (`:`), a space, and the definition after the term. ```markdown - term Ice: Ice sloths thrive below freezing temperatures. @@ -225,8 +225,8 @@ space, the `term` keyword, and another space. Then add a colon (`:`), a space, a - term Lightning: Lightning sloths thrive in stormy climates. ``` -A list item's text, including terms and their definitions, can use the same -style attributes as other text, and include links to other content, including +A list item's text, including terms and their definitions, can use the same +style attributes as other text, and include links to other content, including symbols. diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift new file mode 100644 index 000000000..e67b5d8ff --- /dev/null +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -0,0 +1,161 @@ +/* + 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 Markdown + +class InvalidCodeBlockOptionTests: XCTestCase { + + func testNoOptions() { + let markupSource = """ +``` +let a = 1 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: nil) + checker.visit(document) + XCTAssertTrue(checker.problems.isEmpty) + } + + func testOption() { + let markupSource = """ +```nocopy +let a = 1 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: nil) + checker.visit(document) + XCTAssertTrue(checker.problems.isEmpty) + } + + func testMultipleOptionTypos() { + let markupSource = """ +```nocoy +let b = 2 +``` + +```nocoy +let c = 3 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(2, checker.problems.count) + + for problem in checker.problems { + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Unknown option 'nocoy' in code block.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["Replace 'nocoy' with 'nocopy'."]) + } + } + + func testOptionDifferentTypos() throws { + let markupSource = """ +```swift, nocpy +let d = 4 +``` + +```haskell, nocpoy +let e = 5 +``` + +```nocopy +let f = 6 +``` + +```ncopy +let g = 7 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + + XCTAssertEqual(3, checker.problems.count) + + let summaries = checker.problems.map { $0.diagnostic.summary } + XCTAssertEqual(summaries, [ + "Unknown option 'nocpy' in code block.", + "Unknown option 'nocpoy' in code block.", + "Unknown option 'ncopy' in code block.", + ]) + + for problem in checker.problems { + XCTAssertEqual( + "org.swift.docc.InvalidCodeBlockOption", + problem.diagnostic.identifier + ) + + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssert(solution.summary.hasSuffix("with 'nocopy'.")) + + } + } + + func testLanguageNotFirst() { + let markupSource = """ +```nocopy, swift, highlight=[1] +let b = 2 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(1, checker.problems.count) + + for problem in checker.problems { + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Unknown option 'swift' in code block.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["If 'swift' is the language for this code block, then write 'swift' as the first option."]) + } + } + + func testInvalidHighlightIndex() throws { + let markupSource = """ +```swift, nocopy, highlight=[2] +let b = 2 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(1, checker.problems.count) + let problem = try XCTUnwrap(checker.problems.first) + + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Invalid 'highlight' index in '[2]' for a code block with 1 line. Valid range is 1...1.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["If you intended the last line, change '2' to 1."]) + } + + func testInvalidHighlightandStrikeoutIndex() throws { + let markupSource = """ +```swift, nocopy, highlight=[0], strikeout=[-1, 4] +let a = 1 +let b = 2 +let c = 3 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(2, checker.problems.count) + + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", checker.problems[0].diagnostic.identifier) + XCTAssertEqual(checker.problems[0].diagnostic.summary, "Invalid 'highlight' index in '[0]' for a code block with 3 lines. Valid range is 1...3.") + XCTAssertEqual(checker.problems[1].diagnostic.summary, "Invalid 'strikeout' indexes in '[-1, 4]' for a code block with 3 lines. Valid range is 1...3.") + XCTAssertEqual(checker.problems[1].possibleSolutions.map(\.summary), ["If you intended the last line, change '4' to 3."]) + } +} + diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 23da7c124..b80312614 100644 --- a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift @@ -54,7 +54,7 @@ class RenderContentMetadataTests: XCTestCase { RenderInlineContent.text("Content"), ]) - let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata)) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, options: nil)) let data = try JSONEncoder().encode(code) let roundtrip = try JSONDecoder().decode(RenderBlockContent.self, from: data) diff --git a/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift b/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift index d539531a3..fc63eb88e 100644 --- a/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift @@ -44,7 +44,7 @@ class RenderNodeSerializationTests: XCTestCase { .strong(inlineContent: [.text("Project > Run")]), .text(" menu item, or the following code:"), ])), - .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil)), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, options: nil)), ])) ] @@ -71,16 +71,16 @@ class RenderNodeSerializationTests: XCTestCase { let assessment1 = TutorialAssessmentsRenderSection.Assessment(title: [.paragraph(.init(inlineContent: [.text("Lorem ipsum dolor sit amet?")]))], content: nil, choices: [ - .init(content: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "That's right!"), - .init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Not quite."), + .init(content: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil, options: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "That's right!"), + .init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil, options: nil))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Not quite."), .init(content: [.paragraph(.init(inlineContent: [.text("None of the above.")]))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: nil), ]) let assessment2 = TutorialAssessmentsRenderSection.Assessment(title: [.paragraph(.init(inlineContent: [.text("Duis aute irure dolor in reprehenderit?")]))], content: [.paragraph(.init(inlineContent: [.text("What is the airspeed velocity of an unladen swallow?")]))], choices: [ - .init(content: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Correct."), - .init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Yep."), + .init(content: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, options: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Correct."), + .init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil, options: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Yep."), .init(content: [.paragraph(.init(inlineContent: [.text("None of the above.")]))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Close!"), ]) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 8a23b1324..28af2d014 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -13,6 +13,8 @@ import Markdown @testable import SwiftDocC import XCTest +typealias Position = RenderBlockContent.CodeBlockOptions.Position + class RenderContentCompilerTests: XCTestCase { func testLinkOverrideTitle() async throws { let (bundle, context) = try await testBundleAndContext(named: "LegacyBundle_DoNotUseInNewTests") @@ -223,4 +225,488 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(documentThematicBreak, thematicBreak) } } + + func testCopyToClipboard() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift + let x = 1 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.options?.copyToClipboard, true) + } + + func testNoCopyToClipboard() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift, nocopy + let x = 1 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.options?.copyToClipboard, false) + } + + func testCopyToClipboardNoLang() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```nocopy + let x = 1 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.syntax, nil) + XCTAssertEqual(codeListing.options?.copyToClipboard, false) + } + + func testCopyToClipboardNoFeatureFlag() async throws { + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift + let x = 1 + ``` + """# + let document = Document(parsing: source) + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.options?.copyToClipboard, nil) + } + + func testNoCopyToClipboardNoFeatureFlag() async throws { + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift, nocopy + let x = 1 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.syntax, "swift, nocopy") + XCTAssertEqual(codeListing.options?.copyToClipboard, nil) + } + + func testShowLineNumbers() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift, showLineNumbers + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.options?.showLineNumbers, true) + } + + func testLowercaseShowLineNumbers() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift, showlinenumbers + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.options?.showLineNumbers, true) + } + + func testWrapAndHighlight() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + let (bundle, context) = try await testBundleAndContext() + var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = #""" + ```swift, wrap=20, highlight=[2] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + let document = Document(parsing: source) + + let result = document.children.flatMap { compiler.visit($0) } + + let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) + guard case let .codeListing(codeListing) = renderCodeBlock else { + XCTFail("Expected RenderBlockContent.codeListing") + return + } + + XCTAssertEqual(codeListing.syntax, "swift") + XCTAssertEqual(codeListing.options?.wrap, 20) + let line = Position(line: 2) + XCTAssertEqual(codeListing.options?.lineAnnotations, + [RenderBlockContent.CodeBlockOptions.LineAnnotation( + style: "highlight", + range: line.. Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ .paragraph(.init(inlineContent: [ diff --git a/features.json b/features.json index a14d784fe..014b9bf65 100644 --- a/features.json +++ b/features.json @@ -1,5 +1,8 @@ { "features": [ + { + "name": "code-block-annotations" + }, { "name": "diagnostics-file" },