diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..689c7e24 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +Bug/issue #, if applicable: + +## Summary + +_Provide a description of what your PR addresses, explaining the expected user experience. +Also, provide an overview of your implementation._ + +## Dependencies + +_Describe any dependencies this PR might have, such as an associated branch in another repository._ + +## Testing + +_Describe how a reviewer can test the functionality of your PR. Provide test content to test with if +applicable._ + +Steps: +1. _Provide setup instructions._ +2. _Explain in detail how the functionality can be tested._ + +## Checklist + +Make sure you check off the following items. If they cannot be completed, provide a reason. + +- [ ] Added tests +- [ ] Ran the `./bin/test` script and it succeeded +- [ ] Updated documentation if necessary diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dfe1e4bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2021 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 + +.DS_Store +/.build +/Packages +/*.xcodeproj diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..b0a0ab2f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +The code of conduct for this project can be found at https://swift.org/code-of-conduct. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5ddaf8c4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +By submitting a pull request, you represent that you have the right to license your +contribution to Apple and the community, and agree by submitting the patch that +your contributions are licensed under the [Swift license](https://swift.org/LICENSE.txt). + +--- + +Before submitting the pull request, please make sure you have tested your changes +and that they follow the Swift project [guidelines for contributing +code](https://swift.org/contributing/#contributing-code). + + diff --git a/Documentation/BlockDirectives.md b/Documentation/BlockDirectives.md new file mode 100644 index 00000000..882b59fe --- /dev/null +++ b/Documentation/BlockDirectives.md @@ -0,0 +1,174 @@ +# Block Directives + +Block directives are a syntax extension that create attributed containers to hold other block elements, such as paragraphs and lists, or even other block directives. Here is what one looks like: + +```markdown +@Directive(x: 1, y: 2 + z: 3) { + - A + - B + - C +} +``` + +This creates a syntax tree that looks like this: + +``` +Document +└─ BlockDirective name: "Directive" + ├─ Argument text segments: + | "x: 1, y: 2" + | " z: 3" + └─ UnorderedList + ├─ ListItem + │ └─ Paragraph + │ └─ Text "A" + ├─ ListItem + │ └─ Paragraph + │ └─ Text "B" + └─ ListItem + └─ Paragraph + └─ Text "C" +``` + +There are three main pieces to a block directive: the name, the argument text, and its content. + +## Names + +Block directives are opened with an at-symbol `@` immediately followed by a non-empty name. Most characters are allowed except whitespace and punctuation used for other parts of block directive syntax unless they are escaped, such as parentheses `()`, curly brackets `{}`, commas `,`, and colons `:`. + +``` +BlockDirectiveOpening -> @ BlockDirectiveName +BlockDirectiveName -> [^(){}:, \t] +``` + +## Argument Text + +Block directives can have one or more *argument text segments* inside parentheses. + +``` +ArgumentText -> ( ArgumentTextSegment ArgumentTextRest? ) + | ε +ArgumentTextRest -> \n ArgumentText +ArgumentTextSegment* -> [^)] + +* Escaping allowed with a backslash \ character. +``` + +If you don't need any argument text, you can simply omit the parentheses. + +``` +@Directive { + - A + - B + - C +} +``` + +You can parse argument text segments however you like. Swift Markdown also includes a default name-value argument parser that can cover lots of use cases. These are comma-separated pairs of name and value *literals*. For example: + +```markdown +@Directive(x: 1, y: "2") +``` + +When using the name-value argument parser, this results in arguments `x` with value `1` and `y` with value `2`. Names and values are both strings; it's up to you to decide how to convert them into something more specific. + +Here is the grammar of name-value argument syntax: + +``` +Arguments -> Argument ArgumentsRest? +ArgumentsRest -> , Arguments +Argument -> Literal : Literal +Literal -> QuotedLiteral + | UnquotedLiteral +QuotedLiteral -> " QuotedLiteralContent " +QuotedLiteralContent* -> [^:{}(),"] +UnquotedLiteral* -> [^ \t:{}(),"] + +* Escaping allowed with a backslash \ character. +``` + +> Note: Because of the way Markdown is usually parsed, name-value arguments cannot span multiple lines. + +## Content + +Wrap content with curly brackets `{}`. + +```markdown +@Outer { + @Inner { + - A + - B + - C + } +} +``` + +If a block directive doesn't have any content, you can omit the curly brackets: + +``` +@TOC + +# Title + +... +``` + +## Nesting and Indentation + +Since it's very common for block directives to nest, you can indent the lines that make up the name, arguments, and contents any amount. + +```markdown +@Outer { + @Inner { + - A + - B + } +} +``` + +For the contents, indentation is established by the first non-blank line, assuming that indentation for the rest of a directive's contents. Runs of lines that don't make up the definition of a block directive are handed off to the cmark parser. For `@Inner`'s contents above, the cmark parser will see: + +```markdown +- A + - B +``` + +Swift Markdown adjusts the source locations reported by cmark after parsing. + +## Enabling Block Directive Syntax + +Pass the `.parseBlockDirectives` option when parsing a document to enable block directive syntax: + +```swift +let document = Document(parsing: source, options: .parseBlockDirectives) +``` + +## Collecting Diagnostics + +When parsing block directive syntax, Swift Markdown supplies an optional diagnostic infrastructure for reporting parsing problems to a user. See ``Diagnostic``, ``DiagnosticEngine``, and ``DiagnosticConsumer``. + +Here is a simple case if you just want to collect diagnostics: + +```swift +class DiagnosticCollector: DiagnosticConsumer { + var diagnostics = [Diagnostic]() + func receive(_ diagnostic: Diagnostic) { + diagnostics.append(diagnostic) + } +} + +let collector = DiagnosticCollector() +let diagnostics = DiagnosticEngine() +diagnostics.subscribe(collector) + +let document = Document(parsing: source, + options: .parseBlockDirectives, + diagnostics: diagnostics) + +for diagnostic in collector.diagnostics { + print(diagnostic) +} +``` + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..78129ccb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +### Runtime Library Exception to the Apache 2.0 License: ### + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 00000000..555f51d8 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,41 @@ + + The Swift Markdown Project + ========================== + +Please visit the Swift Markdown web site for more information: + + * https://github.com/apple/swift-markdown + +Copyright (c) 2021 Apple Inc. and the Swift project authors + +The Swift Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +------------------------------------------------------------------------------- + +This product contains Swift Argument Parser. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-argument-parser + +--- + +This product contains a derivation of the cmark-gfm project, available at +https://github.com/apple/swift-cmark. + + * LICENSE (BSD-2): + * https://opensource.org/licenses/BSD-2-Clause + * HOMEPAGE: + * https://github.com/github/cmark-gfm + diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..c1153796 --- /dev/null +++ b/Package.swift @@ -0,0 +1,54 @@ +// swift-tools-version:4.2 +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 PackageDescription +import class Foundation.ProcessInfo + +let package = Package( + name: "swift-markdown", + products: [ + .library( + name: "Markdown", + targets: ["Markdown"]), + .executable( + name: "markdown-tool", + targets: ["markdown-tool"]), + ], + targets: [ + .target( + name: "Markdown", + dependencies: ["cmark-gfm", "cmark-gfm-extensions", "CAtomic"]), + .target( + name: "markdown-tool", + dependencies: ["Markdown", .product(name: "ArgumentParser", package: "swift-argument-parser")]), + .testTarget( + name: "MarkdownTests", + dependencies: ["Markdown"]), + .target(name: "CAtomic"), + ] +) + +// If the `SWIFTCI_USE_LOCAL_DEPS` environment variable is set, +// we're building in the Swift.org CI system alongside other projects in the Swift toolchain and +// we can depend on local versions of our dependencies instead of fetching them remotely. +if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { + // Building standalone, so fetch all dependencies remotely. + package.dependencies += [ + .package(url: "https://github.com/apple/swift-cmark.git", .branch("gfm")), + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.4.4")), + ] +} else { + // Building in the Swift.org CI system, so rely on local versions of dependencies. + package.dependencies += [ + .package(path: "../swift-cmark-gfm"), + .package(path: "../swift-argument-parser"), + ] +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..ff6831b6 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Swift Markdown + +Swift `Markdown` is a Swift package for parsing, building, editing, and analyzing Markdown documents. + +The parser is powered by GitHub-flavored Markdown's [cmark-gfm](https://github.com/github/cmark-gfm) implementation, so it follows the spec closely. As the needs of the community change, the effective dialect implemented by this library may change. + +The markup tree provided by this package is comprised of immutable/persistent, thread-safe, copy-on-write value types that only copy substructure that has changed. Other examples of the main strategy behind this library can be seen in Swift's [lib/Syntax](https://github.com/apple/swift/tree/master/lib/Syntax) and its Swift bindings, [SwiftSyntax](https://github.com/apple/swift-syntax). + +## Getting Started Using Markup + +In your `Package.swift` Swift Package Manager manifest, add the following dependency to your `dependencies` argument: + +```swift +.package(url: "ssh://git@github.com/apple/swift-markdown.git", .branch("main")), +``` + +Add the dependency to any targets you've declared in your manifest: + +```swift +.target(name: "MyTarget", dependencies: ["Markdown"]), +``` + +## Parsing + +To parse a document, use `Document(parsing:)`, supplying a `String` or `URL`: + +```swift +import Markdown + +let source = "This is a markup *document*." +let document = Document(parsing: source) +print(document.debugDescription()) +// Document +// └─ Paragraph +// ├─ Text "This is a markup " +// ├─ Emphasis +// │ └─ Text "document" +// └─ Text "." +``` + +Parsing text is just one way to build a tree of `Markup` elements. You can also build them yourself declaratively. + +## Building Markup Trees + +You can build trees using initializers for the various element types provided. + +```swift +import Markdown + +let document = Document( + Paragraph( + Text("This is a "), + Emphasis( + Text("paragraph.")))) +``` + +This would be equivalent to parsing `"This is a *paragraph.*"` but allows you to programmatically insert content from other data sources into individual elements. + +## Modifying Markup Trees with Persistence + +Swift Markdown uses a [persistent](https://en.wikipedia.org/wiki/Persistent_data_structure) tree for its backing storage, providing effectively immutable, copy-on-write value types that only copy the substructure necessary to create a unique root without affecting the previous version of the tree. + +### Modifying Elements Directly + +If you just need to make a quick change, you can modify an element anywhere in a tree, and Swift Markdown will create copies of substructure that cannot be shared. + +```swift +import Markdown + +let source = "This is *emphasized.*" +let document = Document(parsing: source) +print(document.debugDescription()) +// Document +// └─ Paragraph +// ├─ Text "This is " +// └─ Emphasis +// └─ Text "emphasized." + +var text = document.child(through: + 0, // Paragraph + 1, // Emphasis + 0) as! Text // Text + +text.string = "really emphasized!" +print(text.root.debugDescription()) +// Document +// └─ Paragraph +// ├─ Text "This is " +// └─ Emphasis +// └─ Text "really emphasized!" + +// The original document is unchanged: + +print(document.debugDescription()) +// Document +// └─ Paragraph +// ├─ Text "This is " +// └─ Emphasis +// └─ Text "emphasized." +``` + +If you find yourself needing to systematically change many parts of a tree, or even provide a complete transformation into something else, maybe the familiar [Visitor Pattern](https://en.wikipedia.org/wiki/Visitor_pattern) is what you want. + +## Visitors, Walkers, and Rewriters + +There is a core `MarkupVisitor` protocol that provides the basis for transforming, walking, or rewriting a markup tree. + +```swift +public protocol MarkupVisitor { + associatedtype Result +} +``` + +Using its `Result` type, you can transform a markup tree into anything: another markup tree, or perhaps a tree of XML or HTML elements. There are two included refinements of `MarkupVisitor` for common uses. + +The first refinement, `MarkupWalker`, has an associated `Result` type of `Void`, so it's meant for summarizing or detecting aspects of a markup tree. If you wanted to append to a string as elements are visited, this might be a good tool for that. + +```swift +import Markdown + +/// Counts `Link`s in a `Document`. +struct LinkCounter: MarkupWalker { + var count = 0 + mutating func visitLink(_ link: Link) { + if link.destination == "https://swift.org" { + count += 1 + } + descendInto(link) + } +} + +let source = "There are [two](https://swift.org) links to here." +let document = Document(parsing: source) +print(document.debugDescription()) +var linkCounter = LinkCounter() +linkCounter.visit(document) +print(linkCounter.count) +// 2 +``` + +The second refinement, `MarkupRewriter`, has an associated `Result` type of `Markup?`, so it's meant to change or even remove elements from a markup tree. You can return `nil` to delete an element, or return another element to substitute in its place. + +```swift +import Markdown + +/// Delete all **strong** elements in a markup tree. +struct StrongDeleter: MarkupRewriter { + mutating func visitStrong(_ strong: Strong) -> Markup? { + return nil + } +} + +let source = "Now you see me, **now you don't**" +let document = Document(parsing: source) +var strongDeleter = StrongDeleter() +let newDocument = strongDeleter.visit(document) + +print(newDocument!.debugDescription()) +// Document +// └─ Paragraph +// └─ Text "Now you see me, " +``` + +## Block Directives + +Swift Markdown includes a syntax extension for attributed block elements. See [Block Directive](Documentation/BlockDirectives.md) documentation for more information. + +## Getting Involved + +### Submitting a Bug Report + +Swift Markdown tracks all bug reports with [Swift JIRA](https://bugs.swift.org/). +You can use the "SwiftMarkdown" component for issues and feature requests specific to Swift Markdown. +When you submit a bug report we ask that you follow the +Swift [Bug Reporting](https://swift.org/contributing/#reporting-bugs) guidelines +and provide as many details as possible. + +### Submitting a Feature Request + +For feature requests, please feel free to create an issue +on [Swift JIRA](https://bugs.swift.org/) with the `New Feature` type +or start a discussion on the [Swift Forums](https://forums.swift.org/c/development/swift-docc). + +Don't hesitate to submit a feature request if you see a way +Swift Markdown can be improved to better meet your needs. + +### Contributing to Swift Markdown + +Please see the [contributing guide](https://swift.org/contributing/#contributing-code) for more information. + + diff --git a/Sources/CAtomic/CAtomic.c b/Sources/CAtomic/CAtomic.c new file mode 100644 index 00000000..b10e921f --- /dev/null +++ b/Sources/CAtomic/CAtomic.c @@ -0,0 +1,21 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +#include + +static _Atomic uint64_t _cmarkup_unique_id = 0; + +uint64_t _cmarkup_current_unique_id(void) { + return _cmarkup_unique_id; +} + +uint64_t _cmarkup_increment_and_get_unique_id(void) { + return ++_cmarkup_unique_id; +} diff --git a/Sources/CAtomic/include/CAtomic.h b/Sources/CAtomic/include/CAtomic.h new file mode 100644 index 00000000..db27b2b5 --- /dev/null +++ b/Sources/CAtomic/include/CAtomic.h @@ -0,0 +1,17 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +#include + +/// The current unique atomic identifier. +uint64_t _cmarkup_current_unique_id(void); + +/// Increment the current unique identifier atomically and return it. +uint64_t _cmarkup_increment_and_get_unique_id(void); diff --git a/Sources/Markdown/Base/ChildIndexPath.swift b/Sources/Markdown/Base/ChildIndexPath.swift new file mode 100644 index 00000000..0ae2a6fd --- /dev/null +++ b/Sources/Markdown/Base/ChildIndexPath.swift @@ -0,0 +1,63 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An array of indexes for traversing deeply into a markup tree. +public typealias ChildIndexPath = [Int] + +/// A description of a traversal through a markup tree by index and optional expected type. +public struct TypedChildIndexPath: RandomAccessCollection, ExpressibleByArrayLiteral { + /// A pair consisting of an expected index and optional expected type for a child element. + /// + /// This type is a shorthand convenience when creating a ``TypedChildIndexPath`` from an array literal. + public typealias ArrayLiteralElement = (Int, Markup.Type?) + + /// An element of a complex child index path. + public struct Element { + /// The index to use when descending into the children. + var index: Int + + /** + The expected type of the child at ``index``. + + Use this to restrict the type of node to enter at this point in the traversal. If the child doesn't match this type, the traversal will fail. To allow any type of child markup type, set this to `nil`. + */ + var expectedType: Markup.Type? + } + + /// The elements of the path. + private var elements: [Element] + + /// Create an empty path. + public init() { + elements = [] + } + + /// Create a path from a sequence of index-type pairs. + public init(_ elements: S) where S.Element == Element { + self.elements = Array(elements) + } + + /// Create a path from a sequence of index-type pairs. + public init(arrayLiteral elements: ArrayLiteralElement...) { + self.elements = elements.map { Element(index: $0.0, expectedType: $0.1) } + } + + public var startIndex: Int { + return elements.startIndex + } + + public var endIndex: Int { + return elements.endIndex + } + + public subscript(index: Int) -> Element { + return elements[index] + } +} diff --git a/Sources/Markdown/Base/DirectiveArgument.swift b/Sources/Markdown/Base/DirectiveArgument.swift new file mode 100644 index 00000000..04f36027 --- /dev/null +++ b/Sources/Markdown/Base/DirectiveArgument.swift @@ -0,0 +1,367 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// The argument text provided to a directive, which can be parsed +/// into various kinds of arguments. +/// +/// For example, take the following directive: +/// +/// ```markdown +/// @Dir(x: 1, +/// y: 2) +/// ``` +/// +/// The following line segments would be provided as ``DirectiveArgumentText``, +/// parsed as one logical string: +/// +/// ``` +/// x: 1, +/// ``` +/// ``` +/// y: 2 +/// ``` +public struct DirectiveArgumentText: Equatable { + + /// Errors parsing name-value arguments from argument text segments. + public enum ParseError: Equatable { + /// A duplicate argument was given. + case duplicateArgument(name: String, firstLocation: SourceLocation, duplicateLocation: SourceLocation) + + /// A character was expected but not found at a source location. + case missingExpectedCharacter(Character, location: SourceLocation) + + /// Unexpected character at a source location. + case unexpectedCharacter(Character, location: SourceLocation) + } + + /// A segment of a line of argument text. + public struct LineSegment: Equatable { + /// The segment's untrimmed text from which arguments can be parsed. + public var untrimmedText: String + + /// The index in ``untrimmedText`` where the line started. + public var lineStartIndex: String.Index + + /// The index from which parsing should start. + public var parseIndex: String.Index + + /// The range from which a segment was extracted from a line + /// of source, or `nil` if it was provided by other means. + public var range: SourceRange? + + /// The segment's text starting from ``parseIndex``. + public var trimmedText: Substring { + return untrimmedText[parseIndex...] + } + + /// Create an argument line segment. + /// - Parameters: + /// - untrimmedText: the segment's untrimmed text from which arguments can be parsed. + /// - lineStartIndex: the index in ``text`` where the line started. + /// - parseIndex: index from which parsing should start. + /// - range: The range from which a segment was extracted from a line + /// of source, or `nil` if the argument text was provided by other means. + init(untrimmedText: String, lineStartIndex: String.Index, parseIndex: String.Index? = nil, range: SourceRange? = nil) { + self.untrimmedText = untrimmedText + self.lineStartIndex = lineStartIndex + self.parseIndex = parseIndex ?? untrimmedText.startIndex + self.range = range + } + + /// Returns a Boolean value indicating whether two line segments are equal. + /// - Parameter lhs: a line segment to compare + /// - Parameter rhs: another line segment to compare + /// - Returns: `true` if the two segments are equal. + public static func ==(lhs: LineSegment, rhs: LineSegment) -> Bool { + return lhs.untrimmedText == rhs.untrimmedText && + lhs.lineStartIndex == rhs.lineStartIndex && + lhs.parseIndex == rhs.parseIndex && + lhs.range == rhs.range + } + + /// Parse a quoted literal. + /// + /// ``` + /// quoted-literal -> " unquoted-literal " + /// ``` + func parseQuotedLiteral(from line: inout TrimmedLine, + parseErrors: inout [ParseError]) -> TrimmedLine.Lex? { + precondition(line.text.starts(with: "\"")) + _ = line.take(1) + + guard let contents = line.lex(until: { + switch $0 { + case "\"": + return .stop + default: + return .continue + } + }, allowEscape: true, allowEmpty: true) else { + return nil + } + + _ = parseCharacter("\"", from: &line, + required: true, + allowEscape: true, + diagnoseIfNotFound: true, + parseErrors: &parseErrors) + + return contents + } + + /// Parse an unquoted literal. + /// + /// ``` + /// unquoted-literal -> [^, :){] + /// ``` + func parseUnquotedLiteral(from line: inout TrimmedLine) -> TrimmedLine.Lex? { + let result = line.lex(until: { + switch $0 { + case ",", " ", ":", ")", "{": + return .stop + default: + return .continue + } + }, allowEscape: true) + return result + } + + /// Parse a literal. + /// + /// ``` + /// literal -> quoted-literal + /// | unquoted-literal + /// ``` + func parseLiteral(from line: inout TrimmedLine, parseErrors: inout [ParseError]) -> TrimmedLine.Lex? { + line.lexWhitespace() + if line.text.starts(with: "\"") { + return parseQuotedLiteral(from: &line, parseErrors: &parseErrors) + } else { + return parseUnquotedLiteral(from: &line) + } + } + + /// Attempt to parse a single character. + /// + /// - Parameters: + /// - character: the expected character to parse + /// - line: the trimmed line from which to parse + /// - required: whether the character is required + /// - allowEscape: whether to allow the character to be escaped + /// - diagnoseIfNotFound: if `true` and the character was both required and not found, emit a diagnostic + /// - diagnosticEngine: the diagnostic engine to use if diagnosing + /// - Returns: `true` if the character was found. + func parseCharacter(_ character: Character, + from line: inout TrimmedLine, + required: Bool, + allowEscape: Bool, + diagnoseIfNotFound: Bool, + parseErrors: inout [ParseError]) -> Bool { + guard line.lex(character, allowEscape: allowEscape) != nil || !required else { + if diagnoseIfNotFound, + let expectedLocation = line.location { + parseErrors.append(.missingExpectedCharacter(character, location: expectedLocation)) + } + return false + } + + return true + } + + /// Parse the line segment as name-value argument pairs separated by commas. + /// + /// ``` + /// name-value-arguments -> name-value-argument name-value-arguments-rest + /// name-value-argument -> literal : literal + /// name-value-arguments-rest -> , name-value-arguments | ε + /// ``` + /// + /// Note the following aspects of this parsing function. + /// + /// - An argument-name pair is only recognized within a single line or line segment; + /// that is, an argument cannot span multiple lines. + /// - A comma is expected between name-value pairs. + /// - Parameter diagnosticEngine: the diagnostic engine to use for emitting diagnostics. + /// - Returns: an array of successfully parsed ``DirectiveArgument`` values. + public func parseNameValueArguments(parseErrors: inout [ParseError]) -> [DirectiveArgument] { + var arguments = [DirectiveArgument]() + + var line = TrimmedLine(untrimmedText[...], + source: range?.lowerBound.source, + lineNumber: range?.lowerBound.line, + parseIndex: parseIndex) + line.lexWhitespace() + while !line.isEmptyOrAllWhitespace { + guard let name = parseLiteral(from: &line, parseErrors: &parseErrors) else { + while parseCharacter(",", from: &line, required: true, allowEscape: false, diagnoseIfNotFound: false, parseErrors: &parseErrors) { + if let location = line.location { + parseErrors.append(.unexpectedCharacter(",", location: location)) + } + } + _ = line.lex(untilCharacter: ",") + continue + } + _ = parseCharacter(":", from: &line, required: true, allowEscape: false, diagnoseIfNotFound: true, + parseErrors: &parseErrors) + guard let value = parseLiteral(from: &line, parseErrors: &parseErrors) else { + while parseCharacter(",", from: &line, required: true, allowEscape: false, diagnoseIfNotFound: false, parseErrors: &parseErrors) { + if let location = line.location { + parseErrors.append(.unexpectedCharacter(",", location: location)) + } + } + _ = line.lex(untilCharacter: ",") + continue + } + let nameRange: SourceRange? + let valueRange: SourceRange? + + if let lineLocation = line.location, + let range = name.range { + nameRange = SourceLocation(line: lineLocation.line, column: range.lowerBound.column, source: range.lowerBound.source)..(_ string: S) { + let text = String(string) + self.segments = [LineSegment(untrimmedText: text, lineStartIndex: text.startIndex, range: nil)] + } + + /// Create a body of argument text from a sequence of ``LineSegment`` elements. + public init(segments: Segments) where Segments.Element == LineSegment { + self.segments = Array(segments) + } + + /// `true` if there are no segments or all segments consist entirely of whitespace. + public var isEmpty: Bool { + return segments.isEmpty || segments.allSatisfy { + $0.untrimmedText.isEmpty || $0.untrimmedText.allSatisfy { + $0 == " " || $0 == "\t" + } + } + } + + /// Parse the line segments as name-value argument pairs separated by commas. + /// + /// ``` + /// name-value-arguments -> name-value-argument name-value-arguments-rest + /// name-value-argument -> literal : literal + /// name-value-arguments-rest -> , name-value-arguments | ε + /// ``` + /// + /// Note the following aspects of this parsing function. + /// + /// - An argument-name pair is only recognized within a single line or line segment; + /// that is, an argument cannot span multiple lines. + /// - A comma is expected between name-value pairs. + /// - Parameter parseErrors: an array to collect errors while parsing arguments. + /// - Returns: an array of successfully parsed ``DirectiveArgument`` values. + public func parseNameValueArguments(parseErrors: inout [ParseError]) -> [DirectiveArgument] { + var arguments = [DirectiveArgument]() + for segment in segments { + let segmentArguments = segment.parseNameValueArguments(parseErrors: &parseErrors) + for argument in segmentArguments { + if let originalArgument = arguments.first(where: { $0.name == argument.name }), + let firstLocation = originalArgument.nameRange?.lowerBound, + let duplicateLocation = argument.nameRange?.lowerBound { + parseErrors.append(.duplicateArgument(name: argument.name, + firstLocation: firstLocation, + duplicateLocation: duplicateLocation)) + } + arguments.append(argument) + } + } + + if arguments.count > 1 { + for argument in arguments.prefix(arguments.count - 1) { + if !argument.hasTrailingComma, + let valueRange = argument.valueRange { + parseErrors.append(.missingExpectedCharacter(",", location: valueRange.upperBound)) + } + } + } + return arguments + } + + /// Parse the line segments as name-value argument pairs separated by commas. + /// + /// ``` + /// name-value-arguments -> name-value-argument name-value-arguments-rest + /// name-value-argument -> literal : literal + /// name-value-arguments-rest -> , name-value-arguments | ε + /// ``` + /// + /// Note the following aspects of this parsing function. + /// + /// - An argument-name pair is only recognized within a single line or line segment; + /// that is, an argument cannot span multiple lines. + /// - A comma is expected between name-value pairs. + /// - Returns: an array of successfully parsed ``DirectiveArgument`` values. + /// + /// This overload discards parse errors. + /// + /// - SeeAlso: ``parseNameValueArguments(parseErrors:)`` + public func parseNameValueArguments() -> [DirectiveArgument] { + var parseErrors = [ParseError]() + return parseNameValueArguments(parseErrors: &parseErrors) + } +} + +/// A directive argument, parsed from the form `name: value` or `name: "value"`. +public struct DirectiveArgument: Equatable { + /// The name of the argument. + public var name: String + + /// The range of the argument name if it was parsed from source text. + public var nameRange: SourceRange? + + /// The value of the argument. + public var value: String + + /// The range of the argument value if it was parsed from source text. + public var valueRange: SourceRange? + + /// `true` if the argument value was followed by a comma. + public var hasTrailingComma: Bool +} + diff --git a/Sources/Markdown/Base/Document.swift b/Sources/Markdown/Base/Document.swift new file mode 100644 index 00000000..0a48118f --- /dev/null +++ b/Sources/Markdown/Base/Document.swift @@ -0,0 +1,77 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +/// A markup element representing the top level of a whole document. +/// +/// - note: Although this could be considered a block element that can contain block elements, a `Document` itself can't be the child of any other markup, so it is not considered a block element. +public struct Document: Markup, BasicBlockContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .document = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Document.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Document { + // MARK: Primitive + + /// Parse a string into a `Document`. + /// + /// - parameter string: the input Markdown text to parse. + /// - parameter options: options for parsing Markdown text. + /// - parameter source: an explicit source URL from which the input `string` came for marking source locations. + /// This need not be a file URL. + init(parsing string: String, source: URL? = nil, options: ParseOptions = []) { + if options.contains(.parseBlockDirectives) { + self = BlockDirectiveParser.parse(string, source: source, + options: options) + } else { + self = MarkupParser.parseString(string, source: source, options: options) + } + } + + /// Parse a file's contents into a `Document`. + /// + /// - parameter file: a file URL from which to load Markdown text to parse. + /// - parameter options: options for parsing Markdown text. + init(parsing file: URL, options: ParseOptions = []) throws { + let string = try String(contentsOf: file) + if options.contains(.parseBlockDirectives) { + self = BlockDirectiveParser.parse(string, source: file, + options: options) + } else { + self = MarkupParser.parseString(string, source: file, options: options) + } + } + + /// Create a document from a sequence of block markup elements. + init(_ children: Children) where Children.Element == BlockMarkup { + try! self.init(.document(parsedRange: nil, children.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitDocument(self) + } +} diff --git a/Sources/Markdown/Base/LiteralMarkup.swift b/Sources/Markdown/Base/LiteralMarkup.swift new file mode 100644 index 00000000..83e18b36 --- /dev/null +++ b/Sources/Markdown/Base/LiteralMarkup.swift @@ -0,0 +1,15 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An element that is represented with just some plain text. +public protocol LiteralMarkup: Markup { + /// Create an element from its literal text. + init(_ literalText: String) +} diff --git a/Sources/Markdown/Base/Markup.swift b/Sources/Markdown/Base/Markup.swift new file mode 100644 index 00000000..a3213491 --- /dev/null +++ b/Sources/Markdown/Base/Markup.swift @@ -0,0 +1,379 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// Creates an element of a type that corresponds to the kind of markup data and casts back up to `Markup`. +func makeMarkup(_ data: _MarkupData) -> Markup { + switch data.raw.markup.data { + case .blockQuote: + return BlockQuote(data) + case .codeBlock: + return CodeBlock(data) + case .customBlock: + return CustomBlock(data) + case .document: + return Document(data) + case .heading: + return Heading(data) + case .thematicBreak: + return ThematicBreak(data) + case .htmlBlock: + return HTMLBlock(data) + case .listItem: + return ListItem(data) + case .orderedList: + return OrderedList(data) + case .unorderedList: + return UnorderedList(data) + case .paragraph: + return Paragraph(data) + case .blockDirective: + return BlockDirective(data) + case .inlineCode: + return InlineCode(data) + case .customInline: + return CustomInline(data) + case .emphasis: + return Emphasis(data) + case .image: + return Image(data) + case .inlineHTML: + return InlineHTML(data) + case .lineBreak: + return LineBreak(data) + case .link: + return Link(data) + case .softBreak: + return SoftBreak(data) + case .strong: + return Strong(data) + case .text: + return Text(data) + case .strikethrough: + return Strikethrough(data) + case .table: + return Table(data) + case .tableRow: + return Table.Row(data) + case .tableHead: + return Table.Head(data) + case .tableBody: + return Table.Body(data) + case .tableCell: + return Table.Cell(data) + case .symbolLink: + return SymbolLink(data) + } +} + +/// A markup element. +/// +/// > Note: All supported markup elements are already implemented in the framework. +/// Use this protocol only as a generic constraint. +public protocol Markup { + /// Accept a `MarkupVisitor` and call the specific visitation method for this element. + /// + /// - parameter visitor: The `MarkupVisitor` visiting the element. + /// - returns: The result of the visit. + func accept(_ visitor: inout V) -> V.Result + + /// The data backing the markup element. + /// > Note: This property is an implementation detail; do not use it directly. + var _data: _MarkupData { get set } +} + +// MARK: - Private API + +extension Markup { + /// The raw markup backing the element. + var raw: AbsoluteRawMarkup { + return _data.raw + } + + /// The total number of nodes in the subtree rooted at this element, including this one. + /// + /// For example: + /// ``` + /// Document + /// └─ Paragraph + /// ├─ Text "Just a " + /// ├─ Emphasis + /// │ └─ Text "sentence" + /// └─ Text "." + /// ``` + /// + /// - Complexity: `O(1)` + var subtreeCount: Int { + return raw.markup.header.subtreeCount + } + + /// Return this element without ``SoftBreak`` elements, or `nil` if this + /// is a ``SoftBreak`` element. + var withoutSoftBreaks: Self? { + var softBreakDeleter = SoftBreakDeleter() + return softBreakDeleter.visit(self) as? Self + } + + /// Returns a copy of this element with the given children instead. + /// + /// - parameter newChildren: A sequence of children to use instead of the current children. + /// - warning: This does not check for compatibility. This API should only be used when the type of the children are already known to be the right kind. + public func withUncheckedChildren(_ newChildren: Children) -> Markup where Children.Element == Markup { + let newRaw = raw.markup.withChildren(newChildren.map { $0.raw.markup }) + return makeMarkup(_data.replacingSelf(newRaw)) + } +} + +// MARK: - Public API + +extension Markup { + /// The text range where this element was parsed, or `nil` if it was constructed outside of parsing. + /// + /// - Complexity: `O(height)` (The root element holds range information for its subtree) + public var range: SourceRange? { + return _data.range + } + + /// The root of the tree in which this element resides, or the element itself if it is the root. + /// + /// - Complexity: `O(height)` + public var root: Markup { + return makeMarkup(_data.root) + } + + /// The parent of this element, or `nil` if this is a root. + public var parent: Markup? { + return _data.parent + } + + /// Returns this element detached from its parent. + public var detachedFromParent: Markup { + guard _data.id.childId != 0 else { + // This is already a root. + return self + } + let newRaw = AbsoluteRawMarkup(markup: raw.markup, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + return makeMarkup(_MarkupData(newRaw, parent: nil)) + } + + /// The number of this element's children. + /// + /// - Complexity: `O(1)` + public var childCount: Int { + return raw.markup.header.childCount + } + + /// `true` if this element has no children. + /// + /// - Complexity: `O(1)` + public var isEmpty: Bool { + return childCount == 0 + } + + /// The children of the element. + public var children: MarkupChildren { + return MarkupChildren(self) + } + + /// Returns the child at the given position if it is within the bounds of `children.indices`. + /// + /// - Complexity: `O(childCount)` + public func child(at position: Int) -> Markup? { + precondition(position >= 0, "Cannot retrieve a child at negative index: \(position)") + guard position <= raw.markup.childCount else { + return nil + } + var iterator = children.dropFirst(position).makeIterator() + return iterator.next() + } + + /// Traverse this markup tree by descending into the child at the index of each path element, returning `nil` if there is no child at that index or if the expected type for that path element doesn't match. + /// + /// For example, given the following tree: + /// ``` + /// Document + /// └─ Paragraph + /// ├─ Text "This is " + /// ├─ Emphasis + /// │ └─ Text "emphasized" + /// └─ Text "." + /// ``` + /// + /// To get the `Text "emphasized"` element, you could provide the following path: + /// + /// ```swift + /// [ + /// (0, Paragraph.self), // Document's child 0, a Paragraph element + /// (1, Emphasis.self), // Paragraph's child 1, an Emphasis element + /// (0, Text.self), // Emphasis's child 0, the `Text "emphasized"` element. + /// ] + /// ``` + /// + /// Using a `TypedChildIndexPath` without any expected types: + /// ```swift + /// [ + /// (0, nil), + /// (1, nil), + /// (0, nil), + /// ] + /// ``` + /// would also provide a match. + /// + /// An example of a path that wouldn't match the `Text "emphasized"` element would be: + /// + /// ```swift + /// [ + /// (0, Paragraph.self), + /// // The search would fail here because this element + /// // isn't `Strong` but `Emphasized`. + /// (1, Strong.self), + /// (0, Text.self), + /// ] + /// ``` + public func child(through path: TypedChildIndexPath) -> Markup? { + var element: Markup = self + for pathElement in path { + guard pathElement.index <= raw.markup.childCount else { + return nil + } + + guard let childElement = element.child(at: pathElement.index) else { + return nil + } + + element = childElement + + guard let expectedType = pathElement.expectedType else { + continue + } + guard type(of: element) == expectedType else { + return nil + } + } + return element + } + + /// Traverse this markup tree by descending into the child at the index of each path element, returning `nil` if there is no child at that index. + /// + /// For example, given the following tree: + /// ``` + /// Document + /// └─ Paragraph + /// ├─ Text "This is " + /// ├─ Emphasis + /// │ └─ Text "emphasized" + /// └─ Text "." + /// ``` + /// + /// To get the `Text "emphasized"` element, you would provide the following path: + /// + /// ```swift + /// [ + /// 0, // Document's child 0, a Paragraph element + /// 1, // Paragraph's child 1, an Emphasis element + /// 0, // Emphasis's child 0, the `Text "emphasized"` element. + /// ] + /// ``` + /// + /// This would be equivalent to using the `TypedChildIndexPath` without any expected types: + /// ```swift + /// [ + /// (0, nil), + /// (1, nil), + /// (0, nil), + /// ] + /// ``` + public func child(through path: S) -> Markup? where S.Element == Int { + let pathElements = path.map { TypedChildIndexPath.Element(index: $0, expectedType: nil)} + return child(through: TypedChildIndexPath(pathElements)) + } + + /// Traverse this markup tree by descending into the child at the index of each path element, returning `nil` if there is no child at that index. + /// + /// For example, given the following tree: + /// ``` + /// Document + /// └─ Paragraph + /// ├─ Text "This is " + /// ├─ Emphasis + /// │ └─ Text "emphasized" + /// └─ Text "." + /// ``` + /// + /// To get the `Text "emphasized"` element, you would provide the following path: + /// + /// ```swift + /// [ + /// 0, // Document's child 0, a Paragraph element + /// 1, // Paragraph's child 1, an Emphasis element + /// 0, // Emphasis's child 0, the `Text "emphasized"` element. + /// ] + /// ``` + /// + /// This would be equivalent to using the `TypedChildIndexPath` without any expected types: + /// ```swift + /// [ + /// (0, nil), + /// (1, nil), + /// (0, nil), + /// ] + /// ``` + public func child(through path: ChildIndexPath.Element...) -> Markup? { + return child(through: path) + } + + /// The index in the parent's children. + public var indexInParent: Int { + return _data.indexInParent + } + + /// Returns `true` if this element is identical to another, comparing internal unique identifiers. + /// + /// - Note: Use this to bypass checking for structural equality. + /// - Complexity: `O(1)` + public func isIdentical(to other: Markup) -> Bool { + return self._data.id == other._data.id + } + + /// Returns true if this element has the same tree structure underneath it as another element. + /// + /// - Complexity: `O(subtreeCount)` + public func hasSameStructure(as other: Markup) -> Bool { + return self.raw.markup.hasSameStructure(as: other.raw.markup) + } + + /// Print this element with the given formatting rules. + public func format(options: MarkupFormatter.Options = .default) -> String { + let elementToFormat: Markup + + if options.preferredLineLimit != nil { + // If there is a preferred line limit, first remove + // all existing soft breaks, unwrapping all lines to their + // maximum length. + guard let withoutSoftBreaks = self.withoutSoftBreaks else { + return "" + } + elementToFormat = withoutSoftBreaks + } else { + elementToFormat = self + } + + var formatter = MarkupFormatter(formattingOptions: options) + formatter.visit(elementToFormat) + return formatter.result + } +} + +/// Replaces soft break elements with a single space. +fileprivate struct SoftBreakDeleter: MarkupRewriter { + func visitSoftBreak(_ softBreak: SoftBreak) -> Markup? { + return Text(" ") + } +} + diff --git a/Sources/Markdown/Base/MarkupChildren.swift b/Sources/Markdown/Base/MarkupChildren.swift new file mode 100644 index 00000000..959f6f40 --- /dev/null +++ b/Sources/Markdown/Base/MarkupChildren.swift @@ -0,0 +1,109 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A lazy sequence consisting of an element's child elements. +/// +/// This is a `Sequence` and not a `Collection` because +/// information that locates a child element under a parent element is not +/// cached and calculated on demand. +public struct MarkupChildren: Sequence { + public struct Iterator: IteratorProtocol { + let parent: Markup + var childMetadata: MarkupMetadata + + init(_ parent: Markup) { + self.parent = parent + self.childMetadata = parent.raw.metadata.firstChild() + } + + public mutating func next() -> Markup? { + let index = childMetadata.indexInParent + guard index < parent.childCount else { + return nil + } + let rawChild = parent.raw.markup.child(at: index) + let absoluteRawChild = AbsoluteRawMarkup(markup: rawChild, metadata: childMetadata) + let data = _MarkupData(absoluteRawChild, parent: parent) + childMetadata = childMetadata.nextSibling(from: rawChild) + return makeMarkup(data) + } + } + + /// The parent whose children this sequence represents. + let parent: Markup + + /// Create a lazy sequence of an element's children. + /// + /// - parameter parent: the parent whose children this sequence represents. + init(_ parent: Markup) { + self.parent = parent + } + + // MARK: Sequence + + public func makeIterator() -> Iterator { + return Iterator(parent) + } + + /// A reversed view of the element's children. + public func reversed() -> ReversedMarkupChildren { + return ReversedMarkupChildren(parent) + } +} + +/// A sequence consisting of an element's child elements in reverse. +/// +/// This is a `Sequence` and not a `Collection` because +/// information that locates a child element under a parent element is not +/// cached and calculated on demand. +public struct ReversedMarkupChildren: Sequence { + public struct Iterator: IteratorProtocol { + /// The parent whose children this sequence represents. + /// + /// This is also necessary for creating an "absolute" child from + /// parentless ``RawMarkup``. + let parent: Markup + + /// The metadata to use when creating an absolute child element. + var childMetadata: MarkupMetadata + + init(_ parent: Markup) { + self.parent = parent + self.childMetadata = parent.raw.metadata.lastChildMetadata(of: parent.raw.markup) + } + + public mutating func next() -> Markup? { + let index = childMetadata.indexInParent + guard index >= 0 else { + return nil + } + let rawChild = parent.raw.markup.child(at: index) + let absoluteRawChild = AbsoluteRawMarkup(markup: rawChild, metadata: childMetadata) + let data = _MarkupData(absoluteRawChild, parent: parent) + childMetadata = childMetadata.previousSibling(from: rawChild) + return makeMarkup(data) + } + } + + /// The parent whose children this sequence represents. + let parent: Markup + + /// Create a reversed view of an element's children. + /// + /// - parameter parent: The parent whose children this sequence will represent. + init(_ parent: Markup) { + self.parent = parent + } + + // MARK: Sequence + public func makeIterator() -> Iterator { + return Iterator(parent) + } +} diff --git a/Sources/Markdown/Base/MarkupData.swift b/Sources/Markdown/Base/MarkupData.swift new file mode 100644 index 00000000..317261f3 --- /dev/null +++ b/Sources/Markdown/Base/MarkupData.swift @@ -0,0 +1,170 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A unique identifier for an element in any markup tree currently in memory. +struct MarkupIdentifier: Equatable { + /// A globally unique identifier for the root of the tree, + /// acting as a scope for child identifiers. + let rootId: UInt64 + + /// A locally unique identifier for a child under a root. + let childId: Int + + /// Returns the identifier for the first child of this identifier's element. + func firstChild() -> MarkupIdentifier { + return .init(rootId: rootId, childId: childId + 1) + } + + /// Returns the identifier for the next sibling of the given raw element. + /// + /// - Note: This method assumes that this identifier belongs to `raw`. + func nextSibling(from raw: RawMarkup) -> MarkupIdentifier { + return .init(rootId: rootId, childId: childId + raw.subtreeCount) + } + + /// Returns the identifier for the previous sibling of the given raw element. + /// + /// - Note: This method assumes that this identifier belongs to `raw`. + func previousSibling(from raw: RawMarkup) -> MarkupIdentifier { + return .init(rootId: rootId, childId: childId - raw.subtreeCount) + } + + /// Returns the identifier for the last child of this identifier's element. + func lastChildOfParent(_ parent: RawMarkup) -> MarkupIdentifier { + return .init(rootId: rootId, childId: childId + parent.subtreeCount) + } + + /// Returns an identifier for a new root element. + static func newRoot() -> MarkupIdentifier { + return .init(rootId: AtomicCounter.next(), childId: 0) + } +} + +/// Metadata for a specific markup element in memory. +struct MarkupMetadata { + /// A unique identifier under a root element. + let id: MarkupIdentifier + + /// The index in the parent's children. + let indexInParent: Int + + init(id: MarkupIdentifier, indexInParent: Int) { + self.id = id + self.indexInParent = indexInParent + } + + /// Returns metadata for the first child of this metadata's element. + func firstChild() -> MarkupMetadata { + return MarkupMetadata(id: id.firstChild(), indexInParent: 0) + } + + /// Returns metadata for the next sibling of the given raw element. + /// + /// - Note: This method assumes that this metadata belongs to `raw`. + func nextSibling(from raw: RawMarkup) -> MarkupMetadata { + return MarkupMetadata(id: id.nextSibling(from: raw), indexInParent: indexInParent + 1) + } + + /// Returns metadata for the previous sibling of the given raw element. + /// + /// - Note: This method assumes that this metadata belongs to `raw`. + func previousSibling(from raw: RawMarkup) -> MarkupMetadata { + return MarkupMetadata(id: id.previousSibling(from: raw), indexInParent: indexInParent - 1) + } + + /// Returns metadata for the last child of this identifier's element. + /// + /// - Note: This method assumes that this metadata belongs to `parent`. + func lastChildMetadata(of parent: RawMarkup) -> MarkupMetadata { + return MarkupMetadata(id: id.lastChildOfParent(parent), indexInParent: parent.childCount - 1) + } +} + +/// A specific occurrence of a reusable `RawMarkup` element in a markup tree. +/// +/// Since `RawMarkup` nodes can be reused in different trees, there needs to be a way +/// to tell the difference between a paragraph that occurs in one document and +/// that same paragraph reused in another document. +/// +/// This bundles a `RawMarkup` node with some metadata that keeps track of +/// where and in which tree the element resides. +struct AbsoluteRawMarkup { + /// The relative, sharable raw markup element. + let markup: RawMarkup + + /// Metadata associated with this particular occurrence of the raw markup. + let metadata: MarkupMetadata +} + +/// Internal data for a markup element. +/// +/// Unlike `RawMarkup`, this represents a specific element of markup in a specific tree, +/// allowing mechanisms such as finding an element's parent, siblings, and order in which +/// it occurred among its siblings. +/// > Warning: This type is an implementation detail and is not meant to be used directly. +public struct _MarkupData { + /// The `AbsoluteRawMarkup` backing this element's data. + let raw: AbsoluteRawMarkup + + /// This element's parent, or `nil` if this is a root. + let parent: Markup? + + /// The index of the element in its parent if it has one, else `0`. + var indexInParent: Int { + return raw.metadata.indexInParent + } + + /// A unique identifier for this data. Use as you would pointer identity. + var id: MarkupIdentifier { + return raw.metadata.id + } + + /// The root of the tree in which the element resides. + var root: _MarkupData { + guard let parent = parent else { + return self + } + return parent._data.root + } + + /// The source range of the element if it was parsed from text; otherwise, nil. + var range: SourceRange? { + return raw.markup.parsedRange + } + + // Keep the `init` internal as this type is not meant to be initialized outside the framework. + + /// Creates a `MarkupData` from the given `RawMarkup` and place in an immuatable markup tree, explicitly specifying a unique identifier. + /// + /// - precondition: `uniqueIdentifier <= AtomicCounter.current`. + /// + /// - parameter raw: The `AbsoluteRawMarkup` representing the element. + /// - parameter parent: This element's parent, `nil` if the element is a root. + init(_ raw: AbsoluteRawMarkup, parent: Markup? = nil) { + self.raw = raw + self.parent = parent + } + + /// Returns the replaced element in a new tree. + func replacingSelf(_ newRaw: RawMarkup) -> _MarkupData { + if let parent = parent { + let newParent = parent._data.substitutingChild(newRaw, at: indexInParent) + return newParent.child(at: indexInParent)!._data + } else { + return _MarkupData(AbsoluteRawMarkup(markup: newRaw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)), parent: nil) + } + } + + /// Returns a new `MarkupData` with the given child now at the `index`. + func substitutingChild(_ rawChild: RawMarkup, at index: Int) -> Markup { + let newRaw = raw.markup.substitutingChild(rawChild, at: index) + return makeMarkup(replacingSelf(newRaw)) + } +} diff --git a/Sources/Markdown/Base/PlainTextConvertibleMarkup.swift b/Sources/Markdown/Base/PlainTextConvertibleMarkup.swift new file mode 100644 index 00000000..f84a06a8 --- /dev/null +++ b/Sources/Markdown/Base/PlainTextConvertibleMarkup.swift @@ -0,0 +1,15 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An element that can be converted to plain text without formatting. +public protocol PlainTextConvertibleMarkup: Markup { + /// The plain text content of an element. + var plainText: String { get } +} diff --git a/Sources/Markdown/Base/RawMarkup.swift b/Sources/Markdown/Base/RawMarkup.swift new file mode 100644 index 00000000..1f80439a --- /dev/null +++ b/Sources/Markdown/Base/RawMarkup.swift @@ -0,0 +1,323 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +/// The data specific to a kind of markup element. +/// +/// Some elements don't currently track any specific data and act as basic containers for their children. In some cases, there is an expectation regarding children. +/// +/// For example, a `Document` can't contain another `Document` and lists can only contain `ListItem`s as children. Since `RawMarkup` is a single type, these are enforced through preconditions; however, those rules are enforced as much as possible at compile time in the various `Markup` types. +enum RawMarkupData: Equatable { + case blockQuote + case codeBlock(String, language: String?) + case customBlock + case document + case heading(level: Int) + case thematicBreak + case htmlBlock(String) + case listItem(checkbox: Checkbox?) + case orderedList + case unorderedList + case paragraph + case blockDirective(name: String, nameLocation: SourceLocation?, arguments: DirectiveArgumentText) + + case inlineCode(String) + case customInline(String) + case emphasis + case image(source: String?, title: String?) + case inlineHTML(String) + case lineBreak + case link(destination: String?) + case softBreak + case strong + case text(String) + case symbolLink(destination: String?) + + // Extensions + case strikethrough + + // `alignments` indicate the fixed column count of every row in the table. + case table(columnAlignments: [Table.ColumnAlignment?]) + case tableHead + case tableBody + case tableRow + case tableCell +} + +/// The header for the `RawMarkup` managed buffer. +/// +/// > Warning: **Do not mutate** anything to do with `RawMarkupHeader` +/// > or change any property to variable. +/// > Although this is a struct, this is used as the header type for a +/// > managed buffer type with reference semantics. +public struct RawMarkupHeader { + /// The data specific to this element. + let data: RawMarkupData + + /// The number of children. + let childCount: Int + + /// The number of elements in this subtree, including this one. + let subtreeCount: Int + + /// The range of a raw markup element if it was parsed from source; otherwise, `nil`. + /// + /// > Warning: This should only ever be mutated by `RangeAdjuster` while + /// > parsing. **Do not** expose this through any public API. + var parsedRange: SourceRange? +} + +final class RawMarkup: ManagedBuffer { + enum Error: LocalizedError { + case concreteConversionError(from: RawMarkup, to: Markup.Type) + var errorDescription: String? { + switch self { + case let .concreteConversionError(raw, to: type): + return "Can't wrap a \(raw.data) in a \(type)" + } + } + } + private static func create(data: RawMarkupData, parsedRange: SourceRange?, children: [RawMarkup]) -> RawMarkup { + let buffer = self.create(minimumCapacity: children.count) { _ in + RawMarkupHeader(data: data, + childCount: children.count, + subtreeCount: /* self */ 1 + children.subtreeCount, + parsedRange: parsedRange) + } + let raw = unsafeDowncast(buffer, to: RawMarkup.self) + var children = children + raw.withUnsafeMutablePointerToElements { elementsBasePtr in + elementsBasePtr.initialize(from: &children, count: children.count) + } + return raw + } + + /// The data specific to this kind of element. + var data: RawMarkupData { + return header.data + } + + /// Copy and retain the tail-allocated children into an `Array`. + func copyChildren() -> [RawMarkup] { + return self.withUnsafeMutablePointerToElements { + return Array(UnsafeBufferPointer(start: $0, count: self.header.childCount)) + } + } + + /// The nth `RawMarkup` child under this element. + func child(at index: Int) -> RawMarkup { + precondition(index < header.childCount) + return self.withUnsafeMutablePointerToElements { + return $0.advanced(by: index).pointee + } + } + + /// The number of children directly under this element. + var childCount: Int { + return header.childCount + } + + /// The total number of children under this element, down to the leaves, + /// including the element itself. + var subtreeCount: Int { + return header.subtreeCount + } + + /// The range of the element if it was parsed from source; otherwise, `nil`. + var parsedRange: SourceRange? { + return header.parsedRange + } + + /// The children of this element. + var children: AnySequence { + return AnySequence((0.. RawMarkup in + self.child(at: 0) + }) + } + + deinit { + return self.withUnsafeMutablePointerToElements { + $0.deinitialize(count: header.childCount) + } + } + + // MARK: Aspects + + /// Returns `true` if this element has the same tree structure underneath it as another element. + func hasSameStructure(as other: RawMarkup) -> Bool { + if self === other { + return true + } + guard self.header.childCount == other.header.childCount, + self.header.data == other.header.data else { + return false + } + for i in 0.. RawMarkup { + var newChildren = copyChildren() + newChildren[index] = newChild + return RawMarkup.create(data: header.data, parsedRange: newChild.header.parsedRange, children: newChildren) + } + + func withChildren(_ newChildren: Children) -> RawMarkup where Children.Element == RawMarkup { + return .create(data: header.data, parsedRange: header.parsedRange, children: Array(newChildren)) + } + + // MARK: Block Creation + + static func blockQuote(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .blockQuote, parsedRange: parsedRange, children: children) + } + + static func codeBlock(parsedRange: SourceRange?, code: String, language: String?) -> RawMarkup { + return .create(data: .codeBlock(code, language: language), parsedRange: parsedRange, children: []) + } + + static func customBlock(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .customBlock, parsedRange: parsedRange, children: children) + } + + static func document(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .document, parsedRange: parsedRange, children: children) + } + + static func heading(level: Int, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .heading(level: level), parsedRange: parsedRange, children: children) + } + + static func thematicBreak(parsedRange: SourceRange?) -> RawMarkup { + return .create(data: .thematicBreak, parsedRange: parsedRange, children: []) + } + + static func htmlBlock(parsedRange: SourceRange?, html: String) -> RawMarkup { + return .create(data: .htmlBlock(html), parsedRange: parsedRange, children: []) + } + + static func listItem(checkbox: Checkbox?, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .listItem(checkbox: checkbox), parsedRange: parsedRange, children: children) + } + + static func orderedList(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .orderedList, parsedRange: parsedRange, children: children) + } + + static func unorderedList(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .unorderedList, parsedRange: parsedRange, children: children) + } + + static func paragraph(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .paragraph, parsedRange: parsedRange, children: children) + } + + static func blockDirective(name: String, nameLocation: SourceLocation?, argumentText: DirectiveArgumentText, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .blockDirective(name: name, nameLocation: nameLocation, arguments: argumentText), parsedRange: parsedRange, children: children) + } + + // MARK: Inline Creation + + static func inlineCode(parsedRange: SourceRange?, code: String) -> RawMarkup { + return .create(data: .inlineCode(code), parsedRange: parsedRange, children: []) + } + + static func customInline(parsedRange: SourceRange?, text: String) -> RawMarkup { + return .create(data: .customInline(text), parsedRange: parsedRange, children: []) + } + + static func emphasis(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .emphasis, parsedRange: parsedRange, children: children) + } + + static func image(source: String?, title: String?, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .image(source: source, title: title), parsedRange: parsedRange, children: children) + } + + static func inlineHTML(parsedRange: SourceRange?, html: String) -> RawMarkup { + return .create(data: .inlineHTML(html), parsedRange: parsedRange, children: []) + } + + static func lineBreak(parsedRange: SourceRange?) -> RawMarkup { + return .create(data: .lineBreak, parsedRange: parsedRange, children: []) + } + + static func link(destination: String?, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .link(destination: destination), parsedRange: parsedRange, children: children) + } + + static func softBreak(parsedRange: SourceRange?) -> RawMarkup { + return .create(data: .softBreak, parsedRange: parsedRange, children: []) + } + + static func strong(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .strong, parsedRange: parsedRange, children: children) + } + + static func text(parsedRange: SourceRange?, string: String) -> RawMarkup { + return .create(data: .text(string), parsedRange: parsedRange, children: []) + } + + static func symbolLink(parsedRange: SourceRange?, destination: String?) -> RawMarkup { + return .create(data: .symbolLink(destination: destination), parsedRange: parsedRange, children: []) + } + + // MARK: Extensions + + static func strikethrough(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .strikethrough, parsedRange: parsedRange, children: children) + } + + static func table(columnAlignments: [Table.ColumnAlignment?], parsedRange: SourceRange?, header: RawMarkup, body: RawMarkup) -> RawMarkup { + let maxColumnCount = max(header.childCount, + body.children.reduce(0, { (result, child) -> Int in + return max(result, child.childCount) + })) + let alignments = columnAlignments + Array(repeating: nil, + count: max(columnAlignments.count, + maxColumnCount) - columnAlignments.count) + return .create(data: .table(columnAlignments: alignments), parsedRange: parsedRange, children: [header, body]) + } + + static func tableRow(parsedRange: SourceRange?, _ columns: [RawMarkup]) -> RawMarkup { + precondition(columns.allSatisfy { $0.header.data == .tableCell }) + return .create(data: .tableRow, parsedRange: parsedRange, children: columns) + } + + static func tableHead(parsedRange: SourceRange?, columns: [RawMarkup]) -> RawMarkup { + precondition(columns.allSatisfy { $0.header.data == .tableCell }) + return .create(data: .tableHead, parsedRange: parsedRange, children: columns) + } + + static func tableBody(parsedRange: SourceRange?, rows: [RawMarkup]) -> RawMarkup { + precondition(rows.allSatisfy { $0.header.data == .tableRow }) + return .create(data: .tableBody, parsedRange: parsedRange, children: rows) + } + + static func tableCell(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .tableCell, parsedRange: parsedRange, children: children) + } +} + +fileprivate extension Sequence where Element == RawMarkup { + var subtreeCount: Int { + return self.lazy.map { $0.subtreeCount }.reduce(0, +) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/BlockDirective.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/BlockDirective.swift new file mode 100644 index 00000000..835bb5c7 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/BlockDirective.swift @@ -0,0 +1,180 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An element with attribute text that wraps other block elements. +/// +/// A block directive can be used to tag wrapped block elements or be a novel block element in itself. +/// The contents within may be more block directives or the other typical Markdown elements. +/// +/// For example, a block directive could serve as a placeholder for a table of contents that can be rendered +/// and inlined later: +/// +/// ```markdown +/// @TOC +/// +/// # Title +/// ... +/// ``` +/// +/// A block directive could also add attribute data to the wrapped elements. +/// Contents inside parentheses `(...)` are considered *argument text*. There is +/// no particular mandatory format for argument text but a default `name: value` style +/// argument parser is included. +/// +/// ```markdown +/// @Wrapped(paperStyle: shiny) { +/// - A +/// - B +/// } +/// ``` +/// +/// Block directives can be indented any amount. +/// +/// ```markdown +/// @Outer { +/// @TwoSpaces { +/// @FourSpaces +/// } +/// } +/// ``` +/// +/// The indentation for the contents of a block directive are measured using +/// the first non-blank line. For example: +/// +/// ```markdown +/// @Outer { +/// This line establishes indentation to be removed from these inner contents. +/// This line will line up with the last. +/// } +/// ``` +/// +/// The parser will see the following logical lines for the inner content, +/// adjusting source locations after the parse. +/// +/// ```markdown +/// This line establishes indentation to be removed from these inner contents. +/// This line will line up with the last. +/// ``` +public struct BlockDirective: BlockContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .blockDirective = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: BlockDirective.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension BlockDirective { + + /// Create a block directive. + /// + /// - parameter name: The name of the directive. + /// - parameter argumentText: The text to use when interpreting arguments to the directive. + /// - parameter children: block child elements. + init(name: String, + argumentText: String? = nil, + children: Children) where Children.Element == BlockMarkup { + let argumentSegments = argumentText?.split(separator: "\n", + maxSplits: .max, + omittingEmptySubsequences: false).map { lineText -> DirectiveArgumentText.LineSegment in + let untrimmedText = String(lineText) + return DirectiveArgumentText.LineSegment(untrimmedText: untrimmedText, + lineStartIndex: untrimmedText.startIndex, + range: nil) + } ?? [] + try! self.init(.blockDirective(name: name, + nameLocation: nil, + argumentText: DirectiveArgumentText(segments: argumentSegments), + parsedRange: nil, + children.map { $0.raw.markup })) + } + + /// Create a block directive. + /// + /// - parameter name: The name of the directive. + /// - parameter argumentText: The text to use when interpreting arguments to the directive. + /// - parameter children: block child elements. + init(name: String, + argumentText: String? = nil, + children: BlockMarkup...) { + self.init(name: name, argumentText: argumentText, children: children) + } + + /// The name of the directive. + var name: String { + get { + guard case let .blockDirective(name, _, _) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + + } + return name + } + set { + _data = _data.replacingSelf(.blockDirective(name: newValue, + nameLocation: nil, + argumentText: argumentText, + parsedRange: nil, + _data.raw.markup.copyChildren())) + } + } + + /// The source location from which the directive's name was parsed, if it + /// was parsed from source. + var nameLocation: SourceLocation? { + guard case let .blockDirective(_, nameLocation, _) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + + } + return nameLocation + } + + /// The source range from which the directive's name was parsed, if it was + /// parsed from source. + var nameRange: SourceRange? { + guard let start = nameLocation else { + return nil + } + let end = SourceLocation(line: start.line, column: start.column + name.utf8.count, source: start.source) + return start..(_ visitor: inout V) -> V.Result { + return visitor.visitBlockDirective(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/BlockQuote.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/BlockQuote.swift new file mode 100644 index 00000000..3ea6ed14 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/BlockQuote.swift @@ -0,0 +1,41 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block quote. +public struct BlockQuote: BlockMarkup, BasicBlockContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .blockQuote = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: BlockQuote.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension BlockQuote { + // MARK: BasicBlockContainer + + init(_ children: Children) where Children.Element == BlockMarkup { + try! self.init(.blockQuote(parsedRange: nil, children.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitBlockQuote(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/CustomBlock.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/CustomBlock.swift new file mode 100644 index 00000000..ccee12dd --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/CustomBlock.swift @@ -0,0 +1,41 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A custom block markup element. +/// +/// - note: This element does not yet allow for custom information to be appended and is included for backward compatibility with CommonMark. It wraps any block element. +public struct CustomBlock: BlockMarkup, BasicBlockContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .customBlock = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: CustomBlock.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension CustomBlock { + init(_ children: Children) where Children.Element == BlockMarkup { + try! self.init(.customBlock(parsedRange: nil, children.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitCustomBlock(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/ListItem.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/ListItem.swift new file mode 100644 index 00000000..1f71505f --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/ListItem.swift @@ -0,0 +1,70 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A checkbox that can represent an on/off state. +public enum Checkbox { + /// The checkbox is checked, representing an "on", "true", or "incomplete" state. + case checked + /// The checkbox is unchecked, representing an "off", "false", or "incomplete" state. + case unchecked +} + +/// A list item in an ordered or unordered list. +public struct ListItem: BlockContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .listItem = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: ListItem.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension ListItem { + /// Create a list item. + /// - Parameter checkbox: An optional ``Checkbox`` for the list item. + /// - Parameter children: The child block elements of the list item. + init(checkbox: Checkbox? = .none, _ children: BlockMarkup...) { + try! self.init(.listItem(checkbox: checkbox, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// Create a list item. + /// - Parameter checkbox: An optional ``Checkbox`` for the list item. + /// - Parameter children: The child block elements of the list item. + init(checkbox: Checkbox? = .none, _ children: Children) where Children.Element == BlockMarkup { + try! self.init(.listItem(checkbox: checkbox, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// An optional ``Checkbox`` for the list item, which can indicate completion of a task, or some other off/on information. + var checkbox: Checkbox? { + get { + guard case let .listItem(checkbox) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return checkbox + } + set { + _data = _data.replacingSelf(.listItem(checkbox: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitListItem(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/OrderedList.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/OrderedList.swift new file mode 100644 index 00000000..b3fe1884 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/OrderedList.swift @@ -0,0 +1,40 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An ordered list. +public struct OrderedList: ListItemContainer, BlockMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .orderedList = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: OrderedList.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension OrderedList { + // MARK: ListItemContainer + + init(_ items: Items) where Items.Element == ListItem { + try! self.init(.orderedList(parsedRange: nil, items.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitOrderedList(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Block Container Blocks/UnorderedList.swift b/Sources/Markdown/Block Nodes/Block Container Blocks/UnorderedList.swift new file mode 100644 index 00000000..dac9ce20 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Block Container Blocks/UnorderedList.swift @@ -0,0 +1,41 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An unordered list. +public struct UnorderedList: ListItemContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .unorderedList = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: UnorderedList.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension UnorderedList { + // MARK: ListItemContainer + + init(_ items: Items) where Items.Element == ListItem { + try! self.init(.unorderedList(parsedRange: nil, items.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitUnorderedList(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Inline Container Blocks/Paragraph.swift b/Sources/Markdown/Block Nodes/Inline Container Blocks/Paragraph.swift new file mode 100644 index 00000000..ef681627 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Inline Container Blocks/Paragraph.swift @@ -0,0 +1,41 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A paragraph. +public struct Paragraph: BlockMarkup, BasicInlineContainer { + public var _data: _MarkupData + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .paragraph = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Paragraph.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } +} + +// MARK: - Public API + +public extension Paragraph { + // MARK: InlineContainer + + init(_ newChildren: Children) where Children.Element == InlineMarkup { + try! self.init(.paragraph(parsedRange: nil, newChildren.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitParagraph(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Leaf Blocks/CodeBlock.swift b/Sources/Markdown/Block Nodes/Leaf Blocks/CodeBlock.swift new file mode 100644 index 00000000..07214fb9 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Leaf Blocks/CodeBlock.swift @@ -0,0 +1,66 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A code block. +public struct CodeBlock: BlockMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .codeBlock = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: CodeBlock.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension CodeBlock { + /// Create a code block with raw `code` and optional `language`. + init(language: String? = nil, _ code: String) { + try! self.init(RawMarkup.codeBlock(parsedRange: nil, code: code, language: language)) + } + + /// The name of the syntax or programming language of the code block, which may be `nil` when unspecified. + var language: String? { + get { + guard case let .codeBlock(_, language) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return language + } + set { + _data = _data.replacingSelf(.codeBlock(parsedRange: nil, code: code, language: newValue)) + } + } + + /// The raw text representing the code of this block. + var code: String { + get { + guard case let .codeBlock(code, _) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return code + } + set { + _data = _data.replacingSelf(.codeBlock(parsedRange: nil, code: newValue, language: language)) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitCodeBlock(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Leaf Blocks/HTMLBlock.swift b/Sources/Markdown/Block Nodes/Leaf Blocks/HTMLBlock.swift new file mode 100644 index 00000000..c41c79e7 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Leaf Blocks/HTMLBlock.swift @@ -0,0 +1,52 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block element containing raw HTML. +public struct HTMLBlock: BlockMarkup, LiteralMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .htmlBlock = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: HTMLBlock.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension HTMLBlock { + init(_ literalText: String) { + try! self.init(.htmlBlock(parsedRange: nil, html: literalText)) + } + + /// The raw HTML text comprising the block. + var rawHTML: String { + get { + guard case let .htmlBlock(text) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return text + } + set { + _data = _data.replacingSelf(.htmlBlock(parsedRange: nil, html: newValue)) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitHTMLBlock(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Leaf Blocks/Heading.swift b/Sources/Markdown/Block Nodes/Leaf Blocks/Heading.swift new file mode 100644 index 00000000..55fe45c3 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Leaf Blocks/Heading.swift @@ -0,0 +1,67 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A heading. +public struct Heading: BlockMarkup, InlineContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .heading = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Heading.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Heading { + // MARK: Primitive + + /// Create a heading with a level and a sequence of children. + init(level: Int, _ children: Children) where Children.Element == InlineMarkup { + try! self.init(.heading(level: level, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// The level of the heading, starting at `1`. + var level: Int { + get { + guard case let .heading(level) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return level + } + set { + precondition(newValue > 0, "Heading level must be 1 or greater") + guard level != newValue else { + return + } + _data = _data.replacingSelf(.heading(level: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + + // MARK: Secondary + + /// Create a heading with a level and a sequence of children. + init(level: Int, _ children: InlineMarkup...) { + self.init(level: level, children) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitHeading(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Leaf Blocks/ThematicBreak.swift b/Sources/Markdown/Block Nodes/Leaf Blocks/ThematicBreak.swift new file mode 100644 index 00000000..8beeb491 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Leaf Blocks/ThematicBreak.swift @@ -0,0 +1,39 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A thematic break. +public struct ThematicBreak: BlockMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .thematicBreak = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: ThematicBreak.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension ThematicBreak { + /// Create a thematic break. + init() { + try! self.init(.thematicBreak(parsedRange: nil)) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitThematicBreak(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Tables/Table.swift b/Sources/Markdown/Block Nodes/Tables/Table.swift new file mode 100644 index 00000000..b21d4fed --- /dev/null +++ b/Sources/Markdown/Block Nodes/Tables/Table.swift @@ -0,0 +1,134 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + + +/// A table. +/// +/// A table consists of a *head*, a single row of cells; and a *body*, which can contain zero or more *rows*. +/// +/// There are a few invariants on the table which must be kept due to the parser's implementation of the [spec](https://github.github.com/gfm/#tables-extension-). +/// +/// - All rows must have the same number of cells. Therefore, sibling rows will be expanded with empty cells to fit larger incoming rows. Trimming columns from the table requires explicit action +/// - Column alignment applies to all cells within in the same column. See ``columnAlignments``. +public struct Table: BlockMarkup { + /// The alignment of all cells under a table column. + public enum ColumnAlignment { + /// Left alignment. + case left + + /// Center alignment. + case center + + /// Right alignment. + case right + } + + public var _data: _MarkupData + + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .table = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Table.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } +} + +// MARK: - Public API + +public extension Table { + /// Create a table from a header, body, and optional column alignments. + /// + /// - parameter columnAlignments: An optional list of alignments for each column, + /// truncated or expanded with `nil` to fit the table's maximum column count. + /// - parameter head: A ``Table/Head-swift.struct`` element serving as the table's head. + /// - parameter body: A ``Table/Body-swift.struct`` element serving as the table's body. + init(columnAlignments: [ColumnAlignment?]? = nil, + header: Head = Head(), + body: Body = Body()) { + try! self.init(RawMarkup.table(columnAlignments: columnAlignments ?? [], + parsedRange: nil, + header: header.raw.markup, + body: body.raw.markup)) + } + + /// The maximum number of columns in each row. + var maxColumnCount: Int { + return max(head.childCount, body.maxColumnCount) + } + + /// The table's header, a single row of cells. + var head: Head { + get { + return child(at: 0) as! Head + } + set { + _data = _data.replacingSelf(.table(columnAlignments: columnAlignments, + parsedRange: nil, + header: newValue.raw.markup, + body: raw.markup.child(at: 1))) + } + } + + /// The table's body, a collection of rows. + var body: Body { + get { + return child(at: 1) as! Body + } + set { + _data = _data.replacingSelf(.table(columnAlignments: columnAlignments, + parsedRange: nil, + header: raw.markup.child(at: 0), + body: newValue.raw.markup)) + } + } + + /// Alignments to apply to each cell in each column. + var columnAlignments: [Table.ColumnAlignment?] { + get { + guard case let .table(columnAlignments: alignments) = self.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return alignments + } + + set { + _data = _data.replacingSelf(.table(columnAlignments: newValue, + parsedRange: nil, + header: raw.markup.child(at: 0), + body: raw.markup.child(at: 1))) + } + } + + /// `true` if both the ``Table/head-swift.property`` and ``Table/body-swift.property`` are empty. + var isEmpty: Bool { + return head.isEmpty && body.isEmpty + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result where V : MarkupVisitor { + return visitor.visitTable(self) + } +} + +fileprivate extension Array where Element == Table.ColumnAlignment? { + func ensuringCount(atLeast minCount: Int) -> Self { + if count < minCount { + return self + Array(repeating: nil, count: count - minCount) + } else { + return self + } + } +} diff --git a/Sources/Markdown/Block Nodes/Tables/TableBody.swift b/Sources/Markdown/Block Nodes/Tables/TableBody.swift new file mode 100644 index 00000000..e039c008 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Tables/TableBody.swift @@ -0,0 +1,81 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +extension Table { + /// The body of a table consisting of zero or more ``Table/Row`` elements. + public struct Body : Markup { + public var _data: _MarkupData + + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .tableBody = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Table.Body.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + /// The maximum number of columns seen in all rows. + var maxColumnCount: Int { + return children.reduce(0) { (result, row) -> Int in + return max(result, row.childCount) + } + } + } +} + +// MARK: - Public API + +public extension Table.Body { + /// Create a table body from a sequence of ``Table/Row`` elements. + init(_ rows: Rows) where Rows.Element == Table.Row { + try! self.init(RawMarkup.tableBody(parsedRange: nil, rows: rows.map { $0.raw.markup })) + } + + /// Create a table body from a sequence of ``Table/Row`` elements. + init(_ rows: Table.Row...) { + self.init(rows) + } + + /// The rows of the body. + /// + /// - Precondition: All children of a `ListItemContainer` + /// must be a `ListItem`. + var rows: LazyMapSequence { + return children.lazy.map { $0 as! Table.Row } + } + + /// Replace all list items with a sequence of items. + mutating func setRows(_ newRows: Rows) where Rows.Element == Table.Row { + replaceRowsInRange(0..(_ range: Range, with incomingRows: Rows) where Rows.Element == Table.Row { + var rawChildren = raw.markup.copyChildren() + rawChildren.replaceSubrange(range, with: incomingRows.map { $0.raw.markup }) + let newRaw = raw.markup.withChildren(rawChildren) + _data = _data.replacingSelf(newRaw) + } + + /// Append a row to the list. + mutating func appendRow(_ row: Table.Row) { + replaceRowsInRange(childCount..(_ visitor: inout V) -> V.Result where V : MarkupVisitor { + return visitor.visitTableBody(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Tables/TableCell.swift b/Sources/Markdown/Block Nodes/Tables/TableCell.swift new file mode 100644 index 00000000..dbb30cc4 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Tables/TableCell.swift @@ -0,0 +1,44 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +extension Table { + /// A cell in a table. + public struct Cell: Markup, BasicInlineContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .tableCell = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Table.Cell.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } + } +} + +// MARK: - Public API + +public extension Table.Cell { + + // MARK: BasicInlineContainer + + init(_ children: Children) where Children : Sequence, Children.Element == InlineMarkup { + try! self.init(RawMarkup.tableCell(parsedRange: nil, children.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result where V : MarkupVisitor { + return visitor.visitTableCell(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Tables/TableCellContainer.swift b/Sources/Markdown/Block Nodes/Tables/TableCellContainer.swift new file mode 100644 index 00000000..8c8c5ffe --- /dev/null +++ b/Sources/Markdown/Block Nodes/Tables/TableCellContainer.swift @@ -0,0 +1,68 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A container of ``Table/Cell`` elements. +public protocol TableCellContainer: Markup, ExpressibleByArrayLiteral { + /// Create a row from cells. + /// + /// - parameter cells: A sequence of ``Table/Cell`` elements from which to make this row. + init(_ cells: Cells) where Cells.Element == Table.Cell +} + +// MARK: - Public API + +public extension TableCellContainer { + /// Create a row from one cell. + /// + /// - parameter cell: The one cell comprising the row. + init(_ cell: Table.Cell) { + self.init(CollectionOfOne(cell)) + } + + /// Create a row from cells. + /// + /// - parameter cells: A sequence of ``Table/Cell`` elements from which to make this row. + init(_ cells: Table.Cell...) { + self.init(cells) + } + + init(arrayLiteral elements: Table.Cell...) { + self.init(elements) + } + + /// The cells of the row. + /// + /// - Precondition: All children of a ``TableCellContainer`` must be a `Table.Cell`. + var cells: LazyMapSequence { + return children.lazy.map { $0 as! Table.Cell } + } + + /// Replace all cells with a sequence of cells. + /// + /// - parameter newCells: A sequence of ``Table/Cell`` elements that will replace all of the cells in this row. + mutating func setCells(_ newCells: Cells) where Cells.Element == Table.Cell { + replaceCellsInRange(0..(_ range: Range, with incomingCells: Cells) where Cells.Element == Table.Cell { + var rawChildren = raw.markup.copyChildren() + rawChildren.replaceSubrange(range, with: incomingCells.map { $0.raw.markup }) + let newRaw = raw.markup.withChildren(rawChildren) + _data = _data.replacingSelf(newRaw) + } + + /// Append a cell to the row. + /// + /// - parameter cell: The cell to append to the row. + mutating func appendCell(_ cell: Table.Cell) { + replaceCellsInRange(childCount..(_ cells: Cells) where Cells : Sequence, Cells.Element == Table.Cell { + try! self.init(.tableHead(parsedRange: nil, columns: cells.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result where V : MarkupVisitor { + return visitor.visitTableHead(self) + } +} diff --git a/Sources/Markdown/Block Nodes/Tables/TableRow.swift b/Sources/Markdown/Block Nodes/Tables/TableRow.swift new file mode 100644 index 00000000..f3a40169 --- /dev/null +++ b/Sources/Markdown/Block Nodes/Tables/TableRow.swift @@ -0,0 +1,47 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +public protocol _TableRowProtocol : TableCellContainer {} + +extension Table { + /// A row of cells in a table. + public struct Row: Markup, _TableRowProtocol { + public var _data: _MarkupData + + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .tableRow = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Table.Row.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + } +} + +// MARK: - Public API + +public extension Table.Row { + + // MARK: TableCellContainer + + init(_ cells: Cells) where Cells : Sequence, Cells.Element == Table.Cell { + try! self.init(.tableRow(parsedRange: nil, cells.map { $0.raw.markup })) + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result where V : MarkupVisitor { + return visitor.visitTableRow(self) + } +} diff --git a/Sources/Markdown/Infrastructure/Replacement.swift b/Sources/Markdown/Infrastructure/Replacement.swift new file mode 100644 index 00000000..d827720f --- /dev/null +++ b/Sources/Markdown/Infrastructure/Replacement.swift @@ -0,0 +1,35 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A textual replacement. +public struct Replacement: CustomStringConvertible, CustomDebugStringConvertible { + /// The range of source text to replace. + public var range: SourceRange + + /// The text to substitute in the ``range``. + public var replacementText: String + + /// Create a textual replacement. + /// + /// - parameter range: The range of the source text to replace. + /// - parameter replacementText: The text to substitute in the range. + public init(range: SourceRange, replacementText: String) { + self.range = range + self.replacementText = replacementText + } + + public var description: String { + return "\(range.diagnosticDescription()): fixit: \(replacementText)" + } + + public var debugDescription: String { + return description + } +} diff --git a/Sources/Markdown/Infrastructure/SourceLocation.swift b/Sources/Markdown/Infrastructure/SourceLocation.swift new file mode 100644 index 00000000..58453ea0 --- /dev/null +++ b/Sources/Markdown/Infrastructure/SourceLocation.swift @@ -0,0 +1,106 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +/// A location in a source file. +public struct SourceLocation: Hashable, CustomStringConvertible, Comparable { + public static func < (lhs: SourceLocation, rhs: SourceLocation) -> Bool { + if lhs.line < rhs.line { + return true + } else if lhs.line == rhs.line { + return lhs.column < rhs.column + } else { + return false + } + } + + /// The line number of the location. + public var line: Int + + /// The number of Unicode code units from the start of the line to the character at this source location. + public var column: Int + + /// The source file for which this location applies, if it came from an accessible location. + public var source: URL? + + /// Create a source location with line, column, and optional source to which the location applies. + /// + /// - parameter line: The line number of the location, starting with 1. + /// - parameter column: The column of the location, starting with 1. + /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific + /// file or resource that needs to be identified. + public init(line: Int, column: Int, source: URL?) { + self.line = line + self.column = column + self.source = source + } + + public var description: String { + let path = source.map { + $0.path.isEmpty + ? "" + : "\($0.path):" + } ?? "" + return "\(path)\(line):\(column)" + } +} + +extension Range { + /// Widen this range to contain another. + mutating func widen(toFit other: Self) { + self = Swift.min(self.lowerBound, other.lowerBound).. + +extension SourceRange { + @available(*, deprecated, message: "Use lowerBound.. String { + let path = lowerBound.source.map { + $0.path.isEmpty + ? "" + : "\($0.path):" + } ?? "" + + var result = "\(path)\(lowerBound)" + if lowerBound != upperBound { + result += "-\(upperBound)" + } + return result + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/Emphasis.swift b/Sources/Markdown/Inline Nodes/Inline Containers/Emphasis.swift new file mode 100644 index 00000000..69fdf34a --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/Emphasis.swift @@ -0,0 +1,50 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A markup element that tags inline elements with emphasis. +public struct Emphasis: RecurringInlineMarkup, BasicInlineContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .emphasis = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Emphasis.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Emphasis { + // MARK: BasicInlineContainer + + init(_ newChildren: Children) where Children : Sequence, Children.Element == InlineMarkup { + try! self.init(RawMarkup.emphasis(parsedRange: nil, newChildren.map { $0.raw.markup })) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + let childrenPlainText = children.compactMap { + return ($0 as? InlineMarkup)?.plainText + }.joined() + return "\(childrenPlainText)" + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitEmphasis(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/Image.swift b/Sources/Markdown/Inline Nodes/Inline Containers/Image.swift new file mode 100644 index 00000000..cde5bf03 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/Image.swift @@ -0,0 +1,101 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An inline image reference. +public struct Image: InlineMarkup, InlineContainer { + public var _data: _MarkupData + + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .image = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Image.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } +} + +// MARK: - Public API + +public extension Image { + /// Create an image from a source and zero or more child inline elements. + init(source: String? = nil, title: String? = nil, _ children: Children) where Children.Element == RecurringInlineMarkup { + let titleToUse: String? + if let t = title, t.isEmpty { + titleToUse = nil + } else { + titleToUse = title + } + + let sourceToUse: String? + if let s = source, s.isEmpty { + sourceToUse = nil + } else { + sourceToUse = source + } + + try! self.init(.image(source: sourceToUse, title: titleToUse, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// Create an image from a source and zero or more child inline elements. + init(source: String? = nil, title: String? = nil, _ children: RecurringInlineMarkup...) { + self.init(source: source, title: title, children) + } + + /// The image's source. + var source: String? { + get { + guard case let .image(source, _) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return source + } + set { + guard newValue != source else { + return + } + if let s = newValue, s.isEmpty { + _data = _data.replacingSelf(.image(source: nil, title: title, parsedRange: nil, _data.raw.markup.copyChildren())) + } else { + _data = _data.replacingSelf(.image(source: newValue, title: title, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + } + + /// The image's title. + var title: String? { + get { + guard case let .image(_, title) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return title + } + set { + guard newValue != title else { + return + } + + if let t = newValue, t.isEmpty { + _data = _data.replacingSelf(.image(source: source, title: nil, parsedRange: nil, _data.raw.markup.copyChildren())) + } else { + _data = _data.replacingSelf(.image(source: source, title: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitImage(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/Link.swift b/Sources/Markdown/Inline Nodes/Inline Containers/Link.swift new file mode 100644 index 00000000..54ae3d8f --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/Link.swift @@ -0,0 +1,71 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A link. +public struct Link: InlineMarkup, InlineContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .link = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Link.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Link { + /// Create a link with a destination and zero or more child inline elements. + init(destination: String? = nil, _ children: Children) where Children.Element == RecurringInlineMarkup { + + let destinationToUse: String? + if let d = destination, d.isEmpty { + destinationToUse = nil + } else { + destinationToUse = destination + } + + try! self.init(.link(destination: destinationToUse, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// Create a link with a destination and zero or more child inline elements. + init(destination: String, _ children: RecurringInlineMarkup...) { + self.init(destination: destination, children) + } + + /// The link's destination. + var destination: String? { + get { + guard case let .link(destination) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return destination + } + set { + if let d = newValue, d.isEmpty { + _data = _data.replacingSelf(.link(destination: nil, parsedRange: nil, _data.raw.markup.copyChildren())) + } else { + _data = _data.replacingSelf(.link(destination: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitLink(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/Strikethrough.swift b/Sources/Markdown/Inline Nodes/Inline Containers/Strikethrough.swift new file mode 100644 index 00000000..f094de35 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/Strikethrough.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// Inline elements that should be rendered with a strike through them. +public struct Strikethrough: RecurringInlineMarkup, BasicInlineContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .strikethrough = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Strikethrough.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Strikethrough { + // MARK: BasicInlineContainer + + init(_ newChildren: Children) where Children : Sequence, Children.Element == InlineMarkup { + try! self.init(.strikethrough(parsedRange: nil, newChildren.map { $0.raw.markup })) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + let childrenPlainText = children.compactMap { + return ($0 as? InlineMarkup)?.plainText + }.joined() + return "~\(childrenPlainText)~" + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitStrikethrough(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/Strong.swift b/Sources/Markdown/Inline Nodes/Inline Containers/Strong.swift new file mode 100644 index 00000000..64d2000e --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/Strong.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An element that tags inline elements with strong emphasis. +public struct Strong: RecurringInlineMarkup, BasicInlineContainer { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .strong = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Strong.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Strong { + // MARK: BasicInlineContainer + + init(_ newChildren: Children) where Children : Sequence, Children.Element == InlineMarkup { + try! self.init(.strong(parsedRange: nil, newChildren.map { $0.raw.markup })) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + let childrenPlainText = children.compactMap { + return ($0 as? InlineMarkup)?.plainText + }.joined() + return "\(childrenPlainText)" + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitStrong(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/CustomInline.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/CustomInline.swift new file mode 100644 index 00000000..0505bb93 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/CustomInline.swift @@ -0,0 +1,54 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A custom inline markup element. +/// +/// - note: This element does not yet allow for custom information to be appended and is included for backward compatibility with CommonMark. It wraps raw text. +public struct CustomInline: RecurringInlineMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .customInline = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: CustomInline.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension CustomInline { + /// Create a custom inline element from raw text. + init(_ text: String) { + try! self.init(.customInline(parsedRange: nil, text: text)) + } + + /// The raw inline text of the element. + var text: String { + guard case let .customInline(text) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return text + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return text + } + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitCustomInline(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/InlineCode.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/InlineCode.swift new file mode 100644 index 00000000..c0000131 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/InlineCode.swift @@ -0,0 +1,60 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An inline code markup element, representing some code-like or "code voice" text. +public struct InlineCode: RecurringInlineMarkup { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .inlineCode = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: InlineCode.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension InlineCode { + /// Create an inline code element from a string. + init(_ code: String) { + try! self.init(.inlineCode(parsedRange: nil, code: code)) + } + + /// The literal text content. + var code: String { + get { + guard case let .inlineCode(code) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return code + } + set { + self._data = _data.replacingSelf(.inlineCode(parsedRange: nil, code: newValue)) + } + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return "`\(code)`" + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitInlineCode(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/InlineHTML.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/InlineHTML.swift new file mode 100644 index 00000000..90334649 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/InlineHTML.swift @@ -0,0 +1,58 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An inline markup element containing raw HTML. +public struct InlineHTML: RecurringInlineMarkup, LiteralMarkup { + public var _data: _MarkupData + init(_ raw: RawMarkup) throws { + guard case .inlineHTML = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: InlineHTML.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension InlineHTML { + init(_ literalText: String) { + try! self.init(.inlineHTML(parsedRange: nil, html: literalText)) + } + + /// The raw HTML text. + var rawHTML: String { + get { + guard case let .inlineHTML(text) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return text + } + set { + _data = _data.replacingSelf(.inlineHTML(parsedRange: nil, html: newValue)) + } + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return rawHTML + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitInlineHTML(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/LineBreak.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/LineBreak.swift new file mode 100644 index 00000000..60b5cfcb --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/LineBreak.swift @@ -0,0 +1,47 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A line break. +public struct LineBreak: RecurringInlineMarkup { + public var _data: _MarkupData + + init(_ data: _MarkupData) { + self._data = data + } + + init(_ raw: RawMarkup) throws { + guard case .lineBreak = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: LineBreak.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } +} + +// MARK: - Public API + +public extension LineBreak { + /// Create a hard line break. + init() { + try! self.init(.lineBreak(parsedRange: nil)) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return "\n" + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitLineBreak(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/SoftBreak.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/SoftBreak.swift new file mode 100644 index 00000000..12774bc5 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/SoftBreak.swift @@ -0,0 +1,47 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A soft break. +public struct SoftBreak: RecurringInlineMarkup { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .softBreak = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: SoftBreak.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension SoftBreak { + /// Create a soft line break. + init() { + try! self.init(.softBreak(parsedRange: nil)) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return " " + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitSoftBreak(self) + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/SymbolLink.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/SymbolLink.swift new file mode 100644 index 00000000..586652e2 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/SymbolLink.swift @@ -0,0 +1,71 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A link to a symbol. +/// +/// Symbol links are written the same as inline code spans but with +/// two backticks `\`` instead of one. The contents inside the backticks become +/// the link's destination. +/// +/// Symbol links should be typically rendered with "code voice", usually +/// monospace. +public struct SymbolLink: InlineMarkup { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .symbolLink = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: SymbolLink.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension SymbolLink { + /// Create a symbol link with a destination. + init(destination: String? = nil) { + try! self.init(.symbolLink(parsedRange: nil, destination: destination ?? "")) + } + + /// The link's destination. + var destination: String? { + get { + guard case let .symbolLink(destination) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return destination + } + set { + if let newDestination = newValue, newDestination.isEmpty { + _data = _data.replacingSelf(.symbolLink(parsedRange: nil, destination: nil)) + } else { + _data = _data.replacingSelf(.symbolLink(parsedRange: nil, destination: newValue)) + } + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitSymbolLink(self) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return "``\(destination ?? "")``" + } +} diff --git a/Sources/Markdown/Inline Nodes/Inline Leaves/Text.swift b/Sources/Markdown/Inline Nodes/Inline Leaves/Text.swift new file mode 100644 index 00000000..7b90a660 --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Leaves/Text.swift @@ -0,0 +1,59 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// Plain text. +public struct Text: RecurringInlineMarkup, LiteralMarkup { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .text = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: Text.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension Text { + init(_ literalText: String) { + try! self.init(.text(parsedRange: nil, string: literalText)) + } + + /// The raw text of the element. + var string: String { + get { + guard case let .text(string) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return string + } + set { + _data = _data.replacingSelf(.text(parsedRange: nil, string: newValue)) + } + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return string + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitText(self) + } +} diff --git a/Sources/Markdown/Interpretive Nodes/Aside.swift b/Sources/Markdown/Interpretive Nodes/Aside.swift new file mode 100644 index 00000000..e2c45786 --- /dev/null +++ b/Sources/Markdown/Interpretive Nodes/Aside.swift @@ -0,0 +1,137 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +/// An auxiliary aside element interpreted from a block quote. +/// +/// Asides are written as a block quote starting with a special plain-text tag, +/// such as `note:` or `tip:`: +/// +/// ```markdown +/// > Tip: This is a `tip` aside. +/// > It may have a presentation similar to a block quote, but with a +/// > different meaning, as it doesn't quote speech. +/// ``` +public struct Aside { + /// The kind of aside. + public enum Kind: String, CaseIterable { + /// A "note" aside. + case note = "Note" + + /// A "tip" aside. + case tip = "Tip" + + /// An "important" aside. + case important = "Important" + + /// An "experiment" aside. + case experiment = "Experiment" + + /// A "warning" aside. + case warning = "Warning" + + /// An "attention" aside. + case attention = "Attention" + + /// An "author" aside. + case author = "Author" + + /// An "authors" aside. + case authors = "Authors" + + /// A "bug" aside. + case bug = "Bug" + + /// A "complexity" aside. + case complexity = "Complexity" + + /// A "copyright" aside. + case copyright = "Copyright" + + /// A "date" aside. + case date = "Date" + + /// An "invariant" aside. + case invariant = "Invariant" + + /// A "mutatingVariant" aside. + case mutatingVariant = "MutatingVariant" + + /// A "nonMutatingVariant" aside. + case nonMutatingVariant = "NonMutatingVariant" + + /// A "postcondition" aside. + case postcondition = "Postcondition" + + /// A "precondition" aside. + case precondition = "Precondition" + + /// A "remark" aside. + case remark = "Remark" + + /// A "requires" aside. + case requires = "Requires" + + /// A "since" aside. + case since = "Since" + + /// A "todo" aside. + case todo = "ToDo" + + /// A "version" aside. + case version = "Version" + + /// A "throws" aside. + case `throws` = "Throws" + + public init?(rawValue: String) { + // Allow lowercase aside prefixes to match. + let casesAndLowercasedRawValues = Kind.allCases.map { (kind: $0, rawValue: $0.rawValue.lowercased() )} + guard let matchingCaseAndRawValue = casesAndLowercasedRawValues.first(where: { $0.rawValue == rawValue.lowercased() }) else { + return nil + } + self = matchingCaseAndRawValue.kind + } + } + + /// The kind of aside interpreted from the initial text of the ``BlockQuote``. + public var kind: Kind + + /// The block elements of the aside taken from the ``BlockQuote``, + /// excluding the initial text tag. + public var content: [BlockMarkup] + + /// Create an aside from a block quote. + public init(_ blockQuote: BlockQuote) { + // Try to find an initial `tag:` text at the beginning. + guard var initialText = blockQuote.child(through: [ + (0, Paragraph.self), + (0, Text.self), + ]) as? Text, + let firstColonIndex = initialText.string.firstIndex(where: { $0 == ":" }), + let kind = Kind(rawValue: String(initialText.string[initialText.string.startIndex.. Bool { + precondition(parseState == .argumentsStart) + + var line = line + line.lexWhitespace() + + if line.lex("(") != nil { + parseState = .argumentsText + // There may be garbage after the left parenthesis `(`, but we'll + // still consider subsequent lines for argument text, so we'll + // indicate acceptance either way at this point. + _ = parseArgumentsText(from: line) + endLocation = line.location! + return true + } else { + parseState = .contentsStart + return parseContentsStart(from: line) + } + } + + /// Continue parsing from the `argumentsText` state. + @discardableResult + mutating func parseArgumentsText(from line: TrimmedLine) -> Bool { + precondition(parseState == .argumentsText) + var accepted = false + var line = line + if let argumentsText = line.lex(until: { $0 == ")" ? .stop : .continue }, + allowEscape: true, allowQuote: true) { + self.argumentsText.append(argumentsText) + accepted = true + } + + if line.text.starts(with: ")") { + parseState = .argumentsEnd + parseArgumentsEnd(from: line) + } + + return accepted + } + + /// Continue parsing from the `argumentsEnd` state. + @discardableResult + mutating func parseArgumentsEnd(from line: TrimmedLine) -> Bool { + precondition(parseState == .argumentsEnd) + var line = line + line.lexWhitespace() + if line.lex(")") != nil { + parseState = .contentsStart + endLocation = line.location! + parseContentsStart(from: line) + } else { + return false + } + return true + } + + /// Continue parsing from the `contentsStart` state. + @discardableResult + mutating func parseContentsStart(from line: TrimmedLine) -> Bool { + precondition(parseState == .contentsStart) + var line = line + line.lexWhitespace() + if line.lex("{") != nil { + parseState = .contents + endLocation = line.location! + parseContentsEnd(from: line) + } else { + return false + } + return true + } + + /// Continue parsing from the `contentsEnd` state. + @discardableResult + mutating func parseContentsEnd(from line: TrimmedLine) -> Bool { + precondition(isAwaitingChildContent) + var line = line + line.lexWhitespace() + if line.lex("}") != nil { + parseState = .done + endLocation = line.location! + } else { + return false + } + return true + } + + /// Accept a line into this block directive container, returning `true` + /// if the container should be closed. + /// + /// - Returns: `true` if this block directive container accepted the line. + mutating func accept(_ line: TrimmedLine) -> Bool { + switch parseState { + case .argumentsStart: + return parseArgumentsStart(from: line) + case .argumentsText: + return parseArgumentsText(from: line) + case .argumentsEnd: + return parseArgumentsEnd(from: line) + case .contentsStart: + return parseContentsStart(from: line) + case .contents, .contentsEnd: + return parseContentsEnd(from: line) + case .done: + fatalError("A closed block directive container cannot accept further lines of content.") + } + } +} + +struct TrimmedLine { + /// A successful result of scanning for a prefix on a ``TrimmedLine``. + struct Lex: Equatable { + /// A signal whether to continue searching for characters. + enum Continuation { + /// Stop searching. + case stop + /// Continue searching. + case `continue` + } + /// The resulting text from scanning a line. + let text: Substring + + /// The range of the text if known. + let range: SourceRange? + } + + /// The original untrimmed text of the line. + let untrimmedText: Substring + + /// The starting parse index. + let startParseIndex: Substring.Index + + /// The current index a parser is looking at on a line. + var parseIndex: Substring.Index + + /// The line number of this line in the source if known, starting with `0`. + let lineNumber: Int? + + /// The source file or resource from which the line came, + /// or `nil` if no such file or resource can be identified. + let source: URL? + + /// `true` if this line is empty or consists of all space `" "` or tab + /// `"\t"` characters. + var isEmptyOrAllWhitespace: Bool { + return text.isEmpty || text.allSatisfy { + $0 == " " || $0 == "\t" + } + } + + /// - parameter untrimmedText: ``untrimmedText`` + init(_ untrimmedText: Substring, source: URL?, lineNumber: Int?, parseIndex: Substring.Index? = nil) { + self.untrimmedText = untrimmedText + self.source = source + self.parseIndex = parseIndex ?? untrimmedText.startIndex + self.lineNumber = lineNumber + self.startParseIndex = self.parseIndex + } + + /// Return the UTF-8 source location of the parse index if the line + /// number is known. + var location: SourceLocation? { + guard let lineNumber = lineNumber else { + return nil + } + let startIndex = (self.lineNumber ?? 1) == 1 + ? untrimmedText.startIndex + : startParseIndex + let alreadyParsedPrefix = untrimmedText[startIndex.. Int in + switch character { + case " ": + return count + 1 + case "\t": + // Align up to units of 4. + // We're using 4 instead of 8 here because cmark has traditionally + // considered a tab to be equivalent to 4 spaces. + return (count + 4) & ~0b11 + default: + fatalError("Non-whitespace character found while calculating equivalent indentation column count") + } + } + } + + var isProbablyCodeFence: Bool { + var line = self + line.lexWhitespace() + return line.text.starts(with: "```") || line.text.starts(with: "~~~") + } + + /// Take a prefix from the start of the line. + /// + /// - parameter `maxLength`: The maximum number of characters to take from the start of the line. + /// - returns: A ``Lex`` if there were any characters to take, otherwise `nil`. + mutating func take(_ maxLength: Int, allowEmpty: Bool = false) -> Lex? { + guard allowEmpty || maxLength > 0 else { + return nil + } + let startIndex = parseIndex + let startLocation = location + let consumedText = text.prefix(maxLength) + guard allowEmpty || !consumedText.isEmpty else { + return nil + } + parseIndex = untrimmedText.index(parseIndex, offsetBy: consumedText.count, limitedBy: untrimmedText.endIndex) ?? untrimmedText.endIndex + let endIndex = parseIndex + let endLocation = location + let text = untrimmedText[startIndex.. Lex? { + return lex { (c) -> Lex.Continuation in + switch c { + case stopCharacter: + return .stop + default: + return .continue + } + } + } + + mutating func lex(until stop: (Character) -> Lex.Continuation, + allowEscape: Bool = false, + allowQuote: Bool = false, + allowEmpty: Bool = false) -> Lex? { + var takeCount = 0 + var prefix = text.makeIterator() + var isEscaped = false + var isQuoted = false + + while let c = prefix.next() { + if isEscaped { + isEscaped = false + takeCount += 1 + continue + } + if allowEscape, + c == "\\" { + isEscaped = true + takeCount += 1 + continue + } + if isQuoted { + if c == "\"" { + isQuoted = false + } + takeCount += 1 + continue + } + if allowQuote, + c == "\"" { + isQuoted = true + takeCount += 1 + continue + } + if case .stop = stop(c) { + break + } + takeCount += 1 + } + + guard allowEmpty || takeCount > 0 else { + return nil + } + + return take(takeCount, allowEmpty: allowEmpty) + } + + /// Attempt to lex a character from the current parse point. + /// + /// - parameter character: the character to expect. + /// - parameter allowEscape: if `true`, the function will not match the `character` + /// if a backslash `\` character precedes it. + mutating func lex(_ character: Character, + allowEscape: Bool = false) -> Lex? { + var count = 0 + return lex(until: { + switch ($0, count) { + case (character, 0): + count += 1 + return .continue + default: + return .stop + } + }, allowEscape: allowEscape) + } + + @discardableResult + mutating func lexWhitespace(maxLength: Int? = nil) -> Lex? { + if var maxLength = maxLength { + let result = lex { + guard maxLength > 0, + $0 == " " || $0 == "\t" else { + return .stop + } + maxLength -= 1 + return .continue + } + return result + } else { + return lex { + switch $0 { + case " ", "\t": + return .continue + default: + return .stop + } + } + } + } +} + +/// A hierarchy of containers for the first phase of parsing Markdown that includes block directives. +private enum ParseContainer: CustomStringConvertible { + /// The root document container, which can contain block directives or runs of lines. + case root([ParseContainer]) + + /// A run of lines of regular Markdown. + case lineRun([TrimmedLine], isInCodeFence: Bool) + + /// A block directive container, which can contain other block directives or runs of lines. + case blockDirective(PendingBlockDirective, [ParseContainer]) + + init(parsingHierarchyFrom trimmedLines: TrimmedLines) where TrimmedLines.Element == TrimmedLine { + self = ParseContainerStack(parsingHierarchyFrom: trimmedLines).top + } + + var children: [ParseContainer] { + switch self { + case .root(let children): + return children + case .blockDirective(_, let children): + return children + case .lineRun: + return [] + } + } + + var isInCodeFence: Bool { + guard case let .lineRun(_, inCodeFence) = self, + inCodeFence else { + return false + } + return true + } + + private struct Printer { + var indent = 0 + var pendingNewlines = 0 + var result = "" + + mutating func addressPendingNewlines() { + for _ in 0..(_ text: S) { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + for i in lines.indices { + if i != lines.startIndex { + queueNewline() + } + addressPendingNewlines() + result += lines[i] + } + } + + mutating private func print(children: Children) where Children.Element == ParseContainer { + queueNewline() + indent += 4 + for child in children { + print(container: child) + } + indent -= 4 + } + + mutating func print(container: ParseContainer) { + switch container { + case .root(let children): + print("* Root Document") + print(children: children) + case .lineRun(let lines, _): + print("* Line Run") + queueNewline() + indent += 4 + for line in lines { + print(line.text.debugDescription) + queueNewline() + } + indent -= 4 + case .blockDirective(let pendingBlockDirective, let children): + print("* Block directive '\(pendingBlockDirective.name)'") + if !pendingBlockDirective.argumentsText.isEmpty { + queueNewline() + indent += 2 + print("Arguments Text:") + indent += 2 + queueNewline() + print(pendingBlockDirective.argumentsText.map { $0.text.debugDescription }.joined(separator: "\n")) + indent -= 4 + } + print(children: children) + } + } + } + + var description: String { + var printer = Printer() + printer.print(container: self) + return printer.result + } + + mutating func updateIndentation(under parent: inout ParseContainer?, for line: TrimmedLine) { + switch self { + case .root: + return + case .lineRun: + var newParent: ParseContainer? = nil + parent?.updateIndentation(under: &newParent, for: line) + case .blockDirective(var pendingBlockDirective, let children): + pendingBlockDirective.updateIndentation(for: line) + self = .blockDirective(pendingBlockDirective, children) + } + } + + /// The number of characters to remove from the front of a line + /// in this container to prevent the CommonMark parser from interpreting too + /// much indentation. + /// + /// For example: + /// + /// ``` + /// @Thing { + /// ^ @Thing { + /// | ^ @Thing { + /// | | ^ This line has indentation adjustment 4, + /// | | | for the two spaces after the innermost `@Thing`. + /// | | | This means that this paragraph is indented only 2 spaces, + /// | | | not to be interpreted as an indented code block. + /// | | | + /// 01234 + /// ``` + /// + /// Finally, if a line run is a child of a directive, the contents + /// may be indented to make things easier to read, like so: + /// + /// @Outer { + /// - A + /// - List + /// - Within + /// } + /// + /// The line run presented to the CommonMark parser should be: + /// """ + /// - A + /// - List + /// - Within + /// """ + func indentationAdjustment(under parent: ParseContainer?) -> Int { + switch self { + case .root: + return 0 + case .lineRun: + return parent?.indentationAdjustment(under: nil) ?? 0 + case .blockDirective(let pendingBlockDirective, _): + return pendingBlockDirective.indentationColumnCount + } + } + + /// Convert this container to the corresponding ``RawMarkup`` node. + func convertToRawMarkup(ranges: inout RangeTracker, + parent: ParseContainer?, + options: ParseOptions) -> [RawMarkup] { + switch self { + case let .root(children): + let rawChildren = children.flatMap { + $0.convertToRawMarkup(ranges: &ranges, parent: self, options: options) + } + return [.document(parsedRange: ranges.totalRange, rawChildren)] + case let .lineRun(lines, _): + // Get the maximum number of initial indentation characters to remove from the start + // of each of these `lines` from the first sibling under `parent` (which could be `self`). + let indentationColumnCount = indentationAdjustment(under: parent) + + // Trim up to that number of whitespace characters off. + // We need to keep track of what we removed because cmark will report different source locations than what we + // had in the source. We'll adjust those when we get them back. + let trimmedIndentationAndLines = lines.map { line -> (line: TrimmedLine, + indentation: TrimmedLine.Lex?) in + var trimmedLine = line + let trimmedWhitespace = trimmedLine.lexWhitespace(maxLength: indentationColumnCount) + return (trimmedLine, trimmedWhitespace) + } + + // Build the logical block of text that cmark will see. + let logicalText = trimmedIndentationAndLines + .map { $0.line.text } + .joined(separator: "\n") + + // Ask cmark to parse it. Now we have a Markdown `Document` consisting + // of the contents of this line run. + let parsedSubdocument = MarkupParser.parseString(logicalText, source: lines.first?.source, options: options) + + // Now, we'll adjust the columns of all of the source positions as + // needed to offset that indentation trimming we did above. + // Note that the child identifiers under this document will start at + // 0, so we will need to adjust those as well, because child identifiers + // start at 0 from the `root`. + + var columnAdjuster = RangeAdjuster(startLine: lines.first?.lineNumber ?? 1, + ranges: ranges, + trimmedIndentationPerLine: trimmedIndentationAndLines.map { $0.indentation }) + for child in parsedSubdocument.children { + columnAdjuster.visit(child) + } + + // Write back the adjusted ranges. + ranges = columnAdjuster.ranges + + return parsedSubdocument.children.map { $0.raw.markup } + case let .blockDirective(pendingBlockDirective, children): + let range = pendingBlockDirective.atLocation..(parsingHierarchyFrom trimmedLines: TrimmedLines) where TrimmedLines.Element == TrimmedLine { + self.stack = [.root([])] + for line in trimmedLines { + accept(line) + } + closeAll() + } + + /// `true` if the next line would occur inside a block directive. + private var isInBlockDirective: Bool { + return stack.first { + guard case .blockDirective(let pendingBlockDirective, _) = $0, + case .contents = pendingBlockDirective.parseState else { + return false + } + return true + } != nil + } + + private func isCodeFenceOrIndentedCodeBlock(on line: TrimmedLine) -> Bool { + // Check if this line is indented 4 or more spaces relative to the current + // indentation adjustment. + let indentationAdjustment = top.indentationAdjustment(under: stack.dropLast().last) + let relativeIndentation = line.indentationColumnCount - indentationAdjustment + + guard relativeIndentation < 4 else { + return true + } + + return line.isProbablyCodeFence || top.isInCodeFence + } + + /// Try to parse a block directive opening from a ``LineSegment``, returning `nil` if + /// the segment does not open a block directive. + /// + /// - Parameter line: The trimmed line to check for a prefix opening a new block directive. + private func parseBlockDirectiveOpening(on line: TrimmedLine) -> PendingBlockDirective? { + guard !isCodeFenceOrIndentedCodeBlock(on: line) else { + return nil + } + var remainder = line + remainder.lexWhitespace() + guard let at = remainder.lex("@") else { + return nil + } + + guard let name = remainder.lex(until: { + switch $0 { + case "(", ")", ",", ":", "{", " ", "\t": + return .stop + default: + return .continue + } + }, allowEscape: false) else { + return nil + } + + var pendingBlockDirective = PendingBlockDirective(atIndentationColumnCount: line.indentationColumnCount, + atLocation: at.range!.lowerBound, + nameLocation: name.range!.lowerBound, + name: name.text, + endLocation: name.range!.upperBound) + + // There may be garbage after a block directive opening but we were + // still able to open a new block directive, so we'll consider the + // rest of the line to be accepted regardless of what comes after. + _ = pendingBlockDirective.accept(remainder) + + return pendingBlockDirective + } + + /// Accept a trimmed line, opening new block directives as indicated by the source, + /// closing a block directive if applicable, or adding the line to a run of lines to be parsed + /// as Markdown later. + private mutating func accept(_ line: TrimmedLine) { + if line.isEmptyOrAllWhitespace, + case let .blockDirective(pendingBlockDirective, _) = top { + switch pendingBlockDirective.parseState { + case .argumentsStart, + .contentsStart, + .done: + closeTop() + + default: + break + } + } + + // If we're inside a block directive, check to see whether we need to update its + // indentation calculation to account for its content. + updateIndentation(for: line) + + // Check to see if this line closes a block directive with a right + // curly brace. This may implicitly close several directives, such as in + // the following scenario: + // + // @Outer { + // @WillBeClosedImplicitly + // @WillBeClosedImplicitly + // } + // + // The last right curly brace } will close the second `@WillBeClosedImplicitly` implicitly, + // as the right curly brace } is meant to close `@Outer`. + if !isCodeFenceOrIndentedCodeBlock(on: line) { + var line = line + line.lexWhitespace() + if line.lex("}") != nil { + // Try to find a topmost open block directive on the stack + // that is in the parse state `.contents` or `.contentsEnd`. + // This right curly brace is meant for it. + let foundIndex = stack.reversed().firstIndex { + guard case .blockDirective(let pendingBlockDirective, _) = $0, + pendingBlockDirective.isAwaitingChildContent else { + return false + } + return true + } + + if let foundIndex = foundIndex { + let startIndex = stack.reversed().startIndex + let closeCount = stack.reversed().distance(from: startIndex, to: foundIndex) + for _ in 0.. 0) + stack[stack.count - 1] = newTop + } + } + + /// Close all open containers, returning the final, root document container. + private mutating func closeAll() { + while stack.count > 1 { + closeTop() + } + } + + /// Remove the topmost container and add it as a child to the one underneath. + /// + /// - Precondition: There must be at least two elements on the stack, one to be removed as the child, and one to accept the child as a parent. + /// - Precondition: The container underneath the top container must not be a line run as they cannot have children. + private mutating func closeTop() { + precondition(stack.count > 1) + let child = pop() + + if case .blockDirective(let pendingBlockDirective, _) = child, + pendingBlockDirective.parseState == .contents || + pendingBlockDirective.parseState == .contentsEnd { + // Unclosed block directive? + } + + switch pop() { + case .root(var children): + children.append(child) + push(.root(children)) + case .blockDirective(let pendingBlockDirective, var children): + children.append(child) + push(.blockDirective(pendingBlockDirective, children)) + case .lineRun: + fatalError("Line runs cannot have children") + } + } + + /// Open a new container and place it at the top of the stack. + private mutating func push(_ container: ParseContainer) { + if case .root = container, !stack.isEmpty { + fatalError("Cannot push additional document containers onto the parse container stack") + } + stack.append(container) + } + + @discardableResult + private mutating func pop() -> ParseContainer { + return stack.popLast()! + } +} + +extension Document { + /// Convert a ``ParseContainer` to a ``Document``. + /// + /// - Precondition: The `rootContainer` must be the `.root` case. + fileprivate init(converting rootContainer: ParseContainer, from source: URL?, + options: ParseOptions) { + guard case .root = rootContainer else { + fatalError("Tried to convert a non-root container to a `Document`") + } + + var rangeTracker = RangeTracker(totalRange: SourceLocation(line: 1, column: 1, source: source).. Document { + let string = try String(contentsOf: input, encoding: .utf8) + return parse(string, source: input, options: options) + } + + /// Parse the input. + static func parse(_ input: String, source: URL?, + options: ParseOptions = []) -> Document { + // Phase 0: Split the input into lines lazily, keeping track of + // line numbers, consecutive blank lines, and start positions on each line where indentation ends. + // These trim points may be used to adjust the indentation seen by the CommonMark parser when + // the need to parse regular markdown lines arises. + let trimmedLines = LazySplitLines(input[...], source: source) + + // Phase 1: Categorize the lines into a hierarchy of block containers by parsing the prefix + // of the line, opening and closing block directives appropriately, and folding elements + // into a root document. + let rootContainer = ParseContainer(parsingHierarchyFrom: trimmedLines) + + // Phase 2: Convert the hierarchy of block containers into a real ``Document``. + // This is where the CommonMark parser is called upon to parse runs of lines of content, + // adjusting source locations back to the original source. + return Document(converting: rootContainer, from: source, options: options) + } +} diff --git a/Sources/Markdown/Parser/CommonMarkConverter.swift b/Sources/Markdown/Parser/CommonMarkConverter.swift new file mode 100644 index 00000000..2dd21a18 --- /dev/null +++ b/Sources/Markdown/Parser/CommonMarkConverter.swift @@ -0,0 +1,610 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 cmark_gfm +import cmark_gfm_extensions +import Foundation + +/// String-based CommonMark node type identifiers. +/// +/// CommonMark node types do have a raw-value enum `cmark_node_type`. +/// However, in light of extensions, these enum values are not public and +/// must use strings instead to identify types. +/// +/// For example, consider the task list item: +/// +/// ```markdown +/// - [x] Completed +/// ``` +/// +/// This internally reuses the regular `CMARK_NODE_ITEM` enum type but will +/// return the type name string "tasklist" or "item" depending on whether +/// there was a `[ ]` or `[x]` after the list marker. +/// So, the raw `cmark_node_type` is no longer reliable on its own, unfortunately. +/// +/// These values are taken from the `cmark_node_get_type_string` implementation +/// in the underlying cmark dependency. +/// +/// > Warning: **Do not make these public.**. +fileprivate enum CommonMarkNodeType: String { + case document + case blockQuote = "block_quote" + case list + case item + case codeBlock = "code_block" + case htmlBlock = "html_block" + case customBlock = "custom_block" + case paragraph + case heading + case thematicBreak = "thematic_break" + case text + case softBreak = "softbreak" + case lineBreak = "linebreak" + case code + case html = "html_inline" + case customInline = "custom_inline" + case emphasis = "emph" + case strong + case link + case image + case none = "NONE" + case unknown = "" + + // Extensions + + case strikethrough + + case table + case tableHead = "table_header" + case tableRow = "table_row" + case tableCell = "table_cell" + + case taskListItem = "tasklist" +} + +/// Represents the result of a cmark conversion: the current `MarkupConverterState` and the resulting converted node. +fileprivate struct MarkupConversion { + let state: MarkupConverterState + let result: Result +} + +/// Represents the current state of cmark -> `Markup` conversion. +fileprivate struct MarkupConverterState { + fileprivate struct PendingTableBody { + var range: SourceRange? + } + /// The original source whose conversion created this state. + let source: URL? + + /// An opaque pointer to a `cmark_iter` used during parsing. + let iterator: UnsafeMutablePointer? + + /// The last `cmark_event_type` during parsing. + let event: cmark_event_type + + /// An opaque pointer to the last parsed `cmark_node`. + let node: UnsafeMutablePointer? + + /// Options to consider when converting to `Markup` elements. + let options: ParseOptions + + private(set) var headerSeen: Bool + private(set) var pendingTableBody: PendingTableBody? + + init(source: URL?, iterator: UnsafeMutablePointer?, event: cmark_event_type, node: UnsafeMutablePointer?, options: ParseOptions, headerSeen: Bool, pendingTableBody: PendingTableBody?) { + self.source = source + self.iterator = iterator + self.event = event + self.node = node + self.options = options + self.headerSeen = headerSeen + self.pendingTableBody = pendingTableBody + + switch (event, nodeType) { + case (CMARK_EVENT_EXIT, .tableHead): + self.headerSeen = true + case (CMARK_EVENT_ENTER, .tableRow) where headerSeen: + if self.pendingTableBody == nil { + self.pendingTableBody = PendingTableBody(range: self.range(self.node)) + precondition(self.pendingTableBody != nil) + } + case (CMARK_EVENT_EXIT, .table): + if let endOfTable = self.range(self.node)?.upperBound, + let pendingTableRange = self.pendingTableBody?.range { + self.pendingTableBody?.range = pendingTableRange.lowerBound.. MarkupConverterState { + let newEvent = cmark_iter_next(iterator) + let newNode = cmark_iter_get_node(iterator) + return MarkupConverterState(source: source, iterator: iterator, event: newEvent, node: newNode, options: options, headerSeen: clearPendingTableBody ? false : headerSeen, pendingTableBody: clearPendingTableBody ? nil : pendingTableBody) + } + + /// The type of the last parsed cmark node. + var nodeType: CommonMarkNodeType { + let typeString = String(cString: cmark_node_get_type_string(node)) + guard let type = CommonMarkNodeType(rawValue: typeString) else { + fatalError("Unknown cmark node type '\(typeString)' encountered during conversion") + } + return type + } + + /// The source range where a node occurred, according to cmark. + func range(_ node: UnsafeMutablePointer?) -> SourceRange? { + let startLine = Int(cmark_node_get_start_line(node)) + let startColumn = Int(cmark_node_get_start_column(node)) + guard startLine > 0 && startColumn > 0 else { + // cmark doesn't track the positions for this node. + return nil + } + + let endLine = Int(cmark_node_get_end_line(node)) + let endColumn = Int(cmark_node_get_end_column(node)) + 1 + + guard endLine > 0 && endColumn > 0 else { + // cmark doesn't track the positions for this node. + return nil + } + + // If this is a symbol link / code span, set the locations to include the ticks. + let backtickCount = Int(cmark_node_get_backtick_count(node)) + + let start = SourceLocation(line: startLine, column: startColumn - backtickCount, source: source) + let end = SourceLocation(line: endLine, column: endColumn + backtickCount, source: source) + + // Sometimes the cmark range is invalid (rdar://73376719) + guard start <= end else { return nil } + return start.. MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + + switch state.nodeType { + case .document: + return convertDocument(state) + case .blockQuote: + return convertBlockQuote(state) + case .list: + return convertList(state) + case .item: + return convertListItem(state) + case .codeBlock: + return convertCodeBlock(state) + case .htmlBlock: + return convertHTMLBlock(state) + case .customBlock: + return convertCustomBlock(state) + case .paragraph: + return convertParagraph(state) + case .heading: + return convertHeading(state) + case .thematicBreak: + return convertThematicBreak(state) + case .text: + return convertText(state) + case .softBreak: + return convertSoftBreak(state) + case .lineBreak: + return convertLineBreak(state) + case .code: + return convertInlineCode(state) + case .html: + return convertInlineHTML(state) + case .customInline: + return convertCustomInline(state) + case .emphasis: + return convertEmphasis(state) + case .strong: + return convertStrong(state) + case .link: + return convertLink(state) + case .image: + return convertImage(state) + case .strikethrough: + return convertStrikethrough(state) + case .taskListItem: + return convertTaskListItem(state) + case .table: + return convertTable(state) + case .tableHead: + return convertTableHeader(state) + case .tableRow: + return convertTableRow(state) + case .tableCell: + return convertTableCell(state) + default: + fatalError("Unknown cmark node type '\(state.nodeType.rawValue)' encountered during conversion") + } + } + + /// Returns the raw literal text for a cmark node. + /// + /// - parameter node: An opaque pointer to a `cmark_node`. + private static func getLiteralContent(node: UnsafeMutablePointer!) -> String { + guard let rawText = cmark_node_get_literal(node) else { + fatalError("Expected literal content for cmark node but got null pointer") + } + return String(cString: rawText) + } + + /// Converts the children of the given state's cmark node and return them all. + /// + /// - parameter originalState: The state containing the node whose children you want to convert. + /// - returns: A new conversion containing all of the node's converted children. + private static func convertChildren(_ originalState: MarkupConverterState) -> MarkupConversion<[RawMarkup]> { + let root = originalState.node + var state = originalState.next() + var layout = [RawMarkup]() + + while state.node != root && state.event != CMARK_EVENT_EXIT { + let conversion = convertAnyElement(state) + layout.append(conversion.result) + state = conversion.state + } + return MarkupConversion(state: state, result: layout) + } + + private static func convertDocument(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .document) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + precondition(childConversion.state.node == state.node) + return MarkupConversion(state: childConversion.state.next(), result: .document(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertBlockQuote(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .blockQuote) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .blockQuote(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertList(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .list) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + + for child in childConversion.result { + guard case .listItem = child.data else { + fatalError("Converted cmark list had a node other than RawMarkup.listItem") + } + } + + switch cmark_node_get_list_type(state.node) { + case CMARK_BULLET_LIST: + return MarkupConversion(state: childConversion.state.next(), result: .unorderedList(parsedRange: parsedRange, childConversion.result)) + case CMARK_ORDERED_LIST: + return MarkupConversion(state: childConversion.state.next(), result: .orderedList(parsedRange: parsedRange, childConversion.result)) + default: + fatalError("cmark reported a list node but said its list type is CMARK_NO_LIST?") + } + } + + private static func convertListItem(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .item) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .listItem(checkbox: .none, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertCodeBlock(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .codeBlock) + let parsedRange = state.range(state.node) + let language = String(cString: cmark_node_get_fence_info(state.node)) + let code = getLiteralContent(node: state.node) + + return MarkupConversion(state: state.next(), result: .codeBlock(parsedRange: parsedRange, code: code, language: language.isEmpty ? nil : language)) + } + + private static func convertHTMLBlock(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .htmlBlock) + let parsedRange = state.range(state.node) + let html = getLiteralContent(node: state.node) + return MarkupConversion(state: state.next(), result: .htmlBlock(parsedRange: parsedRange, html: html)) + } + + private static func convertCustomBlock(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .customBlock) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .customBlock(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertParagraph(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .paragraph) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .paragraph(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertHeading(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .heading) + let parsedRange = state.range(state.node) + let headingLevel = Int(cmark_node_get_heading_level(state.node)) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .heading(level: headingLevel, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertThematicBreak(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .thematicBreak) + let parsedRange = state.range(state.node) + return MarkupConversion(state: state.next(), result: .thematicBreak(parsedRange: parsedRange)) + } + + private static func convertText(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .text) + let parsedRange = state.range(state.node) + let string = getLiteralContent(node: state.node) + return MarkupConversion(state: state.next(), result: .text(parsedRange: parsedRange, string: string)) + } + + private static func convertSoftBreak(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .softBreak) + let parsedRange = state.range(state.node) + return MarkupConversion(state: state.next(), result: .softBreak(parsedRange: parsedRange)) + } + + private static func convertLineBreak(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .lineBreak) + let parsedRange = state.range(state.node) + return MarkupConversion(state: state.next(), result: .lineBreak(parsedRange: parsedRange)) + } + + private static func convertInlineCode(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .code) + let parsedRange = state.range(state.node) + let literalContent = getLiteralContent(node: state.node) + if state.options.contains(.parseSymbolLinks), + cmark_node_get_backtick_count(state.node) > 1 { + return MarkupConversion(state: state.next(), result: .symbolLink(parsedRange: parsedRange, destination: literalContent)) + } else { + return MarkupConversion(state: state.next(), result: .inlineCode(parsedRange: parsedRange, code: literalContent)) + } + } + + private static func convertInlineHTML(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .html) + let parsedRange = state.range(state.node) + let html = getLiteralContent(node: state.node) + return MarkupConversion(state: state.next(), result: .inlineHTML(parsedRange: parsedRange, html: html)) + } + + private static func convertCustomInline(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .customInline) + let parsedRange = state.range(state.node) + let text = getLiteralContent(node: state.node) + return MarkupConversion(state: state.next(), result: .customInline(parsedRange: parsedRange, text: text)) + } + + private static func convertEmphasis(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .emphasis) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .emphasis(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertStrong(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .strong) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .strong(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertLink(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .link) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let destination = String(cString: cmark_node_get_url(state.node)) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .link(destination: destination, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertImage(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .image) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let destination = String(cString: cmark_node_get_url(state.node)) + let title = String(cString: cmark_node_get_title(state.node)) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .image(source: destination, title: title, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertStrikethrough(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .strikethrough) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .strikethrough(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertTaskListItem(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .taskListItem) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let checkbox: Checkbox = cmark_gfm_extensions_get_tasklist_item_checked(state.node) ? .checked : .unchecked + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .listItem(checkbox: checkbox, parsedRange: parsedRange, childConversion.result)) + } + + private static func convertTable(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .table) + let parsedRange = state.range(state.node) + let columnCount = Int(cmark_gfm_extensions_get_table_columns(state.node)) + let columnAlignments = (0.. Table.ColumnAlignment? in + // cmark tracks left, center, and right alignments as the ASCII + // characters 'l', 'c', and 'r'. + let ascii = cmark_gfm_extensions_get_table_alignments(state.node)[column] + let scalar = UnicodeScalar(ascii) + let character = Character(scalar) + switch character { + case "l": + return .left + case "r": + return .right + case "c": + return .center + case "\0": + return nil + default: + fatalError("Unexpected table column character for cmark table: \(character) (0x\(String(ascii, radix: 16)))") + } + } + + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + + var children = childConversion.result + + let header: RawMarkup + if let firstChild = children.first, + case .tableHead = firstChild.data { + header = firstChild + children.removeFirst() + } else { + header = .tableHead(parsedRange: nil, columns: []) + } + + if children.isEmpty { + precondition(childConversion.state.pendingTableBody == nil) + } + + let body: RawMarkup + if !children.isEmpty { + let pendingBody = childConversion.state.pendingTableBody! + body = RawMarkup.tableBody(parsedRange: pendingBody.range, rows: children) + } else { + body = .tableBody(parsedRange: nil, rows: []) + } + + return MarkupConversion(state: childConversion.state.next(clearPendingTableBody: true), + result: .table(columnAlignments: columnAlignments, + parsedRange: parsedRange, + header: header, + body: body)) + } + + private static func convertTableHeader(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .tableHead) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .tableHead(parsedRange: parsedRange, columns: childConversion.result)) + } + + private static func convertTableRow(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .tableRow) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .tableRow(parsedRange: parsedRange, childConversion.result)) + } + + private static func convertTableCell(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .tableCell) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .tableCell(parsedRange: parsedRange, childConversion.result)) + } + + static func parseString(_ string: String, source: URL?, options: ParseOptions) -> Document { + cmark_gfm_core_extensions_ensure_registered() + let parser = cmark_parser_new(CMARK_OPT_SMART) + cmark_parser_attach_syntax_extension(parser, cmark_find_syntax_extension("table")) + cmark_parser_attach_syntax_extension(parser, cmark_find_syntax_extension("strikethrough")) + cmark_parser_attach_syntax_extension(parser, cmark_find_syntax_extension("tasklist")) + cmark_parser_feed(parser, string, string.utf8.count) + let rawDocument = cmark_parser_finish(parser) + let initialState = MarkupConverterState(source: source, iterator: cmark_iter_new(rawDocument), event: CMARK_EVENT_NONE, node: nil, options: options, headerSeen: false, pendingTableBody: nil).next() + precondition(initialState.event == CMARK_EVENT_ENTER) + precondition(initialState.nodeType == .document) + let conversion = convertAnyElement(initialState) + guard case .document = conversion.result.data else { + fatalError("cmark top-level conversion didn't produce a RawMarkup.document") + } + + let finalState = conversion.state.next() + precondition(finalState.event == CMARK_EVENT_DONE) + precondition(finalState.node == nil) + precondition(initialState.iterator == finalState.iterator) + + precondition(initialState.node != nil) + + cmark_node_free(initialState.node) + cmark_iter_free(finalState.iterator) + cmark_parser_free(parser) + + let data = _MarkupData(AbsoluteRawMarkup(markup: conversion.result, + metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0))) + return makeMarkup(data) as! Document + } +} + diff --git a/Sources/Markdown/Parser/LazySplitLines.swift b/Sources/Markdown/Parser/LazySplitLines.swift new file mode 100644 index 00000000..68358d1e --- /dev/null +++ b/Sources/Markdown/Parser/LazySplitLines.swift @@ -0,0 +1,71 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +/// A lazy sequence of split lines that keeps track of initial indentation and +/// consecutive runs of empty lines. +struct LazySplitLines: Sequence { + struct Iterator: IteratorProtocol { + /// The current running count of consecutive empty lines before the current iteration. + private var precedingConsecutiveEmptyLineCount = 0 + + /// The raw lines to be iterated. + private let rawLines: [Substring] + + /// The current index of the iteration. + private var index: Array.Index + + /// The source file or resource from which the line came, + /// or `nil` if no such file or resource can be identified. + private var source: URL? + + init(_ input: S, source: URL?) where S.SubSequence == Substring { + self.rawLines = input.split(separator: "\n", maxSplits: Int.max, omittingEmptySubsequences: false) + self.index = rawLines.startIndex + self.source = source + } + + mutating func next() -> TrimmedLine? { + guard index != rawLines.endIndex else { + return nil + } + + let segment = TrimmedLine(rawLines[index], source: source, lineNumber: index + 1) + + index = rawLines.index(after: index) + + if segment.text.isEmpty { + precedingConsecutiveEmptyLineCount += 1 + } else { + precedingConsecutiveEmptyLineCount = 0 + } + + return segment + } + } + + /// The input to be lazily split on newlines. + private let input: Substring + + /// The source file or resource from which the line came, + /// or `nil` if no such file or resource can be identified. + private let source: URL? + + init(_ input: Substring, source: URL?) { + self.input = input + self.source = source + } + + func makeIterator() -> Iterator { + return Iterator(input, source: source) + } +} + diff --git a/Sources/Markdown/Parser/ParseOptions.swift b/Sources/Markdown/Parser/ParseOptions.swift new file mode 100644 index 00000000..407f0a52 --- /dev/null +++ b/Sources/Markdown/Parser/ParseOptions.swift @@ -0,0 +1,25 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// Options for parsing Markdown. +public struct ParseOptions: OptionSet { + public var rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Enable block directive syntax. + public static let parseBlockDirectives = ParseOptions(rawValue: 1 << 0) + + /// Enable interpretation of symbol links from inline code spans surrounded by two backticks. + public static let parseSymbolLinks = ParseOptions(rawValue: 1 << 1) +} + diff --git a/Sources/Markdown/Parser/RangeAdjuster.swift b/Sources/Markdown/Parser/RangeAdjuster.swift new file mode 100644 index 00000000..6fbb1420 --- /dev/null +++ b/Sources/Markdown/Parser/RangeAdjuster.swift @@ -0,0 +1,52 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A type for adjusting the columns of elements that are parsed in *line runs* +/// of the block directive parser to their locations before their indentation was trimmed. +struct RangeAdjuster: MarkupWalker { + /// The line number of the first line in the line run that needs adjustment. + var startLine: Int + + /// The tracker that will collect the adjusted ranges. + var ranges: RangeTracker + + /// An array of whitespace spans that were removed for each line, indexed + /// by line number. `nil` means that no whitespace was removed on that line. + var trimmedIndentationPerLine: [TrimmedLine.Lex?] + + mutating func defaultVisit(_ markup: Markup) { + /// This should only be used in the parser where ranges are guaranteed + /// to be filled in from cmark. + let adjustedRange = markup.range.map { range -> SourceRange in + // Add back the offset to the column as if the indentation weren't stripped. + let start = SourceLocation(line: startLine + range.lowerBound.line - 1, + column: range.lowerBound.column + (trimmedIndentationPerLine[range.lowerBound.line - 1]?.text.count ?? 0), + source: range.lowerBound.source) + let end = SourceLocation(line: startLine + range.upperBound.line - 1, + column: range.upperBound.column + (trimmedIndentationPerLine[range.upperBound.line - 1]?.text.count ?? 0), + source: range.upperBound.source) + return start.. Markup? { + let newChildren = markup.children.compactMap { + return self.visit($0) + } + return markup.withUncheckedChildren(newChildren) + } + public mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { + return defaultVisit(blockQuote) + } + public mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { + return defaultVisit(codeBlock) + } + public mutating func visitCustomBlock(_ customBlock: CustomBlock) -> Result { + return defaultVisit(customBlock) + } + public mutating func visitDocument(_ document: Document) -> Result { + return defaultVisit(document) + } + public mutating func visitHeading(_ heading: Heading) -> Result { + return defaultVisit(heading) + } + public mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { + return defaultVisit(thematicBreak) + } + public mutating func visitHTMLBlock(_ html: HTMLBlock) -> Result { + return defaultVisit(html) + } + public mutating func visitListItem(_ listItem: ListItem) -> Result { + return defaultVisit(listItem) + } + public mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { + return defaultVisit(orderedList) + } + public mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { + return defaultVisit(unorderedList) + } + public mutating func visitParagraph(_ paragraph: Paragraph) -> Result { + return defaultVisit(paragraph) + } + public mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> Result { + return defaultVisit(blockDirective) + } + public mutating func visitInlineCode(_ inlineCode: InlineCode) -> Result { + return defaultVisit(inlineCode) + } + public mutating func visitCustomInline(_ customInline: CustomInline) -> Result { + return defaultVisit(customInline) + } + public mutating func visitEmphasis(_ emphasis: Emphasis) -> Result { + return defaultVisit(emphasis) + } + public mutating func visitImage(_ image: Image) -> Result { + return defaultVisit(image) + } + public mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> Result { + return defaultVisit(inlineHTML) + } + public mutating func visitLineBreak(_ lineBreak: LineBreak) -> Result { + return defaultVisit(lineBreak) + } + public mutating func visitLink(_ link: Link) -> Result { + return defaultVisit(link) + } + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Result { + return defaultVisit(softBreak) + } + public mutating func visitStrong(_ strong: Strong) -> Result { + return defaultVisit(strong) + } + public mutating func visitText(_ text: Text) -> Result { + return defaultVisit(text) + } +} diff --git a/Sources/Markdown/Structural Restrictions/BasicBlockContainer.swift b/Sources/Markdown/Structural Restrictions/BasicBlockContainer.swift new file mode 100644 index 00000000..7db4b8b1 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/BasicBlockContainer.swift @@ -0,0 +1,24 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block element that can contain only other block elements and doesn't require any other information. +public protocol BasicBlockContainer: BlockContainer { + /// Create this element from a sequence of block markup elements. + init(_ children: Children) where Children.Element == BlockMarkup +} + +// MARK: - Public API + +extension BasicBlockContainer { + /// Create this element with a sequence of block markup elements. + public init(_ children: BlockMarkup...) { + self.init(children) + } +} diff --git a/Sources/Markdown/Structural Restrictions/BasicInlineContainer.swift b/Sources/Markdown/Structural Restrictions/BasicInlineContainer.swift new file mode 100644 index 00000000..00a0d8d1 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/BasicInlineContainer.swift @@ -0,0 +1,22 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block or inline markup element that can contain only `InlineMarkup` elements and doesn't require any other information. +public protocol BasicInlineContainer: InlineContainer { + /// Create this element with a sequence of inline markup elements. + init(_ children: Children) where Children.Element == InlineMarkup +} + +extension BasicInlineContainer { + /// Create this element with a sequence of inline markup elements. + public init(_ children: InlineMarkup...) { + self.init(children) + } +} diff --git a/Sources/Markdown/Structural Restrictions/BlockContainer.swift b/Sources/Markdown/Structural Restrictions/BlockContainer.swift new file mode 100644 index 00000000..0c5773b7 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/BlockContainer.swift @@ -0,0 +1,37 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block element whose children must conform to `BlockMarkup` +public protocol BlockContainer: BlockMarkup {} + +// MARK: - Public API + +public extension BlockContainer { + /// The inline child elements of this element. + /// + /// - Precondition: All children of an `InlineContainer` + /// must conform to `InlineMarkup`. + var blockChildren: LazyMapSequence { + return children.lazy.map { $0 as! BlockMarkup } + } + + /// Replace all inline child elements with a new sequence of inline elements. + mutating func setBlockChildren(_ newChildren: Items) where Items.Element == BlockMarkup { + replaceChildrenInRange(0..(_ range: Range, with incomingItems: Items) where Items.Element == BlockMarkup { + var rawChildren = raw.markup.copyChildren() + rawChildren.replaceSubrange(range, with: incomingItems.map { $0.raw.markup }) + let newRaw = raw.markup.withChildren(rawChildren) + _data = _data.replacingSelf(newRaw) + } +} diff --git a/Sources/Markdown/Structural Restrictions/BlockMarkup.swift b/Sources/Markdown/Structural Restrictions/BlockMarkup.swift new file mode 100644 index 00000000..2b7eb491 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/BlockMarkup.swift @@ -0,0 +1,12 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A block markup element. +public protocol BlockMarkup: Markup {} diff --git a/Sources/Markdown/Structural Restrictions/InlineContainer.swift b/Sources/Markdown/Structural Restrictions/InlineContainer.swift new file mode 100644 index 00000000..48d861b1 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/InlineContainer.swift @@ -0,0 +1,45 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An element whose children must conform to `InlineMarkup` +public protocol InlineContainer: PlainTextConvertibleMarkup {} + +// MARK: - Public API + +public extension InlineContainer { + /// The inline child elements of this element. + /// + /// - Precondition: All children of an `InlineContainer` + /// must conform to `InlineMarkup`. + var inlineChildren: LazyMapSequence { + return children.lazy.map { $0 as! InlineMarkup } + } + + /// Replace all inline child elements with a new sequence of inline elements. + mutating func setInlineChildren(_ newChildren: Items) where Items.Element == InlineMarkup { + replaceChildrenInRange(0..(_ range: Range, with incomingItems: Items) where Items.Element == InlineMarkup { + var rawChildren = raw.markup.copyChildren() + rawChildren.replaceSubrange(range, with: incomingItems.map { $0.raw.markup }) + let newRaw = raw.markup.withChildren(rawChildren) + _data = _data.replacingSelf(newRaw) + } + + // MARK: PlainTextConvertibleMarkup + + var plainText: String { + return children.compactMap { + return ($0 as? InlineMarkup)?.plainText + }.joined() + } +} diff --git a/Sources/Markdown/Structural Restrictions/InlineMarkup.swift b/Sources/Markdown/Structural Restrictions/InlineMarkup.swift new file mode 100644 index 00000000..00d43f55 --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/InlineMarkup.swift @@ -0,0 +1,19 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An inline markup element. +public protocol InlineMarkup: PlainTextConvertibleMarkup {} + +/// An inline element that can recur throughout any structure. +/// +/// This is mostly used to prevent some kinds of elements from nesting; for +/// example, you cannot put a ``Link`` inside another ``Link`` or an ``Image`` +/// inside another ``Image``. +public protocol RecurringInlineMarkup: InlineMarkup {} diff --git a/Sources/Markdown/Structural Restrictions/ListItemContainer.swift b/Sources/Markdown/Structural Restrictions/ListItemContainer.swift new file mode 100644 index 00000000..39748dda --- /dev/null +++ b/Sources/Markdown/Structural Restrictions/ListItemContainer.swift @@ -0,0 +1,54 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// A markup element that can contain only `ListItem`s as children and require no other information. +public protocol ListItemContainer: BlockMarkup { + /// Create a list from a sequence of items. + init(_ items: Items) where Items.Element == ListItem +} + +// MARK: - Public API + +public extension ListItemContainer { + /// Create a list with one item. + init(_ item: ListItem) { + self.init(CollectionOfOne(item)) + } + /// Create a list with the given `ListItem`s. + init(_ items: ListItem...) { + self.init(items) + } + + /// The items of the list. + /// + /// - Precondition: All children of a `ListItemContainer` + /// must be a `ListItem`. + var listItems: LazyMapSequence { + return children.lazy.map { $0 as! ListItem } + } + + /// Replace all list items with a sequence of items. + mutating func setListItems(_ newItems: Items) where Items.Element == ListItem { + replaceItemsInRange(0..(_ range: Range, with incomingItems: Items) where Items.Element == ListItem { + var rawChildren = raw.markup.copyChildren() + rawChildren.replaceSubrange(range, with: incomingItems.map { $0.raw.markup }) + let newRaw = raw.markup.withChildren(rawChildren) + _data = _data.replacingSelf(newRaw) + } + + /// Append an item to the list. + mutating func appendItem(_ item: ListItem) { + replaceItemsInRange(childCount.. UInt64 { + return _cmarkup_increment_and_get_unique_id() + } +} diff --git a/Sources/Markdown/Utility/CharacterExtensions.swift b/Sources/Markdown/Utility/CharacterExtensions.swift new file mode 100644 index 00000000..1b5d915b --- /dev/null +++ b/Sources/Markdown/Utility/CharacterExtensions.swift @@ -0,0 +1,16 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +extension Character { + /// The character as a ``Swift.String`` surrounded by single quotation marks `'`. + var singleQuoted: String { + return "'\(self)'" + } +} diff --git a/Sources/Markdown/Utility/CollectionExtensions.swift b/Sources/Markdown/Utility/CollectionExtensions.swift new file mode 100644 index 00000000..c5991090 --- /dev/null +++ b/Sources/Markdown/Utility/CollectionExtensions.swift @@ -0,0 +1,26 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +extension RangeReplaceableCollection { + /// Append filler elements until ``count`` is at least `minCount`. + mutating func ensureCount(atLeast minCount: Int, filler: Element) { + let neededElementCount = minCount - count + if neededElementCount > 0 { + self.append(contentsOf: Array(repeating: filler, count: neededElementCount)) + } + } + + /// Return a copy of `self` with filler elements appended until ``count`` is at least `minCount`. + func ensuringCount(atLeast minCount: Int, filler: Element) -> Self { + var maybeExtend = self + maybeExtend.ensureCount(atLeast: minCount, filler: filler) + return maybeExtend + } +} diff --git a/Sources/Markdown/Utility/StringExtensions.swift b/Sources/Markdown/Utility/StringExtensions.swift new file mode 100644 index 00000000..22704e75 --- /dev/null +++ b/Sources/Markdown/Utility/StringExtensions.swift @@ -0,0 +1,16 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +extension StringProtocol { + /// `self` surrounded by single quotation marks `'`. + var singleQuoted: String { + return "'\(self)'" + } +} diff --git a/Sources/Markdown/Visitor/MarkupVisitor.swift b/Sources/Markdown/Visitor/MarkupVisitor.swift new file mode 100644 index 00000000..2532f7f5 --- /dev/null +++ b/Sources/Markdown/Visitor/MarkupVisitor.swift @@ -0,0 +1,365 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// Visits `Markup` elements and returns a result. +/// +/// - note: This interface only provides requirements for visiting each kind of element. It does not require each visit method to descend into child elements. +/// +/// Generally, ``MarkupWalker`` is best for walking a ``Markup`` tree if the ``Result`` type is `Void` or is built up some other way, or ``MarkupRewriter`` for recursively changing a tree's structure. This type serves as a common interface to both. However, for building up other structured result types you can implement ``MarkupWalker`` directly. +public protocol MarkupVisitor { + + /** + The result type returned when visiting a element. + */ + associatedtype Result + + /** + A default implementation to use when a visitor method isn't implemented for a particular element. + - parameter markup: the element to visit. + - returns: The result of the visit. + */ + mutating func defaultVisit(_ markup: Markup) -> Result + + /** + Visit any kind of `Markup` element and return the result. + + - parameter markup: Any kind of `Markup` element. + - returns: The result of the visit. + */ + mutating func visit(_ markup: Markup) -> Result + + /** + Visit a `BlockQuote` element and return the result. + + - parameter blockQuote: A `BlockQuote` element. + - returns: The result of the visit. + */ + mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result + + /** + Visit a `CodeBlock` element and return the result. + + - parameter codeBlock: An `CodeBlock` element. + - returns: The result of the visit. + */ + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result + + /** + Visit a `CustomBlock` element and return the result. + + - parameter customBlock: An `CustomBlock` element. + - returns: The result of the visit. + */ + mutating func visitCustomBlock(_ customBlock: CustomBlock) -> Result + + /** + Visit a `Document` element and return the result. + + - parameter document: An `Document` element. + - returns: The result of the visit. + */ + mutating func visitDocument(_ document: Document) -> Result + + /** + Visit a `Heading` element and return the result. + + - parameter heading: An `Heading` element. + - returns: The result of the visit. + */ + mutating func visitHeading(_ heading: Heading) -> Result + + /** + Visit a `ThematicBreak` element and return the result. + + - parameter thematicBreak: An `ThematicBreak` element. + - returns: The result of the visit. + */ + mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result + + /** + Visit an `HTML` element and return the result. + + - parameter html: An `HTML` element. + - returns: The result of the visit. + */ + mutating func visitHTMLBlock(_ html: HTMLBlock) -> Result + + /** + Visit a `ListItem` element and return the result. + + - parameter listItem: An `ListItem` element. + - returns: The result of the visit. + */ + mutating func visitListItem(_ listItem: ListItem) -> Result + + /** + Visit a `OrderedList` element and return the result. + + - parameter orderedList: An `OrderedList` element. + - returns: The result of the visit. + */ + mutating func visitOrderedList(_ orderedList: OrderedList) -> Result + + /** + Visit a `UnorderedList` element and return the result. + + - parameter unorderedList: An `UnorderedList` element. + - returns: The result of the visit. + */ + mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result + + /** + Visit a `Paragraph` element and return the result. + + - parameter paragraph: An `Paragraph` element. + - returns: The result of the visit. + */ + mutating func visitParagraph(_ paragraph: Paragraph) -> Result + + /** + Visit a `BlockDirective` element and return the result. + + - parameter blockDirective: A `BlockDirective` element. + - returns: The result of the visit. + */ + mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> Result + + /** + Visit a `InlineCode` element and return the result. + + - parameter inlineCode: An `InlineCode` element. + - returns: The result of the visit. + */ + mutating func visitInlineCode(_ inlineCode: InlineCode) -> Result + + /** + Visit a `CustomInline` element and return the result. + + - parameter customInline: An `CustomInline` element. + - returns: The result of the visit. + */ + mutating func visitCustomInline(_ customInline: CustomInline) -> Result + + /** + Visit a `Emphasis` element and return the result. + + - parameter emphasis: An `Emphasis` element. + - returns: The result of the visit. + */ + mutating func visitEmphasis(_ emphasis: Emphasis) -> Result + + /** + Visit a `Image` element and return the result. + + - parameter image: An `Image` element. + - returns: The result of the visit. + */ + mutating func visitImage(_ image: Image) -> Result + + /** + Visit a `InlineHTML` element and return the result. + + - parameter inlineHTML: An `InlineHTML` element. + - returns: The result of the visit. + */ + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> Result + + /** + Visit a `LineBreak` element and return the result. + + - parameter lineBreak: An `LineBreak` element. + - returns: The result of the visit. + */ + mutating func visitLineBreak(_ lineBreak: LineBreak) -> Result + + /** + Visit a `Link` element and return the result. + + - parameter link: An `Link` element. + - returns: The result of the visit. + */ + mutating func visitLink(_ link: Link) -> Result + + /** + Visit a `SoftBreak` element and return the result. + + - parameter softBreak: An `SoftBreak` element. + - returns: The result of the visit. + */ + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Result + + /** + Visit a `Strong` element and return the result. + + - parameter strong: An `Strong` element. + - returns: The result of the visit. + */ + mutating func visitStrong(_ strong: Strong) -> Result + + /** + Visit a `Text` element and return the result. + + - parameter text: A `Text` element. + - returns: The result of the visit. + */ + mutating func visitText(_ text: Text) -> Result + + /** + Visit a `Strikethrough` element and return the result. + + - parameter strikethrough: A `Strikethrough` element. + - returns: The result of the visit. + */ + mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> Result + + /** + Visit a `Table` element and return the result. + + - parameter table: A `Table` element. + - returns: The result of the visit. + */ + mutating func visitTable(_ table: Table) -> Result + + /** + Visit a `Table.Head` element and return the result. + + - parameter tableHead: A `Table.Head` element. + - returns: The result of the visit. + */ + mutating func visitTableHead(_ tableHead: Table.Head) -> Result + + /** + Visit a `Table.Body` element and return the result. + + - parameter tableBody: A `Table.Body` element. + - returns: The result of the visit. + */ + mutating func visitTableBody(_ tableBody: Table.Body) -> Result + + /** + Visit a `Table.Row` element and return the result. + + - parameter tableRow: A `Table.Row` element. + - returns: The result of the visit. + */ + mutating func visitTableRow(_ tableRow: Table.Row) -> Result + + /** + Visit a `Table.Cell` element and return the result. + + - parameter tableCell: A `Table.Cell` element. + - returns: The result of the visit. + */ + mutating func visitTableCell(_ tableCell: Table.Cell) -> Result + + /** + Visit a `SymbolLink` element and return the result. + + - parameter symbolLink: A `SymbolLink` element. + - returns: The result of the visit. + */ + mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> Result +} + +extension MarkupVisitor { + // Default implementation: call `accept` on the markup element, + // dispatching into each leaf element's implementation, which then + // dispatches to the correct visit___ method. + public mutating func visit(_ markup: Markup) -> Result { + return markup.accept(&self) + } + public mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { + return defaultVisit(blockQuote) + } + public mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { + return defaultVisit(codeBlock) + } + public mutating func visitCustomBlock(_ customBlock: CustomBlock) -> Result { + return defaultVisit(customBlock) + } + public mutating func visitDocument(_ document: Document) -> Result { + return defaultVisit(document) + } + public mutating func visitHeading(_ heading: Heading) -> Result { + return defaultVisit(heading) + } + public mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { + return defaultVisit(thematicBreak) + } + public mutating func visitHTMLBlock(_ html: HTMLBlock) -> Result { + return defaultVisit(html) + } + public mutating func visitListItem(_ listItem: ListItem) -> Result { + return defaultVisit(listItem) + } + public mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { + return defaultVisit(orderedList) + } + public mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { + return defaultVisit(unorderedList) + } + public mutating func visitParagraph(_ paragraph: Paragraph) -> Result { + return defaultVisit(paragraph) + } + public mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> Result { + return defaultVisit(blockDirective) + } + public mutating func visitInlineCode(_ inlineCode: InlineCode) -> Result { + return defaultVisit(inlineCode) + } + public mutating func visitCustomInline(_ customInline: CustomInline) -> Result { + return defaultVisit(customInline) + } + public mutating func visitEmphasis(_ emphasis: Emphasis) -> Result { + return defaultVisit(emphasis) + } + public mutating func visitImage(_ image: Image) -> Result { + return defaultVisit(image) + } + public mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> Result { + return defaultVisit(inlineHTML) + } + public mutating func visitLineBreak(_ lineBreak: LineBreak) -> Result { + return defaultVisit(lineBreak) + } + public mutating func visitLink(_ link: Link) -> Result { + return defaultVisit(link) + } + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Result { + return defaultVisit(softBreak) + } + public mutating func visitStrong(_ strong: Strong) -> Result { + return defaultVisit(strong) + } + public mutating func visitText(_ text: Text) -> Result { + return defaultVisit(text) + } + public mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> Result { + return defaultVisit(strikethrough) + } + public mutating func visitTable(_ table: Table) -> Result { + return defaultVisit(table) + } + public mutating func visitTableHead(_ tableHead: Table.Head) -> Result { + return defaultVisit(tableHead) + } + public mutating func visitTableBody(_ tableBody: Table.Body) -> Result { + return defaultVisit(tableBody) + } + public mutating func visitTableRow(_ tableRow: Table.Row) -> Result { + return defaultVisit(tableRow) + } + public mutating func visitTableCell(_ tableCell: Table.Cell) -> Result { + return defaultVisit(tableCell) + } + public mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> Result { + return defaultVisit(symbolLink) + } +} diff --git a/Sources/Markdown/Walker/MarkupWalker.swift b/Sources/Markdown/Walker/MarkupWalker.swift new file mode 100644 index 00000000..ad146696 --- /dev/null +++ b/Sources/Markdown/Walker/MarkupWalker.swift @@ -0,0 +1,26 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +/// An interface for walking a `Markup` tree without altering it. +public protocol MarkupWalker: MarkupVisitor where Result == Void {} + +extension MarkupWalker { + /// Continue walking by descending in the given element. + /// + /// - Parameter markup: the element whose children the walker should visit. + public mutating func descendInto(_ markup: Markup) { + for child in markup.children { + visit(child) + } + } + public mutating func defaultVisit(_ markup: Markup) { + descendInto(markup) + } +} diff --git a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift new file mode 100644 index 00000000..d3f6995d --- /dev/null +++ b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift @@ -0,0 +1,1075 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +fileprivate extension Markup { + /// The parental chain of elements from a root to this element. + var parentalChain: [Markup] { + var stack: [Markup] = [self] + var current: Markup = self + while let parent = current.parent { + stack.append(parent) + current = parent + } + return stack.reversed() + } + + /// Return the first ancestor that matches a condition, or `nil` if there is no such ancestor. + func firstAncestor(where ancestorMatches: (Markup) -> Bool) -> Markup? { + var currentParent = parent + while let current = currentParent { + if ancestorMatches(current) { + return current + } + currentParent = current.parent + } + return nil + } +} + +fileprivate extension String { + /// This string, split by newline characters, dropping leading and trailing lines that are empty. + var trimmedLineSegments: ArraySlice { + var splitLines = split(separator: "\n", omittingEmptySubsequences: false)[...].drop { $0.isEmpty } + while let lastLine = splitLines.last, lastLine.isEmpty { + splitLines = splitLines.dropLast() + } + return splitLines + } +} + +fileprivate extension CodeBlock { + /// The code contents split by newline (`\n`), dropping leading and trailing lines that are empty. + var trimmedLineSegments: ArraySlice { + return code.trimmedLineSegments + } +} + +fileprivate extension HTMLBlock { + /// The HTML contents split by newline (`\n`), dropping leading and trailing lines that are empty. + var trimmedLineSegments: ArraySlice { + return rawHTML.trimmedLineSegments + } +} + +fileprivate extension Table.Cell { + /// Format a table cell independently as if it were its own document. + /// The ``visitTable(_:)`` method will use this to put a formatted + /// table together after formatting and measuring the dimensions of all + /// of the cells. + func formatIndependently(options: MarkupFormatter.Options) -> String { + /// Replaces all soft and hard breaks with a single space. + struct BreakDeleter: MarkupRewriter { + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Markup? { + return Text(" ") + } + + mutating func visitLineBreak(_ softBreak: SoftBreak) -> Markup? { + return Text(" ") + } + } + // By "independently", we mean that the cell should be printed without + // being affected by ancestral elements. + // For example, a table might be inside a blockquote. + // We don't want any outside context to affect the printing of the cell + // in this method. So, we'll copy the cell out of its parent + // before printing. + + // The syntax for table cells doesn't support newlines, unfortunately. + // Just in case some were inserted programmatically, remove them. + var breakDeleter = BreakDeleter() + let detachedSelfWithoutBreaks = breakDeleter.visit(self.detachedFromParent)! + + var cellFormatter = MarkupFormatter(formattingOptions: options) + for inline in detachedSelfWithoutBreaks.children { + cellFormatter.visit(inline) + } + return cellFormatter.result + } +} + +/// Prints a `Markup` tree with formatting options. +public struct MarkupFormatter: MarkupWalker { + /** + Formatting options for Markdown, based on [CommonMark](https://commonmark.org). + */ + public struct Options { + /** + The marker character to use for unordered lists. + */ + public enum UnorderedListMarker: String, CaseIterable { + /// A dash character (`-`). + case dash = "-" + + /// A plus character (`+`). + case plus = "+" + + /// A star/asterisk character (`*`). + case star = "*" + } + + /// When to use a code fence for code blocks. + public enum UseCodeFence: String, CaseIterable { + /// Use a code fence only when a language is present on the + /// code block already. + case onlyWhenLanguageIsPresent = "when-language-present" + + /// Always use a code fence. + case always = "always" + + /// Never use a code fence. + /// + /// > Note: This will strip code block languages. + case never = "never" + } + + /// The character to use for thematic breaks. + public enum ThematicBreakCharacter: String, CaseIterable { + /// A dash character (`-`). + case dash = "-" + + /// An underline/underbar character (`_`). + case underline = "_" + + /// A star/asterisk character (`*`). + case star = "*" + } + + /// The character to use for emphasis and strong emphasis markers. + public enum EmphasisMarker: String, CaseIterable { + /// A star/asterisk character (`*`). + case star = "*" + + /// An underline/underbar character (`_`). + case underline = "_" + } + + /// The preferred heading style. + public enum PreferredHeadingStyle: String, CaseIterable { + /// ATX-style headings. + /// + /// Example: + /// ```markdown + /// # Level-1 heading + /// ## Level-2 heading + /// ... + /// ``` + case atx = "atx" + + /// Setext-style headings, limited to level 1 and 2 headings. + /// + /// Example: + /// ```markdown + /// Level-1 Heading + /// =============== + /// + /// Level-2 Heading + /// --------------- + /// ``` + /// + /// > Note: Setext-style headings only define syntax for heading + /// > levels 1 and 2. To preserve structure, headings with level + /// > 3 or above will use ATX-style headings. + case setext = "setext" + } + + /// The start numeral and counting style for ordered lists. + public enum OrderedListNumerals { + /// Use `start` for all ordered list markers, letting markdown + /// parsers automatically increment from the `start`. + case allSame(UInt) + + /// Print increasing ordered list marker numerals with each + /// list item. + case incrementing(start: UInt) + } + + /** + The preferred maximum line length and element for splitting that reach that preferred limit. + - Note: This is a *preferred* line limit, not an absolute one. + */ + public struct PreferredLineLimit { + /// The element to use when splitting lines that are longer than the preferred line length. + public enum SplittingElement: String, CaseIterable { + /** + Split ``Text`` elements with ``SoftBreak`` elements if a line length + approaches the preferred maximum length if possible. + */ + case softBreak = "soft-break" + /** + Split ``Text`` elements with ``LineBreak`` (a.k.a. *hard break*) elements if a line length + approaches the preferred maximum length if possible. + */ + case hardBreak = "hard-break" + } + + /// The method for splitting lines + public var lineSplittingElement: SplittingElement + + /// The preferred maximum line length. + public var maxLength: Int + + /** + Create a preferred line limit. + + - parameter maxLength: The maximum line length desired. + - parameter splittingElement: The element used to split ``Text`` elements. + */ + public init(maxLength: Int, breakWith splittingElement: SplittingElement) { + precondition(maxLength > 0) + self.maxLength = maxLength + self.lineSplittingElement = splittingElement + } + } + + // MARK: Option Properties + + var orderedListNumerals: OrderedListNumerals + var unorderedListMarker: UnorderedListMarker + var useCodeFence: UseCodeFence + var defaultCodeBlockLanguage: String? + var thematicBreakCharacter: ThematicBreakCharacter + var thematicBreakLength: UInt + var emphasisMarker: EmphasisMarker + var condenseAutolinks: Bool + var preferredHeadingStyle: PreferredHeadingStyle + var preferredLineLimit: PreferredLineLimit? + var customLinePrefix: String + + /** + Create a set of formatting options to use when printing an element. + + - Parameters: + - unorderedListMarker: The character to use for unordered list markers. + - orderedListNumerals: The counting behavior and start numeral for ordered list markers. + - useCodeFence: Decides when to use code fences on code blocks + - defaultCodeBlockLanguage: The default language string to use when code blocks don't have a language and will be printed as fenced code blocks. + - thematicBreakCharacter: The character to use for thematic breaks. + - thematicBreakLength: The length of printed thematic breaks. + - emphasisMarker: The character to use for emphasis and strong emphasis markers. + - condenseAutolinks: Print links whose link text and destination match as autolinks, e.g. ``. + - preferredHeadingStyle: The preferred heading style. + - lineLimit: The preferred maximum line length and method for splitting ``Text`` elements in an attempt to maintain that line length. + - customLinePrefix: An addition prefix to print at the start of each line, useful for adding documentation comment markers. + */ + public init(unorderedListMarker: UnorderedListMarker = .dash, + orderedListNumerals: OrderedListNumerals = .allSame(1), + useCodeFence: UseCodeFence = .always, + defaultCodeBlockLanguage: String? = nil, + thematicBreakCharacter: ThematicBreakCharacter = .dash, + thematicBreakLength: UInt = 5, + emphasisMarker: EmphasisMarker = .star, + condenseAutolinks: Bool = true, + preferredHeadingStyle: PreferredHeadingStyle = .atx, + preferredLineLimit: PreferredLineLimit? = nil, + customLinePrefix: String = "") { + self.unorderedListMarker = unorderedListMarker + self.orderedListNumerals = orderedListNumerals + self.useCodeFence = useCodeFence + self.defaultCodeBlockLanguage = defaultCodeBlockLanguage + self.thematicBreakCharacter = thematicBreakCharacter + self.emphasisMarker = emphasisMarker + self.condenseAutolinks = condenseAutolinks + self.preferredHeadingStyle = preferredHeadingStyle + self.preferredLineLimit = preferredLineLimit + // Per CommonMark spec, thematic breaks must be at least + // three characters long. + self.thematicBreakLength = max(3, thematicBreakLength) + self.customLinePrefix = customLinePrefix + } + + /// The default set of formatting options. + public static let `default` = Options() + } + + /// Formatting options to use while printing. + public var formattingOptions: Options + + /// The formatted result. + public private(set) var result = "" + // > Warning! Do not directly append to ``result`` in any method except: + // > - ``print(_:for:)`` + // > - ``addressPendingNewlines(for:)`` + // > + // > Be careful to update ``state`` when appending to ``result``. + // > + // > Use ``print(_:for:)`` for all general purpose printing. This + // > makes sure pending newlines, indentation, and prefixes are + // > consistently addressed. + + /// Create a `MarkupPrinter` with formatting options. + public init(formattingOptions: Options = .default) { + self.formattingOptions = formattingOptions + } + + // MARK: Formatter State + + /// The state of the formatter, excluding the formatted result so that + /// unnecessary String copies aren't made. + /// + /// Since the formatted result is only ever appended to, we can use + /// prior state to erase what was printed since the last state save. + struct State { + /// The current length of the formatted result. + /// + /// This is used to undo speculative prints in certain situations. + var currentLength = 0 + + /// The number of newlines waiting to be printed before any other + /// content is printed. + var queuedNewlines = 0 + + /// The number of empty lines up to now. + var newlineStreak = 0 + + /// The length of the last line. + var lastLineLength = 0 + + /// The current line number. + var lineNumber = 0 + } + + /// The state of the formatter. + var state = State() + + // MARK: Formatter Utilities + + /// True if the current line length is over the preferred line limit. + var isOverPreferredLineLimit: Bool { + guard let lineLimit = formattingOptions.preferredLineLimit else { + return false + } + return state.lastLineLength >= lineLimit.maxLength + } + + /** + Given a parental chain, returns the line prefix needed when printing + a new line while visiting a given element. + + For example, in the following hierarchy: + + ``` + Document + └─ BlockQuote + ``` + + Each new line in the block quote needs a "`> `" line prefix before printing + anything else inside it. + + To refine this example, say we have this hierarchy: + + ``` + Document + └─ BlockQuote + └─ Paragraph + ├─ Text "A" + ├─ SoftBreak + └─ Text "blockquote" + ``` + + When we go to print `Text("A")`, we need to start with the line prefix + "`> `": + + ``` + > A + ``` + + We see the `SoftBreak` and queue up a newline. + Moving to `Text("blockquote")`, we address the queue newline first: + + ``` + > A + > + ``` + + and then print its contents. + + ``` + > A + > blockquote. + ``` + + This should work with multiple nesting. + */ + func linePrefix(for element: Markup) -> String { + var prefix = formattingOptions.customLinePrefix + var unorderedListCount = 0 + var orderedListCount = 0 + for element in element.parentalChain { + if element is BlockQuote { + prefix += "> " + } else if element is UnorderedList { + if unorderedListCount > 0 { + prefix += " " + } + unorderedListCount += 1 + } else if element is OrderedList { + if orderedListCount > 0 { + prefix += " " + } + orderedListCount += 1 + } else if !(element is ListItem), + let parentListItem = element.parent as? ListItem { + /* + Align contents with list item markers. + + Example, unordered lists: + + - First line + Second line, aligned. + + Example, ordered lists: + + 1. First line + Second line, aligned. + 1000. First line + Second line, aligned. + */ + + if parentListItem.parent is UnorderedList { + // Unordered list markers are of fixed length. + prefix += " " + } else if let numeralPrefix = numeralPrefix(for: parentListItem) { + prefix += String(repeating: " ", count: numeralPrefix.count) + } + } else if element is BlockDirective { + prefix += " " + } + } + return prefix + } + + /// Queue a newline for printing, which will be lazily printed the next + /// time any non-newline characters are printed. + mutating func queueNewline(count: Int = 1) { + state.queuedNewlines += count + } + + /// Address pending newlines while printing an element. + /// + /// > Note: When printing a newline, each kind of element may require + /// > a prefix on each line in order to continue printing its content, + /// > such as block quotes, which require a `>` character on each line. + /// + /// - SeeAlso: ``linePrefix(for:)``. + mutating func addressPendingNewlines(for element: Markup) { + guard state.queuedNewlines > 0 else { + // Return early to prevent current line length from + // getting modified below. + return + } + + let prefix = linePrefix(for: element) + + for _ in 0.. String? { + guard listItem.parent is OrderedList else { + return nil + } + let numeral: UInt + switch formattingOptions.orderedListNumerals { + case let .allSame(n): + numeral = n + case let .incrementing(start): + numeral = start + UInt(listItem.indexInParent) + } + return "\(numeral). " + } + + /// Address any pending newlines and print raw text while visiting an element. + mutating func print(_ rawText: S, for element: Markup) { + addressPendingNewlines(for: element) + + // If this is the first time we're printing something, we can't + // use newlines and ``addressPendingNewlines(for:)` to drive + // printing a prefix, so add the prefix manually just this once. + if result.isEmpty { + let prefix = linePrefix(for: element) + result += prefix + state.currentLength += prefix.count + state.lastLineLength += prefix.count + } + + result += rawText + state.currentLength += rawText.count + state.lastLineLength += rawText.count + state.newlineStreak = 0 + } + + /// Print raw text while visiting an element, wrapping automatically with + /// soft or hard line breaks. + /// + /// If there is no preferred line limit set in the formatting options, + /// ``print(_:for:)`` as usual without automatic wrapping. + mutating func softWrapPrint(_ string: String, for element: InlineMarkup) { + guard let lineLimit = formattingOptions.preferredLineLimit else { + print(string, for: element) + return + } + + // Headings may not have soft breaks. + guard element.firstAncestor(where: { $0 is Heading }) == nil else { + print(string, for: element) + return + } + + let words = string.components(separatedBy: CharacterSet(charactersIn: " \t"))[...] + + /// Hard line breaks in Markdown require two spaces before the newline; soft breaks require none. + let breakSuffix: String + switch lineLimit.lineSplittingElement { + case .hardBreak: + breakSuffix = " " + case .softBreak: + breakSuffix = "" + } + + var wordsThisLine = 0 + for index in words.indices { + let word = words[index] + + if index == words.startIndex && wordsThisLine == 0 { + // Always print the first word if it's at the start of a line. + // A break won't help here and could actually hurt by + // unintentionally starting a new paragraph. + // However, there is one exception: + // we might already be right at the edge of a line when + // this method was called. + if state.lastLineLength + word.count >= lineLimit.maxLength { + queueNewline() + } + print(word, for: element) + wordsThisLine += 1 + continue + } + + if state.lastLineLength + word.count + breakSuffix.count + 1 >= lineLimit.maxLength { + print(breakSuffix, for: element) + queueNewline() + wordsThisLine = 0 + } + + // Insert a space between words. + if wordsThisLine > 0 { + print(" ", for: element) + } + + // Finally, print the word. + print(word, for: element) + wordsThisLine += 1 + } + } + + /// Restore state to a previous state, trimming off what was printed since then. + mutating func restoreState(to previousState: State) { + result.removeLast(state.currentLength - previousState.currentLength) + state = previousState + } + + // MARK: Formatter Walker Methods + + public func defaultVisit(_ markup: Markup) { + fatalError("Formatter not implemented for \(type(of: markup))") + } + + public mutating func visitDocument(_ document: Document) { + descendInto(document) + } + + public mutating func visitParagraph(_ paragraph: Paragraph) { + if paragraph.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + descendInto(paragraph) + } + + public mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + if codeBlock.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + + let lines = codeBlock.trimmedLineSegments + + let shouldUseFence: Bool + + switch formattingOptions.useCodeFence { + case .never: + shouldUseFence = false + case .always: + shouldUseFence = true + case .onlyWhenLanguageIsPresent: + shouldUseFence = codeBlock.language != nil + } + + if shouldUseFence { + print("```", for: codeBlock) + print(codeBlock.language ?? formattingOptions.defaultCodeBlockLanguage ?? "", for: codeBlock) + queueNewline() + } + + for index in lines.indices { + let line = lines[index] + + if index != lines.startIndex { + queueNewline() + } + + let indentation = shouldUseFence ? "" : " " + print(indentation + line, for: codeBlock) + } + + if shouldUseFence { + queueNewline() + print("```", for: codeBlock) + } + } + + public mutating func visitBlockQuote(_ blockQuote: BlockQuote) { + if let parent = blockQuote.parent { + if parent is BlockQuote { + queueNewline() + } else if blockQuote.indexInParent > 0 { + queueNewline() + addressPendingNewlines(for: parent) + queueNewline() + } + } + descendInto(blockQuote) + } + + mutating public func visitUnorderedList(_ unorderedList: UnorderedList) { + if unorderedList.indexInParent > 0 && !(unorderedList.parent?.parent is ListItemContainer) { + ensurePrecedingNewlineCount(atLeast: 2) + } + descendInto(unorderedList) + } + + mutating public func visitOrderedList(_ orderedList: OrderedList) { + if orderedList.indexInParent > 0 && !(orderedList.parent?.parent is ListItemContainer) { + ensurePrecedingNewlineCount(atLeast: 2) + } + descendInto(orderedList) + } + + public mutating func visitHTMLBlock(_ html: HTMLBlock) { + if html.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + for lineSegment in html.trimmedLineSegments { + print(lineSegment, for: html) + queueNewline() + } + } + + public mutating func visitListItem(_ listItem: ListItem) { + if listItem.indexInParent > 0 || listItem.parent?.indexInParent ?? 0 > 0 { + ensurePrecedingNewlineCount(atLeast: 1) + } + + let checkbox = listItem.checkbox.map { + switch $0 { + case .checked: return "[x] " + case .unchecked: return "[ ] " + } + } ?? "" + + if listItem.parent is UnorderedList { + print("\(formattingOptions.unorderedListMarker.rawValue) \(checkbox)", for: listItem) + } else if let numeralPrefix = numeralPrefix(for: listItem) { + print("\(numeralPrefix)\(checkbox)", for: listItem) + } + descendInto(listItem) + } + + public mutating func visitHeading(_ heading: Heading) { + if heading.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + + if case .setext = formattingOptions.preferredHeadingStyle, + heading.level < 3 /* See fatalError below. */ { + // Print a Setext-style heading. + descendInto(heading) + queueNewline() + + let headingMarker: String + switch heading.level { + case 1: + headingMarker = "=" + case 2: + headingMarker = "-" + default: + fatalError("Unexpected heading level \(heading.level) while formatting for setext-style heading") + } + print(String(repeating: headingMarker, count: state.lastLineLength), for: heading) + } else { + // Print an ATX-style heading. + print(String(repeating: "#", count: heading.level), for: heading) + print(" ", for: heading) + descendInto(heading) + } + } + + public mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) { + if thematicBreak.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + let breakString = String(repeating: formattingOptions.thematicBreakCharacter.rawValue, + count: Int(formattingOptions.thematicBreakLength)) + print(breakString, for: thematicBreak) + } + + public mutating func visitInlineCode(_ inlineCode: InlineCode) { + let savedState = state + softWrapPrint("`\(inlineCode.code)`", for: inlineCode) + + // Splitting inline code elements is allowed if it contains spaces. + // If printing with automatic wrapping still put us over the line, + // prefer to print it on the next line to give as much opportunity + // to keep the contents on one line. + if inlineCode.indexInParent > 0 && (isOverPreferredLineLimit || state.lineNumber > savedState.lineNumber) { + restoreState(to: savedState) + queueNewline() + softWrapPrint("`\(inlineCode.code)`", for: inlineCode) + } + } + + public mutating func visitEmphasis(_ emphasis: Emphasis) { + print(formattingOptions.emphasisMarker.rawValue, for: emphasis) + descendInto(emphasis) + print(formattingOptions.emphasisMarker.rawValue, for: emphasis) + } + + public mutating func visitImage(_ image: Image) { + let savedState = state + func printImage() { + print("![", for: image) + descendInto(image) + print("](", for: image) + print(image.source ?? "", for: image) + if let title = image.title { + print(" \"\(title)\"", for: image) + } + print(")", for: image) + } + + printImage() + + // Image elements' source URLs can't be split. If wrapping the alt text + // of an image still put us over the line, prefer to print it on the + // next line to give as much opportunity to keep the alt text contents on one line. + if image.indexInParent > 0 && (isOverPreferredLineLimit || state.lineNumber > savedState.lineNumber) { + restoreState(to: savedState) + queueNewline() + printImage() + } + } + + public mutating func visitInlineHTML(_ inlineHTML: InlineHTML) { + print(inlineHTML.rawHTML, for: inlineHTML) + } + + public mutating func visitLineBreak(_ lineBreak: LineBreak) { + print(" ", for: lineBreak) + queueNewline() + } + + public mutating func visitLink(_ link: Link) { + let savedState = state + if formattingOptions.condenseAutolinks, + let destination = link.destination, + link.childCount == 1, + let text = link.child(at: 0) as? Text, + // Print autolink-style + destination == text.string { + print("<\(destination)>", for: link) + } else { + func printRegularLink() { + // Print as a regular link + print("[", for: link) + descendInto(link) + print("](", for: link) + print(link.destination ?? "", for: link) + print(")", for: link) + } + + printRegularLink() + + // Link elements' destination URLs can't be split. If wrapping the link text + // of a link still put us over the line, prefer to print it on the + // next line to give as much opportunity to keep the link text contents on one line. + if link.indexInParent > 0 && (isOverPreferredLineLimit || state.lineNumber > savedState.lineNumber) { + restoreState(to: savedState) + queueNewline() + printRegularLink() + } + } + } + + public mutating func visitSoftBreak(_ softBreak: SoftBreak) { + queueNewline() + } + + public mutating func visitStrong(_ strong: Strong) { + print(String(repeating: formattingOptions.emphasisMarker.rawValue, count: 2), for: strong) + descendInto(strong) + print(String(repeating: formattingOptions.emphasisMarker.rawValue, count: 2), for: strong) + } + + public mutating func visitText(_ text: Text) { + // This is where most of the wrapping occurs. + // Text elements have the most flexible content of all, not containing + // any special Markdown syntax or punctuation. + // We can do this because the model differentiates between real Text + // content and string-like data, such as URLs. + softWrapPrint(text.string, for: text) + } + + public mutating func visitStrikethrough(_ strikethrough: Strikethrough) { + print("~", for: strikethrough) + descendInto(strikethrough) + print("~", for: strikethrough) + } + + /// Format a table as an indivisible unit. + /// + /// Because tables likely print multiple cells of inline content next + /// to each other on the same line, we're breaking with the pattern + /// a little bit here and not descending into the substructure of a table + /// automatically in this ``MarkupFormatter``. We'll handle all of the + /// cells right here. + public mutating func visitTable(_ table: Table) { + if table.indexInParent > 0 { + ensurePrecedingNewlineCount(atLeast: 2) + } + // The general strategy is to print each table cell completely + // independently, measuring each of their dimensions, and then expanding + // them so that all cells in the same column have the same width + // by adding trailing spaces accordingly. + + // Once that's done, we should be able to go through and print + // the cells in a straightforward way by just adding the pipes `|` and + // delimiter row. Let's go! + + /// The column count of all of the printed rows. + /// + /// Markdown parsers like CommonMark drop extraneous columns and we + /// want to prevent information loss when printing, so we'll expand + /// all of the rows to have the same number of columns. + let uniformColumnCount = table.maxColumnCount + + /// A copy of this formatter's options to prevent capture of `self` + /// in some `map` calls below. + let cellFormattingOptions = formattingOptions + + // First, format each cell independently. + + /// All of the independently formatted head cells' text, adding cells + /// as needed to meet the uniform `uniformColumnCount`. + let headCellTexts = Array(table.head.cells.map { + $0.formatIndependently(options: cellFormattingOptions) + }).ensuringCount(atLeast: uniformColumnCount, filler: "") + + /// All of the independently formatted body cells' text by row, adding + /// cells to each row to meet the `uniformColumnCount`. + let bodyRowTexts = Array(table.body.rows.map { row -> [String] in + return Array(row.cells.map { + $0.formatIndependently(options: cellFormattingOptions) + }).ensuringCount(atLeast: uniformColumnCount, + filler: "") + }) + + // Next, calculate the maximum width of each column. + + /// The column alignments of the table, filled out to `uniformColumnCount`. + let columnAlignments = table.columnAlignments + .ensuringCount(atLeast: uniformColumnCount, + filler: nil) + + // Start with the column alignments. The following are the minimum to + // specify each column alignment in markdown: + // - left: `:-`; can be expanded to `:-----` etc. + // - right: `-:`; can be expanded to `-----:` etc. + // - center: `:-:`; can be expanded to `:------:` etc. + // - nil: `-`; can be expanded to `-------` etc. + + /// The final widths of each column in characters. + var finalColumnWidths = columnAlignments.map { alignment -> Int in + guard let alignment = alignment else { + return 0 + } + switch alignment { + case .left, + .right: + return 2 + case .center: + return 3 + } + } + + precondition(finalColumnWidths.count == headCellTexts.count) + + // Update max column widths from each header cell. + finalColumnWidths = zip(finalColumnWidths, headCellTexts).map { + return max($0, $1.count) + } + + // Update max column widths from each cell of each row in the body. + for row in bodyRowTexts { + finalColumnWidths.ensureCount(atLeast: row.count, filler: 0) + for (i, cellText) in row.enumerated() { + finalColumnWidths[i] = max(finalColumnWidths[i], + cellText.count) + } + } + + // We now know the width that each printed column will be. + + /// Each of the header cells expanded to the right dimensions by + /// extending each line with spaces to fit the uniform column width. + let expandedHeaderCellTexts = (0.. String in + let minLineLength = finalColumnWidths[column] + return headCellTexts[column] + .ensuringCount(atLeast: minLineLength, filler: " ") + } + + /// Rendered delimter row cells with the correct width. + let delimiterRowCellTexts = columnAlignments.enumerated() + .map { (column, alignment) -> String in + let columnWidth = finalColumnWidths[column] + guard let alignment = alignment else { + return String(repeating: "-", count: columnWidth) + } + switch alignment { + case .left: + let dashes = String(repeating: "-", count: columnWidth - 1) + return ":\(dashes)" + case .right: + let dashes = String(repeating: "-", count: columnWidth - 1) + return "\(dashes):" + case .center: + let dashes = String(repeating: "-", count: columnWidth - 2) + return ":\(dashes):" + } + } + + /// Each of the body cells expanded to the right dimensions by + /// extending each line with spaces to fit the uniform column width + /// appropriately for their row and column. + let expandedBodyRowTexts = bodyRowTexts.enumerated() + .map { (row, rowCellTexts) -> [String] in + return (0.. String in + let minLineLength = finalColumnWidths[column] + return rowCellTexts[column] + .ensuringCount(atLeast: minLineLength, filler: " ") + } + } + + // Print the expanded head cells. + print("|", for: table.head) + print(expandedHeaderCellTexts.joined(separator: "|"), for: table.head) + print("|", for: table.head) + queueNewline() + + // Print the delimiter row. + print("|", for: table.head) + print(delimiterRowCellTexts.joined(separator: "|"), for: table.head) + print("|", for: table.head) + queueNewline() + + // Print the body rows. + for (row, bodyRow) in expandedBodyRowTexts.enumerated() { + print("|", for: table.body.child(at: row)!) + print(bodyRow.joined(separator: "|"), for: table.body.child(at: row)!) + print("|", for: table.body.child(at: row)!) + queueNewline() + } + } + + /// See ``MarkupFormatter/visitTable(_:)-61rlp`` for more information. + public mutating func visitTableHead(_ tableHead: Table.Head) { + fatalError("Do not call \(#function) directly; markdown tables must be formatted as a single unit. Call `visitTable` on the parent table") + } + + /// See ``MarkupFormatter/visitTable(_:)-61rlp`` for more information. + public mutating func visitTableBody(_ tableBody: Table.Body) { + fatalError("Do not call \(#function) directly; markdown tables must be formatted as a single unit. Call `visitTable` on the parent table") + } + + /// See ``MarkupFormatter/visitTable(_:)-61rlp`` for more information. + public mutating func visitTableRow(_ tableRow: Table.Row) { + fatalError("Do not call \(#function) directly; markdown tables must be formatted as a single unit. Call `visitTable` on the parent table") + } + + /// See ``MarkupFormatter/visitTable(_:)-61rlp`` for more information. + public mutating func visitTableCell(_ tableCell: Table.Cell) { + fatalError("Do not call \(#function) directly; markdown tables must be formatted as a single unit. Call `visitTable` on the parent table") + } + + public mutating func visitBlockDirective(_ blockDirective: BlockDirective) { + ensurePrecedingNewlineCount(atLeast: 1) + print("@", for: blockDirective) + print(blockDirective.name, for: blockDirective) + + if !blockDirective.argumentText.segments.isEmpty { + print("(", for: blockDirective) + + for (i, segment) in blockDirective.argumentText.segments.enumerated() { + if i != 0 { + queueNewline() + } + print(segment.trimmedText, for: blockDirective) + } + + print(")", for: blockDirective) + } + + if blockDirective.childCount > 0 { + print(" {", for: blockDirective) + } + + descendInto(blockDirective) + + queueNewline() + + if blockDirective.childCount > 0 { + print("}", for: blockDirective) + } + } + + public mutating func visitSymbolLink(_ symbolLink: SymbolLink) { + print("``", for: symbolLink) + print(symbolLink.destination ?? "", for: symbolLink) + print("``", for: symbolLink) + } +} diff --git a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift new file mode 100644 index 00000000..47358740 --- /dev/null +++ b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift @@ -0,0 +1,261 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Foundation + +fileprivate extension String { + /// A version of `self` with newline "\n" characters escaped as "\\n". + var newlineEscaped: String { + return replacingOccurrences(of: "\n", with: "\\n") + } +} + +/// Options when printing a debug description of a markup tree. +public struct MarkupDumpOptions: OptionSet { + public let rawValue: UInt + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Include source locations and ranges of each element in the dump. + public static let printSourceLocations = MarkupDumpOptions(rawValue: 1 << 0) + + /// Include internal unique identifiers of each element in the dump. + public static let printUniqueIdentifiers = MarkupDumpOptions(rawValue: 1 << 1) + + /// Print all optional information about a markup tree. + public static let printEverything: MarkupDumpOptions = [ + .printSourceLocations, + .printUniqueIdentifiers, + ] +} + +extension Markup { + /// Print a debug representation of the tree. + /// - Parameter options: options to use while printing. + /// - Returns: a description illustrating the hierarchy and contents of each element of the tree. + public func debugDescription(options: MarkupDumpOptions = []) -> String { + var dumper = MarkupTreeDumper(options: options) + dumper.visit(self) + return dumper.result + } +} + +/// A `MarkupWalker` that dumps a textual representation of a `Markup` tree for debugging. +/// +/// - note: This type is utilized by a public `Markup.dump()` method available on all markup elements. +/// +/// For example, this doc comment parsed as markdown would look like the following. +/// +/// ```plain +/// Document +/// ├─ Paragraph +/// │ ├─ Text "A " +/// │ ├─ InlineCode `MarkupWalker` +/// │ ├─ Text " that dumps a textual representation of a " +/// │ ├─ InlineCode `Markup` +/// │ └─ Text " tree for debugging." +/// ├─ UnorderedList +/// │ └─ ListItem +/// │ └─ Paragraph +/// │ ├─ Text "note: This type is utilized by a public " +/// │ ├─ InlineCode `Markup.dump()` +/// │ └─ Text " method available on all markup elements." +/// └─ Paragraph +/// └─ Text "For example, this doc comment parsed as markdown would look like the following." +/// ``` +struct MarkupTreeDumper: MarkupWalker { + let options: MarkupDumpOptions + init(options: MarkupDumpOptions) { + self.options = options + } + + /// The resulting string built up during dumping. + var result = "" + + /// The current path in the tree so far, used for printing edges + /// in the dumped tree. + private var path = [Markup]() + + private mutating func dump(_ markup: Markup, customDescription: String? = nil) { + indent(markup) + result += "\(type(of: markup))" + if options.contains(.printSourceLocations), + let range = markup.range { + result += " @\(range.diagnosticDescription(includePath: false))" + } + if options.contains(.printUniqueIdentifiers) { + if markup.parent == nil { + result += " Root #\(markup._data.id.rootId)" + } + result += " #\(markup._data.id.childId)" + } + if let customDescription = customDescription { + if !customDescription.starts(with: "\n") { + result += " " + } + result += "\(customDescription)" + } + increasingDepth(markup) + } + + mutating func defaultVisit(_ markup: Markup) { + dump(markup) + } + + private var lineIndentPrefix: String { + var prefix = "" + for (depth, element) in path.enumerated().reversed() { + guard let lastChildIndex = element.parent?.children.reversed().first?.indexInParent, + lastChildIndex != element.indexInParent else { + if depth > 0 { + prefix.append(" ") + } + continue + } + prefix.append(" │") + } + return String(prefix.reversed()) + } + + private mutating func indentLiteralBlock(_ string: String, from element: Markup, countLines: Bool = false) -> String { + path.append(element) + let prefix = lineIndentPrefix + let result = string.split(separator: "\n").enumerated().map { (index, line) in + let lineNumber = countLines ? "\(index + 1) " : "" + return "\(prefix)\(lineNumber)\(line)" + }.joined(separator: "\n") + path.removeLast() + return result + } + + /** + Add an indentation prefix for a markup element using the current `path`. + - parameter markup: The `Markup` element about to be printed + */ + private mutating func indent(_ markup: Markup) { + if !path.isEmpty { + result.append("\n") + } + + result += lineIndentPrefix + + guard let lastChildIndex = markup.parent?.children.reversed().first?.indexInParent else { + return + } + let treeMarker = markup.indexInParent == lastChildIndex ? "└─ " : "├─ " + result.append(treeMarker) + } + + /** + Push `element` to the current path and descend into the children, popping `element` from the path when returning. + + - parameter element: The parent element you're descending into. + */ + private mutating func increasingDepth(_ element: Markup) { + path.append(element) + descendInto(element) + path.removeLast() + } + + mutating func visitText(_ text: Text) { + dump(text, customDescription: "\"\(text.string)\"") + } + + mutating func visitHTMLBlock(_ html: HTMLBlock) { + dump(html, customDescription: "\n\(indentLiteralBlock(html.rawHTML, from: html))") + } + + mutating func visitLink(_ link: Link) { + dump(link, customDescription: link.destination.map { "destination: \"\($0)\"" } ?? "") + } + + mutating func visitImage(_ image: Image) { + var description = image.source.map { "source: \"\($0)\"" } ?? "" + if let title = image.title { + description += " title: \"\(title)\"" + } + dump(image, customDescription: description) + } + + mutating func visitHeading(_ heading: Heading) { + dump(heading, customDescription: "level: \(heading.level)") + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + let lines = indentLiteralBlock(codeBlock.code, from: codeBlock, countLines: false) + dump(codeBlock, customDescription: "language: \(codeBlock.language ?? "none")\n\(lines)") + } + + mutating func visitBlockDirective(_ blockDirective: BlockDirective) { + var argumentsDescription: String + if !blockDirective.argumentText.segments.isEmpty { + var description = "\n" + description += "├─ Argument text segments:\n" + description += blockDirective.argumentText.segments.map { segment -> String in + let range: String + if options.contains(.printSourceLocations) { + range = segment.range.map { "@\($0.diagnosticDescription()): " } ?? "" + } else { + range = "" + } + let segmentText = segment.untrimmedText[segment.parseIndex...].debugDescription + return "| \(range)\(segmentText)" + }.joined(separator: "\n") + + argumentsDescription = "\n" + indentLiteralBlock(description, from: blockDirective) + } else { + argumentsDescription = "" + } + dump(blockDirective, customDescription: "name: \(blockDirective.name.debugDescription)\(argumentsDescription)") + } + + mutating func visitInlineCode(_ inlineCode: InlineCode) { + dump(inlineCode, customDescription: "`\(inlineCode.code)`") + } + + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) { + dump(inlineHTML, customDescription: "\(inlineHTML.rawHTML)") + } + + mutating func visitCustomInline(_ customInline: CustomInline) { + dump(customInline, customDescription: "customInline.text") + } + + mutating func visitListItem(_ listItem: ListItem) { + let checkboxDescription: String? = listItem.checkbox.map { + switch $0 { + case .checked: return "checkbox: [x]" + case .unchecked: return "checkbox: [ ]" + } + } + dump(listItem, customDescription: checkboxDescription) + } + + mutating func visitTable(_ table: Table) { + let alignments = table.columnAlignments.map { + switch $0 { + case nil: + return "-" + case .left: + return "l" + case .right: + return "r" + case .center: + return "c" + } + }.joined(separator: "|") + dump(table, customDescription: "alignments: |\(alignments)|") + } + + mutating func visitSymbolLink(_ symbolLink: SymbolLink) { + dump(symbolLink, customDescription: symbolLink.destination.map { "destination: \($0)" }) + } +} diff --git a/Sources/markdown-tool/Commands/DumpTreeCommand.swift b/Sources/markdown-tool/Commands/DumpTreeCommand.swift new file mode 100644 index 00000000..2b166103 --- /dev/null +++ b/Sources/markdown-tool/Commands/DumpTreeCommand.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 ArgumentParser +import Markdown + +extension MarkdownCommand { + /// A command to dump the parsed input's debug tree representation. + struct DumpTree: ParsableCommand { + static let configuration = CommandConfiguration(commandName: "dump-tree", abstract: "Dump the parsed standard input as a tree representation for debugging") + + @Argument(help: "Optional input file path of a Markdown file to format; default: standard input") + var inputFilePath: String? + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Print source locations for each element where applicable") + var sourceLocations: Bool = false + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Print internal unique identifiers for each element") + var uniqueIdentifiers: Bool = false + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Parse block directives") + var parseBlockDirectives: Bool = false + + func run() throws { + let parseOptions: ParseOptions = parseBlockDirectives ? [.parseBlockDirectives] : [] + let document: Document + if let inputFilePath = inputFilePath { + (_, document) = try MarkdownCommand.parseFile(at: inputFilePath, options: parseOptions) + } else { + (_, document) = try MarkdownCommand.parseStandardInput(options: parseOptions) + } + var dumpOptions = MarkupDumpOptions() + if sourceLocations { + dumpOptions.insert(.printSourceLocations) + } + if uniqueIdentifiers { + dumpOptions.insert(.printUniqueIdentifiers) + } + print(document.debugDescription(options: dumpOptions)) + } + } +} diff --git a/Sources/markdown-tool/Commands/FormatCommand.swift b/Sources/markdown-tool/Commands/FormatCommand.swift new file mode 100644 index 00000000..927a8029 --- /dev/null +++ b/Sources/markdown-tool/Commands/FormatCommand.swift @@ -0,0 +1,242 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 ArgumentParser +import Foundation +import Markdown + +/// A convenience enum to allow the command line to have separate options +/// for counting behavior and start numeral. +enum OrderedListCountingBehavior: String, ExpressibleByArgument, CaseIterable { + /// Use the same numeral for all ordered list items, inferring order. + case allSame = "all-same" + + /// Literally increment the numeral for each list item in a list. + case incrementing +} + +extension ExpressibleByArgument where Self: RawRepresentable, Self.RawValue == String { + public init?(argument: String) { + self.init(rawValue: argument) + } +} + +/// A convenience enum to allow the command line to validate the maximum line length. +struct MaximumLineLength: ExpressibleByArgument { + var length: Int + init?(length: Int) { + guard length > 0 else { + return nil + } + self.length = length + } + + init?(argument: String) { + guard let length = Int(argument) else { + return nil + } + self.init(length: length) + } +} + +extension MarkupFormatter.Options.UnorderedListMarker: ExpressibleByArgument {} +extension MarkupFormatter.Options.UseCodeFence: ExpressibleByArgument {} +extension MarkupFormatter.Options.ThematicBreakCharacter: ExpressibleByArgument {} +extension MarkupFormatter.Options.EmphasisMarker: ExpressibleByArgument {} +extension MarkupFormatter.Options.PreferredHeadingStyle: ExpressibleByArgument {} +extension MarkupFormatter.Options.PreferredLineLimit.SplittingElement: ExpressibleByArgument {} + +extension MarkdownCommand { + /** + Takes formatting options and markdown on standard input, applying those options while printing to standard output. + */ + struct Format: ParsableCommand { + enum Error: LocalizedError { + case formattedOutputHasDifferentStructures + case diffingFailed + var errorDescription: String? { + switch self { + case .formattedOutputHasDifferentStructures: + return "Formatted output did not have same structure as the output." + case .diffingFailed: + return "Failed to diff original and formatted tree representations" + } + } + } + static let configuration = CommandConfiguration(commandName: "format", abstract: "Format markdown on standard input to standard output") + + @Option(help: "Ordered list start numeral") + var orderedListStartNumeral: UInt = 1 + + @Option(help: "Ordered list counting; choices: \(OrderedListCountingBehavior.allCases.map { $0.rawValue }.joined(separator: ", "))") + var orderedListCounting: OrderedListCountingBehavior = .allSame + + @Option(help: "Unordered list marker; choices: \(MarkupFormatter.Options.UnorderedListMarker.allCases.map { $0.rawValue }.joined(separator: ", "))") + var unorderedListMarker: String = "-" + + @Option(help: "Use fenced code blocks; choices: \(MarkupFormatter.Options.UseCodeFence.allCases.map { $0.rawValue }.joined(separator: ", "))") + var useCodeFence: MarkupFormatter.Options.UseCodeFence = .always + + @Option(help: "Default code block language to use for fenced code blocks") + var defaultCodeBlockLanguage: String? + + @Option(help: "The character to use for thematic breaks; choices: \(MarkupFormatter.Options.ThematicBreakCharacter.allCases.map { $0.rawValue }.joined(separator: ", "))") + var thematicBreakCharacter: MarkupFormatter.Options.ThematicBreakCharacter = .dash + + @Option(help: "The length of thematic breaks") + var thematicBreakLength: UInt = 5 + + @Option(help: "Emphasis marker; choices: \(MarkupFormatter.Options.EmphasisMarker.allCases.map { $0.rawValue }.joined(separator: ", "))") + var emphasisMarker: String = "*" + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Condense links whose text matches their destination to 'autolinks' e.g. ") + var condenseAutolinks: Bool = true + + @Option(help: "Preferred heading style; choices: \(MarkupFormatter.Options.PreferredHeadingStyle.allCases.map { $0.rawValue }.joined(separator: ", "))") + var preferredHeadingStyle: MarkupFormatter.Options.PreferredHeadingStyle = .atx + + @Option(help: "Preferred maximum line length, enforced with hard or soft breaks to split text elements where possible (see --line-splitting-element)") + var preferredMaximumLineLength: MaximumLineLength? + + @Option(help: "The kind of element to use to split text elements while enforcing --preferred-maximum-line-length; choices: \(MarkupFormatter.Options.PreferredLineLimit.SplittingElement.allCases.map { $0.rawValue }.joined(separator: ", "))") + var lineSplittingElement: MarkupFormatter.Options.PreferredLineLimit.SplittingElement = .softBreak + + @Option(help: "Prepend this prefix to every line") + var customLinePrefix: String = "" + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Check that the formatted output is structurally equivalent (has the same AST structure) to the input") + var checkStructuralEquivalence: Bool = false + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Parse block directives") + var parseBlockDirectives: Bool = false + + @Flag(inversion: .prefixedNo, exclusivity: .chooseLast, help: "Interpret inline code spans with two backticks as a symbol link element") + var parseSymbolLinks: Bool = false + + @Argument(help: "Input file (default: standard input)") + var inputFilePath: String? + + /// Search for the an executable with a given base name. + func findExecutable(named name: String) throws -> String? { + let which = Process() + which.launchPath = "/usr/bin/which" + which.arguments = [name] + let standardOutput = Pipe() + which.standardOutput = standardOutput + which.launch() + which.waitUntilExit() + + guard which.terminationStatus == 0, + let output = String(data: standardOutput.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) else { + return nil + } + + return output.trimmingCharacters(in: CharacterSet(charactersIn: "\n")) + } + + func checkStructuralEquivalence(between original: Document, + and formatted: Document, + source: String) throws { + struct FileHandlerOutputStream: TextOutputStream { + private let fileHandle: FileHandle + let encoding: String.Encoding + + init(_ fileHandle: FileHandle, encoding: String.Encoding = .utf8) { + self.fileHandle = fileHandle + self.encoding = encoding + } + + mutating func write(_ string: String) { + if let data = string.data(using: encoding) { + fileHandle.write(data) + } + } + } + + var standardError = FileHandlerOutputStream(FileHandle.standardError, encoding: .utf8) + + guard preferredMaximumLineLength == nil else { + print("Skipping structural equivalence check because --preferred-maximum-line-length was used, which can intentionally change document structure by inserting soft or hard line breaks.", to: &standardError) + return + } + + if !original.hasSameStructure(as: formatted) { + print("Error: Formatted markup tree had different structure from the original!", to: &standardError) + print("Please file a bug with the following information:", to: &standardError) + print("Original source:", to: &standardError) + print("```markdown", to: &standardError) + print(source, to: &standardError) + print("```", to: &standardError) + print("Original structure:", to: &standardError) + print(original.debugDescription(), to: &standardError) + print("----------------", to: &standardError) + print("Structure after formatting:", to: &standardError) + print(formatted.debugDescription(), to: &standardError) + throw Error.formattedOutputHasDifferentStructures + } + } + + func run() throws { + var parseOptions = ParseOptions() + if parseBlockDirectives { + parseOptions.insert(.parseBlockDirectives) + } + if parseSymbolLinks { + parseOptions.insert(.parseSymbolLinks) + } + let source: String + let document: Document + if let inputFilePath = inputFilePath { + (source, document) = try MarkdownCommand.parseFile(at: inputFilePath, options: parseOptions) + } else { + (source, document) = try MarkdownCommand.parseStandardInput(options: parseOptions) + } + + guard let emphasisMarker = MarkupFormatter.Options.EmphasisMarker(argument: emphasisMarker) else { + throw ArgumentParser.ValidationError("The value '\(self.emphasisMarker)' is invalid for '--emphasis-marker'") + } + + guard let unorderedListMarker = MarkupFormatter.Options.UnorderedListMarker(argument: unorderedListMarker) else { + throw ArgumentParser.ValidationError("The value '\(self.emphasisMarker)' is invalid for '--unordered-list-marker'") + } + + let orderedListNumerals: MarkupFormatter.Options.OrderedListNumerals + switch orderedListCounting { + case .allSame: + orderedListNumerals = .allSame(orderedListStartNumeral) + case .incrementing: + orderedListNumerals = .incrementing(start: orderedListStartNumeral) + } + + let preferredLineLimit = preferredMaximumLineLength.map { + MarkupFormatter.Options.PreferredLineLimit(maxLength: $0.length, breakWith: lineSplittingElement) + } + + let formatOptions = MarkupFormatter.Options(unorderedListMarker: unorderedListMarker, + orderedListNumerals: orderedListNumerals, + useCodeFence: useCodeFence, + defaultCodeBlockLanguage: defaultCodeBlockLanguage, + thematicBreakCharacter: thematicBreakCharacter, + thematicBreakLength: thematicBreakLength, + emphasisMarker: emphasisMarker, + condenseAutolinks: condenseAutolinks, + preferredHeadingStyle: preferredHeadingStyle, + preferredLineLimit: preferredLineLimit, + customLinePrefix: customLinePrefix) + let formatted = document.format(options: formatOptions) + print(formatted) + if checkStructuralEquivalence { + try checkStructuralEquivalence(between: document, + and: Document(parsing: formatted, options: parseOptions), + source: source) + } + } + } +} diff --git a/Sources/markdown-tool/main.swift b/Sources/markdown-tool/main.swift new file mode 100644 index 00000000..cbb59a22 --- /dev/null +++ b/Sources/markdown-tool/main.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 ArgumentParser +import Foundation +import Markdown + +struct MarkdownCommand: ParsableCommand { + enum Error: LocalizedError { + case couldntDecodeInputAsUTF8 + + var errorDescription: String? { + switch self { + case .couldntDecodeInputAsUTF8: + return "Couldn't decode input as UTF-8" + } + } + } + + static let configuration = CommandConfiguration(commandName: "markdown", shouldDisplay: false, subcommands: [ + DumpTree.self, + Format.self, + ]) + + static func parseFile(at path: String, options: ParseOptions) throws -> (source: String, parsed: Document) { + let data = try Data(contentsOf: URL( fileURLWithPath: path)) + guard let inputString = String(data: data, encoding: .utf8) else { + throw Error.couldntDecodeInputAsUTF8 + } + return (inputString, Document(parsing: inputString, options: options)) + } + + static func parseStandardInput(options: ParseOptions) throws -> (source: String, parsed: Document) { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + guard let stdinString = String(data: stdinData, encoding: .utf8) else { + throw Error.couldntDecodeInputAsUTF8 + } + return (stdinString, Document(parsing: stdinString, options: options)) + } +} + +MarkdownCommand.main() diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 00000000..cb4b250d --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,18 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 + +import MarkupTests + +var tests = [XCTestCaseEntry]() +tests += MarkupTests.__allTests() + +XCTMain(tests) diff --git a/Tests/MarkdownTests/Base/AtomicCounterTests.swift b/Tests/MarkdownTests/Base/AtomicCounterTests.swift new file mode 100644 index 00000000..cc2126bc --- /dev/null +++ b/Tests/MarkdownTests/Base/AtomicCounterTests.swift @@ -0,0 +1,42 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class AtomicCounterTests: XCTestCase { + func testIncremental() { + XCTAssertEqual(AtomicCounter.current, AtomicCounter.current) + XCTAssertNotEqual(AtomicCounter.next(), AtomicCounter.next()) + } + + func testSimultaneousFetch() { + var counters = Set() + let group = DispatchGroup() + let fetchQueue = DispatchQueue(label: "AtomicCounterTests.testSimultaneousFetch.fetch", attributes: [.concurrent]) + let collectQueue = DispatchQueue(label: "AtomicCounterTests.testSimultaneousFetch.collect") + let numTasks = 4 + let idsPerQueue = 200000 + for _ in 0.. Int { + let total = + (pow(Double(N), Double(h + 1)) - 1) + / + Double(N - 1) + return Int(total) + } + + func buildCustomBlock(height: Int, width: Int) -> CustomBlock { + guard height > 0 else { + return CustomBlock() + } + return CustomBlock(Array(repeating: buildCustomBlock(height: height - 1, width: width), count: width)) + } + + /// No two children should have the same child identifier. + func testChildIDsAreUnique() { + let height = 5 + let width = 5 + + let customBlock = buildCustomBlock(height: height, width: width) + + print(customBlock.debugDescription(options: .printEverything)) + + struct IDCounter: MarkupWalker { + var id = 0 + + mutating func defaultVisit(_ markup: Markup) { + XCTAssertEqual(id, markup._data.id.childId) + id += 1 + descendInto(markup) + } + } + + var counter = IDCounter() + counter.visit(customBlock) + XCTAssertEqual(totalElementsInTree(height: height, width: width), counter.id) + } + + /// The very first child id shall be 1 greater than that of its parent. + func testFirstChildIdentifier() { + func checkFirstChildOf(_ markup: Markup, expectedId: Int) { + guard let firstChild = markup.child(at: 0) else { + return + } + XCTAssertEqual(expectedId, firstChild.raw.metadata.id.childId) + // As we descend depth-first, each first child identifier shall be one more than the last. + checkFirstChildOf(firstChild, expectedId: expectedId + 1) + } + + checkFirstChildOf(buildCustomBlock(height: 100, width: 1), expectedId: 1) + } + + func testNextSiblingIdentifier() { + let height = 2 + let width = 100 + let customBlock = buildCustomBlock(height: height, width: width) + + var id = 1 + for child in customBlock.children { + // Every branch in the tree should use 1 + 100 identifiers. + XCTAssertEqual(id, child.raw.metadata.id.childId) + id += width + 1 + } + } + + func testPreviousSiblingIdentifier() { + let height = 2 + let width = 100 + let customBlock = buildCustomBlock(height: height, width: width) + + var id = totalElementsInTree(height: height, width: width) + for child in customBlock.children.reversed() { + // Every branch in the tree should use 1 + 100 identifiers. + XCTAssertEqual(id, child.raw.metadata.id.childId) + id -= width + 1 + } + } +} diff --git a/Tests/MarkdownTests/Base/MarkupTests.swift b/Tests/MarkdownTests/Base/MarkupTests.swift new file mode 100644 index 00000000..6ba2cbff --- /dev/null +++ b/Tests/MarkdownTests/Base/MarkupTests.swift @@ -0,0 +1,285 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +/// Tests public API of `Markup`. +final class MarkupTests: XCTestCase { + func testRangeUnparsed() { + let document = Document(Paragraph(Strong(Text("OK")))) + XCTAssertNil(document.range) + + let paragraph = document.child(at: 0) as! Paragraph + XCTAssertNil(paragraph.range) + + let strong = paragraph.child(at: 0) as! Strong + XCTAssertNil(strong.range) + + let text = strong.child(at: 0) as! Text + XCTAssertNil(text.range) + } + + func testRangeParsed() { + let source = "**OK**" + + let document = Document(parsing: source) + XCTAssertEqual(SourceLocation(line: 1, column: 1, source: nil).. BlockMarkup in + guard let existingParagraph = child as? Paragraph else { + return Paragraph(Text("Replaced paragraph.")) + } + return existingParagraph + }) + XCTAssertEqual(3, document.childCount) + XCTAssertEqual(SourceLocation(line: 1, column: 1, source: nil).. Markup? { + return Emphasis(text) + } + } + + var textReplacer = TextReplacer() + let newDocument = textReplacer.visit(document) as! Document + + /// Verifies that all ``Text`` elements have a non-nil range, + /// all ``Emphasis`` elements do not have a nil range, and + /// all other elements have a non-nil range. + struct VerifyTextRangesInPlaceAndEmphasisRangesNil: MarkupWalker { + mutating func visitText(_ text: Text) { + XCTAssertNotNil(text.range) + } + + mutating func visitEmphasis(_ emphasis: Emphasis) { + XCTAssertNil(emphasis.range) + descendInto(emphasis) + } + + mutating func defaultVisit(_ markup: Markup) { + XCTAssertNotNil(markup.range) + descendInto(markup) + } + } + + do { + var rangeVerifier = VerifyTextRangesInPlaceAndEmphasisRangesNil() + rangeVerifier.visit(newDocument) + } + } +} diff --git a/Tests/MarkdownTests/Base/PlainTextConvertibleMarkupTests.swift b/Tests/MarkdownTests/Base/PlainTextConvertibleMarkupTests.swift new file mode 100644 index 00000000..55467a5e --- /dev/null +++ b/Tests/MarkdownTests/Base/PlainTextConvertibleMarkupTests.swift @@ -0,0 +1,75 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class PlainTextConvertibleMarkupTests: XCTestCase { + func testParagraph() { + let paragraph = Paragraph( + Text("This is a "), + Emphasis(Text("paragraph")), + Text(".")) + + XCTAssertEqual("This is a paragraph.", paragraph.plainText) + } + + func testEmphasis() { + let emphasis = Emphasis(Text("Emphasis")) + XCTAssertEqual("Emphasis", emphasis.plainText) + } + + func testImage() { + let image = Image(source: "test.png", title: "", Text("This "), Text("is "), Text("an "), Text("image.")) + XCTAssertEqual("This is an image.", image.plainText) + } + + func testLink() { + let link = Link(destination: "test.png", + Text("This "), + Text("is "), + Text("a "), + Text("link.")) + XCTAssertEqual("This is a link.", link.plainText) + } + + func testStrong() { + let strong = Strong(Text("Strong")) + XCTAssertEqual("Strong", strong.plainText) + } + + func testCustomInline() { + let customInline = CustomInline("Custom inline") + XCTAssertEqual("Custom inline", customInline.plainText) + } + + func testInlineCode() { + let inlineCode = InlineCode("foo") + XCTAssertEqual("`foo`", inlineCode.plainText) + } + + func testInlineHTML() { + let inlineHTML = InlineHTML("
") + XCTAssertEqual("
", inlineHTML.plainText) + } + + func testLineBreak() { + XCTAssertEqual("\n", LineBreak().plainText) + } + + func testSoftBreak() { + XCTAssertEqual(" ", SoftBreak().plainText) + } + + func testText() { + let text = Text("OK") + XCTAssertEqual("OK", text.plainText) + } +} diff --git a/Tests/MarkdownTests/Base/RawMarkupTests.swift b/Tests/MarkdownTests/Base/RawMarkupTests.swift new file mode 100644 index 00000000..c3441613 --- /dev/null +++ b/Tests/MarkdownTests/Base/RawMarkupTests.swift @@ -0,0 +1,78 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class RawMarkupTests: XCTestCase { + func testHasSameStructureAs() { + do { // Identity match + let document = RawMarkup.document(parsedRange: nil, []) + XCTAssert(document.hasSameStructure(as: document)) + } + + do { // Empty match + XCTAssert(RawMarkup.document(parsedRange: nil, []).hasSameStructure(as: .document(parsedRange: nil, []))) + } + + do { // Same child count, but different structure + let document1 = RawMarkup.document(parsedRange: nil, [.paragraph(parsedRange: nil, [])]) + let document2 = RawMarkup.document(parsedRange: nil, [.thematicBreak(parsedRange: nil)]) + XCTAssertFalse(document1.hasSameStructure(as: document2)) + } + + do { // Different child count + let document1 = RawMarkup.document(parsedRange: nil, [.paragraph(parsedRange: nil, [])]) + let document2 = RawMarkup.document(parsedRange: nil, [.paragraph(parsedRange: nil, []), .thematicBreak(parsedRange: nil)]) + XCTAssertFalse(document1.hasSameStructure(as: document2)) + } + + do { // Same child count, different structure, nested + let document1 = RawMarkup.document(parsedRange: nil, [ + .paragraph(parsedRange: nil, [ + .text(parsedRange: nil, string: "Hello") + ]), + .paragraph(parsedRange: nil, [ + .text(parsedRange: nil, string: "World") + ]), + ]) + let document2 = RawMarkup.document(parsedRange: nil, [ + .paragraph(parsedRange: nil, [ + .text(parsedRange: nil, string: "Hello"), + ]), + .paragraph(parsedRange: nil, [ + .emphasis(parsedRange: nil, [ + .text(parsedRange: nil, string: "World"), + ]), + ]), + ]) + XCTAssertFalse(document1.hasSameStructure(as: document2)) + } + } + + /// When an element changes a child, unchanged children should use the same `RawMarkup` as before. + func testSharing() { + let originalRoot = Document( + Paragraph(Text("ChangeMe")), + Paragraph(Text("Unchanged"))) + + let firstText = originalRoot.child(through: [ + 0, // Paragraph + 0, // Text + ]) as! Text + + var newText = firstText + newText.string = "Changed" + let newRoot = newText.root + + XCTAssertFalse(originalRoot.child(at: 0)!.raw.markup === newRoot.child(at: 0)!.raw.markup) + XCTAssertTrue(originalRoot.child(at: 1)!.raw.markup === newRoot.child(at: 1)!.raw.markup) + } +} diff --git a/Tests/MarkdownTests/Base/RawMarkupToMarkupTests.swift b/Tests/MarkdownTests/Base/RawMarkupToMarkupTests.swift new file mode 100644 index 00000000..ebf9698c --- /dev/null +++ b/Tests/MarkdownTests/Base/RawMarkupToMarkupTests.swift @@ -0,0 +1,114 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class RawMarkupToMarkupTests: XCTestCase { + func testParagraph() { + XCTAssertNoThrow(try Paragraph(.paragraph(parsedRange: nil, []))) + XCTAssertThrowsError(try Paragraph(.softBreak(parsedRange: nil))) + } + + func testCodeBlock() { + XCTAssertNoThrow(try CodeBlock(.codeBlock(parsedRange: nil, code: "", language: nil))) + XCTAssertThrowsError(try CodeBlock(.softBreak(parsedRange: nil))) + } + + func testHTMLBlock() { + XCTAssertNoThrow(try HTMLBlock(.htmlBlock(parsedRange: nil, html: ""))) + XCTAssertThrowsError(try HTMLBlock(.softBreak(parsedRange: nil))) + } + + func testHeading() { + XCTAssertNoThrow(try Heading(.heading(level: 1, parsedRange: nil, []))) + XCTAssertThrowsError(try Heading(.softBreak(parsedRange: nil))) + } + + func testThematicBreak() { + XCTAssertNoThrow(try ThematicBreak(.thematicBreak(parsedRange: nil))) + XCTAssertThrowsError(try ThematicBreak(.softBreak(parsedRange: nil))) + } + + func testBlockQuote() { + XCTAssertNoThrow(try BlockQuote(.blockQuote(parsedRange: nil, []))) + XCTAssertThrowsError(try BlockQuote(.softBreak(parsedRange: nil))) + } + + func testListItem() { + XCTAssertNoThrow(try ListItem(.listItem(checkbox: .none, parsedRange: nil, []))) + XCTAssertThrowsError(try ListItem(.softBreak(parsedRange: nil))) + } + + func testOrderedList() { + XCTAssertNoThrow(try OrderedList(.orderedList(parsedRange: nil, []))) + XCTAssertThrowsError(try OrderedList(.softBreak(parsedRange: nil))) + } + + func testUnorderedList() { + XCTAssertNoThrow(try UnorderedList(.unorderedList(parsedRange: nil, []))) + XCTAssertThrowsError(try UnorderedList(.softBreak(parsedRange: nil))) + } + + func testCustomBlock() { + XCTAssertNoThrow(try CustomBlock(.customBlock(parsedRange: nil, []))) + XCTAssertThrowsError(try CustomBlock(.softBreak(parsedRange: nil))) + } + + func testCustomInline() { + XCTAssertNoThrow(try CustomInline(.customInline(parsedRange: nil, text: ""))) + XCTAssertThrowsError(try CustomInline(.softBreak(parsedRange: nil))) + } + + func testInlineCode() { + XCTAssertNoThrow(try InlineCode(.inlineCode(parsedRange: nil, code: ""))) + XCTAssertThrowsError(try InlineCode(.softBreak(parsedRange: nil))) + } + + func testInlineHTML() { + XCTAssertNoThrow(try InlineHTML(.inlineHTML(parsedRange: nil, html: ""))) + XCTAssertThrowsError(try InlineHTML(.softBreak(parsedRange: nil))) + } + + func testLineBreak() { + XCTAssertNoThrow(try LineBreak(.lineBreak(parsedRange: nil))) + XCTAssertThrowsError(try LineBreak(.softBreak(parsedRange: nil))) + } + + func testSoftBreak() { + XCTAssertNoThrow(try SoftBreak(.softBreak(parsedRange: nil))) + XCTAssertThrowsError(try SoftBreak(.lineBreak(parsedRange: nil))) + } + + func testText() { + XCTAssertNoThrow(try Text(.text(parsedRange: nil, string: ""))) + XCTAssertThrowsError(try Text(.softBreak(parsedRange: nil))) + } + + func testEmphasis() { + XCTAssertNoThrow(try Emphasis(.emphasis(parsedRange: nil, []))) + XCTAssertThrowsError(try Emphasis(.softBreak(parsedRange: nil))) + } + + func testStrong() { + XCTAssertNoThrow(try Strong(.strong(parsedRange: nil, []))) + XCTAssertThrowsError(try Strong(.softBreak(parsedRange: nil))) + } + + func testImage() { + XCTAssertNoThrow(try Image(.image(source: "", title: "", parsedRange: nil, []))) + XCTAssertThrowsError(try Image(.softBreak(parsedRange: nil))) + } + + func testLink() { + XCTAssertNoThrow(try Link(.link(destination: "", parsedRange: nil, []))) + XCTAssertThrowsError(try Link(.softBreak(parsedRange: nil))) + } +} diff --git a/Tests/MarkdownTests/Base/StableIdentifierTests.swift b/Tests/MarkdownTests/Base/StableIdentifierTests.swift new file mode 100644 index 00000000..21c99236 --- /dev/null +++ b/Tests/MarkdownTests/Base/StableIdentifierTests.swift @@ -0,0 +1,30 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +/// Test that unique identifiers aren't recreated for the same elements. +final class StableIdentifierTests: XCTestCase { + /// Children are constructed on the fly; test that each time they are gotten, they have the same identifier. + func testStableIdentifiers() { + let paragraph = Paragraph(Emphasis(Text("OK."))) + + // A copy of a node should have the same identifier. + let paragraphCopy = paragraph + XCTAssertTrue(paragraph.isIdentical(to: paragraphCopy)) + + // A child gotten twice should have the same identifier both times. + XCTAssertTrue(paragraph.child(at: 0)!.isIdentical(to: paragraph.child(at: 0)!)) + + // Similarly, for deeper nodes. + XCTAssertTrue(paragraph.child(at: 0)!.child(at: 0)!.isIdentical(to: paragraph.child(at: 0)!.child(at: 0)!)) + } +} diff --git a/Tests/MarkdownTests/Block Nodes/CodeBlockTests.swift b/Tests/MarkdownTests/Block Nodes/CodeBlockTests.swift new file mode 100644 index 00000000..70af5b72 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/CodeBlockTests.swift @@ -0,0 +1,48 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class CodeBlockTests: XCTestCase { + var testCodeBlock: CodeBlock { + let language = "swift" + let code = "func foo() {}" + let codeBlock = CodeBlock(language: language, code) + XCTAssertEqual(.some(language), codeBlock.language) + XCTAssertEqual(code, codeBlock.code) + return codeBlock + } + + func testCodeBlockLanguage() { + let codeBlock = testCodeBlock + var newCodeBlock = codeBlock + newCodeBlock.language = "c" + + XCTAssertEqual(.some("c"), newCodeBlock.language) + XCTAssertFalse(codeBlock.isIdentical(to: newCodeBlock)) + + var codeBlockWithoutLanguage = newCodeBlock + codeBlockWithoutLanguage.language = nil + XCTAssertNil(codeBlockWithoutLanguage.language) + XCTAssertFalse(codeBlock.isIdentical(to: codeBlockWithoutLanguage)) + } + + func testCodeBlockCode() { + let codeBlock = testCodeBlock + let newCode = "func bar() {}" + var newCodeBlock = codeBlock + newCodeBlock.code = newCode + + XCTAssertEqual(newCode, newCodeBlock.code) + XCTAssertEqual(codeBlock.language, newCodeBlock.language) + XCTAssertFalse(codeBlock.isIdentical(to: newCodeBlock)) + } +} diff --git a/Tests/MarkdownTests/Block Nodes/DocumentTests.swift b/Tests/MarkdownTests/Block Nodes/DocumentTests.swift new file mode 100644 index 00000000..57078ec2 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/DocumentTests.swift @@ -0,0 +1,43 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +import Foundation +import Markdown + +final class DocumentTests: XCTestCase { + func testDocumentFromSequence() { + let children = [ + Paragraph(Text("First")), + Paragraph(Text("Second")), + ] + let document = Document(children) + let expectedDump = """ + Document + ├─ Paragraph + │ └─ Text "First" + └─ Paragraph + └─ Text "Second" + """ + XCTAssertEqual(expectedDump, document.debugDescription()) + } + + func testParseURL() { + let readmeURL = URL(fileURLWithPath: #file) + .deletingLastPathComponent() // Block Nodes + .appendingPathComponent("..") // MarkupTests + .appendingPathComponent("..") // Tests + .appendingPathComponent("..") // Project + .appendingPathComponent("README.md") + XCTAssertNoThrow(try Document(parsing: readmeURL)) + XCTAssertThrowsError(try Document(parsing: URL(fileURLWithPath: #file) + .appendingPathComponent("doesntexist"))) + } +} diff --git a/Tests/MarkdownTests/Block Nodes/HTMLBlockTests.swift b/Tests/MarkdownTests/Block Nodes/HTMLBlockTests.swift new file mode 100644 index 00000000..be14ce68 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/HTMLBlockTests.swift @@ -0,0 +1,26 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class HTMLTestsTests: XCTestCase { + func testHTMLBlockRawHTML() { + let rawHTML = "Hi!" + let html = HTMLBlock(rawHTML) + XCTAssertEqual(rawHTML, html.rawHTML) + + let newRawHTML = "
" + var newHTML = html + newHTML.rawHTML = newRawHTML + XCTAssertEqual(newRawHTML, newHTML.rawHTML) + XCTAssertFalse(html.isIdentical(to: newHTML)) + } +} diff --git a/Tests/MarkdownTests/Block Nodes/HeadingTests.swift b/Tests/MarkdownTests/Block Nodes/HeadingTests.swift new file mode 100644 index 00000000..fe18a273 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/HeadingTests.swift @@ -0,0 +1,30 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class HeadingTests: XCTestCase { + func testLevel() { + let heading = Heading(level: 1, [Text("Some text")]) + XCTAssertEqual(1, heading.level) + + var newHeading = heading + newHeading.level = 2 + XCTAssertEqual(2, newHeading.level) + XCTAssertFalse(heading.isIdentical(to: newHeading)) + + // If you don't actually change the level, you get the same node back. + var newHeadingUnchanged = heading + newHeadingUnchanged.level = heading.level + XCTAssertTrue(heading.isIdentical(to: newHeadingUnchanged)) + XCTAssertTrue(heading.isIdentical(to: newHeadingUnchanged)) + } +} diff --git a/Tests/MarkdownTests/Block Nodes/ParagraphTests.swift b/Tests/MarkdownTests/Block Nodes/ParagraphTests.swift new file mode 100644 index 00000000..4a91d244 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/ParagraphTests.swift @@ -0,0 +1,147 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class ParagraphTests: XCTestCase { + func testParagraphInserting() { + let paragraph = Paragraph(Strong(Text("OK"))) + let id = paragraph._data.id + + do { // Insert nothing + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<0, with: []) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Strong(Text("OK"))), expectedId: id) + let expectedDump = """ + Paragraph + └─ Strong + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + + do { // Insert one + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<0, with: [InlineCode("Array")]) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Strong(Text("OK"))), expectedId: id) + let expectedDump = """ + Paragraph + ├─ InlineCode `Array` + └─ Strong + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + + do { // Insert many + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<0, with: [Text](repeating: Text("OK"), count: 5)) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Strong(Text("OK"))), expectedId: id) + let expectedDump = """ + Paragraph + ├─ Text "OK" + ├─ Text "OK" + ├─ Text "OK" + ├─ Text "OK" + ├─ Text "OK" + └─ Strong + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + } + + func testParagraphReplacingChildrenInRange() { + let paragraph = Paragraph(Text("1"), Text("2"), Text("3"), Text("4")) + let id = paragraph._data.id + do { // Replace one + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<1, with: [Text("Changed")]) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + ├─ Text "Changed" + ├─ Text "2" + ├─ Text "3" + └─ Text "4" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + + do { // Replace many + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<3, with: [Text("Changed")]) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + ├─ Text "Changed" + └─ Text "4" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + + do { // Replace all + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<4, with: [Text("Changed")]) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + └─ Text "Changed" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + } + + func testParagraphDeleting() { + let paragraph = Paragraph(Text("1"), Text("2"), Text("3"), Text("4")) + let id = paragraph._data.id + + do { // None + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<0, with: []) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + XCTAssertEqual(paragraph.debugDescription(), newParagraph.debugDescription()) + } + + do { // One + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<1, with: []) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + ├─ Text "2" + ├─ Text "3" + └─ Text "4" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + + do { // Many + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<3, with: []) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + └─ Text "4" + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + do { // All + var newParagraph = paragraph + newParagraph.replaceChildrenInRange(0..<4, with: []) + assertElementDidntChange(paragraph, assertedStructure: Paragraph(Text("1"), Text("2"), Text("3"), Text("4")), expectedId: id) + let expectedDump = """ + Paragraph + """ + XCTAssertEqual(expectedDump, newParagraph.debugDescription()) + } + } +} diff --git a/Tests/MarkdownTests/Block Nodes/TableTests.swift b/Tests/MarkdownTests/Block Nodes/TableTests.swift new file mode 100644 index 00000000..b840e6b0 --- /dev/null +++ b/Tests/MarkdownTests/Block Nodes/TableTests.swift @@ -0,0 +1,208 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class TableTests: XCTestCase { + func testGetHeader() { + do { // none + let table = Table() + XCTAssertTrue(table.columnAlignments.isEmpty) + XCTAssertTrue(table.isEmpty) + } + + do { // some + let table = Table(header: Table.Head(Table.Cell(Text("OK")))) + XCTAssertNotNil(table.head) + let expectedDump = """ + Table alignments: |-| + ├─ Head + │ └─ Cell + │ └─ Text "OK" + └─ Body + """ + XCTAssertEqual(expectedDump, table.debugDescription()) + XCTAssertTrue(table.body.isEmpty) + } + } + + func testSetHeader() { + do { // non-empty -> empty + let source = """ + |x|y|z| + |-|-|-| + |1|2|3| + """ + let document = Document(parsing: source) + var table = document.child(at: 0) as! Table + XCTAssertFalse(table.head.isEmpty) + table.head = [] + XCTAssertTrue(table.head.isEmpty) + + let expectedDump = """ + Document + └─ Table alignments: |-|-|-| + ├─ Head + └─ Body + └─ Row + ├─ Cell + │ └─ Text "1" + ├─ Cell + │ └─ Text "2" + └─ Cell + └─ Text "3" + """ + XCTAssertEqual(expectedDump, table.root.debugDescription()) + } + + do { // empty -> non-empty + var table = Table() + XCTAssertTrue(table.head.isEmpty) + table.head = Table.Head( + Table.Cell(Text("x")), Table.Cell(Text("y")), Table.Cell(Text("z")) + ) + XCTAssertFalse(table.head.isEmpty) + let expectedDump = """ + Table alignments: |-|-|-| + ├─ Head + │ ├─ Cell + │ │ └─ Text "x" + │ ├─ Cell + │ │ └─ Text "y" + │ └─ Cell + │ └─ Text "z" + └─ Body + """ + XCTAssertEqual(expectedDump, table.root.debugDescription()) + } + } + + func testGetBody() { + do { // none + let table = Table() + XCTAssertEqual(0, table.body.childCount) + } + + do { // some + let table = Table(body: Table.Body([ + Table.Row(), + Table.Row(), + Table.Row(), + ])) + XCTAssertTrue(table.head.isEmpty) + XCTAssertEqual(3, table.body.childCount) + } + } + + func testSetBody() { + do { // non-empty -> empty + let source = """ + |x|y|z| + |-|-|-| + |1|2|3| + """ + let document = Document(parsing: source) + var table = document.child(at: 0) as! Table + XCTAssertFalse(table.head.isEmpty) + XCTAssertFalse(table.body.isEmpty) + table.body.setRows([]) + XCTAssertFalse(table.head.isEmpty) + XCTAssertTrue(table.body.isEmpty) + + let expectedDump = """ + Document + └─ Table alignments: |-|-|-| + ├─ Head + │ ├─ Cell + │ │ └─ Text "x" + │ ├─ Cell + │ │ └─ Text "y" + │ └─ Cell + │ └─ Text "z" + └─ Body + """ + XCTAssertEqual(expectedDump, table.root.debugDescription()) + } + + do { // empty -> non-empty + let source = """ + |x|y|z| + |-|-|-| + """ + let document = Document(parsing: source) + var table = document.child(at: 0) as! Table + XCTAssertFalse(table.head.isEmpty) + XCTAssertTrue(table.body.isEmpty) + table.body.setRows([ + Table.Row([ + Table.Cell(Text("1")), + Table.Cell(Text("2")), + Table.Cell(Text("3")), + ]) + ]) + XCTAssertFalse(table.head.isEmpty) + XCTAssertFalse(table.body.isEmpty) + + let expectedDump = """ + Document + └─ Table alignments: |-|-|-| + ├─ Head + │ ├─ Cell + │ │ └─ Text "x" + │ ├─ Cell + │ │ └─ Text "y" + │ └─ Cell + │ └─ Text "z" + └─ Body + └─ Row + ├─ Cell + │ └─ Text "1" + ├─ Cell + │ └─ Text "2" + └─ Cell + └─ Text "3" + """ + XCTAssertEqual(expectedDump, table.root.debugDescription()) + + } + } + + func testParse() { + let source = """ + |x|y| + |-|-| + |1|2| + |3|4| + """ + let document = Document(parsing: source) + let expectedDump = """ + Document @1:1-4:6 + └─ Table @1:1-4:6 alignments: |-|-| + ├─ Head @1:1-1:6 + │ ├─ Cell @1:2-1:3 + │ │ └─ Text @1:2-1:3 "x" + │ └─ Cell @1:4-1:5 + │ └─ Text @1:4-1:5 "y" + └─ Body @3:1-4:6 + ├─ Row @3:1-3:6 + │ ├─ Cell @3:2-3:3 + │ │ └─ Text @3:2-3:3 "1" + │ └─ Cell @3:4-3:5 + │ └─ Text @3:4-3:5 "2" + └─ Row @4:1-4:6 + ├─ Cell @4:2-4:3 + │ └─ Text @4:2-4:3 "3" + └─ Cell @4:4-4:5 + └─ Text @4:4-4:5 "4" + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/ImageTests.swift b/Tests/MarkdownTests/Inline Nodes/ImageTests.swift new file mode 100644 index 00000000..d712c3e0 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/ImageTests.swift @@ -0,0 +1,54 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class ImageTests: XCTestCase { + func testImageSource() { + let source = "test.png" + let image = Image(source: source, title: "") + XCTAssertEqual(source, image.source) + XCTAssertEqual(0, image.childCount) + + let newSource = "new.png" + var newImage = image + newImage.source = newSource + XCTAssertEqual(newSource, newImage.source) + XCTAssertFalse(image.isIdentical(to: newImage)) + } + + func testImageTitle() { + let title = "title" + let image = Image(source: "_", title: title) + XCTAssertEqual(title, image.title) + XCTAssertEqual(0, image.childCount) + + do { + let source = "![Alt](test.png \"\(title)\")" + let document = Document(parsing: source) + let image = document.child(through:[ + (0, Paragraph.self), + (0, Image.self) + ]) as! Image + XCTAssertEqual(title, image.title) + } + } + + func testLinkFromSequence() { + let children = [Text("Hello, world!")] + let image = Image(source: "test.png", title: "title", children) + let expectedDump = """ + Image source: "test.png" title: "title" + └─ Text "Hello, world!" + """ + XCTAssertEqual(expectedDump, image.debugDescription()) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/InlineCodeTests.swift b/Tests/MarkdownTests/Inline Nodes/InlineCodeTests.swift new file mode 100644 index 00000000..4f7c9614 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/InlineCodeTests.swift @@ -0,0 +1,28 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class InlineCodeTests: XCTestCase { + func testInlineCodeString() { + let text = "foo()" + let text2 = "bar()" + let inlineCode = InlineCode(text) + + XCTAssertEqual(text, inlineCode.code) + + var inlineCodeWithText2 = inlineCode + inlineCodeWithText2.code = text2 + + XCTAssertEqual(text2, inlineCodeWithText2.code) + XCTAssertFalse(inlineCode.isIdentical(to: inlineCodeWithText2)) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/InlineHTMLTests.swift b/Tests/MarkdownTests/Inline Nodes/InlineHTMLTests.swift new file mode 100644 index 00000000..b4cafc2f --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/InlineHTMLTests.swift @@ -0,0 +1,27 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class InlineHTMLTests: XCTestCase { + func testInlineHTMLRawHTML() { + let rawHTML = "bold" + let rawHTML2 = "

para

" + + let inlineHTML = InlineHTML(rawHTML) + XCTAssertEqual(rawHTML, inlineHTML.rawHTML) + + var newInlineHTML = inlineHTML + newInlineHTML.rawHTML = rawHTML2 + XCTAssertEqual(rawHTML2, newInlineHTML.rawHTML) + XCTAssertFalse(inlineHTML.isIdentical(to: newInlineHTML)) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/LineBreakTests.swift b/Tests/MarkdownTests/Inline Nodes/LineBreakTests.swift new file mode 100644 index 00000000..369d27e5 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/LineBreakTests.swift @@ -0,0 +1,28 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class LineBreakTests: XCTestCase { + /// Tests that creation doesn't crash. + func testLineBreak() { + _ = LineBreak() + } + + /// Test that line breaks are parsed correctly. + /// (Lots of folks have trailing whitespace trimming on). + func testParseLineBreak() { + let source = "Paragraph. \nStill the same paragraph." + let document = Document(parsing: source) + let paragraph = document.child(at: 0) as! Paragraph + XCTAssertTrue(Array(paragraph.children)[1] is LineBreak) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/LinkTests.swift b/Tests/MarkdownTests/Inline Nodes/LinkTests.swift new file mode 100644 index 00000000..895b66af --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/LinkTests.swift @@ -0,0 +1,37 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class LinkTests: XCTestCase { + func testLinkDestination() { + let destination = "destination" + let link = Link(destination: destination) + XCTAssertEqual(destination, link.destination) + XCTAssertEqual(0, link.childCount) + + let newDestination = "newdestination" + var newLink = link + newLink.destination = newDestination + XCTAssertEqual(newDestination, newLink.destination) + XCTAssertFalse(link.isIdentical(to: newLink)) + } + + func testLinkFromSequence() { + let children = [Text("Hello, world!")] + let link = Link(destination: "destination", children) + let expectedDump = """ + Link destination: "destination" + └─ Text "Hello, world!" + """ + XCTAssertEqual(expectedDump, link.debugDescription()) + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/SoftBreakTests.swift b/Tests/MarkdownTests/Inline Nodes/SoftBreakTests.swift new file mode 100644 index 00000000..1840e115 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/SoftBreakTests.swift @@ -0,0 +1,19 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class SoftBreakTests: XCTestCase { + /// Tests that creation doesn't crash. + func testSoftBreak() { + _ = SoftBreak() + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/SymbolLinkTests.swift b/Tests/MarkdownTests/Inline Nodes/SymbolLinkTests.swift new file mode 100644 index 00000000..1c21bb1d --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/SymbolLinkTests.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class SymbolLinkTests: XCTestCase { + func testSymbolLinkDestination() { + let destination = "destination" + let symbolLink = SymbolLink(destination: destination) + XCTAssertEqual(destination, symbolLink.destination) + XCTAssertEqual(0, symbolLink.childCount) + + let newDestination = "newdestination" + var newSymbolLink = symbolLink + newSymbolLink.destination = newDestination + XCTAssertEqual(newDestination, newSymbolLink.destination) + XCTAssertFalse(symbolLink.isIdentical(to: newSymbolLink)) + } + + func testDetectionFromInlineCode() { + let source = "``foo()``" + do { // option on + let document = Document(parsing: source, options: .parseSymbolLinks) + let expectedDump = """ + Document @1:1-1:10 + └─ Paragraph @1:1-1:10 + └─ SymbolLink @1:1-1:10 destination: foo() + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } + do { // option off + let document = Document(parsing: source) + let expectedDump = """ + Document @1:1-1:10 + └─ Paragraph @1:1-1:10 + └─ InlineCode @1:1-1:10 `foo()` + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } + } +} diff --git a/Tests/MarkdownTests/Inline Nodes/TextTests.swift b/Tests/MarkdownTests/Inline Nodes/TextTests.swift new file mode 100644 index 00000000..bafb1992 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/TextTests.swift @@ -0,0 +1,26 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class TextTests: XCTestCase { + func testWithText() { + let string = "OK" + let text = Text(string) + XCTAssertEqual(string, text.string) + + let string2 = "Changed" + var newText = text + newText.string = string2 + XCTAssertEqual(string2, newText.string) + XCTAssertFalse(text.isIdentical(to: newText)) + } +} diff --git a/Tests/MarkdownTests/Interpretive Nodes/AsideTests.swift b/Tests/MarkdownTests/Interpretive Nodes/AsideTests.swift new file mode 100644 index 00000000..b0a67dd1 --- /dev/null +++ b/Tests/MarkdownTests/Interpretive Nodes/AsideTests.swift @@ -0,0 +1,76 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +class AsideTests: XCTestCase { + func testTags() { + for kind in Aside.Kind.allCases { + let source = "> \(kind.rawValue): This is a `\(kind.rawValue)` aside." + let document = Document(parsing: source) + let blockQuote = document.child(at: 0) as! BlockQuote + let aside = Aside(blockQuote) + XCTAssertEqual(kind, aside.kind) + + // Note that the initial text in the paragraph has been adjusted + // to after the tag. + let expectedRootDump = """ + Document + └─ BlockQuote + └─ Paragraph + ├─ Text "This is a " + ├─ InlineCode `\(kind.rawValue)` + └─ Text " aside." + """ + XCTAssertEqual(expectedRootDump, aside.content[0].root.debugDescription()) + } + } + + func testMissingTag() { + let source = "> This is a regular block quote." + let document = Document(parsing: source) + let blockQuote = document.child(at: 0) as! BlockQuote + let aside = Aside(blockQuote) + XCTAssertEqual(.note, aside.kind) + XCTAssertTrue(aside.content[0].root.isIdentical(to: document)) + } + + func testUnknownTag() { + let source = "> Hmm: This is something..." + let document = Document(parsing: source) + let blockQuote = document.child(at: 0) as! BlockQuote + let aside = Aside(blockQuote) + XCTAssertEqual(.note, aside.kind) + XCTAssertTrue(aside.content[0].root.isIdentical(to: document)) + } + + func testNoParagraphAtStart() { + let source = """ + > - A + > - List? + """ + let document = Document(parsing: source) + let blockQuote = document.child(at: 0) as! BlockQuote + let aside = Aside(blockQuote) + XCTAssertEqual(.note, aside.kind) + XCTAssertTrue(aside.content[0].root.isIdentical(to: document)) + } + + func testCaseInsensitive() { + for kind in Aside.Kind.allCases { + let source = "> \(kind.rawValue.lowercased()): This is a `\(kind.rawValue)` aside." + let document = Document(parsing: source) + let blockQuote = document.child(at: 0) as! BlockQuote + let aside = Aside(blockQuote) + XCTAssertEqual(kind, aside.kind) + } + } +} diff --git a/Tests/MarkdownTests/Parsing/BlockDirectiveParserTests.swift b/Tests/MarkdownTests/Parsing/BlockDirectiveParserTests.swift new file mode 100644 index 00000000..78a701e2 --- /dev/null +++ b/Tests/MarkdownTests/Parsing/BlockDirectiveParserTests.swift @@ -0,0 +1,780 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +@testable import Markdown +import XCTest + +fileprivate extension RandomAccessCollection where Element == DirectiveArgument { + /// Look for an argument named `name` or log an XCTest failure. + subscript(_ name: S, file: StaticString = #filePath, line: UInt = #line) -> DirectiveArgument? { + guard let found = self.first(where: { + $0.name == name + }) else { + XCTFail("Expected argument named \(name) but it was not found", file: file, line: line) + return nil + } + return found + } +} + +class BlockDirectiveArgumentParserTests: XCTestCase { + func testNone() throws { + let source = "@Outer" + let document = BlockDirectiveParser.parse(source, source: nil) + let directive = document.child(at: 0) as! BlockDirective + + XCTAssertEqual("Outer", directive.name) + XCTAssertEqual(SourceLocation(line: 1, column: 1, source: nil).. Quote + > Quote + + ``foo()`` + + @Outer { + @Inner + + - A + } + """ + let source = URL(string: "https://swift.org/test.md")! + let document = Document(parsing: text, source: source, options: [.parseBlockDirectives, .parseSymbolLinks]) + var checker = CheckAllElementsHaveSourceLocationURL(source: source) + checker.visit(document) + + let expectedDump = """ + Document @/test.md:/test.md:1:1-/test.md:16:2 + ├─ Paragraph @/test.md:/test.md:1:1-/test.md:1:21 + │ └─ Text @/test.md:/test.md:1:1-/test.md:1:21 "This is a paragraph." + ├─ UnorderedList @/test.md:/test.md:3:1-/test.md:6:1 + │ ├─ ListItem @/test.md:/test.md:3:1-/test.md:3:4 + │ │ └─ Paragraph @/test.md:/test.md:3:3-/test.md:3:4 + │ │ └─ Text @/test.md:/test.md:3:3-/test.md:3:4 "A" + │ └─ ListItem @/test.md:/test.md:4:1-/test.md:6:1 + │ ├─ Paragraph @/test.md:/test.md:4:3-/test.md:4:4 + │ │ └─ Text @/test.md:/test.md:4:3-/test.md:4:4 "B" + │ └─ UnorderedList @/test.md:/test.md:5:3-/test.md:6:1 + │ └─ ListItem @/test.md:/test.md:5:3-/test.md:6:1 + │ └─ Paragraph @/test.md:/test.md:5:5-/test.md:5:6 + │ └─ Text @/test.md:/test.md:5:5-/test.md:5:6 "C" + ├─ BlockQuote @/test.md:/test.md:7:1-/test.md:8:8 + │ └─ Paragraph @/test.md:/test.md:7:3-/test.md:8:8 + │ ├─ Text @/test.md:/test.md:7:3-/test.md:7:8 "Quote" + │ ├─ SoftBreak + │ └─ Text @/test.md:/test.md:8:3-/test.md:8:8 "Quote" + ├─ Paragraph @/test.md:/test.md:10:1-/test.md:10:10 + │ └─ SymbolLink @/test.md:/test.md:10:1-/test.md:10:10 destination: foo() + └─ BlockDirective @/test.md:/test.md:12:1-/test.md:16:2 name: "Outer" + ├─ BlockDirective @/test.md:/test.md:13:3-/test.md:13:9 name: "Inner" + └─ UnorderedList @/test.md:/test.md:15:3-/test.md:15:6 + └─ ListItem @/test.md:/test.md:15:3-/test.md:15:6 + └─ Paragraph @/test.md:/test.md:15:5-/test.md:15:6 + └─ Text @/test.md:/test.md:15:5-/test.md:15:6 "A" + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } +} diff --git a/Tests/MarkdownTests/Performance/EditPerformanceTests.swift b/Tests/MarkdownTests/Performance/EditPerformanceTests.swift new file mode 100644 index 00000000..2fa0779a --- /dev/null +++ b/Tests/MarkdownTests/Performance/EditPerformanceTests.swift @@ -0,0 +1,52 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +import Markdown + +final class EditPerformanceTests: XCTestCase { + static let maxDepth = 5000 + /// Test the performance of changing a leaf in an unrealistically deep markup tree. + func testChangeTextInDeepTree() { + func buildDeepListItem(depth: Int) -> ListItem { + guard depth < EditPerformanceTests.maxDepth else { + return ListItem(Paragraph(Text("A"), Text("B"), Text("C"))) + } + return ListItem(buildDeepList(depth: depth + 1)) + } + + func buildDeepList(depth: Int = 0) -> UnorderedList { + guard depth < EditPerformanceTests.maxDepth else { + return UnorderedList(buildDeepListItem(depth: depth)) + } + return UnorderedList(buildDeepListItem(depth: depth + 1)) + } + + let list = buildDeepList() + var deepChild: Markup = list + while let child = deepChild.child(at: 0) { + deepChild = child + } + + var deepText = (deepChild as! Text) + measure { + deepText.string = "Z" + } + } + + /// Test the performance of change an element among unrealistically many siblings. + func testChangeTextInWideParagraph() { + let paragraph = Paragraph((0..<10000).map { _ in Text("OK") }) + var firstText = paragraph.child(at: 0) as! Text + measure { + firstText.string = "OK" + } + } +} diff --git a/Tests/MarkdownTests/Performance/MarkupChildrenPerformanceTests.swift b/Tests/MarkdownTests/Performance/MarkupChildrenPerformanceTests.swift new file mode 100644 index 00000000..18997f3b --- /dev/null +++ b/Tests/MarkdownTests/Performance/MarkupChildrenPerformanceTests.swift @@ -0,0 +1,52 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +import Markdown + +final class MarkupChildrenPerformanceTests: XCTestCase { + /// Iteration over the children should be fast: no heap allocation should be necessary. + let paragraph = Paragraph((0..<10000).map { _ in Text("OK") }) + func testIterateChildrenForward() { + measure { + for child in paragraph.children { + _ = child + } + } + } + + /// Iteration over the children in reverse should be fast: no heap allocation should be necessary. + func testIterateChildrenReversed() { + let paragraph = Paragraph((0..<10000).map { _ in Text("OK") }) + measure { + for child in paragraph.children.reversed() { + _ = child + } + } + } + + func testDropFirst() { + let paragraph = Paragraph((0..<10000).map { _ in Text("OK") }) + measure { + for child in paragraph.children.dropFirst(5000) { + _ = child + } + } + } + + func testSuffix() { + let paragraph = Paragraph((0..<10000).map { _ in Text("OK") }) + measure { + for child in paragraph.children.suffix(5000) { + _ = child + } + } + } +} diff --git a/Tests/MarkdownTests/Structural Restrictions/BasicBlockContainerTests.swift b/Tests/MarkdownTests/Structural Restrictions/BasicBlockContainerTests.swift new file mode 100644 index 00000000..373e1597 --- /dev/null +++ b/Tests/MarkdownTests/Structural Restrictions/BasicBlockContainerTests.swift @@ -0,0 +1,113 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class BasicBlockContainerTests: XCTestCase { + func testFromSequence() { + let expectedChildren = Array(repeating: Paragraph(Text("OK")), count: 3) + let blockQuote = BlockQuote(expectedChildren) + let gottenChildren = Array(blockQuote.children) + XCTAssertEqual(expectedChildren.count, gottenChildren.count) + for (expected, gotten) in zip(expectedChildren, gottenChildren) { + XCTAssertEqual(expected.debugDescription(), gotten.detachedFromParent.debugDescription()) + } + } + + func testReplacingChildrenInRange() { + let blockQuote = BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)) + let id = blockQuote._data.id + + do { // Insert one + let insertedChild = Paragraph(Text("Inserted")) + var newBlockQuote = blockQuote + newBlockQuote.replaceChildrenInRange(0..<0, with: CollectionOfOne(insertedChild)) + assertElementDidntChange(blockQuote, assertedStructure: BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)), expectedId: id) + XCTAssertEqual(insertedChild.debugDescription(), (newBlockQuote.child(at: 0) as! Paragraph).detachedFromParent.debugDescription()) + XCTAssertEqual(4, newBlockQuote.childCount) + } + + do { // Insert multiple + let insertedChildren = Array(repeating: Paragraph(Text("Inserted")), count: 3) + var newBlockQuote = blockQuote + newBlockQuote.replaceChildrenInRange(0..<0, with: insertedChildren) + assertElementDidntChange(blockQuote, assertedStructure: BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)), expectedId: id) + let expectedDump = """ + BlockQuote + ├─ Paragraph + │ └─ Text "Inserted" + ├─ Paragraph + │ └─ Text "Inserted" + ├─ Paragraph + │ └─ Text "Inserted" + ├─ Paragraph + │ └─ Text "OK" + ├─ Paragraph + │ └─ Text "OK" + └─ Paragraph + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newBlockQuote.debugDescription()) + XCTAssertEqual(6, newBlockQuote.childCount) + } + + do { // Replace one + let replacementChild = Paragraph(Text("Replacement")) + var newBlockQuote = blockQuote + newBlockQuote.replaceChildrenInRange(0..<1, with: CollectionOfOne(replacementChild)) + assertElementDidntChange(blockQuote, assertedStructure: BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)), expectedId: id) + XCTAssertEqual(replacementChild.debugDescription(), (newBlockQuote.child(at: 0) as! Paragraph).detachedFromParent.debugDescription()) + XCTAssertEqual(3, newBlockQuote.childCount) + } + + do { // Replace many + let replacementChild = Paragraph(Text("Replacement")) + var newBlockQuote = blockQuote + newBlockQuote.replaceChildrenInRange(0..<2, with: CollectionOfOne(replacementChild)) + assertElementDidntChange(blockQuote, assertedStructure: BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)), expectedId: id) + let expectedDump = """ + BlockQuote + ├─ Paragraph + │ └─ Text "Replacement" + └─ Paragraph + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newBlockQuote.debugDescription()) + XCTAssertEqual(2, newBlockQuote.childCount) + } + + do { // Replace all + let replacementChild = Paragraph(Text("Replacement")) + var newBlockQuote = blockQuote + newBlockQuote.replaceChildrenInRange(0..<3, with: CollectionOfOne(replacementChild)) + assertElementDidntChange(blockQuote, assertedStructure: BlockQuote(Array(repeating: Paragraph(Text("OK")), count: 3)), expectedId: id) + let expectedDump = """ + BlockQuote + └─ Paragraph + └─ Text "Replacement" + """ + XCTAssertEqual(expectedDump, newBlockQuote.debugDescription()) + XCTAssertEqual(1, newBlockQuote.childCount) + } + } + + func testSetBlockChildren() { + let document = Document(Paragraph(), Paragraph(), Paragraph()) + var newDocument = document + newDocument.setBlockChildren([ThematicBreak(), ThematicBreak()]) + let expectedDump = """ + Document + ├─ ThematicBreak + └─ ThematicBreak + """ + XCTAssertEqual(expectedDump, newDocument.debugDescription()) + } +} diff --git a/Tests/MarkdownTests/Structural Restrictions/BasicInlineContainerTests.swift b/Tests/MarkdownTests/Structural Restrictions/BasicInlineContainerTests.swift new file mode 100644 index 00000000..623871e0 --- /dev/null +++ b/Tests/MarkdownTests/Structural Restrictions/BasicInlineContainerTests.swift @@ -0,0 +1,103 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class BasicInlineContainerTests: XCTestCase { + func testFromSequence() { + let expectedChildren = Array(repeating: Text("OK"), count: 3) + let emphasis = Emphasis(expectedChildren) + let gottenChildren = Array(emphasis.children) + XCTAssertEqual(expectedChildren.count, gottenChildren.count) + for (expected, gotten) in zip(expectedChildren, gottenChildren) { + XCTAssertEqual(expected.debugDescription(), gotten.detachedFromParent.debugDescription()) + } + } + + func testReplacingChildrenInRange() { + let emphasis = Emphasis(Array(repeating: Text("OK"), count: 3)) + let id = emphasis._data.id + + do { // Insert one + let insertedChild = Text("Inserted") + var newEmphasis = emphasis + newEmphasis.replaceChildrenInRange(0..<0, with: CollectionOfOne(insertedChild)) + assertElementDidntChange(emphasis, assertedStructure: Emphasis(Array(repeating: Text("OK"), count: 3)), expectedId: id) + XCTAssertEqual(insertedChild.debugDescription(), (newEmphasis.child(at: 0) as! Text).detachedFromParent.debugDescription()) + XCTAssertEqual(4, newEmphasis.childCount) + } + + do { // Insert multiple + let insertedChildren = Array(repeating: Text("Inserted"), count: 3) + var newEmphasis = emphasis + newEmphasis.replaceChildrenInRange(0..<0, with: insertedChildren) + assertElementDidntChange(emphasis, assertedStructure: Emphasis(Array(repeating: Text("OK"), count: 3)), expectedId: id) + let expectedDump = """ + Emphasis + ├─ Text "Inserted" + ├─ Text "Inserted" + ├─ Text "Inserted" + ├─ Text "OK" + ├─ Text "OK" + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newEmphasis.debugDescription()) + XCTAssertEqual(6, newEmphasis.childCount) + } + + do { // Replace one + let replacementChild = Text("Replacement") + var newEmphasis = emphasis + newEmphasis.replaceChildrenInRange(0..<1, with: CollectionOfOne(replacementChild)) + XCTAssertEqual(replacementChild.debugDescription(), (newEmphasis.child(at: 0) as! Text).detachedFromParent.debugDescription()) + XCTAssertEqual(3, newEmphasis.childCount) + } + + do { // Replace many + let replacementChild = Text("Replacement") + var newEmphasis = emphasis + newEmphasis.replaceChildrenInRange(0..<2, with: CollectionOfOne(replacementChild)) + assertElementDidntChange(emphasis, assertedStructure: Emphasis(Array(repeating: Text("OK"), count: 3)), expectedId: id) + let expectedDump = """ + Emphasis + ├─ Text "Replacement" + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newEmphasis.debugDescription()) + XCTAssertEqual(2, newEmphasis.childCount) + } + + do { // Replace all + let replacementChild = Text("Replacement") + var newEmphasis = emphasis + newEmphasis.replaceChildrenInRange(0..<3, with: CollectionOfOne(replacementChild)) + let expectedDump = """ + Emphasis + └─ Text "Replacement" + """ + XCTAssertEqual(expectedDump, newEmphasis.debugDescription()) + XCTAssertEqual(1, newEmphasis.childCount) + } + } + + func testSetChildren() { + let document = Paragraph(SoftBreak(), SoftBreak(), SoftBreak()) + var newDocument = document + newDocument.setInlineChildren([Text("1"), Text("2")]) + let expectedDump = """ + Paragraph + ├─ Text "1" + └─ Text "2" + """ + XCTAssertEqual(expectedDump, newDocument.debugDescription()) + } +} + diff --git a/Tests/MarkdownTests/Structural Restrictions/ListItemContainerTests.swift b/Tests/MarkdownTests/Structural Restrictions/ListItemContainerTests.swift new file mode 100644 index 00000000..b075847d --- /dev/null +++ b/Tests/MarkdownTests/Structural Restrictions/ListItemContainerTests.swift @@ -0,0 +1,134 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class ListItemContainerTests: XCTestCase { + + // MARK: OrderedList + + func testOrderedListFromSequence() { + let expectedItems = (0..<2).map { + ListItem(Paragraph(Text("\($0)"))) + } + let ol = OrderedList(expectedItems) + let gottenItems = Array(ol.listItems) + XCTAssertEqual(expectedItems.count, gottenItems.count) + for (expected, gotten) in zip(expectedItems, gottenItems) { + XCTAssertEqual(expected.detachedFromParent.debugDescription(), gotten.detachedFromParent.debugDescription()) + } + } + + func testOrderedListWithItems() { + let items: [ListItem] = [ListItem(Paragraph()), ListItem(ThematicBreak()), ListItem(HTMLBlock(""))] + var ol = OrderedList(items) + ol.setListItems(items) + XCTAssertEqual(3, Array(ol.listItems).count) + var itemIterator = ol.listItems.makeIterator() + XCTAssertTrue(itemIterator.next()?.child(at: 0) is Paragraph) + XCTAssertTrue(itemIterator.next()?.child(at: 0) is ThematicBreak) + XCTAssertTrue(itemIterator.next()?.child(at: 0) is HTMLBlock) + } + + func testOrderedListReplacingItemsInRange() { + let list = OrderedList(Array(repeating: ListItem(Paragraph(Text("OK"))), count: 3)) + + do { // Insert one + let insertedItem = ListItem(Paragraph(Text("Inserted"))) + var newList = list + newList.replaceItemsInRange(0..<0, with: CollectionOfOne(insertedItem)) + XCTAssertEqual(insertedItem.debugDescription(), (newList.child(at: 0) as! ListItem).detachedFromParent.debugDescription()) + XCTAssertEqual(4, newList.childCount) + } + + do { // Insert multiple + let insertedItems = Array(repeating: ListItem(Paragraph(Text("Inserted"))), count: 3) + var newList = list + newList.replaceItemsInRange(0..<0, with: insertedItems) + let expectedDump = """ + OrderedList + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Inserted" + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Inserted" + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Inserted" + ├─ ListItem + │ └─ Paragraph + │ └─ Text "OK" + ├─ ListItem + │ └─ Paragraph + │ └─ Text "OK" + └─ ListItem + └─ Paragraph + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newList.debugDescription()) + XCTAssertEqual(6, newList.childCount) + } + + do { // Replace one + let replacementItem = ListItem(Paragraph(Text("Replacement"))) + var newList = list + newList.replaceItemsInRange(0..<1, with: CollectionOfOne(replacementItem)) + XCTAssertEqual(replacementItem.debugDescription(), (newList.child(at: 0) as! ListItem).detachedFromParent.debugDescription()) + XCTAssertEqual(3, newList.childCount) + } + + do { // Replace many + let replacementItem = ListItem(Paragraph(Text("Replacement"))) + var newList = list + newList.replaceItemsInRange(0..<2, with: CollectionOfOne(replacementItem)) + let expectedDump = """ + OrderedList + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Replacement" + └─ ListItem + └─ Paragraph + └─ Text "OK" + """ + XCTAssertEqual(expectedDump, newList.debugDescription()) + XCTAssertEqual(2, newList.childCount) + } + + do { // Replace all + let replacementItem = ListItem(Paragraph(Text("Replacement"))) + var newList = list + newList.replaceItemsInRange(0..<3, with: CollectionOfOne(replacementItem)) + let expectedDump = """ + OrderedList + └─ ListItem + └─ Paragraph + └─ Text "Replacement" + """ + XCTAssertEqual(expectedDump, newList.debugDescription()) + XCTAssertEqual(1, newList.childCount) + } + } + + // MARK: UnorderedList + + func testUnorderedListFromSequence() { + let expectedItems = (0..<2).map { + ListItem(Paragraph(Text("\($0)"))) + } + let ul = UnorderedList(expectedItems) + let gottenItems = Array(ul.listItems) + XCTAssertEqual(expectedItems.count, gottenItems.count) + for (expected, gotten) in zip(expectedItems, gottenItems) { + XCTAssertEqual(expected.detachedFromParent.debugDescription(), gotten.detachedFromParent.debugDescription()) + } + } +} diff --git a/Tests/MarkdownTests/Utility/AssertElementDidntChange.swift b/Tests/MarkdownTests/Utility/AssertElementDidntChange.swift new file mode 100644 index 00000000..d1b57228 --- /dev/null +++ b/Tests/MarkdownTests/Utility/AssertElementDidntChange.swift @@ -0,0 +1,17 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +@testable import Markdown +import XCTest + +func assertElementDidntChange(_ element: Markup, assertedStructure expected: Markup, expectedId: MarkupIdentifier) { + XCTAssertTrue(element.hasSameStructure(as: expected)) + XCTAssertEqual(element._data.id, expectedId) +} diff --git a/Tests/MarkdownTests/Visitors/Everything.md b/Tests/MarkdownTests/Visitors/Everything.md new file mode 100644 index 00000000..e15ed011 --- /dev/null +++ b/Tests/MarkdownTests/Visitors/Everything.md @@ -0,0 +1,35 @@ +# Header + +*Emphasized* **strong** `inline code` [link](foo) ![image](foo). + +- this +- is +- a +- list + +1. eggs +1. milk + +> BlockQuote + +```swift +func foo() { let x = 1 } +``` + +This is an . + +--- + + + An HTML Block. + + +This is some

inline html

. + +line +break + +soft +break + + diff --git a/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift b/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift new file mode 100644 index 00000000..c8a54fa2 --- /dev/null +++ b/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift @@ -0,0 +1,1290 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +@testable import Markdown +import XCTest + +/// Tests single-element printing capabilities +class MarkupFormatterSingleElementTests: XCTestCase { + func testPrintBlockQuote() { + let expected = "> A block quote." + let printed = BlockQuote(Paragraph(Text("A block quote."))).format() + XCTAssertEqual(expected, printed) + } + + func testPrintCodeBlock() { + let expected = """ + ``` + struct MyStruct { + func foo() {} + } + ``` + """ + let code = """ + struct MyStruct { + func foo() {} + } + """ + let printed = CodeBlock(code).format() + XCTAssertEqual(expected, printed) + } + + func testPrintHeadingATX() { + for level in 1..<6 { + let expected = String(repeating: "#", count: level) + " H\(level)" + let printed = Heading(level: level, Text("H\(level)")).format() + XCTAssertEqual(expected, printed) + } + } + + func testPrintHeadingSetext() { + let options = MarkupFormatter.Options(preferredHeadingStyle: .setext) + do { // H1 + let printed = Heading(level: 1, Text("Level-1 Heading")) + .format(options: options) + let expected = """ + Level-1 Heading + =============== + """ + XCTAssertEqual(expected, printed) + } + + do { // H2 + let printed = Heading(level: 2, Text("Level-2 Heading")) + .format(options: options) + let expected = """ + Level-2 Heading + --------------- + """ + XCTAssertEqual(expected, printed) + } + + do { // H3 - there is no Setext H3 and above; fall back to ATX + for level in 3...6 { + let expected = String(repeating: "#", count: level) + " H\(level)" + let printed = Heading(level: level, Text("H\(level)")).format() + XCTAssertEqual(expected, printed) + } + } + + do { // Check last line length works with soft breaks + let printed = Heading(level: 1, + Text("First line"), + SoftBreak(), + Text("Second line")) + .format(options: options) + let expected = """ + First line + Second line + =========== + """ + XCTAssertEqual(expected, printed) + } + } + + func testPrintThematicBreak() { + XCTAssertEqual("-----", ThematicBreak().format()) + } + + func testPrintListItem() { + do { // no checkbox + // A list item printed on its own cannot determine its list marker + // without its parent, so its contents are printed. + let expected = "A list item." + let printed = ListItem(Paragraph(Text("A list item."))).format() + XCTAssertEqual(expected, printed) + } + } + + func testPrintUnorderedList() { + do { // no checkbox + let expected = "- A list item." + let printed = UnorderedList(ListItem(Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + do { // unchecked + let expected = "- [ ] A list item." + let printed = UnorderedList(ListItem(checkbox: .unchecked, + Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + do { // unchecked + let expected = "- [x] A list item." + let printed = UnorderedList(ListItem(checkbox: .checked, + Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + + } + + func testPrintOrderedList() { + do { // no checkbox + let expected = "1. A list item." + let printed = OrderedList(ListItem(Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + do { // unchecked + let expected = "1. [ ] A list item." + let printed = OrderedList(ListItem(checkbox: .unchecked, + Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + do { // checked + let expected = "1. [x] A list item." + let printed = OrderedList(ListItem(checkbox: .checked, + Paragraph(Text("A list item.")))).format() + XCTAssertEqual(expected, printed) + } + } + + func testPrintParagraph() { + let expected = "A paragraph." + let printed = Paragraph(Text("A paragraph.")).format() + XCTAssertEqual(expected, printed) + } + + func testPrintTwoParagraphs() { + let expected = """ + First paragraph. + + Second paragraph. + """ + let printed = Document( + Paragraph(Text("First paragraph.")), + Paragraph(Text("Second paragraph.")) + ).format() + XCTAssertEqual(expected, printed) + } + + func testPrintInlineCode() { + let expected = "`foo`" + let printed = InlineCode("foo").format() + XCTAssertEqual(expected, printed) + } + + func testPrintEmphasis() { + let expected = "*emphasized*" + let printed = Emphasis(Text("emphasized")).format() + XCTAssertEqual(expected, printed) + } + + func testPrintStrong() { + let expected = "**strong**" + let printed = Strong(Text("strong")).format() + XCTAssertEqual(expected, printed) + } + + func testPrintEmphasisInStrong() { + let expected = "***strong***" + let printed = Strong(Emphasis(Text("strong"))).format() + XCTAssertEqual(expected, printed) + } + + func testPrintInlineHTML() { + let expected = "" + let printed = InlineHTML("").format() + XCTAssertEqual(expected, printed) + } + + func testPrintLineBreak() { + let expected = "Line \nbreak." + let printed = Paragraph(Text("Line"), LineBreak(), Text("break.")).format() + XCTAssertEqual(expected, printed) + } + + func testPrintLink() { + let linkText = "Link text" + let destination = "https://swift.org" + let expected = "[\(linkText)](\(destination))" + let printed = Link(destination: destination, Text(linkText)).format() + XCTAssertEqual(expected, printed) + } + + func testPrintLinkCondenseAutolink() { + let linkText = "https://swift.org" + let destination = linkText + let expected = "<\(destination)>" + let printed = Link(destination: destination, Text(linkText)).format() + XCTAssertEqual(expected, printed) + } + + func testPrintImage() { + let source = "test.png" + let altText = "Alt text" + let title = "title" + do { // Everything present + let expected = "![\(altText)](\(source) \"\(title)\")" + let printed = Image(source: source, title: title, Text(altText)).format() + XCTAssertEqual(expected, printed) + } + + do { // Missing title + let expected = "![\(altText)](\(source))" + let printed = Image(source: source, Text(altText)).format() + XCTAssertEqual(expected, printed) + } + + do { // Missing text + let expected = "![](\(source))" + let printed = Image(source: source).format() + XCTAssertEqual(expected, printed) + } + + do { // Missing everything + let expected = "![]()" // Yes, this is valid. + let printed = Image().format() + XCTAssertEqual(expected, printed) + } + } + + func testPrintSoftBreak() { + let expected = "Soft\nbreak." + let printed = Paragraph(Text("Soft"), SoftBreak(), Text("break.")).format() + XCTAssertEqual(expected, printed) + } + + func testPrintText() { + let expected = "Some text." + let printed = Text("Some text.").format() + XCTAssertEqual(expected, printed) + } + + func testPrintSymbolLink() { + let expected = "``foo()``" + let printed = SymbolLink(destination: "foo()").format() + XCTAssertEqual(expected, printed) + } +} + +/// Tests that formatting options work correctly. +class MarkupFormatterOptionsTests: XCTestCase { + func testUnorderedListMarker() { + let original = Document(parsing: "- A") + do { + let printed = original.format(options: .init(unorderedListMarker: .plus)) + XCTAssertEqual("+ A", printed) + } + + do { + let printed = original.format(options: .init(unorderedListMarker: .star)) + XCTAssertEqual("* A", printed) + } + } + + func testUseCodeFence() { + let fenced = """ + ```swift + func foo() {} + ``` + + ----- + + ``` + func foo() {} + ``` + """ + + let refenced = """ + ``` + func foo() {} + ``` + + ----- + + ``` + func foo() {} + ``` + """ + + let unfenced = """ + func foo() {} + + ----- + + func foo() {} + """ + + do { // always + let document = Document(parsing: unfenced) + let printed = document.format(options: .init(useCodeFence: .always)) + XCTAssertEqual(refenced, printed) + } + + do { // never + let document = Document(parsing: fenced) + let printed = document.format(options: .init(useCodeFence: .never)) + XCTAssertEqual(unfenced, printed) + } + + do { // when language present + let document = Document(parsing: fenced) + let expected = """ + ```swift + func foo() {} + ``` + + ----- + + func foo() {} + """ + let printed = document.format(options: .init(useCodeFence: .onlyWhenLanguageIsPresent)) + XCTAssertEqual(expected, printed) + } + } + + func testDefaultCodeBlockLanguage() { + let unfenced = """ + func foo() {} + """ + let fencedWithLanguage = """ + ```swift + func foo() {} + ``` + """ + let fencedWithoutLanguage = """ + ``` + func foo() {} + ``` + """ + do { // nil + let document = Document(parsing: unfenced) + let printed = document.format() + XCTAssertEqual(fencedWithoutLanguage, printed) + } + + do { // swift + let document = Document(parsing: unfenced) + let printed = document.format(options: .init(defaultCodeBlockLanguage: "swift")) + XCTAssertEqual(fencedWithLanguage, printed) + } + } + + func testThematicBreakCharacter() { + let thematicBreakLength: UInt = 5 + for character in MarkupFormatter.Options.ThematicBreakCharacter.allCases { + let options = MarkupFormatter.Options(thematicBreakCharacter: character, thematicBreakLength: thematicBreakLength) + let expected = String(repeating: character.rawValue, count: Int(thematicBreakLength)) + let printed = ThematicBreak().format(options: options) + XCTAssertEqual(expected, printed) + } + } + + func testThematicBreakLength() { + let expected = "---" + let printed = ThematicBreak().format(options: .init(thematicBreakLength: 3)) + XCTAssertEqual(expected, printed) + } + + func testEmphasisMarker() { + do { // Emphasis + let underline = "_emphasized_" + let star = "*emphasized*" + + do { + let document = Document(parsing: underline) + let printed = document.format(options: .init(emphasisMarker: .star)) + XCTAssertEqual(star, printed) + } + + do { + let document = Document(parsing: star) + let printed = document.format(options: .init(emphasisMarker: .underline)) + XCTAssertEqual(underline, printed) + } + } + + do { // Strong + let underline = "__strong__" + let star = "**strong**" + + do { + let document = Document(parsing: underline) + let printed = document.format(options: .init(emphasisMarker: .star)) + XCTAssertEqual(star, printed) + } + + do { + let document = Document(parsing: star) + let printed = document.format(options: .init(emphasisMarker: .underline)) + XCTAssertEqual(underline, printed) + } + + } + } + + func testOrderedListNumeralsAllSame() { + let incrementing = """ + 1. A + 2. B + 3. C + """ + let allSame = """ + 0. A + 0. B + 0. C + """ + do { + let document = Document(parsing: incrementing) + let printed = document.format(options: .init(orderedListNumerals: .allSame(0))) + XCTAssertEqual(allSame, printed) + } + + do { + let document = Document(parsing: allSame) + let printed = document.format(options: .init(orderedListNumerals: .incrementing(start: 1))) + XCTAssertEqual(incrementing, printed) + } + } +} + +/// Tests that an printed and reparsed element has the same structure as +/// the original. +class MarkupFormatterSimpleRoundTripTests: XCTestCase { + func checkRoundTrip(for element: Markup, + file: StaticString = #file, + line: UInt = #line) { + let printed = element.format() + let parsed = Document(parsing: printed) + XCTAssertTrue(element.hasSameStructure(as: parsed), file: file, line: line) + } + + func checkRoundTrip(for source: String, + file: StaticString = #file, + line: UInt = #line) { + checkRoundTrip(for: Document(parsing: source), file: file, line: line) + } + + func checkCharacterEquivalence(for source: String, + file: StaticString = #file, + line: UInt = #line) { + let original = Document(parsing: source) + let printed = original.format() + XCTAssertEqual(source, printed, file: file, line: line) + } + + func testRoundTripInlines() { + checkRoundTrip(for: Document(Paragraph(InlineCode("foo")))) + checkRoundTrip(for: Document(Paragraph(Emphasis(Text("emphasized"))))) + checkRoundTrip(for: Document(Paragraph(Image(source: "test.png", title: "test", [Text("alt")])))) + checkRoundTrip(for: Document(Paragraph(Text("OK."), InlineHTML("")))) + checkRoundTrip(for: Document(Paragraph( + Text("First line"), + LineBreak(), + Text("Second line")))) + checkRoundTrip(for: Document(Paragraph(Link(destination: "https://swift.org", Text("Swift"))))) + checkRoundTrip(for: Document(Paragraph( + Text("First line"), + SoftBreak(), + Text("Second line")))) + checkRoundTrip(for: Document(Paragraph(Strong(Text("strong"))))) + checkRoundTrip(for: Document(Paragraph(Text("OK")))) + checkRoundTrip(for: Document(Paragraph(Emphasis(Strong(Text("emphasized and strong")))))) + checkRoundTrip(for: Document(Paragraph(InlineCode("foo")))) + // According to cmark, ***...*** is always Emphasis(Strong(...)). + } + + func testRoundTripBlockQuote() { + let source = """ + > A + > block + > quote. + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripBlockQuoteInBlockQuote() { + let source = """ + > A block quote + > > Within a block quote! + > + > Resuming outer block quote. + """ + checkRoundTrip(for: source) + } + + func testRoundTripFencedCodeBlockInBlockQuote() { + let source = """ + > ```swift + > struct MyStruct { + > func foo() {} + > } + > ``` + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripUnorderedListSingleFlat() { + let source = "- A" + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripUnorderedListDoubleFlat() { + let source = """ + - A + - B + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripUnorderedListInUnorderedList() { + let source = """ + - A + - B + - C + - D + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripUnorderedListExtremeNest() { + let source = """ + - A + - B + - C + - D + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripOrderedListSingleFlat() { + let source = "1. A" + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripOrderedListDoubleFlat() { + let source = """ + 1. A + 1. B + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripOrderedListInUnorderedList() { + let source = """ + 1. A + 1. B + 1. C + 1. D + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripOrderedListExtremeNest() { + let source = """ + 1. A + 1. B + 1. C + 1. D + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + func testRoundTripFencedCodeBlock() { + let source = """ + ```swift + struct MyStruct { + func foo() {} + } + ``` + """ + checkRoundTrip(for: source) + checkCharacterEquivalence(for: source) + } + + /// Why not? + func testRoundTripReadMe() throws { + let readMeURL = URL(fileURLWithPath: #file) + .deletingLastPathComponent() // (Remove this file) + .deletingLastPathComponent() // ../ (Visitors) + .deletingLastPathComponent() // ../ (MarkdownTests) + .deletingLastPathComponent() // ../ (Tests) + .appendingPathComponent("README.md") // README.md + let document = try Document(parsing: readMeURL) +// try document.format().write(toFile: "/tmp/test.md", atomically: true, encoding: .utf8) + checkRoundTrip(for: document) + }} + +/** + Test enforcement of a preferred maximum line length. + + In general, the formatter should never make changes that would cause the + formatted result to have a different syntax tree structure than the original. + + However, when splitting lines, it has to insert soft/hard breaks into + ``Text`` elements. + + However, it still should never change the structure of + ``BlockMarkup`` elements with line splitting enabled. + + It should also never turn any inline element containing ``Text`` elements + into something else. For example, a link with really long link text shouldn't + be doubly split, turning the image into two paragraphs. + */ +class MarkupFormatterLineSplittingTests: XCTestCase { + typealias PreferredLineLimit = MarkupFormatter.Options.PreferredLineLimit + + /** + Test the basic soft break case in a paragraph. + */ + func testBasicSoftBreaks() { + let lineLength = 20 + let source = "A really really really really really really really really really really really really long line" + let options = MarkupFormatter.Options(preferredLineLimit: PreferredLineLimit(maxLength: lineLength, breakWith: .softBreak)) + let document = Document(parsing: source) + let printed = document.format(options: options) + let expected = """ + A really really + really really + really really + really really + really really + really really long + line + """ + XCTAssertEqual(expected, printed) + let expectedTreeDump = """ + Document + └─ Paragraph + ├─ Text "A really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really long" + ├─ SoftBreak + └─ Text "line" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + + // In this particular case, since this is just a paragraph, + // we can guarantee the maximum line length. + for line in printed.split(separator: "\n") { + XCTAssertLessThanOrEqual(line.count, lineLength) + } + } + + /** + Test the basic hard line break case in a paragraph. + */ + func testBasicHardBreaks() { + let lineLength = 20 + let source = "A really really really really really really really really really really really really long line" + let options = MarkupFormatter.Options(preferredLineLimit: PreferredLineLimit(maxLength: lineLength, breakWith: .hardBreak)) + let document = Document(parsing: source) + let printed = document.format(options: options) + let expected = """ + A really really\u{0020}\u{0020} + really really\u{0020}\u{0020} + really really\u{0020}\u{0020} + really really\u{0020}\u{0020} + really really\u{0020}\u{0020} + really really\u{0020}\u{0020} + long line + """ + XCTAssertEqual(expected, printed) + let expectedTreeDump = """ + Document + └─ Paragraph + ├─ Text "A really really" + ├─ LineBreak + ├─ Text "really really" + ├─ LineBreak + ├─ Text "really really" + ├─ LineBreak + ├─ Text "really really" + ├─ LineBreak + ├─ Text "really really" + ├─ LineBreak + ├─ Text "really really" + ├─ LineBreak + └─ Text "long line" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + + // In this particular case, since this is just a paragraph, + // we can guarantee the maximum line length. + for line in printed.split(separator: "\n") { + XCTAssertLessThanOrEqual(line.count, lineLength) + } + } + + /** + Test that line breaks maintain block structure in a flat, unordered list. + */ + func testInUnorderedListItemSingle() { + let source = """ + - Really really really really really really really really long list item + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + - Really really + really really + really really + really really + long list item + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ UnorderedList + └─ ListItem + └─ Paragraph + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + └─ Text "long list item" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that line breaks maintain block structure in a nested, unordered list. + */ + func testInUnorderedListItemNested() { + let source = """ + - First level + - Second level is really really really really really long. + - First level again. + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + - First level + - Second level is + really really + really really + really long. + - First level + again. + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ UnorderedList + ├─ ListItem + │ ├─ Paragraph + │ │ └─ Text "First level" + │ └─ UnorderedList + │ └─ ListItem + │ └─ Paragraph + │ ├─ Text "Second level is" + │ ├─ SoftBreak + │ ├─ Text "really really" + │ ├─ SoftBreak + │ ├─ Text "really really" + │ ├─ SoftBreak + │ └─ Text "really long." + └─ ListItem + └─ Paragraph + ├─ Text "First level" + ├─ SoftBreak + └─ Text "again." + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that line breaks maintain block structure in a flat, ordered list. + */ + func testInOrderedListItemSingle() { + let source = """ + 1. Really really really really really really really really long list item + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + 1. Really really + really really + really really + really really + long list item + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ OrderedList + └─ ListItem + └─ Paragraph + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + └─ Text "long list item" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that line breaks maintain block structure in a nested list. + */ + func testInOrderedListItemNested() { + let source = """ + 1. First level + 1. Second level is really really really really really long. + 1. First level again. + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + 1. First level + 1. Second level + is really + really really + really really + long. + 1. First level + again. + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ OrderedList + ├─ ListItem + │ ├─ Paragraph + │ │ └─ Text "First level" + │ └─ OrderedList + │ └─ ListItem + │ └─ Paragraph + │ ├─ Text "Second level" + │ ├─ SoftBreak + │ ├─ Text "is really" + │ ├─ SoftBreak + │ ├─ Text "really really" + │ ├─ SoftBreak + │ ├─ Text "really really" + │ ├─ SoftBreak + │ └─ Text "long." + └─ ListItem + └─ Paragraph + ├─ Text "First level" + ├─ SoftBreak + └─ Text "again." + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + func testInOrderedListHugeNumerals() { + let source = """ + 1. Really really really really long line with huge numeral + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(orderedListNumerals: .allSame(1000), + preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + 1000. Really really + really really + long line + with huge + numeral + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ OrderedList + └─ ListItem + └─ Paragraph + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "long line" + ├─ SoftBreak + ├─ Text "with huge" + ├─ SoftBreak + └─ Text "numeral" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that line breaks maintain block structure in a block quote. + */ + func testInBlockQuoteSingle() { + let source = """ + > Really really really really really long line in a block quote. + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + > Really really + > really really + > really long line + > in a block quote. + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ BlockQuote + └─ Paragraph + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really" + ├─ SoftBreak + ├─ Text "really long line" + ├─ SoftBreak + └─ Text "in a block quote." + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that line breaks maintain block structure in a nested block quote. + */ + func testInBlockQuoteNested() { + let source = """ + > Really really really long line + > > Whoa, really really really really long nested block quote + > + > Continuing outer quote. + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + > Really really + > really long line + > >\u{0020} + > > Whoa, really + > > really really + > > really long + > > nested block + > > quote + >\u{0020} + > Continuing outer + > quote. + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ BlockQuote + ├─ Paragraph + │ ├─ Text "Really really" + │ ├─ SoftBreak + │ └─ Text "really long line" + ├─ BlockQuote + │ └─ Paragraph + │ ├─ Text "Whoa, really" + │ ├─ SoftBreak + │ ├─ Text "really really" + │ ├─ SoftBreak + │ ├─ Text "really long" + │ ├─ SoftBreak + │ ├─ Text "nested block" + │ ├─ SoftBreak + │ └─ Text "quote" + └─ Paragraph + ├─ Text "Continuing outer" + ├─ SoftBreak + └─ Text "quote." + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that links are not destroyed when breaking long link text. + */ + func testLongLinkText() { + let source = """ + [Link with really really really long link text](https://swift.org) + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + [Link with really + really really long + link text](https://swift.org) + """ + // Note: Link destinations cannot be split. + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ Paragraph + └─ Link destination: "https://swift.org" + ├─ Text "Link with really" + ├─ SoftBreak + ├─ Text "really really long" + ├─ SoftBreak + └─ Text "link text" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that emphasis elements are not destroyed when breaking long inner text. + */ + func testLongEmphasizedText() { + let source = """ + *Really really really really long emphasized text* + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + *Really really + really really long + emphasized text* + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ Paragraph + └─ Emphasis + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really long" + ├─ SoftBreak + └─ Text "emphasized text" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + /** + Test that Emphasis(Strong(...)) are not destroyed when breaking long inner text. + */ + func testLongEmphasizedStrongText() { + let source = """ + **Really really really really long strongly emphasized text** + """ + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + **Really really + really really long + strongly emphasized + text** + """ + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ Paragraph + └─ Strong + ├─ Text "Really really" + ├─ SoftBreak + ├─ Text "really really long" + ├─ SoftBreak + ├─ Text "strongly emphasized" + ├─ SoftBreak + └─ Text "text" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + func testDontBreakHeadings() { + let source = "### Really really really really really long heading" + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = source + XCTAssertEqual(expected, printed) + + let expectedTreeDump = """ + Document + └─ Heading level: 3 + └─ Text "Really really really really really long heading" + """ + XCTAssertEqual(expectedTreeDump, Document(parsing: printed).debugDescription()) + } + + func testInlineCode() { + let source = "`sdf sdf sdf sdf sdf sdf sdf sdf`" + let document = Document(parsing: source) + let printed = document.format(options: .init(preferredLineLimit: .init(maxLength: 20, breakWith: .softBreak))) + let expected = """ + `sdf sdf sdf sdf + sdf sdf sdf sdf` + """ + XCTAssertEqual(expected, printed) + XCTAssertTrue(document.hasSameStructure(as: Document(parsing: printed))) + } +} + +class MarkupFormatterTableTests: XCTestCase { + /// Test growing cell widths. + func testGrowCellWidths() { + let source = """ + |1|1| + |--|--| + |333|333| + """ + + let expected = """ + |1 |1 | + |---|---| + |333|333| + """ + + let document = Document(parsing: source) + let formatted = document.format() + XCTAssertEqual(expected, formatted) + } + + /// Test that tables nested in other block elements still get printed + /// correctly. + func testNested() { + do { // Inside blockquotes; unlikely but possible + let source = """ + > |1|1| + > |--|--| + > |*333*|*333*| + """ + + let expected = """ + > |1 |1 | + > |-----|-----| + > |*333*|*333*| + """ + + let document = Document(parsing: source) + let formatted = document.format() + XCTAssertEqual(expected, formatted) + } + + do { // Inside a list item; unlikely but possible + let source = """ + - |1|1| + |--|--| + |333|333| + """ + + let expected = """ + - |1 |1 | + |---|---| + |333|333| + """ + + let document = Document(parsing: source) + let formatted = document.format() + XCTAssertEqual(expected, formatted) + } + } + + /// Test that an already uniformly sized table prints pretty much the same. + func testNoGrowth() { + let source = """ + > |1 |1 | + > |---|---| + > |333|333| + """ + + let expected = """ + > |1 |1 | + > |---|---| + > |333|333| + """ + + let document = Document(parsing: source) + let formatted = document.format() + XCTAssertEqual(expected, formatted) + } + + func testNoSoftbreaksPrinted() { + let head = Table.Head(Table.Cell(Text("Not"), + SoftBreak(), + Text("Broken"))) + let document = Document(Table(header: head, body: Table.Body())) + + let expected = """ + |Not Broken| + |----------| + """ + let formatted = document.format() + XCTAssertEqual(expected, formatted) + } + + func testRoundTripStructure() { + let source = """ + |*A*|**B**|~C~| + |:-|:--:|--:| + |[Apple](https://apple.com)|![image](image.png)|| + |
|| | + """ + + let document = Document(parsing: source) + + let expectedDump = """ + Document + └─ Table alignments: |l|c|r| + ├─ Head + │ ├─ Cell + │ │ └─ Emphasis + │ │ └─ Text "A" + │ ├─ Cell + │ │ └─ Strong + │ │ └─ Text "B" + │ └─ Cell + │ └─ Strikethrough + │ └─ Text "C" + └─ Body + ├─ Row + │ ├─ Cell + │ │ └─ Link destination: "https://apple.com" + │ │ └─ Text "Apple" + │ ├─ Cell + │ │ └─ Image source: "image.png" title: "" + │ │ └─ Text "image" + │ └─ Cell + │ └─ Link destination: "https://swift.org" + │ └─ Text "https://swift.org" + └─ Row + ├─ Cell + │ └─ InlineHTML
+ ├─ Cell + └─ Cell + """ + XCTAssertEqual(expectedDump, document.debugDescription()) + + let formatted = document.format() + let expected = """ + |*A* |**B** |~C~ | + |:-------------------------|:--------------------:|------------------:| + |[Apple](https://apple.com)|![image](image.png "")|| + |
| | | + """ + + XCTAssertEqual(expected, formatted) + print(formatted) + + let reparsed = Document(parsing: formatted) + print(reparsed.debugDescription()) + XCTAssertTrue(document.hasSameStructure(as: reparsed)) + } +} diff --git a/Tests/MarkdownTests/Visitors/MarkupRewriterTests.swift b/Tests/MarkdownTests/Visitors/MarkupRewriterTests.swift new file mode 100644 index 00000000..975db00b --- /dev/null +++ b/Tests/MarkdownTests/Visitors/MarkupRewriterTests.swift @@ -0,0 +1,141 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +/// A `Document` that has every kind of element in it at least once. +let everythingDocument = Document(parsing: """ + # Header + + *Emphasized* **strong** `inline code` [link](foo) ![image](foo). + + - this + - is + - a + - list + + 1. eggs + 1. milk + + > BlockQuote + + ```swift + func foo() { + let x = 1 + } + ``` + + // Is this real code? Or just fantasy? + + This is an . + + --- + + + An HTML Block. + + + This is some

inline html

. + + line + break + + soft + break + """) + + +class MarkupRewriterTests: XCTestCase { + /// Tests that a rewriter which makes no modifications results in the same document + func testNullRewriter() { + /// A MarkupRewriter that leaves the tree unchanged + struct NullMarkupRewriter : MarkupRewriter {} + + var nullRewriter = NullMarkupRewriter() + // FIXME: Workaround for rdar://problem/47686212 + let markup = everythingDocument + let shouldBeSame = nullRewriter.visit(markup) as! Document + try! markup.debugDescription().write(to: .init(fileURLWithPath: "/tmp/old.txt"), atomically: true, encoding: .utf8) + try! shouldBeSame.debugDescription().write(to: .init(fileURLWithPath: "/tmp/new.txt"), atomically: true, encoding: .utf8) + XCTAssertEqual(markup.debugDescription(), shouldBeSame.debugDescription()) + } + + /// Tests that a particular kind of element can be deleted + func funcTestDeleteEveryOccurrence() { + struct StrongDeleter: MarkupRewriter { + mutating func visitStrong(_ strong: Strong) -> Markup? { + return nil + } + } + + struct StrongCollector: MarkupWalker { + var strongCount = 0 + mutating func visitStrong(_ strong: Strong) { + strongCount += 1 + defaultVisit(strong) + } + } + + // FIXME: Workaround for rdar://problem/47686212 + let markup = everythingDocument + + var strongCollector = StrongCollector() + strongCollector.visit(markup) + let originalStrongCount = strongCollector.strongCount + XCTAssertEqual(1, originalStrongCount) + + var strongDeleter = StrongDeleter() + let markupWithoutStrongs = strongDeleter.visit(markup)! + strongCollector.strongCount = 0 + strongCollector.visit(markupWithoutStrongs) + let newStrongCount = strongCollector.strongCount + XCTAssertEqual(0, newStrongCount) + } + + /// Tests that all elements of a particular kind are visited and rewritten no matter where in the three. + func testSpecificKindRewrittenEverywhere() { + /// Replaces every `Text` markup element with its uppercased equivalent. + struct UppercaseText: MarkupRewriter { + mutating func visitText(_ text: Text) -> Markup? { + var newText = text + newText.string = text.string.uppercased() + return newText + } + } + + /// Collects and concatenates all `Text` elements' markup into a single string for later test comparison. + struct CollectText: MarkupWalker { + var result = "" + mutating func visitText(_ text: Text) { + result += text.string + } + } + + // FIXME: Workaround for rdar://problem/47686212 + let markup = everythingDocument + + // Combine the text from the original test markup file + var originalTextCollector = CollectText() + originalTextCollector.visit(markup) + let originalText = originalTextCollector.result + + // Create a version of the test markup document with all text elements uppercased. + var uppercaser = UppercaseText() + let uppercased = uppercaser.visit(markup)! + + // Combine the text from the uppercased markup document + var uppercaseTextCollector = CollectText() + uppercaseTextCollector.visit(uppercased) + let uppercasedText = uppercaseTextCollector.result + + XCTAssertEqual(originalText.uppercased(), uppercasedText) + } +} diff --git a/Tests/MarkdownTests/Visitors/MarkupTreeDumperTests.swift b/Tests/MarkdownTests/Visitors/MarkupTreeDumperTests.swift new file mode 100644 index 00000000..d2a588bc --- /dev/null +++ b/Tests/MarkdownTests/Visitors/MarkupTreeDumperTests.swift @@ -0,0 +1,92 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class MarkupTreeDumperTests: XCTestCase { + func testDumpEverything() { + let expectedDump = """ + Document @1:1-37:6 Root #\(everythingDocument.raw.metadata.id.rootId) #0 + ├─ Heading @1:1-1:9 #1 level: 1 + │ └─ Text @1:3-1:9 #2 "Header" + ├─ Paragraph @3:1-3:65 #3 + │ ├─ Emphasis @3:1-3:13 #4 + │ │ └─ Text @3:2-3:12 #5 "Emphasized" + │ ├─ Text @3:13-3:14 #6 " " + │ ├─ Strong @3:14-3:24 #7 + │ │ └─ Text @3:16-3:22 #8 "strong" + │ ├─ Text @3:24-3:25 #9 " " + │ ├─ InlineCode @3:25-3:38 #10 `inline code` + │ ├─ Text @3:38-3:39 #11 " " + │ ├─ Link @3:39-3:50 #12 destination: "foo" + │ │ └─ Text @3:40-3:44 #13 "link" + │ ├─ Text @3:50-3:51 #14 " " + │ ├─ Image @3:51-3:64 #15 source: "foo" title: "" + │ │ └─ Text @3:53-3:58 #16 "image" + │ └─ Text @3:64-3:65 #17 "." + ├─ UnorderedList @5:1-9:1 #18 + │ ├─ ListItem @5:1-5:7 #19 + │ │ └─ Paragraph @5:3-5:7 #20 + │ │ └─ Text @5:3-5:7 #21 "this" + │ ├─ ListItem @6:1-6:5 #22 + │ │ └─ Paragraph @6:3-6:5 #23 + │ │ └─ Text @6:3-6:5 #24 "is" + │ ├─ ListItem @7:1-7:4 #25 + │ │ └─ Paragraph @7:3-7:4 #26 + │ │ └─ Text @7:3-7:4 #27 "a" + │ └─ ListItem @8:1-9:1 #28 + │ └─ Paragraph @8:3-8:7 #29 + │ └─ Text @8:3-8:7 #30 "list" + ├─ OrderedList @10:1-12:1 #31 + │ ├─ ListItem @10:1-10:8 #32 + │ │ └─ Paragraph @10:4-10:8 #33 + │ │ └─ Text @10:4-10:8 #34 "eggs" + │ └─ ListItem @11:1-12:1 #35 + │ └─ Paragraph @11:4-11:8 #36 + │ └─ Text @11:4-11:8 #37 "milk" + ├─ BlockQuote @13:1-13:14 #38 + │ └─ Paragraph @13:3-13:14 #39 + │ └─ Text @13:3-13:13 #40 "BlockQuote" + ├─ CodeBlock @15:1-19:4 #41 language: swift + │ func foo() { + │ let x = 1 + │ } + ├─ CodeBlock @21:5-22:1 #42 language: none + │ // Is this real code? Or just fantasy? + ├─ Paragraph @23:1-23:31 #43 + │ ├─ Text @23:1-23:12 #44 "This is an " + │ ├─ Link @23:12-23:30 #45 destination: "topic://autolink" + │ │ └─ Text @23:13-23:29 #46 "topic://autolink" + │ └─ Text @23:30-23:31 #47 "." + ├─ ThematicBreak @25:1-26:1 #48 + ├─ HTMLBlock @27:1-29:5 #49 + │ + │ An HTML Block. + │ + ├─ Paragraph @31:1-31:33 #50 + │ ├─ Text @31:1-31:14 #51 "This is some " + │ ├─ InlineHTML @31:14-31:17 #52

+ │ ├─ Text @31:17-31:28 #53 "inline html" + │ ├─ InlineHTML @31:28-31:32 #54

+ │ └─ Text @31:32-31:33 #55 "." + ├─ Paragraph @33:1-34:6 #56 + │ ├─ Text @33:1-33:7 #57 "line" + │ ├─ LineBreak #58 + │ └─ Text @34:1-34:6 #59 "break" + └─ Paragraph @36:1-37:6 #60 + ├─ Text @36:1-36:5 #61 "soft" + ├─ SoftBreak #62 + └─ Text @37:1-37:6 #63 "break" + """ + print(everythingDocument.debugDescription(options: [.printEverything])) + XCTAssertEqual(expectedDump, everythingDocument.debugDescription(options: [.printEverything])) + } +} diff --git a/Tests/MarkdownTests/Visitors/MarkupWalkerTests.swift b/Tests/MarkdownTests/Visitors/MarkupWalkerTests.swift new file mode 100644 index 00000000..1c9187f3 --- /dev/null +++ b/Tests/MarkdownTests/Visitors/MarkupWalkerTests.swift @@ -0,0 +1,126 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 Markdown + +final class MarkupWalkerTests: XCTestCase { + /// Test that every element is visited via `defaultVisit` + func testDefaultVisit() { + struct CountEveryElement: MarkupWalker { + var count = 0 + mutating func defaultVisit(_ markup: Markup) { + count += 1 + descendInto(markup) + } + } + + var counter = CountEveryElement() + counter.visit(everythingDocument) + counter.visit(CustomInline("")) + XCTAssertEqual(everythingDocument.subtreeCount + 1, counter.count) + } + + /// Test that every element is visited via a customization of each visit method. + func testVisitsEveryElement() { + struct CountEveryElement: MarkupWalker { + var count = 0 + mutating func visitLink(_ link: Link) -> () { + count += 1 + descendInto(link) + } + mutating func visitText(_ text: Text) -> () { + count += 1 + descendInto(text) + } + mutating func visitImage(_ image: Image) -> () { + count += 1 + descendInto(image) + } + mutating func visitStrong(_ strong: Strong) -> () { + count += 1 + descendInto(strong) + } + mutating func visitHeading(_ heading: Heading) -> () { + count += 1 + descendInto(heading) + } + mutating func visitHTMLBlock(_ html: HTMLBlock) -> () { + count += 1 + descendInto(html) + } + mutating func visitDocument(_ document: Document) -> () { + count += 1 + descendInto(document) + } + mutating func visitEmphasis(_ emphasis: Emphasis) -> () { + count += 1 + descendInto(emphasis) + } + mutating func visitListItem(_ listItem: ListItem) -> () { + count += 1 + descendInto(listItem) + } + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { + count += 1 + descendInto(codeBlock) + } + mutating func visitLineBreak(_ lineBreak: LineBreak) -> () { + count += 1 + descendInto(lineBreak) + } + mutating func visitParagraph(_ paragraph: Paragraph) -> () { + count += 1 + descendInto(paragraph) + } + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> () { + count += 1 + descendInto(softBreak) + } + mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> () { + count += 1 + descendInto(blockQuote) + } + mutating func visitInlineCode(_ inlineCode: InlineCode) -> () { + count += 1 + descendInto(inlineCode) + } + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> () { + count += 1 + descendInto(inlineHTML) + } + mutating func visitCustomBlock(_ customBlock: CustomBlock) -> () { + count += 1 + descendInto(customBlock) + } + mutating func visitOrderedList(_ orderedList: OrderedList) -> () { + count += 1 + descendInto(orderedList) + } + mutating func visitCustomInline(_ customInline: CustomInline) -> () { + count += 1 + descendInto(customInline) + } + mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> () { + count += 1 + descendInto(thematicBreak) + } + mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { + count += 1 + descendInto(unorderedList) + } + } + + var counter = CountEveryElement() + counter.visit(everythingDocument) + counter.visit(CustomInline("")) + XCTAssertEqual(everythingDocument.subtreeCount + 1, counter.count) + } +} diff --git a/Tests/MarkdownTests/XCTestManifests.swift b/Tests/MarkdownTests/XCTestManifests.swift new file mode 100644 index 00000000..cfe30e72 --- /dev/null +++ b/Tests/MarkdownTests/XCTestManifests.swift @@ -0,0 +1,318 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 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 +*/ + +#if !canImport(ObjectiveC) +import XCTest + +extension AtomicCounterTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__AtomicCounterTests = [ + ("testIncremental", testIncremental), + ("testSimultaneousFetch", testSimultaneousFetch), + ] +} + +extension BasicBlockContainerTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__BasicBlockContainerTests = [ + ("testFromSequence", testFromSequence), + ("testReplacing", testReplacing), + ("testReplacingChildrenInRange", testReplacingChildrenInRange), + ] +} + +extension BasicInlineContainerTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__BasicInlineContainerTests = [ + ("testFromSequence", testFromSequence), + ("testReplacing", testReplacing), + ("testReplacingChildrenInRange", testReplacingChildrenInRange), + ] +} + +extension CodeBlockTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CodeBlockTests = [ + ("testCodeBlockCode", testCodeBlockCode), + ("testCodeBlockLanguage", testCodeBlockLanguage), + ] +} + +extension DocumentTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__DocumentTests = [ + ("testDocumentFromSequence", testDocumentFromSequence), + ] +} + +extension EditPerformanceTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__EditPerformanceTests = [ + ("testChangeTextInDeepTree", testChangeTextInDeepTree), + ("testChangeTextInWideParagraph", testChangeTextInWideParagraph), + ] +} + +extension HTMLTestsTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__HTMLTestsTests = [ + ("testHTMLBlockRawHTML", testHTMLBlockRawHTML), + ] +} + +extension HeadingTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__HeadingTests = [ + ("testLevel", testLevel), + ] +} + +extension HierarchyTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__HierarchyTests = [ + ("testRoot", testRoot), + ("testRootSelf", testRootSelf), + ("testTreeAfterLeafChange", testTreeAfterLeafChange), + ("testTreeAfterMiddleChange", testTreeAfterMiddleChange), + ] +} + +extension IdentifierStabilityTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__IdentifierStabilityTests = [ + ("testStableChildrenIdentifiers", testStableChildrenIdentifiers), + ("testStableIdentifierOfCopy", testStableIdentifierOfCopy), + ] +} + +extension ImageTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ImageTests = [ + ("testLinkDestination", testLinkDestination), + ("testLinkFromSequence", testLinkFromSequence), + ] +} + +extension InlineCodeTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__InlineCodeTests = [ + ("testInlineCodeString", testInlineCodeString), + ] +} + +extension InlineHTMLTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__InlineHTMLTests = [ + ("testInlineHTMLRawHTML", testInlineHTMLRawHTML), + ] +} + +extension LineBreakTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__LineBreakTests = [ + ("testLineBreak", testLineBreak), + ("testParseLineBreak", testParseLineBreak), + ] +} + +extension LinkTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__LinkTests = [ + ("testLinkDestination", testLinkDestination), + ("testLinkFromSequence", testLinkFromSequence), + ] +} + +extension ListItemContainerTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ListItemContainerTests = [ + ("testOrderedListFromSequence", testOrderedListFromSequence), + ("testOrderedListReplacingItem", testOrderedListReplacingItem), + ("testOrderedListReplacingItemsInRange", testOrderedListReplacingItemsInRange), + ("testOrderedListWithItems", testOrderedListWithItems), + ("testUnorderedListFromSequence", testUnorderedListFromSequence), + ] +} + +extension MarkupChildrenPerformanceTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__MarkupChildrenPerformanceTests = [ + ("testIterateChildrenForward", testIterateChildrenForward), + ("testIterateChildrenReversed", testIterateChildrenReversed), + ] +} + +extension MarkupRewriterTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__MarkupRewriterTests = [ + ("testNullRewriter", testNullRewriter), + ("testSpecificKindRewrittenEverywhere", testSpecificKindRewrittenEverywhere), + ] +} + +extension MarkupTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__MarkupTests = [ + ("testChildCount", testChildCount), + ("testChildren", testChildren), + ("testIndexInParent", testIndexInParent), + ("testParent", testParent), + ("testRangeParsed", testRangeParsed), + ("testRangeUnparsed", testRangeUnparsed), + ("testRoot", testRoot), + ] +} + +extension MarkupTreeDumperTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__MarkupTreeDumperTests = [ + ("testDumpEverything", testDumpEverything), + ("testDumpUniqueIdentifiers", testDumpUniqueIdentifiers), + ] +} + +extension MarkupWalkerTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__MarkupWalkerTests = [ + ("testDefaultVisit", testDefaultVisit), + ("testVisitsEveryElement", testVisitsEveryElement), + ] +} + +extension ParagraphTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ParagraphTests = [ + ("testParagraphDeleting", testParagraphDeleting), + ("testParagraphInserting", testParagraphInserting), + ("testParagraphReplacing", testParagraphReplacing), + ("testParagraphReplacingChildrenInRange", testParagraphReplacingChildrenInRange), + ] +} + +extension RawMarkupToMarkupTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests_RawMarkupToMarkupTests = [ + ("testBlockQuote", testBlockQuote), + ("testCodeBlock", testCodeBlock), + ("testCustomBlock", testCustomBlock), + ("testCustomInline", testCustomInline), + ("testEmphasis", testEmphasis), + ("testHeading", testHeading), + ("testHTMLBlock", testHTMLBlock), + ("testImage", testImage), + ("testInlineCode", testInlineCode), + ("testInlineHTML", testInlineHTML), + ("testLineBreak", testLineBreak), + ("testLink", testLink), + ("testListItem", testListItem), + ("testOrderedList", testOrderedList), + ("testParagraph", testParagraph), + ("testSoftBreak", testSoftBreak), + ("testStrong", testStrong), + ("testText", testText), + ("testThematicBreak", testThematicBreak), + ("testUnorderedList", testUnorderedList), + ] +} + +extension SoftBreakTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__SoftBreakTests = [ + ("testSoftBreak", testSoftBreak), + ] +} + +extension TextTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__TextTests = [ + ("testWithText", testWithText), + ] +} + +public func __allTests() -> [XCTestCaseEntry] { + return [ + testCase(AtomicCounterTests.__allTests__AtomicCounterTests), + testCase(BasicBlockContainerTests.__allTests__BasicBlockContainerTests), + testCase(BasicInlineContainerTests.__allTests__BasicInlineContainerTests), + testCase(CodeBlockTests.__allTests__CodeBlockTests), + testCase(DocumentTests.__allTests__DocumentTests), + testCase(EditPerformanceTests.__allTests__EditPerformanceTests), + testCase(HTMLTestsTests.__allTests__HTMLTestsTests), + testCase(HeadingTests.__allTests__HeadingTests), + testCase(HierarchyTests.__allTests__HierarchyTests), + testCase(IdentifierStabilityTests.__allTests__IdentifierStabilityTests), + testCase(ImageTests.__allTests__ImageTests), + testCase(InlineCodeTests.__allTests__InlineCodeTests), + testCase(InlineHTMLTests.__allTests__InlineHTMLTests), + testCase(LineBreakTests.__allTests__LineBreakTests), + testCase(LinkTests.__allTests__LinkTests), + testCase(ListItemContainerTests.__allTests__ListItemContainerTests), + testCase(MarkupChildrenPerformanceTests.__allTests__MarkupChildrenPerformanceTests), + testCase(MarkupRewriterTests.__allTests__MarkupRewriterTests), + testCase(MarkupTests.__allTests__MarkupTests), + testCase(MarkupTreeDumperTests.__allTests__MarkupTreeDumperTests), + testCase(MarkupWalkerTests.__allTests__MarkupWalkerTests), + testCase(ParagraphTests.__allTests__ParagraphTests), + testCase(RawMarkupToMarkupTests.__allTests_RawMarkupToMarkupTests), + testCase(SoftBreakTests.__allTests__SoftBreakTests), + testCase(TextTests.__allTests__TextTests), + ] +} +#endif diff --git a/bin/check-source b/bin/check-source new file mode 100755 index 00000000..632b1183 --- /dev/null +++ b/bin/check-source @@ -0,0 +1,152 @@ +#!/bin/bash +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2021 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 +# + +# This script performs code checks such as verifying that source files +# use the correct license header. Its contents are largely copied from +# https://github.com/apple/swift-nio/blob/main/scripts/soundness.sh + +set -eu +here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +function replace_acceptable_years() { + # this needs to replace all acceptable forms with 'YEARS' + sed -e 's/20[12][78901]-20[12][8901]/YEARS/' -e 's/20[12][8901]/YEARS/' +} + +printf "=> Checking for unacceptable language… " +# This greps for unacceptable terminology. The square bracket[s] are so that +# "git grep" doesn't find the lines that greps :). +unacceptable_terms=( + -e blacklis[t] + -e whitelis[t] + -e slav[e] +) + +if git grep --color=never -i "${unacceptable_terms[@]}" > /dev/null; then + printf "\033[0;31mUnacceptable language found.\033[0m\n" + git grep -i "${unacceptable_terms[@]}" + exit 1 +fi +printf "\033[0;32mokay.\033[0m\n" + +printf "=> Checking license headers… " +tmp=$(mktemp /tmp/.swift-markdown-check-source_XXXXXX) + +for language in swift-or-c bash md-or-tutorial html docker; do + declare -a matching_files + declare -a exceptions + declare -a reader + expections=( ) + matching_files=( -name '*' ) + reader=head + case "$language" in + swift-or-c) + exceptions=( -name Package.swift) + matching_files=( -name '*.swift' -o -name '*.c' -o -name '*.h' ) + cat > "$tmp" <<"EOF" +/* + This source file is part of the Swift.org open source project + + Copyright (c) YEARS 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 +*/ +EOF + ;; + bash) + matching_files=( -name '*.sh' ) + cat > "$tmp" <<"EOF" +#!/bin/bash +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) YEARS 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 +# +EOF + ;; + md-or-tutorial) + exceptions=( -path "./.github/*.md") + matching_files=( -name '*.md' -o -name '*.tutorial' ) + reader=tail + cat > "$tmp" <<"EOF" + +EOF + ;; + html) + matching_files=( -name '*.html' ) + cat > "$tmp" <<"EOF" + +EOF + ;; + docker) + matching_files=( -name 'Dockerfile' ) + cat > "$tmp" <<"EOF" +# This source file is part of the Swift.org open source project +# +# Copyright (c) YEARS 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 +EOF + ;; + *) + echo >&2 "ERROR: unknown language '$language'" + ;; + esac + + # Determine which SHA command to use; not all platforms support shasum, but they + # likely either support shasum or sha256sum. + SHA_CMD="" + if [ -x "$(command -v shasum)" ]; then + SHA_CMD="shasum" + elif [ -x "$(command -v sha256sum)" ]; then + SHA_CMD="sha256sum" + else + echo >&2 "No sha command found; install shasum or sha256sum or submit a PR that supports another platform" + fi + + expected_lines=$(cat "$tmp" | wc -l) + expected_sha=$(cat "$tmp" | "$SHA_CMD") + + ( + cd "$here/.." + find . \ + \( \! -path './.build/*' -a \ + \! -name '.' -a \ + \( "${matching_files[@]}" \) -a \ + \( \! \( "${exceptions[@]}" \) \) \) | while read line; do + if [[ "$(cat "$line" | replace_acceptable_years | $reader -n $expected_lines | $SHA_CMD)" != "$expected_sha" ]]; then + printf "\033[0;31mmissing headers in file '$line'!\033[0m\n" + diff -u <(cat "$line" | replace_acceptable_years | $reader -n $expected_lines) "$tmp" + exit 1 + fi + done + ) +done + +printf "\033[0;32mokay.\033[0m\n" +rm "$tmp" + diff --git a/bin/test b/bin/test new file mode 100755 index 00000000..7b6c7f72 --- /dev/null +++ b/bin/test @@ -0,0 +1,32 @@ +#!/bin/bash +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2021 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 +# + +set -eu + +# A `realpath` alternative using the default C implementation. +filepath() { + [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}" +} + +# First get the absolute path to this file so we can get the absolute file path to the SwiftMarkdown root source dir. +SWIFT_MARKDOWN_ROOT="$(dirname $(dirname $(filepath $0)))" + +# Build SwiftMarkdown. +swift test --parallel --package-path "$SWIFT_MARKDOWN_ROOT" + +# Run source code checks for the codebase. +LC_ALL=C "$SWIFT_MARKDOWN_ROOT"/bin/check-source + +# Test utility scripts validity. +printf "=> Validating scripts in bin subdirectory… " + +printf "\033[0;32mokay.\033[0m\n" +