├── .gitignore ├── .swiftformat ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ ├── xcbaselines │ └── TextMarkupKitTests.xcbaseline │ │ ├── E4268565-5626-4DB6-8BE4-644F16CA0148.plist │ │ ├── E429DAB8-0858-45D5-8582-904307AF645A.plist │ │ ├── EBC564DB-470C-458F-9FF0-4E1E8BC3C2B1.plist │ │ └── Info.plist │ └── xcschemes │ └── TextMarkupKitTests.xcscheme ├── CHANGELOG.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── ObjectiveCTextStorageWrapper │ ├── ObjectiveCTextStorageWrapper.m │ └── include │ │ └── ObjectiveCTextStorageWrapper.h └── TextMarkupKit │ ├── AttributesArray.swift │ ├── BufferProtocols.swift │ ├── MarkupFormattedTextEditor.swift │ ├── MarkupFormattingTextView.swift │ ├── MemoizationTable.swift │ ├── MiniMarkdownGrammar+Styles.swift │ ├── MiniMarkdownGrammar.swift │ ├── NSAttributedString+Attributes.swift │ ├── ParsedAttributedString.swift │ ├── ParsedAttributedStringFormatter.swift │ ├── ParsedString.swift │ ├── ParsingRule.swift │ ├── PieceTable.swift │ ├── PieceTableString.swift │ ├── PlainTextGrammar.swift │ ├── SyntaxTreeNode.swift │ ├── SyntaxTreeNodeType.swift │ ├── TextMarkupKit.docc │ └── TextMarkupKit.md │ └── TraceBuffer.swift ├── Tests ├── LinuxMain.swift └── TextMarkupKitTests │ ├── MiniMarkdownGrammarTests.swift │ ├── MiniMarkdownParsingTests.swift │ ├── ParsedAttributedStringTests.swift │ ├── ParsedStringTests.swift │ ├── ParsedTextStorageTests.swift │ ├── PieceTableTests.swift │ └── TestStrings.swift ├── TextMarkupKitSample ├── TextMarkupKitSample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── TextMarkupKitSample │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── Info.plist │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── README.md │ ├── TextMarkupKitSampleApp.swift │ └── TextMarkupKitSampleDocument.swift ├── TextMarkupKitSampleTests │ ├── Info.plist │ └── TextMarkupKitSampleTests.swift └── TextMarkupKitSampleUITests │ ├── Info.plist │ └── TextMarkupKitSampleUITests.swift └── assets └── sample.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --binarygrouping none 2 | --decimalgrouping none 3 | --exclude Pods, Package.swift, Tests/TextMarkupKitTests/TestStrings.swift 4 | --header "// Licensed to the Apache Software Foundation (ASF) under one\n// or more contributor license agreements. See the NOTICE file\n// distributed with this work for additional information\n// regarding copyright ownership. The ASF licenses this file\n// to you under the Apache License, Version 2.0 (the\n// \"License\"); you may not use this file except in compliance\n// with the License. You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing,\n// software distributed under the License is distributed on an\n// \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n// KIND, either express or implied. See the License for the\n// specific language governing permissions and limitations\n// under the License." 5 | --hexgrouping none 6 | --indent 2 7 | --octalgrouping none 8 | --patternlet inline 9 | --stripunusedargs closure-only 10 | --wraparguments beforefirst 11 | --wrapcollections beforefirst 12 | --self init-only 13 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Pods 3 | 4 | analyzer_rules: 5 | - unused_declaration 6 | - unused_import 7 | 8 | disabled_rules: 9 | - todo 10 | - line_length 11 | 12 | opt_in_rules: 13 | - closure_end_indentation 14 | - closure_parameter_position 15 | - closure_spacing 16 | - contains_over_first_not_nil 17 | - convenience_type 18 | - empty_count 19 | - empty_string 20 | - file_header 21 | - sorted_imports 22 | 23 | identifier_name: 24 | excluded: 25 | - i 26 | - id 27 | - db 28 | 29 | line_length: 100 30 | 31 | file_header: 32 | required_string: | 33 | // Copyright © 2017-present Brian's Brain. All rights reserved. 34 | 35 | trailing_comma: 36 | mandatory_comma: true 37 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/TextMarkupKitTests.xcbaseline/E4268565-5626-4DB6-8BE4-644F16CA0148.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | IncrementalParserTests 8 | 9 | testAddSentenceToLargeText() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.51143 15 | baselineIntegrationDisplayName 16 | Apr 28, 2020 at 9:36:27 PM 17 | 18 | 19 | 20 | PerformanceTests 21 | 22 | testCustomProcessor() 23 | 24 | com.apple.XCTPerformanceMetric_WallClockTime 25 | 26 | baselineAverage 27 | 0.1409 28 | baselineIntegrationDisplayName 29 | Apr 10, 2020 at 5:50:46 PM 30 | 31 | 32 | testMiniMarkdownParser() 33 | 34 | com.apple.XCTPerformanceMetric_WallClockTime 35 | 36 | baselineAverage 37 | 2.3191 38 | baselineIntegrationDisplayName 39 | Apr 28, 2020 at 9:36:27 PM 40 | 41 | 42 | testPackratParser() 43 | 44 | com.apple.XCTPerformanceMetric_WallClockTime 45 | 46 | baselineAverage 47 | 0.13244 48 | baselineIntegrationDisplayName 49 | Apr 28, 2020 at 9:36:27 PM 50 | 51 | 52 | 53 | PieceTableTests 54 | 55 | testAppendPerformance() 56 | 57 | com.apple.XCTPerformanceMetric_WallClockTime 58 | 59 | baselineAverage 60 | 0.0038115 61 | baselineIntegrationDisplayName 62 | Apr 28, 2020 at 9:36:27 PM 63 | 64 | 65 | testLargeLocalEditPerformance() 66 | 67 | com.apple.XCTPerformanceMetric_WallClockTime 68 | 69 | baselineAverage 70 | 0.0045928 71 | baselineIntegrationDisplayName 72 | Apr 28, 2020 at 9:36:27 PM 73 | 74 | 75 | testMegabytePieceTablePerformance() 76 | 77 | com.apple.XCTPerformanceMetric_WallClockTime 78 | 79 | baselineAverage 80 | 0.22389 81 | baselineIntegrationDisplayName 82 | Apr 28, 2020 at 9:36:27 PM 83 | 84 | 85 | testMegabyteStringPerformance() 86 | 87 | com.apple.XCTPerformanceMetric_WallClockTime 88 | 89 | baselineAverage 90 | 1.1043 91 | baselineIntegrationDisplayName 92 | Apr 28, 2020 at 9:36:27 PM 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/TextMarkupKitTests.xcbaseline/E429DAB8-0858-45D5-8582-904307AF645A.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | PerformanceTests 8 | 9 | testMiniMarkdownParser() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 2.3335 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/TextMarkupKitTests.xcbaseline/EBC564DB-470C-458F-9FF0-4E1E8BC3C2B1.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | PerformanceTests 8 | 9 | testCustomProcessor() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.14318 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | testMiniMarkdownParser() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.11148 25 | baselineIntegrationDisplayName 26 | Local Baseline 27 | 28 | 29 | testPackratParser() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.11159 35 | baselineIntegrationDisplayName 36 | Local Baseline 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/TextMarkupKitTests.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | E4268565-5626-4DB6-8BE4-644F16CA0148 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 100 13 | cpuCount 14 | 1 15 | cpuKind 16 | Quad-Core Intel Core i7 17 | cpuSpeedInMHz 18 | 4200 19 | logicalCPUCoresPerPackage 20 | 8 21 | modelCode 22 | iMac18,3 23 | physicalCPUCoresPerPackage 24 | 4 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | x86_64 30 | 31 | E429DAB8-0858-45D5-8582-904307AF645A 32 | 33 | localComputer 34 | 35 | busSpeedInMHz 36 | 100 37 | cpuCount 38 | 1 39 | cpuKind 40 | Quad-Core Intel Core i7 41 | cpuSpeedInMHz 42 | 4200 43 | logicalCPUCoresPerPackage 44 | 8 45 | modelCode 46 | iMac18,3 47 | physicalCPUCoresPerPackage 48 | 4 49 | platformIdentifier 50 | com.apple.platform.macosx 51 | 52 | targetArchitecture 53 | x86_64 54 | targetDevice 55 | 56 | modelCode 57 | iPhone12,5 58 | platformIdentifier 59 | com.apple.platform.iphonesimulator 60 | 61 | 62 | EBC564DB-470C-458F-9FF0-4E1E8BC3C2B1 63 | 64 | localComputer 65 | 66 | busSpeedInMHz 67 | 100 68 | cpuCount 69 | 1 70 | cpuKind 71 | Quad-Core Intel Core i7 72 | cpuSpeedInMHz 73 | 4200 74 | logicalCPUCoresPerPackage 75 | 8 76 | modelCode 77 | iMac18,3 78 | physicalCPUCoresPerPackage 79 | 4 80 | platformIdentifier 81 | com.apple.platform.macosx 82 | 83 | targetArchitecture 84 | x86_64h 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/TextMarkupKitTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.11.0] - 2024-04-20 4 | 5 | ### Added 6 | 7 | - Proper `Sendable` and `@MainActor` annotations 8 | 9 | ## [0.10.0] - 2022-09-27 10 | 11 | ### Added 12 | 13 | - `ParsedString.parsedContents` for seeing exactly how the parse tree looks for a ParsedString. 14 | 15 | ### Changed 16 | 17 | - `SyntaxTreeNode.path(to:)` now works when the `location == endIndex`. This used to be an invalid parameter. Now, it is valid and will get associated with the last child in the tree. 18 | - Minor grammar adustments 19 | 20 | ### Fixed 21 | 22 | - Fixed bug where maintaining the `AttributesArray` could result in negative-length runs 23 | 24 | ## [0.9.1] - 2021-12-27 25 | 26 | ### Fixed 27 | 28 | - There was a bug in the algorithm for traslating bounds between original content & up-to-date content in a piece table 29 | 30 | ## [0.9.0] - 2021-12-20 31 | 32 | ### Breaking change! 33 | 34 | - `ParsedString.path(to:)` and `SyntaxTreeNode.path(to:)` are now throwing functions if given an index that is out-of-bounds. 35 | 36 | ## [0.8.0] - 2021-09-26 37 | 38 | ### Changed 39 | 40 | - Got rid of the `key` parameter in `MarkupFormattingTextViewImageStorage` 41 | 42 | ## [0.7.3] - 2021-08-14 43 | 44 | ### Fixed 45 | 46 | - Fixed memory leak of doubly-linked-list syntax nodes 47 | 48 | ## [0.7.2] - 2021-08-08 49 | 50 | ### Fixed 51 | 52 | - Fixed crash when deleting all text 53 | 54 | ## [0.7.1] - 2021-06-28 55 | 56 | ### Changed 57 | 58 | - Cleaned up warnings & tests 59 | 60 | ## [0.7.0] - 2021-06-22 61 | 62 | ### Changed 63 | 64 | - `ParsedAttributedString.Settings` renamed to `ParsedAttributedString.Style` 65 | 66 | ### Added 67 | 68 | - A built-in style for MiniMarkdown text, `MiniMarkdownGrammer.defaultEditingStyle()` 69 | - A sample application to show TextMarkupKit in use 70 | 71 | ## [0.6.0] - 2021-06-21 72 | 73 | ### Changed 74 | 75 | - Added `ParsedAttributedStringFormatter` and `AnyParsedAttributedStringFormatter` to control string formatting. 76 | 77 | ## [0.5.0] - 2021-06-21 78 | 79 | ### Added 80 | 81 | - `MarkupFormattingTextView` for displaying and editing text with the formatting determined by a `ParsedAttributedString` 82 | 83 | ## [0.4.1] - 2021-06-20 Oops 84 | 85 | I left old versions of files in the 0.4.0 release. Clean that up. 86 | 87 | ## [0.4.0] - 2021-06-20 Happy Father's Day! 88 | 89 | Pretty substantial revisions. This now contains the code that has been developed and tested as part of Grail Diary. 90 | 91 | ## [0.3.1] - 2020-06-03 92 | 93 | ### Added 94 | 95 | * Added `PieceTable.sliceCount` 96 | 97 | ## [0.3.0] - 2020-05-12 98 | 99 | ### Fixed 100 | 101 | * Performance! Memoizing the string in the text storage is a big boost. 102 | 103 | ## [0.2.0] - 2020-05-12 104 | 105 | ### Fixed 106 | 107 | * Fixed bug that manifested in crashes while typing. The underlying problem is I was mutating nodes that "belonged" to result objects with no way to update the enclosing result. The fix was to make a copy of the node before mutating. I might want to make Node be a struct but I'm not ready to do that investigation yet. 108 | 109 | ## [0.1.0] - 2020-05-10 110 | 111 | ### Added 112 | 113 | * `IncrementalParsingTextStorage.rawText` to get text without formatting replacements applied. 114 | 115 | ## [0.0.1] - 2020-05-07 116 | 117 | The initial "MVP" release! Provides an implementation of NSTextStorage that does incremental parsing of its contents and uses the resulting parse tree to determine the text attributes. E.g., it can provide syntax highlighting that changes as you type. 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-log", 6 | "repositoryURL": "https://github.com/apple/swift-log.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 10 | "version": "1.4.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TextMarkupKit", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "TextMarkupKit", 13 | targets: [ 14 | "TextMarkupKit", 15 | "ObjectiveCTextStorageWrapper", 16 | ] 17 | ), 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 27 | .target( 28 | name: "TextMarkupKit", 29 | dependencies: [ 30 | .product(name: "Logging", package: "swift-log"), 31 | "ObjectiveCTextStorageWrapper", 32 | ], 33 | exclude: ["TextMarkupKit.docc"] 34 | ), 35 | .target(name: "ObjectiveCTextStorageWrapper", dependencies: []), 36 | .testTarget( 37 | name: "TextMarkupKitTests", 38 | dependencies: [ 39 | "ObjectiveCTextStorageWrapper", 40 | "TextMarkupKit", 41 | ] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TextMarkupKit 2 | 3 | Many iOS applications give you the ability to write plain text in a `UITextView` and format that text based upon simple rules. TextMarkupKit makes it easy to add "format as you type" capabilities to any iOS application. 4 | 5 | 6 | It consists of several interrelated components: 7 | 8 | 1. One set of components let you write a [Parsing Expression Grammar](https://en.wikipedia.org/wiki/Parsing_expression_grammar) to define how to parse the user's input. Because writing grammars is hard, TextMarkupKit lets you design "extensible grammars." Extensible grammars have explicit extension points where people can introduce new rules rather than writing an entire language grammar from scratch. `TextMarkupKit` also provides an extensible grammar for a subset of Markdown syntax called `MiniMarkdownGrammar`. You can extend `MiniMarkdownGrammar` by providing additional parsing rules for block or inline styles. 9 | 2. An implementation of Dubroy & Warth's [Incremental Packrat Parsing](https://ohmlang.github.io/pubs/sle2017/incremental-packrat-parsing.pdf) algorithm to efficiently re-parse text content as the user types in the `UITextView`. 10 | 3. A system to format an `NSAttributedString` based upon the parse tree for its `-string` contents. TextMarkupKit's formatting support was designed around the needs of lightweight "human markup languages" like Markdown instead of syntax highlighting of programming languages. In addition to changing the attributes associated with text, TextMarkupKit's formatting rules let you transform the displayed text itself. For example, you may choose to change a space to a tab when formatting a list, or not show the special formatting delimiters in some modes, or replace an image markup sequence with an actual image attachment. TextMarkupKit supports all of these modes. 11 | 4. A way to efficiently integrate the formatted `NSAttributedString` with TextKit so it can be used with a `UITextView`. 12 | 13 | TextMarkupKit provides the parsing / formatting support for my application [Grail Diary](https://bdewey.com/projects/grail-diary). 14 | 15 | ## Installation 16 | 17 | Install `TextMarkupKit` using Swift Package Manager. 18 | 19 | ``` 20 | dependencies: [ 21 | .package(url: "https://github.com/bdewey/TextMarkupKit", from: "0.7.0"), 22 | ], 23 | 24 | ``` 25 | 26 | Please note that TextMarkupKit is not yet at Version 1.0.0 -- the API is changing frequently and dramatically as I adopt code written for one specific application for general use. 27 | 28 | ## Using TextMarkupKit -- the absolute basics 29 | 30 | While `TextMarkupKit` is designed to support custom formatting and custom markup languages, you can get started with a subset of Markdown out-of-the box. Using UIKit: 31 | 32 | ```swift 33 | import TextMarkupKit 34 | import UIKit 35 | 36 | // textStorage will hold the characters and formatting (determined by the markup rules). 37 | // 38 | // MiniMarkdownGrammar.defaultEditingStyle(): 39 | // - Tells `ParsedAttributedString` to use the rules of MiniMarkdownGrammar to parse the text 40 | // - Provides a default set of formatters to style the parsed text. 41 | let textStorage = ParsedAttributedString(string: "# Hello, world!\n", style: MiniMarkdownGrammar.defaultEditingStyle()) 42 | 43 | // MarkupFormattingTextView is a subclass of UITextView and you can use it anywhere you would use a UITextView. 44 | let textView = MarkupFormattingTextView(parsedAttributedString: textStorage) 45 | ``` 46 | 47 | Using SwiftUI: 48 | 49 | ```swift 50 | import SwiftUI 51 | import TextMarkupKit 52 | 53 | struct ContentView: View { 54 | @Binding var document: TextMarkupKitSampleDocument 55 | 56 | var body: some View { 57 | // `MarkupFormattedTextEditor` is a SwiftUI wrapper around `MarkupFormattingTextView` that commits its changes back to the 58 | // text binding when editing is complete. By default it uses `MiniMarkdownGrammar.defaultEditingStyle()`, but you can provide 59 | // a custom style with the `style:` parameter. 60 | MarkupFormattedTextEditor(text: $document.text) 61 | } 62 | } 63 | ``` 64 | 65 | That's it! You now have a view that will format plain text and automatically adjust as the content changes. Check out the [sample application](TextMarkupKitSample/TextMarkupKitSample) to see this in action. 66 | 67 | ![TextMarkupKit Sample App](assets/sample.png) 68 | 69 | ## Further Reading 70 | 71 | Since this project started for personal use, documentation is sparse. While I build it up, this is an overview of the important areas of code. 72 | 73 | ### Parsing 74 | 75 | - `ParsingRule` is an abstract base class. The job of a parsing rule is to evaluate the input text at specific offset and produce a `ParsingResult`, which is a struct that indicates: 76 | - If the parsing rule succeeded at that location 77 | - If the rule succeeded, how much of the input string is *consumed* by the rule. Parsing continues after the consumed input. 78 | - How much of the input string the `ParsingRule` had to look at at to make its success/fail decision. 79 | - `PackratGrammar` is a protocol something that defines a complete grammar through a graph of `ParsingRule`s. `PackratGrammar` exposes a single rule, `start`, that will be used when attempting to parse a string. 80 | - `MemoizationTable` implements the core incremental packrat parsing algorithm. 81 | 82 | Additionally, `ParsingRule.swift` defines many simple rules that you can combine to build much more complex rules for constructing your grammar. 83 | 84 | - `DotRule` matches any character. 85 | - `Characters` matches any character defined by a `CharacterSet`. 86 | - `Literal` matches a string literal. 87 | - `InOrder` takes an array of child rules and succeeds if **every** one of the child rules succeeds in sequence. 88 | - `Choice` also takes an array of child rules, but matches the **first** of the child rules in the array. 89 | - `AssertionRule` takes a single child rule. It succeeds if its child rule succeeds **but** it does not consume the input. 90 | - `NotAssertionRule`, like `AssertionRule`, takes a single child rule. It will succeed if its child rule fails and vice versa, and never consumes input. 91 | - `RangeRule` takes a single child rule and will try repeatedly match the rule to the input. It succeeds if the number of successful repetitions of the child rule falls within a specified range. 92 | 93 | ### TextStorage 94 | 95 | - `PieceTable` implements the [piece table](https://darrenburns.net/posts/piece-table/) data structure for efficient text editing. 96 | - `PieceTableString` is a subclass of `NSMutableString` that uses a `PieceTable` for its internal storage. 97 | - `ParsedAttributedString` is a subclass of `NSMutableAttributedString` that: 98 | 1. Uses a `PieceTableString` for character storage; 99 | 2. Uses a `MemoizationTable` to parse the string *and* incrementally re-parse the string on each change; 100 | 3. Applies a set of formatting rules based upon the parsed syntax tree to determine the formatting of the string. 101 | - `ObjectiveCTextStorageWrapper` is an `NSTextStorage` implementation that lets you use a `ParsedAttributedString` as the backing storage for `TextKit`, like `UITextView`. 102 | -------------------------------------------------------------------------------- /Sources/ObjectiveCTextStorageWrapper/ObjectiveCTextStorageWrapper.m: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | #import "ObjectiveCTextStorageWrapper.h" 19 | 20 | @interface ObjectiveCTextStorageWrapper () 21 | 22 | @end 23 | 24 | @implementation WrappableTextStorage 25 | 26 | - (NSString *)coreString { 27 | return [self string]; 28 | } 29 | 30 | @end 31 | 32 | @implementation ObjectiveCTextStorageWrapper { 33 | // _storage provides these values through its delegate callback, which we need to save and use. 34 | NSRange _oldRange; 35 | NSInteger _changeInLength; 36 | NSRange _changedAttributesRange; 37 | 38 | // How many times did we get a delegate message? 39 | NSUInteger _countOfDelegateMessages; 40 | } 41 | 42 | - (instancetype)init { 43 | return [self initWithStorage:[[WrappableTextStorage alloc] init]]; 44 | } 45 | 46 | - (instancetype)initWithStorage:(WrappableTextStorage *)storage { 47 | if ((self = [super init]) != nil) { 48 | _storage = storage; 49 | _storage.delegate = self; 50 | } 51 | return self; 52 | } 53 | 54 | /// Provide O(1) access to the underlying character storage. 55 | - (NSString *)string { 56 | return _storage.coreString; 57 | } 58 | 59 | - (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range { 60 | return [_storage attributesAtIndex:location effectiveRange:range]; 61 | } 62 | 63 | - (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range { 64 | [_storage setAttributes:attrs range:range]; 65 | } 66 | 67 | - (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str { 68 | _countOfDelegateMessages = 0; 69 | [_storage replaceCharactersInRange:range withString:str]; 70 | NSAssert(_countOfDelegateMessages == 1, @"Expected exactly one delegate message from editing"); 71 | [self beginEditing]; 72 | [self edited:NSTextStorageEditedCharacters range:_oldRange changeInLength:_changeInLength]; 73 | [self edited:NSTextStorageEditedAttributes range:_changedAttributesRange changeInLength:0]; 74 | [self endEditing]; 75 | } 76 | 77 | - (void)attributedStringDidChangeWithOldRange:(NSRange)oldRange 78 | changeInLength:(NSInteger)changeInLength 79 | changedAttributesRange:(NSRange)changedAttributesRange { 80 | _countOfDelegateMessages += 1; 81 | _oldRange = oldRange; 82 | _changeInLength = changeInLength; 83 | _changedAttributesRange = changedAttributesRange; 84 | } 85 | 86 | @end 87 | -------------------------------------------------------------------------------- /Sources/ObjectiveCTextStorageWrapper/include/ObjectiveCTextStorageWrapper.h: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | #import 19 | 20 | NS_ASSUME_NONNULL_BEGIN 21 | 22 | @protocol WrappableTextStorageDelegate 23 | - (void)attributedStringDidChangeWithOldRange:(NSRange)oldRange 24 | changeInLength:(NSInteger)changeInLength 25 | changedAttributesRange:(NSRange)changedAttributesRange; 26 | @end 27 | 28 | @interface WrappableTextStorage: NSMutableAttributedString 29 | @property (nonatomic, weak) id delegate; 30 | - (id)coreString; 31 | @end 32 | 33 | /// An NSTextStorage implementation that uses a ParsedAttributedString as its underlying storage. 34 | @interface ObjectiveCTextStorageWrapper : NSTextStorage 35 | 36 | /// The underlying ParsedAttributedString for this NSTextStorage instance. Exposed to provide access to things like the AST for the contents. 37 | @property (nonatomic, strong) WrappableTextStorage *storage; 38 | 39 | /// Initializes ParsedTextStorage that wraps and underlying `ParsedAttributedString`. 40 | - (instancetype)initWithStorage:(WrappableTextStorage *)storage NS_DESIGNATED_INITIALIZER; 41 | 42 | @end 43 | 44 | NS_ASSUME_NONNULL_END 45 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/AttributesArray.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import Logging 20 | 21 | /// A helper class that keeps a cache of instantiated `AttributedStringAttributes` for an `AttributedStringAttributesDescriptor`. 22 | /// Note this does **no** cleanup under memory pressure, so make sure to discard the entire cache periodically. 23 | public final class AttributesCache { 24 | private var cache = [AttributedStringAttributesDescriptor: AttributedStringAttributes]() 25 | 26 | public func getAttributes( 27 | for descriptor: AttributedStringAttributesDescriptor 28 | ) -> AttributedStringAttributes { 29 | if let cachedAttributes = cache[descriptor] { 30 | return cachedAttributes 31 | } 32 | let attributes = descriptor.makeAttributes() 33 | cache[descriptor] = attributes 34 | return attributes 35 | } 36 | } 37 | 38 | /// A run-length encoded array of NSAttributedString attributes. 39 | public struct AttributesArray { 40 | private var runs: [Run] 41 | public private(set) var count: Int 42 | private let attributesCache: AttributesCache 43 | 44 | public init(attributesCache: AttributesCache) { 45 | self.runs = [] 46 | self.count = 0 47 | self.attributesCache = attributesCache 48 | } 49 | 50 | public enum Error: Swift.Error { 51 | /// When comparing attribute arrays, the arrays have different lengths. 52 | case arraysHaveDifferentLength 53 | } 54 | 55 | /// Append a single set of attributes for a run of `length` 56 | public mutating func appendAttributes(_ attributes: AttributedStringAttributesDescriptor, length: Int) { 57 | count += length 58 | if let last = runs.last, last.descriptor == attributes { 59 | runs[runs.count - 1].adjustLength(by: length) 60 | } else { 61 | runs.append(Run(descriptor: attributes, length: length)) 62 | } 63 | assert(runs.map { $0.length }.reduce(0, +) == count) 64 | } 65 | 66 | /// Changes the length of a particular run in the array. Useful to keep the attributes array updated in response to typing events. 67 | public mutating func adjustLengthOfRun(at location: Int, by amount: Int, defaultAttributes: AttributedStringAttributesDescriptor) { 68 | // In case amount is negative, we might need to distribute it among multiple runs. Keep track of how much there is to distribute. 69 | var amount = amount 70 | count += amount 71 | 72 | // We're going to loop until we've handled all of `amount` 73 | while amount != 0 { 74 | let index = self.index(startIndex, offsetBy: location) 75 | if index == endIndex { 76 | assert(amount >= 0) 77 | runs.append(Run(descriptor: defaultAttributes, length: amount)) 78 | // All of `amount` has been given to the new run. 79 | amount = 0 80 | } else { 81 | let amountForRun = Swift.max(-runs[index.runIndex].length, amount) 82 | runs[index.runIndex].adjustLength(by: amountForRun) 83 | amount -= amountForRun 84 | } 85 | if runs[index.runIndex].length == 0 { 86 | runs.remove(at: index.runIndex) 87 | } 88 | } 89 | assert(runs.map { $0.length }.reduce(0, +) == count) 90 | } 91 | 92 | /// Gets the attributes at a specific location, along with the range at which the attributes are the same. 93 | public func attributes(at location: Int, effectiveRange: NSRangePointer?) -> AttributedStringAttributes { 94 | let index = self.index(startIndex, offsetBy: location) 95 | effectiveRange?.pointee = NSRange(location: location - index.offsetInRun, length: runs[index.runIndex].length) 96 | return attributesCache.getAttributes(for: runs[index.runIndex].descriptor) 97 | } 98 | 99 | /// Computes a range of locations that bound where the receiver is different from `otherAttributes`. 100 | /// There are guaranteed to be no differences in attributes at locations outside the returned range. 101 | /// If there are no differences between the arrays, returns nil. 102 | /// Note it is invalid if `otherAttributes` represents a different count of text than the receiver, and the method will throw an error in this case. 103 | public func rangeOfAttributeDifferences(from otherAttributes: AttributesArray) throws -> NSRange? { 104 | if count != otherAttributes.count { 105 | throw Error.arraysHaveDifferentLength 106 | } 107 | var firstDifferingIndex = 0 108 | for (lhs, rhs) in zip(runs, otherAttributes.runs) { 109 | if lhs.descriptor != rhs.descriptor { 110 | break 111 | } 112 | firstDifferingIndex += Swift.min(lhs.length, rhs.length) 113 | if lhs.length != rhs.length { 114 | break 115 | } 116 | } 117 | if firstDifferingIndex == count { 118 | return nil 119 | } 120 | var lastDifferingIndex = count 121 | for (lhs, rhs) in zip(runs.reversed(), otherAttributes.runs.reversed()) { 122 | if lhs.descriptor != rhs.descriptor { 123 | break 124 | } 125 | lastDifferingIndex -= Swift.min(lhs.length, rhs.length) 126 | if lhs.length != rhs.length { 127 | break 128 | } 129 | } 130 | assert(lastDifferingIndex >= firstDifferingIndex) 131 | if lastDifferingIndex > firstDifferingIndex { 132 | return NSRange(location: firstDifferingIndex, length: lastDifferingIndex - firstDifferingIndex) 133 | } else { 134 | return nil 135 | } 136 | } 137 | } 138 | 139 | // MARK: - Collection 140 | 141 | extension AttributesArray: Collection { 142 | public struct Index: Comparable { 143 | fileprivate var runIndex: Int 144 | fileprivate var offsetInRun: Int 145 | 146 | public static func < (lhs: AttributesArray.Index, rhs: AttributesArray.Index) -> Bool { 147 | return (lhs.runIndex, lhs.offsetInRun) < (rhs.runIndex, rhs.offsetInRun) 148 | } 149 | } 150 | 151 | public var startIndex: Index { Index(runIndex: 0, offsetInRun: 0) } 152 | public var endIndex: Index { Index(runIndex: runs.endIndex, offsetInRun: 0) } 153 | 154 | public func index(after i: Index) -> Index { 155 | if i.offsetInRun + 1 == runs[i.runIndex].length { 156 | return Index(runIndex: i.runIndex + 1, offsetInRun: 0) 157 | } else { 158 | return Index(runIndex: i.runIndex, offsetInRun: i.offsetInRun + 1) 159 | } 160 | } 161 | 162 | public func index(_ i: Index, offsetBy distance: Int, limitedBy limit: Index) -> Index? { 163 | var distance = distance 164 | var offsetInRun = i.offsetInRun 165 | for runIndex in i.runIndex ..< runs.endIndex { 166 | let run = runs[runIndex] 167 | let charactersInRun = runIndex == limit.runIndex 168 | ? limit.offsetInRun - offsetInRun 169 | : run.length - offsetInRun 170 | if distance < charactersInRun { 171 | return Index(runIndex: runIndex, offsetInRun: offsetInRun + distance) 172 | } 173 | offsetInRun = 0 174 | distance -= charactersInRun 175 | } 176 | if distance == 0 { 177 | return limit 178 | } else { 179 | return nil 180 | } 181 | } 182 | 183 | public func index(_ i: Index, offsetBy distance: Int) -> Index { 184 | return index(i, offsetBy: distance, limitedBy: endIndex)! 185 | } 186 | 187 | public func distance(from start: Index, to end: Index) -> Int { 188 | var distance = 0 189 | for runIndex in start.runIndex ... end.runIndex where runIndex < runs.endIndex { 190 | let run = runs[runIndex] 191 | let lowerBound = (runIndex == start.runIndex) ? start.offsetInRun : 0 192 | let upperBound = (runIndex == end.runIndex) ? end.offsetInRun : run.length 193 | distance += (upperBound - lowerBound) 194 | } 195 | return distance 196 | } 197 | 198 | public subscript(position: Index) -> AttributedStringAttributes { 199 | return attributesCache.getAttributes(for: runs[position.runIndex].descriptor) 200 | } 201 | } 202 | 203 | // MARK: - Private 204 | 205 | private extension AttributesArray { 206 | struct Run { 207 | init(descriptor: AttributedStringAttributesDescriptor, length: Int) { 208 | self.descriptor = descriptor 209 | self.length = length 210 | } 211 | 212 | var descriptor: AttributedStringAttributesDescriptor 213 | var length: Int 214 | 215 | func adjustingLength(by amount: Int) -> Self { 216 | var copy = self 217 | copy.length += amount 218 | return copy 219 | } 220 | 221 | mutating func adjustLength(by amount: Int) { 222 | assert(length + amount >= 0) 223 | length += amount 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/BufferProtocols.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | /// A type that provides safe access to Int-indexed UTF-16 values. 21 | public protocol SafeUnicodeBuffer { 22 | /// How many UTF-16 characters are in the buffer 23 | var count: Int { get } 24 | 25 | /// Gets the UTF-16 value at an index. If the index is out of bounds, returns nil. 26 | func utf16(at index: Int) -> unichar? 27 | 28 | /// Gets a Character that starts at index. Note that Character may be composed of several UTF-16 code units. (E.g., emoji) 29 | func character(at index: Int) -> Character? 30 | 31 | /// Gets a substring from the buffer, objc-style 32 | subscript(range: NSRange) -> [unichar] { get } 33 | 34 | /// The contents of the receiver as a string. 35 | var string: String { get } 36 | } 37 | 38 | /// Make every String a SafeUnicodeBuffer 39 | extension String: SafeUnicodeBuffer { 40 | public subscript(range: NSRange) -> [unichar] { 41 | guard 42 | let lowerBound = index(startIndex, offsetBy: range.location, limitedBy: endIndex), 43 | let upperBound = index(lowerBound, offsetBy: range.length, limitedBy: endIndex) 44 | else { 45 | return [] 46 | } 47 | return Array(utf16[lowerBound ..< upperBound]) 48 | } 49 | 50 | public func utf16(at i: Int) -> unichar? { 51 | guard let stringIndex = index(startIndex, offsetBy: i, limitedBy: endIndex), stringIndex < endIndex else { 52 | return nil 53 | } 54 | return utf16[stringIndex] 55 | } 56 | 57 | public func character(at i: Int) -> Character? { 58 | guard let stringIndex = index(startIndex, offsetBy: i, limitedBy: endIndex), stringIndex < endIndex else { 59 | return nil 60 | } 61 | return self[stringIndex] 62 | } 63 | 64 | public var string: String { self } 65 | } 66 | 67 | public protocol RangeReplaceableSafeUnicodeBuffer: SafeUnicodeBuffer { 68 | /// Replace the UTF-16 values stored in `range` with the values from `str`. 69 | mutating func replaceCharacters(in range: NSRange, with str: String) 70 | } 71 | 72 | public enum ParsingError: Swift.Error { 73 | /// The supplied grammar did not parse the entire contents of the buffer. 74 | /// - parameter length: How much of the buffer was consumed by the grammar. 75 | case incompleteParsing(length: Int) 76 | 77 | /// We just didn't feel like parsing today -- used mostly to test error paths :-) 78 | case didntFeelLikeIt 79 | } 80 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/MarkupFormattedTextEditor.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import SwiftUI 19 | 20 | public struct MarkupFormattedTextEditor: UIViewRepresentable { 21 | public init(text: Binding, style: ParsedAttributedString.Style = MiniMarkdownGrammar.defaultEditingStyle()) { 22 | self._text = text 23 | self.style = style 24 | } 25 | 26 | @Binding public var text: String 27 | public let style: ParsedAttributedString.Style 28 | 29 | public func makeUIView(context: Context) -> MarkupFormattingTextView { 30 | let view = MarkupFormattingTextView(parsedAttributedString: ParsedAttributedString(string: text, style: style)) 31 | view.delegate = context.coordinator 32 | return view 33 | } 34 | 35 | public func updateUIView(_ uiView: MarkupFormattingTextView, context: Context) { 36 | uiView.text = text 37 | } 38 | 39 | public func makeCoordinator() -> Coordinator { 40 | Coordinator(text: $text) 41 | } 42 | 43 | public final class Coordinator: NSObject, UITextViewDelegate { 44 | @Binding private var text: String 45 | 46 | init(text: Binding) { 47 | self._text = text 48 | } 49 | 50 | public func textViewDidEndEditing(_ textView: UITextView) { 51 | text = textView.text 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/MarkupFormattingTextView.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Logging 19 | import MobileCoreServices 20 | import ObjectiveCTextStorageWrapper 21 | import os 22 | import UIKit 23 | import UniformTypeIdentifiers 24 | 25 | private let log = OSLog(subsystem: "org.brians-brain.TextMarkupKit", category: "MarkupFormattingTextView") 26 | 27 | private extension Logging.Logger { 28 | static let textView: Logging.Logger = { 29 | var logger = Logger(label: "org.brians-brain.TextMarkupKit") 30 | logger.logLevel = .info 31 | return logger 32 | }() 33 | } 34 | 35 | /// A protocol that the text views use to store images on paste 36 | public protocol MarkupFormattingTextViewImageStorage { 37 | /// Store image data. 38 | /// - parameter imageData: The image data to store 39 | /// - parameter type: The type of image data (e.g., `UTType.png` or `UTType.jpeg` 40 | /// - returns: A string that represents this image in the markup language. 41 | @MainActor func storeImageData(_ imageData: Data, type: UTType) throws -> String 42 | } 43 | 44 | /// A UITextView subclass that uses a `ParsedAttributedString` for text storage and formatting. 45 | public final class MarkupFormattingTextView: UITextView { 46 | /// Creates a `MarkupFormattingTextView` that uses `parsedAttributedString` as its textStorage. 47 | /// 48 | /// - Parameter parsedAttributedString: The `ParsedAttributedString` to use for text storage and formatting. 49 | /// - Parameter layoutManager: Optional custom NSLayoutManager to use. 50 | public init( 51 | parsedAttributedString: ParsedAttributedString, 52 | layoutManager: NSLayoutManager = NSLayoutManager() 53 | ) { 54 | self.parsedAttributedString = parsedAttributedString 55 | self.storage = ObjectiveCTextStorageWrapper(storage: parsedAttributedString) 56 | let layoutManager = layoutManager 57 | storage.addLayoutManager(layoutManager) 58 | let textContainer = NSTextContainer() 59 | layoutManager.addTextContainer(textContainer) 60 | super.init(frame: .zero, textContainer: textContainer) 61 | pasteConfiguration = UIPasteConfiguration( 62 | acceptableTypeIdentifiers: [ 63 | kUTTypeJPEG as String, 64 | kUTTypePNG as String, 65 | kUTTypeImage as String, 66 | kUTTypePlainText as String, 67 | ] 68 | ) 69 | } 70 | 71 | /// An object that can store pasted image data. 72 | public var imageStorage: MarkupFormattingTextViewImageStorage? 73 | 74 | /// The `ParsedAttributedString` used for text storage and formatting. 75 | public let parsedAttributedString: ParsedAttributedString 76 | 77 | /// A private wrapper around `parsedAttributedString` for efficient interaction with TextKit. 78 | private let storage: ObjectiveCTextStorageWrapper 79 | 80 | @available(*, unavailable) 81 | required init?(coder: NSCoder) { 82 | fatalError("init(coder:) has not been implemented") 83 | } 84 | 85 | override public func copy(_ sender: Any?) { 86 | guard let textStorage = textStorage as? ObjectiveCTextStorageWrapper, let parsedAttributedString = textStorage.storage as? ParsedAttributedString else { 87 | Logger.textView.error("Expected to get a ParsedAttributedString") 88 | return 89 | } 90 | let rawTextRange = parsedAttributedString.rawStringRange(forRange: selectedRange) 91 | let characters = parsedAttributedString.rawString[rawTextRange] 92 | UIPasteboard.general.string = String(utf16CodeUnits: characters, count: characters.count) 93 | } 94 | 95 | override public func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { 96 | Logger.textView.info("Determining if we can paste from \(itemProviders)") 97 | let typeIdentifiers = pasteConfiguration!.acceptableTypeIdentifiers 98 | for itemProvider in itemProviders { 99 | for typeIdentifier in typeIdentifiers where itemProvider.hasItemConformingToTypeIdentifier(typeIdentifier) { 100 | Logger.textView.info("Item provider has type \(typeIdentifier) so we can paste") 101 | return true 102 | } 103 | } 104 | return false 105 | } 106 | 107 | override public func paste(itemProviders: [NSItemProvider]) { 108 | Logger.textView.info("Pasting \(itemProviders)") 109 | super.paste(itemProviders: itemProviders) 110 | } 111 | 112 | override public func paste(_ sender: Any?) { 113 | if let image = UIPasteboard.general.image, let imageStorage = imageStorage { 114 | Logger.textView.info("Pasting an image") 115 | let imageKey: String? 116 | if let jpegData = UIPasteboard.general.data(forPasteboardType: UTType.jpeg.identifier) { 117 | Logger.textView.info("Got JPEG data = \(jpegData.count) bytes") 118 | imageKey = try? imageStorage.storeImageData(jpegData, type: .jpeg) 119 | } else if let pngData = UIPasteboard.general.data(forPasteboardType: UTType.png.identifier) { 120 | Logger.textView.info("Got PNG data = \(pngData.count) bytes") 121 | imageKey = try? imageStorage.storeImageData(pngData, type: .png) 122 | } else if let convertedData = image.jpegData(compressionQuality: 0.8) { 123 | Logger.textView.info("Did JPEG conversion ourselves = \(convertedData.count) bytes") 124 | imageKey = try? imageStorage.storeImageData(convertedData, type: .jpeg) 125 | } else { 126 | Logger.textView.error("Could not get image data") 127 | imageKey = nil 128 | } 129 | if let imageKey = imageKey { 130 | textStorage.replaceCharacters(in: selectedRange, with: imageKey) 131 | } 132 | } else { 133 | Logger.textView.info("Using superclass to paste text") 134 | super.paste(sender) 135 | } 136 | } 137 | 138 | override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 139 | if action == #selector(paste(_:)), UIPasteboard.general.image != nil { 140 | Logger.textView.info("There's an image on the pasteboard, so allow pasting") 141 | return true 142 | } 143 | return super.canPerformAction(action, withSender: sender) 144 | } 145 | 146 | override public func insertText(_ text: String) { 147 | os_signpost(.begin, log: log, name: "keystroke") 148 | super.insertText(text) 149 | os_signpost(.end, log: log, name: "keystroke") 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/MemoizationTable.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import os 20 | 21 | /// A Packrat grammar is a collection of parsing rules, one of which is the designated `start` rule. 22 | public protocol PackratGrammar { 23 | /// The designated starting rule for parsing the grammar. This rule should produce exactly one syntax tree `Node`. 24 | var start: ParsingRule { get } 25 | } 26 | 27 | private let log = OSLog(subsystem: "org.brians-brain.PackratParser", category: "PackratParser") 28 | 29 | /// Implements a packrat parsing algorithm. 30 | public final class MemoizationTable: CustomStringConvertible { 31 | /// Designated initializer. 32 | /// - Parameters: 33 | /// - grammar: The grammar rules to apply to the contents of `buffer` 34 | // TODO: This should probably take a block that constructs a grammar rather than a grammar 35 | public init(grammar: PackratGrammar) { 36 | self.memoizedResults = [] 37 | self.grammar = grammar 38 | } 39 | 40 | public let grammar: PackratGrammar 41 | 42 | private func reserveCapacity(_ capacity: Int) { 43 | assert(memoizedResults.count < capacity) 44 | memoizedResults = Array(repeating: MemoColumn(), count: capacity) 45 | } 46 | 47 | /// Parses the contents of the buffer. 48 | /// - Throws: If the grammar could not parse the entire contents, throws `Error.incompleteParsing`. If the grammar resulted in more than one resulting node, throws `Error.ambiguousParsing`. 49 | /// - Returns: The single node at the root of the syntax tree resulting from parsing `buffer` 50 | public func parseBuffer(_ buffer: SafeUnicodeBuffer) throws -> SyntaxTreeNode { 51 | if memoizedResults.count < buffer.count + 1 { 52 | reserveCapacity(buffer.count + 1) 53 | } 54 | os_signpost(.begin, log: log, name: "parseBuffer") 55 | let result = grammar.start.parsingResult(from: buffer, at: 0, memoizationTable: self) 56 | os_signpost(.end, log: log, name: "parseBuffer") 57 | guard let node = result.node, node.length == buffer.count else { 58 | throw ParsingError.incompleteParsing(length: result.node?.length ?? result.length) 59 | } 60 | #if DEBUG 61 | try! node.validateLength() // swiftlint:disable:this force_try 62 | #endif 63 | return node 64 | } 65 | 66 | public var description: String { 67 | let (totalEntries, successfulEntries) = memoizationStatistics() 68 | let properties: [String: Any] = [ 69 | "totalEntries": totalEntries, 70 | "successfulEntries": successfulEntries, 71 | "memoizationChecks": memoizationChecks, 72 | "memoizationHits": memoizationHits, 73 | "memoizationHitRate": String(format: "%.2f%%", 100.0 * Double(memoizationHits) / Double(memoizationChecks)), 74 | ] 75 | return "PackratParser: \(properties)" 76 | } 77 | 78 | private var memoizationChecks = 0 79 | private var memoizationHits = 0 80 | 81 | /// Returns the memoized result of applying a rule at an index into the buffer, if it exists. 82 | public func memoizedResult(rule: ObjectIdentifier, index: Int) -> ParsingResult? { 83 | let result = memoizedResults[index][rule] 84 | memoizationChecks += 1 85 | if result != nil { memoizationHits += 1 } 86 | return result 87 | } 88 | 89 | /// Memoizes the result of applying a rule at an index in the buffer. 90 | /// - Parameters: 91 | /// - result: The parsing result to memoize. 92 | /// - rule: The rule that generated the result that we are memoizing. 93 | /// - index: The position in the input at which we applied the rule to get the result. 94 | public func memoizeResult(_ result: ParsingResult, rule: ObjectIdentifier, index: Int) { 95 | assert(result.examinedLength > 0) 96 | assert(result.examinedLength >= result.length) 97 | #if DEBUG 98 | do { 99 | try result.node?.validateLength() 100 | } catch { 101 | fatalError() 102 | } 103 | #endif 104 | memoizedResults[index][rule] = result 105 | } 106 | 107 | /// Adjust the memo tables for reuse after an edit to the input text where the characters in `originalRange` were replaced 108 | /// with `replacementLength` characters. 109 | public func applyEdit(originalRange: NSRange, replacementLength: Int) { 110 | precondition(replacementLength >= 0) 111 | let lengthIncrease = replacementLength - originalRange.length 112 | if lengthIncrease < 0 { 113 | // We need to *shrink* the memo table. 114 | memoizedResults.removeSubrange(originalRange.location ..< originalRange.location + abs(lengthIncrease)) 115 | } else if lengthIncrease > 0 { 116 | // We need to *grow* the memo table. 117 | memoizedResults.insert( 118 | contentsOf: [MemoColumn](repeating: MemoColumn(), count: lengthIncrease), 119 | at: originalRange.location 120 | ) 121 | } 122 | // Now that we've adjusted the length of the memo table, everything in these columns is invalid. 123 | let invalidRange = NSRange(location: originalRange.location, length: replacementLength) 124 | for column in Range(invalidRange)! { 125 | memoizedResults[column].removeAll() 126 | } 127 | // Finally go through everything to the left of the removed range and invalidate memoization 128 | // results where it overlaps the edited range. 129 | var removedResults = [Int: [ParsingResult]]() 130 | for column in 0 ..< invalidRange.location { 131 | let invalidLength = invalidRange.location - column 132 | if memoizedResults[column].maxExaminedLength >= invalidLength { 133 | let victims = memoizedResults[column].remove { 134 | $0.examinedLength >= invalidLength 135 | } 136 | removedResults[column] = victims 137 | } 138 | } 139 | } 140 | 141 | // MARK: - Memoization internals 142 | 143 | private var memoizedResults: [MemoColumn] 144 | 145 | public func memoizationStatistics() -> (totalEntries: Int, successfulEntries: Int) { 146 | var totalEntries = 0 147 | var successfulEntries = 0 148 | for column in memoizedResults { 149 | for (_, result) in column { 150 | totalEntries += 1 151 | if result.succeeded { successfulEntries += 1 } 152 | } 153 | } 154 | return (totalEntries: totalEntries, successfulEntries: successfulEntries) 155 | } 156 | 157 | #if DEBUG 158 | func debugPrintInterestingContents() { 159 | for index in memoizedResults.indices { 160 | for (_, result) in memoizedResults[index] where result.succeeded && result.length > 0 { 161 | do { 162 | try result.node?.validateLength() 163 | print("Column \(index): \(result)") 164 | } catch { 165 | print("Column \(index): INVALID LENGTH \(result)") 166 | } 167 | } 168 | } 169 | } 170 | #endif 171 | } 172 | 173 | // MARK: - Memoization 174 | 175 | private extension MemoizationTable { 176 | struct MemoColumn { 177 | private(set) var maxExaminedLength = 0 178 | private var storage = [ObjectIdentifier: ParsingResult]() 179 | 180 | subscript(id: ObjectIdentifier) -> ParsingResult? { 181 | get { 182 | storage[id] 183 | } 184 | set { 185 | guard let newValue = newValue else { 186 | assertionFailure() 187 | return 188 | } 189 | storage[id] = newValue 190 | maxExaminedLength = Swift.max(maxExaminedLength, newValue.examinedLength) 191 | } 192 | } 193 | 194 | mutating func removeAll() { 195 | storage.removeAll() 196 | maxExaminedLength = 0 197 | } 198 | 199 | /// Removes results that match a predicate. 200 | @discardableResult 201 | mutating func remove(where predicate: (ParsingResult) -> Bool) -> [ParsingResult] { 202 | var maxExaminedLength = 0 203 | var removedResults = [ParsingResult]() 204 | let keysAndValues = storage.compactMap { key, value -> (key: ObjectIdentifier, value: ParsingResult)? in 205 | guard !predicate(value) else { 206 | removedResults.append(value) 207 | return nil 208 | } 209 | maxExaminedLength = Swift.max(maxExaminedLength, value.examinedLength) 210 | return (key: key, value: value) 211 | } 212 | storage = Dictionary(uniqueKeysWithValues: keysAndValues) 213 | self.maxExaminedLength = maxExaminedLength 214 | return removedResults 215 | } 216 | } 217 | } 218 | 219 | extension MemoizationTable.MemoColumn: Collection { 220 | typealias Index = Dictionary.Index 221 | var startIndex: Index { storage.startIndex } 222 | var endIndex: Index { storage.endIndex } 223 | func index(after i: Index) -> Index { 224 | return storage.index(after: i) 225 | } 226 | 227 | subscript(position: Index) -> (key: ObjectIdentifier, value: ParsingResult) { 228 | storage[position] 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/MiniMarkdownGrammar+Styles.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import UIKit 19 | 20 | public struct HeaderFormatter: ParsedAttributedStringFormatter { 21 | public func formatNode( 22 | _ node: SyntaxTreeNode, 23 | in buffer: SafeUnicodeBuffer, 24 | at offset: Int, 25 | currentAttributes: AttributedStringAttributesDescriptor 26 | ) -> (attributes: AttributedStringAttributesDescriptor, replacementCharacters: [unichar]?) { 27 | guard let headingLevel = node.children.first?.length else { 28 | assertionFailure() 29 | return (currentAttributes, nil) 30 | } 31 | var attributes = currentAttributes 32 | switch headingLevel { 33 | case 1: 34 | attributes.textStyle = .title2 35 | case 2: 36 | attributes.textStyle = .title3 37 | default: 38 | attributes.textStyle = .title3 39 | } 40 | attributes.listLevel = 1 41 | return (attributes, nil) 42 | } 43 | } 44 | 45 | public extension MiniMarkdownGrammar { 46 | /// A style suitable for editing MiniMarkdown text in a UITextView. 47 | /// 48 | /// * Headers display with the `title` text style 49 | /// * Code is monospaced 50 | /// * Formatting delimiters are shown with `quarternaryLabel` color 51 | /// * Lists use hanging indents for their bullets 52 | /// * Unordered lists use a proper bullet character 53 | /// * Emojis show up appropriately 54 | static func defaultEditingStyle() -> ParsedAttributedString.Style { 55 | let defaultAttributes = AttributedStringAttributesDescriptor(textStyle: .body, color: .label, headIndent: 28, firstLineHeadIndent: 28) 56 | let formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] = [ 57 | .header: AnyParsedAttributedStringFormatter(HeaderFormatter()), 58 | .list: .incrementListLevel, 59 | .delimiter: .color(.quaternaryLabel), 60 | .strongEmphasis: .toggleBold, 61 | .emphasis: .toggleItalic, 62 | .code: .fontDesign(.monospaced), 63 | .hashtag: .backgroundColor(.secondarySystemBackground), 64 | .blockquote: AnyParsedAttributedStringFormatter { 65 | $0.italic = true 66 | $0.blockquoteBorderColor = UIColor.systemOrange 67 | $0.listLevel += 1 68 | }, 69 | .emoji: AnyParsedAttributedStringFormatter { 70 | $0.familyName = "Apple Color Emoji" 71 | }, 72 | .softTab: .substitute("\t"), 73 | .unorderedListOpening: .substitute("\u{2022}"), 74 | ] 75 | return ParsedAttributedString.Style(grammar: MiniMarkdownGrammar(), defaultAttributes: defaultAttributes, formatters: formatters) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/MiniMarkdownGrammar.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | public extension SyntaxTreeNodeType { 21 | static let blankLine: SyntaxTreeNodeType = "blank_line" 22 | static let blockquote: SyntaxTreeNodeType = "blockquote" 23 | static let code: SyntaxTreeNodeType = "code" 24 | static let delimiter: SyntaxTreeNodeType = "delimiter" 25 | static let document: SyntaxTreeNodeType = "document" 26 | static let emphasis: SyntaxTreeNodeType = "emphasis" 27 | static let hashtag: SyntaxTreeNodeType = "hashtag" 28 | static let header: SyntaxTreeNodeType = "header" 29 | static let image: SyntaxTreeNodeType = "image" 30 | static let linkAltText: SyntaxTreeNodeType = "link_alt_text" 31 | static let linkTarget: SyntaxTreeNodeType = "link_target" 32 | static let list: SyntaxTreeNodeType = "list" 33 | static let listDelimiter: SyntaxTreeNodeType = "list_delimiter" 34 | static let listItem: SyntaxTreeNodeType = "list_item" 35 | static let paragraph: SyntaxTreeNodeType = "paragraph" 36 | static let softTab: SyntaxTreeNodeType = "tab" 37 | static let strongEmphasis: SyntaxTreeNodeType = "strong_emphasis" 38 | static let text: SyntaxTreeNodeType = "text" 39 | static let unorderedListOpening: SyntaxTreeNodeType = "unordered_list_opening" 40 | static let orderedListNumber: SyntaxTreeNodeType = "ordered_list_number" 41 | static let orderedListTerminator: SyntaxTreeNodeType = "ordered_list_terminator" 42 | static let emoji: SyntaxTreeNodeType = "emoji" 43 | } 44 | 45 | public enum ListType { 46 | case ordered 47 | case unordered 48 | } 49 | 50 | public enum ListTypeKey: SyntaxTreeNodePropertyKey { 51 | public typealias Value = ListType 52 | 53 | public static let key = "list_type" 54 | } 55 | 56 | /// Implements a subset of Markdown for common "plain text formatting" scenarios. 57 | /// 58 | /// This class is designed to be subclassed so you can extend the grammar. Subclasses can override: 59 | public final class MiniMarkdownGrammar: PackratGrammar { 60 | public init( 61 | trace: Bool = false 62 | ) { 63 | if trace { 64 | self.start = start.trace() 65 | } 66 | } 67 | 68 | /// Singleton for convenience. 69 | @MainActor public static let shared = MiniMarkdownGrammar() 70 | 71 | public private(set) lazy var start: ParsingRule = block.memoize() 72 | .repeating(0...) 73 | .wrapping(in: .document) 74 | 75 | private lazy var coreBlockRules = [ 76 | blankLine, 77 | header, 78 | unorderedList, 79 | orderedList, 80 | blockquote, 81 | ] 82 | 83 | public var customBlockRules: [ParsingRule] = [] { 84 | didSet { 85 | var resolvedRules = coreBlockRules 86 | resolvedRules.append(contentsOf: customBlockRules) 87 | // `paragraph` goes last because it matches everything. No rule after `paragraph` will ever run. 88 | resolvedRules.append(paragraph) 89 | block.rules = resolvedRules 90 | } 91 | } 92 | 93 | private lazy var block: Choice = { 94 | var resolvedRules = coreBlockRules 95 | resolvedRules.append(contentsOf: customBlockRules) 96 | // `paragraph` goes last because it matches everything. No rule after `paragraph` will ever run. 97 | resolvedRules.append(paragraph) 98 | 99 | return Choice(resolvedRules) 100 | }() 101 | 102 | lazy var blankLine = InOrder( 103 | whitespace.repeating(0...), 104 | newline 105 | ).as(.blankLine).memoize() 106 | 107 | lazy var header = InOrder( 108 | Characters(["#"]).repeating(1 ..< 7).as(.delimiter), 109 | softTab, 110 | singleLineStyledText, 111 | paragraphTermination.zeroOrOne().wrapping(in: .text) 112 | ).wrapping(in: .header).memoize() 113 | 114 | lazy var paragraph = InOrder( 115 | nonDelimitedHashtag.zeroOrOne(), 116 | styledText, 117 | paragraphTermination.zeroOrOne().wrapping(in: .text) 118 | ).wrapping(in: .paragraph).memoize() 119 | 120 | public private(set) lazy var paragraphTermination = InOrder( 121 | newline, 122 | Choice(Characters(["#", "\n"]).assert(), unorderedListOpening.assert(), orderedListOpening.assert(), blockquoteOpening.assert()) 123 | ) 124 | 125 | // MARK: - Inline styles 126 | 127 | func delimitedText(_ nodeType: SyntaxTreeNodeType, delimiter: ParsingRule) -> ParsingRule { 128 | let rightFlanking = InOrder(nonWhitespace.as(.text), delimiter.as(.delimiter)).memoize() 129 | return InOrder( 130 | delimiter.as(.delimiter), 131 | nonWhitespace.assert(), 132 | InOrder( 133 | rightFlanking.assertInverse(), 134 | paragraphTermination.assertInverse(), 135 | dot 136 | ).repeating(0...).as(.text), 137 | rightFlanking 138 | ).wrapping(in: nodeType).memoize() 139 | } 140 | 141 | lazy var bold = delimitedText(.strongEmphasis, delimiter: Literal("**")) 142 | lazy var italic = delimitedText(.emphasis, delimiter: Literal("*")) 143 | lazy var underlineItalic = delimitedText(.emphasis, delimiter: Literal("_")) 144 | lazy var code = delimitedText(.code, delimiter: Literal("`")) 145 | lazy var hashtag = InOrder( 146 | whitespace.as(.text), 147 | nonDelimitedHashtag 148 | ) 149 | lazy var nonDelimitedHashtag = InOrder( 150 | Literal("#").as(.text), 151 | Choice(emoji, nonWhitespace.as(.text)).repeating(1...) 152 | ).wrapping(in: .hashtag).memoize() 153 | 154 | lazy var image = InOrder( 155 | Literal("![").as(.text), 156 | Characters(CharacterSet(charactersIn: "\n]").inverted).repeating(0...).as(.linkAltText), 157 | Literal("](").as(.text), 158 | Characters(CharacterSet(charactersIn: "\n)").inverted).repeating(0...).as(.linkTarget), 159 | Literal(")").as(.text) 160 | ).wrapping(in: .image).memoize() 161 | 162 | lazy var emoji = CharacterPredicate { $0.isEmoji }.repeating(1...).as(.emoji).memoize() 163 | 164 | /// Rules that define how to parse "inline styles" (bold, italic, code, etc). Designed to be overridden to add or replace the parsed inline styles. 165 | private lazy var inlineStyleRules: [ParsingRule] = [ 166 | bold, 167 | italic, 168 | underlineItalic, 169 | code, 170 | hashtag, 171 | image, 172 | emoji, 173 | ] 174 | 175 | public var customInlineStyleRules: [ParsingRule] = [] { 176 | didSet { 177 | var resolvedStyleRules = inlineStyleRules 178 | resolvedStyleRules.append(contentsOf: customInlineStyleRules) 179 | unmemoizedTextStyles.rules = resolvedStyleRules 180 | } 181 | } 182 | 183 | private lazy var unmemoizedTextStyles = Choice(inlineStyleRules) 184 | 185 | private lazy var textStyles = unmemoizedTextStyles.memoize() 186 | 187 | lazy var styledText = InOrder( 188 | InOrder(paragraphTermination.assertInverse(), textStyles.assertInverse(), dot).repeating(0...).as(.text), 189 | textStyles.repeating(0...) 190 | ).repeating(0...).memoize() 191 | 192 | /// A variant of `styledText` that terminates on the first newline 193 | public private(set) lazy var singleLineStyledText = InOrder( 194 | InOrder(Characters(["\n"]).assertInverse(), textStyles.assertInverse(), dot).repeating(0...).as(.text), 195 | textStyles.repeating(0...) 196 | ).repeating(0...).memoize() 197 | 198 | // MARK: - Character primitives 199 | 200 | public let dot = DotRule() 201 | public let newline = Characters(["\n"]) 202 | public let whitespace = Characters(.whitespaces) 203 | public let nonWhitespace = Characters(CharacterSet.whitespacesAndNewlines.inverted) 204 | public let digit = Characters(.decimalDigits) 205 | /// One or more whitespace characters that should be interpreted as a single delimiater. 206 | let softTab = Characters(.whitespaces).repeating(1...).as(.softTab) 207 | 208 | // MARK: - Simple block quotes 209 | 210 | // TODO: Support single block quotes that span multiple lines, and block quotes with multiple 211 | // paragraphs. 212 | 213 | lazy var blockquoteOpening = InOrder( 214 | whitespace.repeating(0 ... 3).as(.text), 215 | Characters([">"]).as(.text), 216 | whitespace.zeroOrOne().as(.softTab) 217 | ).wrapping(in: .delimiter).memoize() 218 | 219 | lazy var blockquote = InOrder( 220 | blockquoteOpening, 221 | paragraph 222 | ).as(.blockquote).memoize() 223 | 224 | // MARK: - Lists 225 | 226 | // https://spec.commonmark.org/0.28/#list-items 227 | 228 | lazy var unorderedListOpening = InOrder( 229 | whitespace.repeating(0...).as(.text).zeroOrOne(), 230 | Characters(["*", "-", "+"]).as(.unorderedListOpening), 231 | whitespace.repeating(1 ... 4).as(.softTab) 232 | ).wrapping(in: .listDelimiter).memoize() 233 | 234 | lazy var orderedListOpening = InOrder( 235 | whitespace.repeating(0...).as(.text).zeroOrOne(), 236 | digit.repeating(1 ... 9).as(.orderedListNumber), 237 | Characters([".", ")"]).as(.orderedListTerminator), 238 | whitespace.repeating(1 ... 4).as(.softTab) 239 | ).wrapping(in: .listDelimiter).memoize() 240 | 241 | func list(type: ListType, openingDelimiter: ParsingRule) -> ParsingRule { 242 | let listItem = InOrder( 243 | openingDelimiter, 244 | paragraph 245 | ).wrapping(in: .listItem).memoize() 246 | return InOrder( 247 | listItem, 248 | blankLine.repeating(0...) 249 | ).repeating(1...).wrapping(in: .list).property(key: ListTypeKey.self, value: type).memoize() 250 | } 251 | 252 | lazy var unorderedList = list(type: .unordered, openingDelimiter: unorderedListOpening) 253 | lazy var orderedList = list(type: .ordered, openingDelimiter: orderedListOpening) 254 | } 255 | 256 | private extension Character { 257 | var isSimpleEmoji: Bool { 258 | guard let firstScalar = unicodeScalars.first else { 259 | return false 260 | } 261 | return firstScalar.properties.isEmoji && firstScalar.value > 0x238C 262 | } 263 | 264 | var isCombinedIntoEmoji: Bool { 265 | unicodeScalars.count > 1 && unicodeScalars.first?.properties.isEmoji ?? false 266 | } 267 | 268 | var isEmoji: Bool { isSimpleEmoji || isCombinedIntoEmoji } 269 | } 270 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/NSAttributedString+Attributes.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Logging 19 | import UIKit 20 | 21 | private let logger = Logger(label: "org.brians-brain.AttributedStringAttributes") 22 | 23 | public typealias AttributedStringAttributes = [NSAttributedString.Key: Any] 24 | 25 | public extension NSAttributedString.Key { 26 | /// A UIColor to use when rendering a vertical bar on the leading edge of a block quote. 27 | static let blockquoteBorderColor = NSAttributedString.Key(rawValue: "verticalBarColor") 28 | } 29 | 30 | public struct AttributedStringAttributesDescriptor: Hashable { 31 | public init(textStyle: UIFont.TextStyle = .body, familyName: String? = nil, fontSize: CGFloat = 0, color: UIColor? = nil, backgroundColor: UIColor? = nil, blockquoteBorderColor: UIColor? = nil, kern: CGFloat = 0, bold: Bool = false, italic: Bool = false, headIndent: CGFloat = 0, firstLineHeadIndent: CGFloat = 0, alignment: NSTextAlignment? = nil, lineHeightMultiple: CGFloat = 0, listLevel: Int = 0, attachment: NSTextAttachment? = nil) { 32 | self.textStyle = textStyle 33 | self.familyName = familyName 34 | self.fontSize = fontSize 35 | self.color = color 36 | self.backgroundColor = backgroundColor 37 | self.blockquoteBorderColor = blockquoteBorderColor 38 | self.kern = kern 39 | self.bold = bold 40 | self.italic = italic 41 | self.headIndent = headIndent 42 | self.firstLineHeadIndent = firstLineHeadIndent 43 | self.alignment = alignment 44 | self.lineHeightMultiple = lineHeightMultiple 45 | self.listLevel = listLevel 46 | self.attachment = attachment 47 | } 48 | 49 | public var textStyle: UIFont.TextStyle = .body { 50 | didSet { 51 | fontSize = UIFont.preferredFont(forTextStyle: textStyle).pointSize 52 | } 53 | } 54 | 55 | public var familyName: String? 56 | public var fontSize: CGFloat = 0 57 | public var fontDesign: UIFontDescriptor.SystemDesign = .default 58 | public var color: UIColor? 59 | public var backgroundColor: UIColor? 60 | public var blockquoteBorderColor: UIColor? 61 | public var kern: CGFloat = 0 62 | public var bold: Bool = false 63 | public var italic: Bool = false 64 | public var headIndent: CGFloat = 0 65 | public var firstLineHeadIndent: CGFloat = 0 66 | public var alignment: NSTextAlignment? 67 | public var lineHeightMultiple: CGFloat = 0 68 | public var listLevel: Int = 0 69 | public var attachment: NSTextAttachment? 70 | 71 | public func makeAttributes() -> AttributedStringAttributes { 72 | var attributes: AttributedStringAttributes = [ 73 | .font: makeFont(), 74 | .paragraphStyle: makeParagraphStyle(), 75 | .kern: kern, 76 | ] 77 | color.flatMap { attributes[.foregroundColor] = $0 } 78 | backgroundColor.flatMap { attributes[.backgroundColor] = $0 } 79 | blockquoteBorderColor.flatMap { attributes[.blockquoteBorderColor] = $0 } 80 | attachment.flatMap { attributes[.attachment] = $0 } 81 | return attributes 82 | } 83 | 84 | private func makeFont() -> UIFont { 85 | var fontAttributes = [UIFontDescriptor.AttributeName: Any]() 86 | var size = fontSize 87 | // Set EITHER the family name or the text style, but not both 88 | if let familyName = familyName { 89 | fontAttributes[.family] = familyName 90 | if size == 0 { 91 | size = UIFont.preferredFont(forTextStyle: .body).pointSize 92 | } 93 | } else { 94 | fontAttributes[.textStyle] = textStyle 95 | if size == 0 { 96 | size = UIFont.preferredFont(forTextStyle: textStyle).pointSize 97 | } 98 | } 99 | var fontDescriptor = UIFontDescriptor(fontAttributes: fontAttributes).withDesignIfPossible(fontDesign) 100 | if italic { fontDescriptor = fontDescriptor.withSymbolicTraits(.traitItalic) ?? fontDescriptor } 101 | if bold { fontDescriptor = fontDescriptor.withSymbolicTraits(.traitBold) ?? fontDescriptor } 102 | return UIFont(descriptor: fontDescriptor, size: size) 103 | } 104 | 105 | private func makeParagraphStyle() -> NSParagraphStyle { 106 | let paragraphStyle = NSMutableParagraphStyle() 107 | paragraphStyle.headIndent = headIndent 108 | paragraphStyle.firstLineHeadIndent = firstLineHeadIndent 109 | alignment.flatMap { paragraphStyle.alignment = $0 } 110 | paragraphStyle.lineHeightMultiple = lineHeightMultiple 111 | if listLevel > 0 { 112 | let indentAmountPerLevel: CGFloat = headIndent > 0 ? headIndent : 16 113 | paragraphStyle.headIndent = indentAmountPerLevel * CGFloat(listLevel) 114 | paragraphStyle.firstLineHeadIndent = indentAmountPerLevel * CGFloat(listLevel - 1) 115 | var tabStops: [NSTextTab] = [] 116 | for i in 0 ..< 4 { 117 | let listTab = NSTextTab( 118 | textAlignment: .natural, 119 | location: paragraphStyle.headIndent + CGFloat(i) * indentAmountPerLevel, 120 | options: [:] 121 | ) 122 | tabStops.append(listTab) 123 | } 124 | paragraphStyle.tabStops = tabStops 125 | } 126 | return paragraphStyle 127 | } 128 | } 129 | 130 | /// Convenience extensions for working with an NSAttributedString attributes dictionary. 131 | public extension Dictionary where Key == NSAttributedString.Key, Value == Any { 132 | /// The font attribute. 133 | var font: UIFont { 134 | get { return (self[.font] as? UIFont) ?? UIFont.preferredFont(forTextStyle: .body) } 135 | set { self[.font] = newValue } 136 | } 137 | 138 | /// Setter only: Sets a dynamic font 139 | var textStyle: UIFont.TextStyle? { 140 | get { return nil } 141 | set { 142 | if let textStyle = newValue { 143 | self[.font] = UIFont.preferredFont(forTextStyle: textStyle) 144 | } else { 145 | self[.font] = nil 146 | } 147 | } 148 | } 149 | 150 | /// the font family name 151 | var familyName: String { 152 | get { 153 | return font.familyName 154 | } 155 | set { 156 | font = UIFont(descriptor: font.fontDescriptor.withoutStyle().withFamily(newValue), size: 0) 157 | } 158 | } 159 | 160 | var fontSize: CGFloat { 161 | get { 162 | return font.pointSize 163 | } 164 | set { 165 | font = UIFont(descriptor: font.fontDescriptor.withSize(newValue), size: 0) 166 | } 167 | } 168 | 169 | /// Text foreground color. 170 | var color: UIColor? { 171 | get { return self[.foregroundColor] as? UIColor } 172 | set { self[.foregroundColor] = newValue } 173 | } 174 | 175 | /// Text background color. 176 | var backgroundColor: UIColor? { 177 | get { return self[.backgroundColor] as? UIColor } 178 | set { self[.backgroundColor] = newValue } 179 | } 180 | 181 | /// A color to use when drawing a vertical bar to the left side of block quotes 182 | var blockquoteBorderColor: UIColor? { 183 | get { return self[.blockquoteBorderColor] as? UIColor } 184 | set { self[.blockquoteBorderColor] = newValue } 185 | } 186 | 187 | /// Desired letter spacing. 188 | var kern: CGFloat { 189 | get { return self[.kern] as? CGFloat ?? 0 } 190 | set { self[.kern] = newValue } 191 | } 192 | 193 | /// Whether the font is bold. 194 | var bold: Bool { 195 | get { return containsSymbolicTrait(.traitBold) } 196 | set { 197 | if newValue { 198 | symbolicTraitFormUnion(.traitBold) 199 | } else { 200 | symbolicTraitSubtract(.traitBold) 201 | } 202 | } 203 | } 204 | 205 | /// Whether the font is italic. 206 | var italic: Bool { 207 | get { return containsSymbolicTrait(.traitItalic) } 208 | set { 209 | if newValue { 210 | symbolicTraitFormUnion(.traitItalic) 211 | } else { 212 | symbolicTraitSubtract(.traitItalic) 213 | } 214 | } 215 | } 216 | 217 | /// Tests if the font contains a given symbolic trait. 218 | func containsSymbolicTrait(_ symbolicTrait: UIFontDescriptor.SymbolicTraits) -> Bool { 219 | return font.fontDescriptor.symbolicTraits.contains(symbolicTrait) 220 | } 221 | 222 | /// Sets a symbolic trait. 223 | mutating func symbolicTraitFormUnion(_ symbolicTrait: UIFontDescriptor.SymbolicTraits) { 224 | symbolicTraits = font.fontDescriptor.symbolicTraits.union(symbolicTrait) 225 | } 226 | 227 | /// Clears a symbolic trait. 228 | mutating func symbolicTraitSubtract(_ symbolicTrait: UIFontDescriptor.SymbolicTraits) { 229 | symbolicTraits = font.fontDescriptor.symbolicTraits.subtracting(symbolicTrait) 230 | } 231 | 232 | /// The symbolic traits for the font. Can be nil if there is no font. 233 | /// Attempts to set the symbolic traits to nil will be ignored. 234 | var symbolicTraits: UIFontDescriptor.SymbolicTraits { 235 | get { 236 | return font.fontDescriptor.symbolicTraits 237 | } 238 | set { 239 | guard let descriptor = font.fontDescriptor.withSymbolicTraits(newValue) 240 | else { 241 | logger.error("Unable to set \(String(describing: newValue)) on font: \(String(describing: font))") 242 | return 243 | } 244 | font = UIFont(descriptor: descriptor, size: 0) 245 | } 246 | } 247 | 248 | private var paragraphStyle: NSParagraphStyle? { 249 | get { return self[.paragraphStyle] as? NSParagraphStyle } 250 | set { self[.paragraphStyle] = newValue } 251 | } 252 | 253 | private var mutableParagraphStyle: NSMutableParagraphStyle { 254 | if let paragraphStyle = paragraphStyle { 255 | // swiftlint:disable:next force_cast 256 | return paragraphStyle.mutableCopy() as! NSMutableParagraphStyle 257 | } else { 258 | return NSMutableParagraphStyle() 259 | } 260 | } 261 | 262 | var headIndent: CGFloat { 263 | get { return paragraphStyle?.headIndent ?? 0 } 264 | set { 265 | let style = mutableParagraphStyle 266 | style.headIndent = newValue 267 | paragraphStyle = style 268 | } 269 | } 270 | 271 | var tailIndent: CGFloat { 272 | get { return paragraphStyle?.tailIndent ?? 0 } 273 | set { 274 | let style = mutableParagraphStyle 275 | style.tailIndent = newValue 276 | paragraphStyle = style 277 | } 278 | } 279 | 280 | var firstLineHeadIndent: CGFloat { 281 | get { return paragraphStyle?.firstLineHeadIndent ?? 0 } 282 | set { 283 | let style = mutableParagraphStyle 284 | style.firstLineHeadIndent = newValue 285 | paragraphStyle = style 286 | } 287 | } 288 | 289 | var alignment: NSTextAlignment { 290 | get { return paragraphStyle?.alignment ?? NSParagraphStyle.default.alignment } 291 | set { 292 | let style = mutableParagraphStyle 293 | style.alignment = newValue 294 | paragraphStyle = style 295 | } 296 | } 297 | 298 | var lineHeightMultiple: CGFloat { 299 | get { return paragraphStyle?.lineHeightMultiple ?? 0 } 300 | set { 301 | let style = mutableParagraphStyle 302 | style.lineHeightMultiple = newValue 303 | paragraphStyle = style 304 | } 305 | } 306 | 307 | var listLevel: Int { 308 | get { return self[.listLevel] as? Int ?? 0 } 309 | set { 310 | self[.listLevel] = newValue 311 | let indentAmountPerLevel: CGFloat = headIndent > 0 ? headIndent : 16 312 | let listStyling = mutableParagraphStyle 313 | if listLevel > 0 { 314 | listStyling.headIndent = indentAmountPerLevel * CGFloat(listLevel) 315 | listStyling.firstLineHeadIndent = indentAmountPerLevel * CGFloat(listLevel - 1) 316 | var tabStops: [NSTextTab] = [] 317 | for i in 0 ..< 4 { 318 | let listTab = NSTextTab( 319 | textAlignment: .natural, 320 | location: listStyling.headIndent + CGFloat(i) * indentAmountPerLevel, 321 | options: [:] 322 | ) 323 | tabStops.append(listTab) 324 | } 325 | listStyling.tabStops = tabStops 326 | } else { 327 | listStyling.headIndent = 0 328 | listStyling.firstLineHeadIndent = 0 329 | listStyling.tabStops = [] 330 | } 331 | paragraphStyle = listStyling 332 | } 333 | } 334 | } 335 | 336 | private extension UIFontDescriptor { 337 | /// Returns a copy of the receiver without any .textStyle attribute. 338 | /// .textStyle takes precedence over familyName, so you need to remove the attribute if you want to customize the family. 339 | func withoutStyle() -> UIFontDescriptor { 340 | var attributes = fontAttributes 341 | attributes.removeValue(forKey: .textStyle) 342 | return UIFontDescriptor(fontAttributes: attributes) 343 | } 344 | 345 | func withDesignIfPossible(_ design: SystemDesign) -> UIFontDescriptor { 346 | if let newDescriptor = withDesign(design) { 347 | return newDescriptor 348 | } else { 349 | return self 350 | } 351 | } 352 | } 353 | 354 | private extension NSAttributedString.Key { 355 | static let listLevel = NSAttributedString.Key(rawValue: "org.brians-brain.list-level") 356 | } 357 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/ParsedAttributedString.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import Logging 20 | import ObjectiveCTextStorageWrapper 21 | import os 22 | import UIKit 23 | 24 | private let log = OSLog(subsystem: "org.brians-brain.GrailDiary", category: "ParsedAttributedString") 25 | 26 | private extension Logging.Logger { 27 | static let attributedStringLogger: Logging.Logger = { 28 | var logger = Logger(label: "org.brians-brain.ParsedAttributedString") 29 | logger.logLevel = .info 30 | return logger 31 | }() 32 | } 33 | 34 | /// An NSMutableAttributedString subclass that: 35 | /// 36 | /// 1. Parses its contents based upon the rules of `grammar` 37 | /// 2. Determines the attributes and final contents of the string by applying `formattingFunctions` and `replacementFunctions` to the abstract syntax tree. 38 | /// 39 | /// `formattingFunctions` are fairly straightforward. These are functions that have an opportunity to modify the current string attributes for each node in the abstract syntax tree. The attributes will apply to all characters covered by that node. 40 | /// `replacementFunctions` are a little more complicated. They give an opportunity to *alter the actual string* based upon the nodes of the abstract syntax tree. For example, you can use replacement functions to hide the delimiters in Markdown text, or to replace spaces with tabs. 41 | /// 42 | /// The `string` property contains the contents **after** applying replacements. The `rawString` property contains the contents **before** applying replacements. Importantly, the `rawString` is what gets *parsed* in order to determine `string`. However, when calling `replaceCharacters(in:with:)`, the range is relative to the characters in `string`. The methods `rawStringRange(forRange:)` and `range(forRawStringRange:)` convert ranges between `string` and `rawString` 43 | @objc public final class ParsedAttributedString: WrappableTextStorage { 44 | public struct Style { 45 | public init( 46 | grammar: PackratGrammar, 47 | defaultAttributes: AttributedStringAttributesDescriptor, 48 | formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] 49 | ) { 50 | self.grammar = grammar 51 | self.defaultAttributes = defaultAttributes 52 | self.formatters = formatters 53 | } 54 | 55 | public var grammar: PackratGrammar 56 | public var defaultAttributes: AttributedStringAttributesDescriptor 57 | public var formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] 58 | } 59 | 60 | public convenience init(string: String, style: Style) { 61 | self.init( 62 | string: string, 63 | grammar: style.grammar, 64 | defaultAttributes: style.defaultAttributes, 65 | formatters: style.formatters 66 | ) 67 | } 68 | 69 | override public convenience init() { 70 | assertionFailure("Are you sure you want a plain-text attributed string?") 71 | self.init( 72 | grammar: PlainTextGrammar(), 73 | defaultAttributes: AttributedStringAttributesDescriptor(textStyle: .body, color: .label), 74 | formatters: [:] 75 | ) 76 | } 77 | 78 | public init( 79 | string: String = "", 80 | grammar: PackratGrammar, 81 | defaultAttributes: AttributedStringAttributesDescriptor, 82 | formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] 83 | ) { 84 | self.defaultAttributes = defaultAttributes 85 | self.formatters = formatters 86 | self.rawString = ParsedString(string, grammar: grammar) 87 | self._string = PieceTableString(pieceTable: PieceTable(rawString.text)) 88 | self.attributesArray = AttributesArray(attributesCache: attributesCache) 89 | super.init() 90 | if case .success(let node) = rawString.result { 91 | applyAttributes( 92 | to: node, 93 | attributes: defaultAttributes, 94 | startingIndex: 0, 95 | resultingAttributesArray: &attributesArray 96 | ) 97 | applyReplacements(in: node, startingIndex: 0, to: _string) 98 | } 99 | } 100 | 101 | @available(*, unavailable) 102 | required init?(coder: NSCoder) { 103 | fatalError("init(coder:) has not been implemented") 104 | } 105 | 106 | // MARK: - Stored properties 107 | 108 | /// The "raw" contents of the string. This is what is parsed, and determines what replacements get applied to determine the final contents. 109 | public let rawString: ParsedString 110 | 111 | /// The underlying NSString that backs `string`. 112 | private let _string: PieceTableString // swiftlint:disable:this identifier_name 113 | 114 | /// The contents of the string. This is derived from `rawString` after applying replacements. 115 | override public var string: String { _string as String } 116 | 117 | /// Access the underlying NSString through an API that won't get automatically bridged to `String`... 118 | override public func coreString() -> Any { 119 | _string 120 | } 121 | 122 | private let formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] 123 | 124 | /// Default attributes 125 | private let defaultAttributes: AttributedStringAttributesDescriptor 126 | 127 | private var attributesArray: AttributesArray 128 | 129 | /// Caches a mapping from descriptor to actual attributes for the lifetime of this ParsedAttributedString 130 | private let attributesCache = AttributesCache() 131 | 132 | /// Given a range in `string`, computes the equivalent range in `rawString` 133 | /// - note: Characters from a "replacement" are an atomic unit. If the input range overlaps with part of the characters in a replacement, the resulting range will encompass the entire replacement. 134 | public func rawStringRange(forRange visibleNSRange: NSRange) -> NSRange { 135 | let range = Range(visibleNSRange, in: _string.pieceTable)! 136 | let lowerBound = _string.pieceTable.findOriginalBound(.lowerBound, forBound: range.lowerBound) 137 | let upperBound = _string.pieceTable.findOriginalBound(.upperBound, forBound: range.upperBound) 138 | assert(upperBound >= lowerBound) 139 | return NSRange(location: lowerBound, length: upperBound - lowerBound) 140 | } 141 | 142 | /// Given a range in `rawString`, computes the equivalent range in `string` 143 | /// - note: Characters from a "replacement" are an atomic unit. If the input range overlaps with part of the characters in a replacement, the resulting range will encompass the entire replacement. 144 | public func range(forRawStringRange rawNSRange: NSRange) -> NSRange { 145 | let lowerBound = _string.pieceTable.findBound(.lowerBound, forOriginalBound: rawNSRange.lowerBound) 146 | let upperBound = _string.pieceTable.findBound(.upperBound, forOriginalBound: rawNSRange.upperBound) 147 | return NSRange(lowerBound ..< upperBound, in: _string.pieceTable) 148 | } 149 | 150 | /// Gets a subset of the available characters in storage. 151 | public subscript(range: NSRange) -> [unichar] { rawString[range] } 152 | 153 | /// Returns the path through the syntax tree to the leaf node that contains `index`. 154 | /// - returns: An array of nodes where the first element is the root, and each subsequent node descends one level to the leaf. 155 | public func path(to index: Int) throws -> [AnchoredNode] { 156 | let bufferRange = rawStringRange(forRange: NSRange(location: index, length: 0)) 157 | return try rawString.path(to: bufferRange.location) 158 | } 159 | 160 | /// Replaces the characters in the given range with the characters of the given string. 161 | override public func replaceCharacters(in range: NSRange, with str: String) { 162 | let lengthBeforeChanges = _string.length 163 | let bufferRange = rawStringRange(forRange: range) 164 | rawString.replaceCharacters( 165 | in: bufferRange, 166 | with: str 167 | ) 168 | _string.revertToOriginal() 169 | var newAttributes = AttributesArray(attributesCache: attributesCache) 170 | if case .success(let node) = rawString.result { 171 | os_signpost(.begin, log: log, name: "applyAttributes") 172 | applyAttributes( 173 | to: node, 174 | attributes: defaultAttributes, 175 | startingIndex: 0, 176 | resultingAttributesArray: &newAttributes 177 | ) 178 | os_signpost(.end, log: log, name: "applyAttributes") 179 | applyReplacements(in: node, startingIndex: 0, to: _string) 180 | } else { 181 | newAttributes.appendAttributes(defaultAttributes, length: _string.count) 182 | } 183 | // Deliver delegate messages 184 | Logger.attributedStringLogger.debug("Edit \(range) change in length \(_string.length - lengthBeforeChanges)") 185 | attributesArray.adjustLengthOfRun(at: range.location, by: _string.length - lengthBeforeChanges, defaultAttributes: defaultAttributes) 186 | // swiftlint:disable:next force_try 187 | let changedAttributesRange = (try! attributesArray.rangeOfAttributeDifferences(from: newAttributes)) ?? NSRange(location: range.location, length: 0) 188 | attributesArray = newAttributes 189 | delegate?.attributedStringDidChange( 190 | withOldRange: range, 191 | changeInLength: _string.length - lengthBeforeChanges, 192 | changedAttributesRange: changedAttributesRange 193 | ) 194 | } 195 | 196 | /// Returns the attributes for the character at a given index. 197 | /// - Parameters: 198 | /// - location: The index for which to return attributes. This value must lie within the bounds of the receiver. 199 | /// - range: Upon return, the range over which the attributes and values are the same as those at index. This range isn’t necessarily the maximum range covered, and its extent is implementation-dependent. 200 | /// - Returns: The attributes for the character at index. 201 | override public func attributes( 202 | at location: Int, 203 | effectiveRange range: NSRangePointer? 204 | ) -> [NSAttributedString.Key: Any] { 205 | return attributesArray.attributes(at: location, effectiveRange: range) 206 | } 207 | 208 | /// Sets the attributes for the characters in the specified range to the specified attributes. 209 | /// - Parameters: 210 | /// - attrs: A dictionary containing the attributes to set. 211 | /// - range: The range of characters whose attributes are set. 212 | override public func setAttributes( 213 | _ attrs: [NSAttributedString.Key: Any]?, 214 | range: NSRange 215 | ) { 216 | // IGNORE -- we do syntax highlighting 217 | } 218 | } 219 | 220 | // MARK: - Private 221 | 222 | private extension ParsedAttributedString { 223 | /// Associates AttributedStringAttributes with this part of the syntax tree. 224 | func applyAttributes( 225 | to node: SyntaxTreeNode, 226 | attributes: AttributedStringAttributesDescriptor, 227 | startingIndex: Int, 228 | resultingAttributesArray: inout AttributesArray 229 | ) { 230 | var attributes = attributes 231 | let initialAttributesArrayCount = resultingAttributesArray.count 232 | if let precomputedAttributes = node.attributedStringAttributes { 233 | attributes = precomputedAttributes 234 | } else { 235 | let formatter = formatters[node.type] ?? AnyParsedAttributedStringFormatter.passthrough 236 | let (newAttributes, replacementText) = formatter.formatNode(node, in: rawString, at: startingIndex, currentAttributes: attributes) 237 | attributes = newAttributes 238 | if let replacementText = replacementText { 239 | node.textReplacement = replacementText 240 | node.hasTextReplacement = true 241 | node.textReplacementChangeInLength = replacementText.count - node.length 242 | } else { 243 | node.hasTextReplacement = false 244 | } 245 | node.attributedStringAttributes = attributes 246 | } 247 | var childLength = 0 248 | if node.children.isEmpty || node.textReplacement != nil { 249 | // We are a leaf. Adjust leafNodeRange. 250 | resultingAttributesArray.appendAttributes(attributes, length: node.length + node.textReplacementChangeInLength) 251 | } 252 | if node.textReplacement != nil { 253 | return 254 | } 255 | var childTextReplacementChangeInLength = 0 256 | for child in node.children { 257 | applyAttributes( 258 | to: child, 259 | attributes: attributes, 260 | startingIndex: startingIndex + childLength, 261 | resultingAttributesArray: &resultingAttributesArray 262 | ) 263 | childLength += child.length 264 | childTextReplacementChangeInLength += child.textReplacementChangeInLength 265 | node.hasTextReplacement = node.hasTextReplacement || child.hasTextReplacement 266 | assert(childLength + childTextReplacementChangeInLength == resultingAttributesArray.count - initialAttributesArrayCount) 267 | } 268 | node.textReplacementChangeInLength = childTextReplacementChangeInLength 269 | assert(node.length + node.textReplacementChangeInLength == resultingAttributesArray.count - initialAttributesArrayCount) 270 | } 271 | 272 | func applyReplacements(in node: SyntaxTreeNode, startingIndex: Int, to string: NSMutableString) { 273 | guard node.hasTextReplacement else { return } 274 | if let replacement = node.textReplacement { 275 | string.replaceCharacters(in: NSRange(location: startingIndex, length: node.length), with: String(utf16CodeUnits: replacement, count: replacement.count)) 276 | } else { 277 | for (child, index) in node.childrenAndOffsets(startingAt: startingIndex).reversed() { 278 | applyReplacements(in: child, startingIndex: index, to: string) 279 | } 280 | } 281 | } 282 | } 283 | 284 | /// Key for storing the string attributes associated with a node. 285 | private struct NodeAttributesKey: SyntaxTreeNodePropertyKey { 286 | typealias Value = AttributedStringAttributesDescriptor 287 | 288 | static let key = "attributes" 289 | } 290 | 291 | private struct NodeTextReplacementKey: SyntaxTreeNodePropertyKey { 292 | typealias Value = [unichar] 293 | static let key = "textReplacement" 294 | } 295 | 296 | private struct NodeHasTextReplacementKey: SyntaxTreeNodePropertyKey { 297 | typealias Value = Bool 298 | static let key = "hasTextReplacement" 299 | } 300 | 301 | private struct NodeTextReplacementChangeInLengthKey: SyntaxTreeNodePropertyKey { 302 | typealias Value = Int 303 | static let key = "textReplacementChangeInLength" 304 | } 305 | 306 | private extension SyntaxTreeNode { 307 | /// The attributes associated with this node, if set. 308 | var attributedStringAttributes: AttributedStringAttributesDescriptor? { 309 | get { 310 | self[NodeAttributesKey.self] 311 | } 312 | set { 313 | self[NodeAttributesKey.self] = newValue 314 | } 315 | } 316 | 317 | var textReplacement: [unichar]? { 318 | get { 319 | self[NodeTextReplacementKey.self] 320 | } 321 | set { 322 | self[NodeTextReplacementKey.self] = newValue 323 | } 324 | } 325 | 326 | var hasTextReplacement: Bool { 327 | get { 328 | self[NodeHasTextReplacementKey.self] ?? false 329 | } 330 | set { 331 | self[NodeHasTextReplacementKey.self] = newValue 332 | } 333 | } 334 | 335 | var textReplacementChangeInLength: Int { 336 | get { 337 | self[NodeTextReplacementChangeInLengthKey.self] ?? 0 338 | } 339 | set { 340 | self[NodeTextReplacementChangeInLengthKey.self] = newValue 341 | } 342 | } 343 | 344 | func childrenAndOffsets(startingAt offset: Int) -> [(child: SyntaxTreeNode, offset: Int)] { 345 | var offset = offset 346 | var results = [(child: SyntaxTreeNode, offset: Int)]() 347 | for child in children { 348 | results.append((child: child, offset: offset)) 349 | offset += child.length 350 | } 351 | return results 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/ParsedAttributedStringFormatter.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import UIKit 19 | 20 | /// Determines the attributes and optional replacement text for parsed text in a string. 21 | public protocol ParsedAttributedStringFormatter { 22 | func formatNode( 23 | _ node: SyntaxTreeNode, 24 | in buffer: SafeUnicodeBuffer, 25 | at offset: Int, 26 | currentAttributes: AttributedStringAttributesDescriptor 27 | ) -> (attributes: AttributedStringAttributesDescriptor, replacementCharacters: [unichar]?) 28 | } 29 | 30 | /// Can perform simple attribute modifications and string substitutions that do not depend upon the actual contents of the parsed string. 31 | public struct AnyParsedAttributedStringFormatter: ParsedAttributedStringFormatter { 32 | public init( 33 | _ wrappedFormatter: ParsedAttributedStringFormatter? = nil, 34 | substitution: String? = nil, 35 | formattingFunction: @escaping (inout AttributedStringAttributesDescriptor) -> Void = { _ in /* nothing */ } 36 | ) { 37 | self.wrappedFormatter = wrappedFormatter 38 | self.substitution = substitution 39 | self.formattingFunction = formattingFunction 40 | } 41 | 42 | public let wrappedFormatter: ParsedAttributedStringFormatter? 43 | 44 | /// The substitution string to use for this node, or nil if the text should remain unchanged. 45 | public let substitution: String? 46 | 47 | /// Modifies the current attributes. 48 | public let formattingFunction: (inout AttributedStringAttributesDescriptor) -> Void 49 | 50 | public func formatNode( 51 | _ node: SyntaxTreeNode, 52 | in buffer: SafeUnicodeBuffer, 53 | at offset: Int, 54 | currentAttributes: AttributedStringAttributesDescriptor 55 | ) -> (attributes: AttributedStringAttributesDescriptor, replacementCharacters: [unichar]?) { 56 | var (attributes, replacementCharacters) = wrappedFormatter?.formatNode(node, in: buffer, at: offset, currentAttributes: currentAttributes) ?? (currentAttributes, nil) 57 | formattingFunction(&attributes) 58 | return (attributes, replacementCharacters ?? substitution.flatMap { Array($0.utf16) }) 59 | } 60 | } 61 | 62 | /// Some common formatters. 63 | public extension AnyParsedAttributedStringFormatter { 64 | /// A simple formatter that does nothing. 65 | static let passthrough = AnyParsedAttributedStringFormatter() 66 | 67 | static var toggleItalic: AnyParsedAttributedStringFormatter { 68 | AnyParsedAttributedStringFormatter { $0.italic.toggle() } 69 | } 70 | 71 | static var toggleBold: AnyParsedAttributedStringFormatter { 72 | AnyParsedAttributedStringFormatter { $0.bold.toggle() } 73 | } 74 | 75 | static func fontDesign(_ fontDesign: UIFontDescriptor.SystemDesign) -> AnyParsedAttributedStringFormatter { 76 | AnyParsedAttributedStringFormatter { $0.fontDesign = fontDesign } 77 | } 78 | 79 | static var remove: AnyParsedAttributedStringFormatter { 80 | AnyParsedAttributedStringFormatter(substitution: "") 81 | } 82 | 83 | static var incrementListLevel: AnyParsedAttributedStringFormatter { 84 | AnyParsedAttributedStringFormatter { $0.listLevel += 1 } 85 | } 86 | 87 | static func color(_ color: UIColor?) -> AnyParsedAttributedStringFormatter { 88 | AnyParsedAttributedStringFormatter { $0.color = color } 89 | } 90 | 91 | static func backgroundColor(_ color: UIColor?) -> AnyParsedAttributedStringFormatter { 92 | AnyParsedAttributedStringFormatter { $0.backgroundColor = color } 93 | } 94 | 95 | static func substitute(_ substitution: String) -> AnyParsedAttributedStringFormatter { 96 | AnyParsedAttributedStringFormatter(substitution: substitution) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/ParsedString.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | /// An NSMutableString subclass that parses its contents using the rules of `grammar` and makes 21 | /// the abstract syntax tree available through `result` 22 | @objc public final class ParsedString: NSMutableString { 23 | override public convenience init() { 24 | assertionFailure() 25 | self.init("", grammar: MiniMarkdownGrammar()) 26 | } 27 | 28 | override public convenience init(capacity: Int) { 29 | assertionFailure() 30 | self.init("", grammar: MiniMarkdownGrammar()) 31 | } 32 | 33 | public init(_ string: String, grammar: PackratGrammar) { 34 | let pieceTable = PieceTableString(pieceTable: PieceTable(string)) 35 | self.grammar = grammar 36 | let memoizationTable = MemoizationTable(grammar: grammar) 37 | let result = Result { 38 | try memoizationTable.parseBuffer(pieceTable) 39 | } 40 | self.text = pieceTable 41 | self.memoizationTable = memoizationTable 42 | self.result = result 43 | super.init() 44 | } 45 | 46 | @available(*, unavailable) 47 | required init?(coder: NSCoder) { 48 | fatalError("init(coder:) has not been implemented") 49 | } 50 | 51 | public let text: PieceTableString 52 | private let memoizationTable: MemoizationTable 53 | private let grammar: PackratGrammar 54 | public private(set) var result: Result 55 | 56 | /// Returns the path through the syntax tree to the leaf node that contains `index`. 57 | /// - returns: An array of nodes where the first element is the root, and each subsequent node descends one level to the leaf. 58 | public func path(to index: Int) throws -> [AnchoredNode] { 59 | guard let root = try? result.get() else { return [] } 60 | return try root.path(to: index) 61 | } 62 | 63 | #if DEBUG 64 | public enum ValidationError: Error { 65 | case unparsedText(String) 66 | case validationError(String) 67 | } 68 | 69 | @discardableResult 70 | public func parsedResultsThatMatch( 71 | _ expectedStructure: String 72 | ) throws -> SyntaxTreeNode { 73 | let tree = try result.get() 74 | if tree.length != count { 75 | let unparsedText = text[NSRange(location: tree.length, length: text.count - tree.length)] 76 | throw ValidationError.unparsedText(String(utf16CodeUnits: unparsedText, count: unparsedText.count)) 77 | } 78 | if expectedStructure != tree.compactStructure { 79 | let errorMessage = """ 80 | Got: \(tree.compactStructure) 81 | Expected: \(expectedStructure) 82 | 83 | \(tree.debugDescription(withContentsFrom: text)) 84 | 85 | \(TraceBuffer.shared) 86 | """ 87 | throw ValidationError.validationError(errorMessage) 88 | } 89 | return tree 90 | } 91 | #endif 92 | 93 | public var parsedContents: String { 94 | guard let root = try? result.get() else { return "" } 95 | return root.debugDescription(withContentsFrom: self) 96 | } 97 | } 98 | 99 | extension ParsedString: RangeReplaceableSafeUnicodeBuffer { 100 | public typealias Index = PieceTable.Index 101 | 102 | public var count: Int { text.count } 103 | 104 | public subscript(range: NSRange) -> [unichar] { text[range] } 105 | 106 | public func utf16(at index: Int) -> unichar? { 107 | return text.utf16(at: index) 108 | } 109 | 110 | public func character(at index: Int) -> Character? { 111 | return text.character(at: index) 112 | } 113 | 114 | override public func replaceCharacters(in range: NSRange, with str: String) { 115 | text.replaceCharacters(in: range, with: str) 116 | memoizationTable.applyEdit(originalRange: range, replacementLength: str.utf16.count) 117 | result = Result { 118 | try memoizationTable.parseBuffer(text) 119 | } 120 | } 121 | 122 | public var string: String { text.string } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/PieceTableString.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import Logging 20 | 21 | private let logger = Logger(label: "PieceTableString") 22 | 23 | /// An NSMutableString subclass that uses a PieceTable for its underlying storage. 24 | @objc public class PieceTableString: NSMutableString { 25 | /// The underlying storage. Public so mutations can happen directly to its contents. 26 | public private(set) var pieceTable: PieceTable 27 | 28 | override public init() { 29 | self.pieceTable = PieceTable() 30 | super.init() 31 | } 32 | 33 | override public init(capacity: Int) { 34 | self.pieceTable = PieceTable() 35 | super.init() 36 | } 37 | 38 | init(pieceTable: PieceTable) { 39 | self.pieceTable = pieceTable 40 | super.init() 41 | } 42 | 43 | @available(*, unavailable) 44 | required init?(coder: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | 48 | override public var length: Int { 49 | return pieceTable.count 50 | } 51 | 52 | override public func character(at index: Int) -> unichar { 53 | pieceTable[pieceTable.index(pieceTable.startIndex, offsetBy: index)] 54 | } 55 | 56 | override public func getCharacters(_ buffer: UnsafeMutablePointer, range: NSRange) { 57 | let nativeRange = Range(range, in: pieceTable)! 58 | pieceTable.copyCharacters(at: nativeRange, to: buffer) 59 | } 60 | 61 | override public func replaceCharacters(in range: NSRange, with aString: String) { 62 | pieceTable.replaceSubrange(Range(range, in: pieceTable)!, with: aString.utf16) 63 | } 64 | 65 | public func revertToOriginal() { pieceTable.revertToOriginal() } 66 | } 67 | 68 | extension PieceTableString: SafeUnicodeBuffer { 69 | public var count: Int { length } 70 | 71 | public func utf16(at index: Int) -> unichar? { 72 | if index >= length { return nil } 73 | return character(at: index) 74 | } 75 | 76 | public func character(at index: Int) -> Character? { 77 | if index >= length { return nil } 78 | return pieceTable.character(at: index) 79 | } 80 | 81 | public subscript(range: NSRange) -> [unichar] { pieceTable[range] } 82 | 83 | public var string: String { pieceTable.string } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/PlainTextGrammar.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | public extension SyntaxTreeNodeType { 21 | static let plainText: SyntaxTreeNodeType = "plain-text" 22 | } 23 | 24 | /// Just interprets all text as "plain-text" 25 | public struct PlainTextGrammar: PackratGrammar { 26 | public let start: ParsingRule = DotRule().repeating(0...).wrapping(in: .plainText) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/SyntaxTreeNode.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | extension SyntaxTreeNodeType { 21 | static let documentFragment: SyntaxTreeNodeType = "{{fragment}}" 22 | } 23 | 24 | /// A key for associating values of a specific type with a node. 25 | public protocol SyntaxTreeNodePropertyKey { 26 | associatedtype Value 27 | 28 | /// The string key used to identify the value in the property bag. 29 | static var key: String { get } 30 | 31 | /// Type-safe accessor for getting the value from the property bag. 32 | static func getProperty(from bag: [String: Any]?) -> Value? 33 | 34 | /// Type-safe setter for the value in the property bag. 35 | static func setProperty(_ value: Value, in bag: inout [String: Any]?) 36 | } 37 | 38 | /// Default implementation of getter / setter. 39 | public extension SyntaxTreeNodePropertyKey { 40 | static func getProperty(from bag: [String: Any]?) -> Value? { 41 | guard let bag = bag else { return nil } 42 | if let value = bag[key] { 43 | return (value as! Value) // swiftlint:disable:this force_cast 44 | } else { 45 | return nil 46 | } 47 | } 48 | 49 | static func setProperty(_ value: Value, in bag: inout [String: Any]?) { 50 | if bag == nil { 51 | bag = [key: value] 52 | } else { 53 | bag?[key] = value 54 | } 55 | } 56 | } 57 | 58 | public protocol SyntaxTreeNodeChildren: Sequence { 59 | mutating func removeFirst() 60 | mutating func removeLast() 61 | mutating func append(_ node: SyntaxTreeNode) 62 | mutating func merge(with newElements: Self) 63 | var count: Int { get } 64 | var isEmpty: Bool { get } 65 | var first: SyntaxTreeNode? { get } 66 | var last: SyntaxTreeNode? { get } 67 | } 68 | 69 | /// Holds a SyntaxTreeNode in a doubly-linked list. 70 | private final class DoublyLinkedNode { 71 | init(value: SyntaxTreeNode, next: DoublyLinkedNode? = nil, previous: DoublyLinkedNode? = nil) { 72 | self.value = value 73 | self.next = next 74 | self.previous = previous 75 | } 76 | 77 | let value: SyntaxTreeNode 78 | var next: DoublyLinkedNode? 79 | weak var previous: DoublyLinkedNode? 80 | } 81 | 82 | /// A specific DoublyLinkedList of SyntaxTreeNode values. 83 | public final class DoublyLinkedList: SyntaxTreeNodeChildren { 84 | public var count: Int = 0 85 | 86 | // swiftlint:disable:next empty_count 87 | public var isEmpty: Bool { count == 0 } 88 | 89 | private var head: DoublyLinkedNode? 90 | private weak var tail: DoublyLinkedNode? 91 | 92 | public func removeFirst() { 93 | count -= 1 94 | if head === tail { 95 | // swiftlint:disable:next empty_count 96 | assert(count == 0) 97 | head = nil 98 | tail = nil 99 | } else { 100 | let next = head?.next 101 | next?.previous = nil 102 | head = next 103 | } 104 | assert(map { _ in 1 }.reduce(0, +) == count) 105 | } 106 | 107 | public func removeLast() { 108 | count -= 1 109 | if head === tail { 110 | // swiftlint:disable:next empty_count 111 | assert(count == 0) 112 | head = nil 113 | tail = nil 114 | } else { 115 | let previous = tail?.previous 116 | previous?.next = nil 117 | tail = previous 118 | } 119 | assert(map { _ in 1 }.reduce(0, +) == count) 120 | } 121 | 122 | public func append(_ node: SyntaxTreeNode) { 123 | count += 1 124 | if tail == nil { 125 | let newNode = DoublyLinkedNode(value: node, next: nil, previous: nil) 126 | tail = newNode 127 | head = newNode 128 | } else { 129 | let newNode = DoublyLinkedNode(value: node, next: nil, previous: tail) 130 | tail?.next = newNode 131 | tail = newNode 132 | } 133 | assert(map { _ in 1 }.reduce(0, +) == count) 134 | } 135 | 136 | public func append(contentsOf sequence: S) where S.Element == SyntaxTreeNode { 137 | for otherElement in sequence { 138 | append(otherElement) 139 | } 140 | } 141 | 142 | /// Combine two DoublyLinkedLists in O(1) time. 143 | /// - note: This is destructive. Both the receiver and `newElements` will be the same merged list at the end. 144 | public func merge(with newElements: DoublyLinkedList) { 145 | // swiftlint:disable empty_count 146 | if tail == nil { 147 | assert(count == 0 && head == nil) 148 | head = newElements.head 149 | tail = newElements.tail 150 | count = newElements.count 151 | } else if newElements.head == nil { 152 | assert(newElements.count == 0) 153 | // DO NOTHING 154 | } else { 155 | count += newElements.count 156 | tail?.next = newElements.head 157 | newElements.head?.previous = tail 158 | tail = newElements.tail 159 | } 160 | assert(map { _ in 1 }.reduce(0, +) == count) 161 | } 162 | 163 | public var first: SyntaxTreeNode? { head?.value } 164 | public var last: SyntaxTreeNode? { tail?.value } 165 | } 166 | 167 | extension DoublyLinkedList: Sequence { 168 | public struct Iterator: IteratorProtocol { 169 | fileprivate var node: DoublyLinkedNode? 170 | 171 | public mutating func next() -> SyntaxTreeNode? { 172 | let item = node?.value 173 | node = node?.next 174 | return item 175 | } 176 | } 177 | 178 | public func makeIterator() -> Iterator { 179 | return Iterator(node: head) 180 | } 181 | } 182 | 183 | /// A node in the markup language's syntax tree. 184 | public final class SyntaxTreeNode: CustomStringConvertible { 185 | public init(type: SyntaxTreeNodeType, length: Int = 0) { 186 | self.type = type 187 | self.length = length 188 | } 189 | 190 | public static func makeFragment() -> SyntaxTreeNode { 191 | return SyntaxTreeNode(type: .documentFragment, length: 0) 192 | } 193 | 194 | /// The type of this node. 195 | public var type: SyntaxTreeNodeType 196 | 197 | /// If true, this node should be considered a "fragment" (an ordered list of nodes without a root) 198 | public var isFragment: Bool { 199 | return type === SyntaxTreeNodeType.documentFragment 200 | } 201 | 202 | /// The length of the original text covered by this node (and all children). 203 | /// We only store the length so nodes can be efficiently reused while editing text, but it does mean you need to 204 | /// build up context (start position) by walking the parse tree. 205 | public var length: Int { 206 | willSet { 207 | assert(!frozen) 208 | } 209 | } 210 | 211 | #if DEBUG 212 | @discardableResult 213 | public func validateLength() throws -> Int { 214 | // If we are a leaf, we are valid. 215 | if children.isEmpty { return length } 216 | 217 | // Otherwise, first make sure each child has a valid length. 218 | let childValidatedLength = try children.map { try $0.validateLength() }.reduce(0, +) 219 | if childValidatedLength != length { 220 | throw MachError(.invalidValue) 221 | } 222 | assert(childValidatedLength == length) 223 | return length 224 | } 225 | #endif 226 | 227 | /// We do a couple of tree-construction optimizations that mutate existing nodes that don't "belong" to us 228 | private var disconnectedFromResult = false 229 | 230 | /// Children of this node. 231 | public var children = DoublyLinkedList() { 232 | willSet { 233 | assert(!frozen) 234 | } 235 | } 236 | 237 | public var treeSize: Int { 238 | 1 + children.map { $0.treeSize }.reduce(0, +) 239 | } 240 | 241 | /// Set to true when we expect no more changes to this node. 242 | public private(set) var frozen = false 243 | 244 | public func freeze() { 245 | frozen = true 246 | } 247 | 248 | public func appendChild(_ child: SyntaxTreeNode) { 249 | assert(!frozen) 250 | length += child.length 251 | if child.isFragment { 252 | #if DEBUG 253 | try! child.validateLength() // swiftlint:disable:this force_try 254 | #endif 255 | 256 | let fragmentNodes = child.children 257 | var mergedChildNodes = false 258 | if let last = children.last, let first = fragmentNodes.first, last.children.isEmpty, first.children.isEmpty, last.type == first.type { 259 | // The last of our children & the first of the fragment's children have the same type. Rather than creating two nodes 260 | // of the same type, just change the length of our node. 261 | // TODO: Perhaps this can be done in a cleaner way inside of DoublyLinkedList.append(contentsOf:)? 262 | incrementLastChildNodeLength(by: first.length) 263 | mergedChildNodes = true 264 | } 265 | children.append(contentsOf: fragmentNodes.dropFirst(mergedChildNodes ? 1 : 0)) 266 | 267 | #if DEBUG 268 | try! validateLength() // swiftlint:disable:this force_try 269 | #endif 270 | 271 | } else { 272 | // Special optimization: Adding a terminal node of the same type of the last terminal node 273 | // can just be a range update. 274 | if let lastNode = children.last, lastNode.children.isEmpty, child.children.isEmpty, lastNode.type == child.type { 275 | incrementLastChildNodeLength(by: child.length) 276 | } else { 277 | children.append(child) 278 | } 279 | } 280 | } 281 | 282 | private func incrementLastChildNodeLength(by length: Int) { 283 | guard let last = children.last else { return } 284 | assert(!frozen) 285 | precondition(last.children.isEmpty) 286 | if last.disconnectedFromResult { 287 | last.length += length 288 | } else { 289 | let copy = SyntaxTreeNode(type: last.type, length: last.length + length) 290 | copy.disconnectedFromResult = true 291 | children.removeLast() 292 | children.append(copy) 293 | } 294 | } 295 | 296 | /// True if this node corresponds to no text in the input buffer. 297 | public var isEmpty: Bool { 298 | return length == 0 299 | } 300 | 301 | public var description: String { 302 | "Node: \(length) \(compactStructure)" 303 | } 304 | 305 | /// Walks down the tree of nodes to find a specific node. 306 | public func node(at indexPath: IndexPath) -> SyntaxTreeNode? { 307 | if indexPath.isEmpty { return self } 308 | let nextChild = children.dropFirst(indexPath[0]).first(where: { _ in true }) 309 | assert(nextChild != nil) 310 | return nextChild?.node(at: indexPath.dropFirst()) 311 | } 312 | 313 | /// Enumerates all nodes in the tree in depth-first order. 314 | /// - parameter startIndex: The index at which this node starts. (An AnchoredNode knows this, but a NewNode does not and needs to be told.) 315 | /// - parameter block: The first parameter is the node, the second parameter is the start index of the node, and set the third parameter to `false` to stop enumeration. 316 | public func forEach(startIndex: Int = 0, block: (SyntaxTreeNode, Int, inout Bool) -> Void) { 317 | var stop = false 318 | forEach(stop: &stop, startIndex: startIndex, block: block) 319 | } 320 | 321 | private func forEach(stop: inout Bool, startIndex: Int, block: (SyntaxTreeNode, Int, inout Bool) -> Void) { 322 | guard !stop else { return } 323 | block(self, startIndex, &stop) 324 | var startIndex = startIndex 325 | for child in children where !stop { 326 | child.forEach(stop: &stop, startIndex: startIndex, block: block) 327 | startIndex += child.length 328 | } 329 | } 330 | 331 | public func findNodes(where predicate: (SyntaxTreeNode) -> Bool) -> [SyntaxTreeNode] { 332 | var results = [SyntaxTreeNode]() 333 | forEach { node, _, _ in 334 | if predicate(node) { results.append(node) } 335 | } 336 | return results 337 | } 338 | 339 | public func forEachPath(startIndex: Int = 0, block: ([SyntaxTreeNode], Int, inout Bool) -> Void) { 340 | var stop = false 341 | forEachPath(stop: &stop, incomingPath: [], startIndex: startIndex, block: block) 342 | } 343 | 344 | private func forEachPath(stop: inout Bool, incomingPath: [SyntaxTreeNode], startIndex: Int, block: ([SyntaxTreeNode], Int, inout Bool) -> Void) { 345 | guard !stop else { return } 346 | var currentPath = incomingPath 347 | currentPath.append(self) 348 | block(currentPath, startIndex, &stop) 349 | var startIndex = startIndex 350 | for child in children where !stop { 351 | child.forEachPath(stop: &stop, incomingPath: currentPath, startIndex: startIndex, block: block) 352 | startIndex += child.length 353 | } 354 | } 355 | 356 | /// Returns the first (depth-first) node where `predicate` returns true. 357 | public func first(where predicate: (SyntaxTreeNode) -> Bool) -> SyntaxTreeNode? { 358 | var result: SyntaxTreeNode? 359 | forEach { node, _, stop in 360 | if predicate(node) { 361 | result = node 362 | stop = true 363 | } 364 | } 365 | return result 366 | } 367 | 368 | public enum NodeSearchError: Error { 369 | case indexOutOfRange 370 | } 371 | 372 | /// Walks down the tree and returns the leaf node that contains a specific index. 373 | /// - returns: The leaf node containing the index. 374 | /// - throws: NodeSearchError.indexOutOfRange if the index is not valid. 375 | public func leafNode(containing index: Int) throws -> AnchoredNode { 376 | return try leafNode(containing: index, startIndex: 0) 377 | } 378 | 379 | private func leafNode( 380 | containing index: Int, 381 | startIndex: Int 382 | ) throws -> AnchoredNode { 383 | guard index < startIndex + length else { 384 | throw NodeSearchError.indexOutOfRange 385 | } 386 | if children.isEmpty { return AnchoredNode(node: self, startIndex: startIndex) } 387 | var childIndex = startIndex 388 | for child in children { 389 | if index < childIndex + child.length { 390 | return try child.leafNode(containing: index, startIndex: childIndex) 391 | } 392 | childIndex += child.length 393 | } 394 | throw NodeSearchError.indexOutOfRange 395 | } 396 | 397 | /// Returns the path through the syntax tree to the leaf node that contains `index`. 398 | /// - returns: An array of nodes where the first element is the root, and each subsequent node descends one level to the leaf. 399 | public func path(to index: Int) throws -> [AnchoredNode] { 400 | var results = [AnchoredNode]() 401 | try path(to: index, startIndex: 0, results: &results) 402 | return results 403 | } 404 | 405 | private func path(to index: Int, startIndex: Int, results: inout [AnchoredNode]) throws { 406 | guard (startIndex ... startIndex + length).contains(index) else { 407 | throw MachError(.invalidArgument) 408 | } 409 | results.append(AnchoredNode(node: self, startIndex: startIndex)) 410 | if children.isEmpty { return } 411 | if index == startIndex + length, let lastChild = children.last { 412 | // If we get here, it's because index == startIndex.length. Give this to the last child. 413 | let lastChildIndex = startIndex + length - lastChild.length 414 | try lastChild.path(to: index, startIndex: lastChildIndex, results: &results) 415 | } else { 416 | var childIndex = startIndex 417 | for child in children { 418 | if index < childIndex + child.length { 419 | try child.path(to: index, startIndex: childIndex, results: &results) 420 | return 421 | } 422 | childIndex += child.length 423 | } 424 | } 425 | } 426 | 427 | // MARK: - Properties 428 | 429 | /// Lazily-allocated property bag. 430 | private var propertyBag: [String: Any]? 431 | 432 | /// Type-safe property accessor. 433 | public subscript(key: K.Type) -> K.Value? { 434 | get { 435 | return key.getProperty(from: propertyBag) 436 | } 437 | set { 438 | if let value = newValue { 439 | key.setProperty(value, in: &propertyBag) 440 | } else { 441 | propertyBag?.removeValue(forKey: key.key) 442 | } 443 | } 444 | } 445 | } 446 | 447 | /// An "Anchored" node simply contains a node and its starting position in the text. (Start location isn't part of Node because we 448 | /// reuse nodes across edits.) 449 | /// This is a class and not a struct because structs-that-contain-reference-types can't easily provide value semantics. 450 | public final class AnchoredNode { 451 | public let node: SyntaxTreeNode 452 | public let startIndex: Int 453 | 454 | public init(node: SyntaxTreeNode, startIndex: Int) { 455 | self.node = node 456 | self.startIndex = startIndex 457 | } 458 | 459 | /// Convenience mechanism for getting the range covered by this node. 460 | public var range: NSRange { NSRange(location: startIndex, length: node.length) } 461 | 462 | /// Enumerates all of the nodes, depth-first. 463 | /// - parameter block: Receives the node, the start index of the node. Set the Bool to `false` to stop enumeration. 464 | public func forEach(_ block: (SyntaxTreeNode, Int, inout Bool) -> Void) { 465 | node.forEach(startIndex: startIndex, block: block) 466 | } 467 | 468 | public func forEachPath(_ block: ([AnchoredNode], inout Bool) -> Void) { 469 | var stop = false 470 | forEachPath(stop: &stop, path: [], block: block) 471 | } 472 | 473 | private func forEachPath(stop: inout Bool, path: [AnchoredNode], block: ([AnchoredNode], inout Bool) -> Void) { 474 | guard !stop else { return } 475 | var path = path 476 | path.append(self) 477 | block(path, &stop) 478 | var childIndex = startIndex 479 | for child in node.children where !stop { 480 | let anchoredChild = AnchoredNode(node: child, startIndex: childIndex) 481 | anchoredChild.forEachPath(stop: &stop, path: path, block: block) 482 | childIndex += child.length 483 | } 484 | } 485 | 486 | /// Returns the first AnchoredNode that matches a predicate. 487 | public func first(where predicate: (SyntaxTreeNode) -> Bool) -> AnchoredNode? { 488 | var result: AnchoredNode? 489 | node.forEach(startIndex: startIndex) { candidate, index, stop in 490 | if predicate(candidate) { 491 | result = AnchoredNode(node: candidate, startIndex: index) 492 | stop = true 493 | } 494 | } 495 | return result 496 | } 497 | 498 | public func findNodes(where predicate: (SyntaxTreeNode) -> Bool) -> [AnchoredNode] { 499 | var results = [AnchoredNode]() 500 | node.forEach(startIndex: startIndex) { child, startIndex, _ in 501 | if predicate(child) { 502 | results.append(AnchoredNode(node: child, startIndex: startIndex)) 503 | } 504 | } 505 | return results 506 | } 507 | } 508 | 509 | // MARK: - Debugging support 510 | 511 | extension SyntaxTreeNode { 512 | /// Returns the structure of this node as a compact s-expression. 513 | /// For example, `(document ((header text) blank_line paragraph blank_line paragraph)` 514 | public var compactStructure: String { 515 | var results = "" 516 | writeCompactStructure(to: &results) 517 | return results 518 | } 519 | 520 | /// Recursive helper for generating `compactStructure` 521 | private func writeCompactStructure(to buffer: inout String) { 522 | if children.isEmpty { 523 | buffer.append(type.rawValue) 524 | } else { 525 | buffer.append("(") 526 | buffer.append(type.rawValue) 527 | buffer.append(" ") 528 | for (index, child) in children.enumerated() { 529 | if index > 0 { 530 | buffer.append(" ") 531 | } 532 | child.writeCompactStructure(to: &buffer) 533 | } 534 | buffer.append(")") 535 | } 536 | } 537 | 538 | /// Returns the syntax tree and which parts of `textBuffer` the leaf nodes correspond to. 539 | public func debugDescription(withContentsFrom pieceTable: SafeUnicodeBuffer) -> String { 540 | var lines = "" 541 | writeDebugDescription(to: &lines, pieceTable: pieceTable, location: 0, indentLevel: 0) 542 | return lines 543 | } 544 | 545 | /// Recursive helper function for `debugDescription(of:)` 546 | private func writeDebugDescription( 547 | to lines: inout Target, 548 | pieceTable: SafeUnicodeBuffer, 549 | location: Int, 550 | indentLevel: Int 551 | ) { 552 | var result = String(repeating: " ", count: 2 * indentLevel) 553 | result.append("{\(location), \(length)}") 554 | result.append(type.rawValue) 555 | result.append(": ") 556 | if children.isEmpty { 557 | let chars = pieceTable[NSRange(location: location, length: length)] 558 | let str = String(utf16CodeUnits: chars, count: chars.count) 559 | result.append(str.debugDescription) 560 | } 561 | lines.write(result) 562 | lines.write("\n") 563 | var childLocation = location 564 | for child in children { 565 | child.writeDebugDescription(to: &lines, pieceTable: pieceTable, location: childLocation, indentLevel: indentLevel + 1) 566 | childLocation += child.length 567 | } 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/SyntaxTreeNodeType.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | /// Opaque class representing the type of a markup node. 21 | public final class SyntaxTreeNodeType: RawRepresentable, ExpressibleByStringLiteral, Hashable, CustomStringConvertible, Sendable { 22 | public init(rawValue: String) { 23 | self.rawValue = rawValue 24 | } 25 | 26 | public init(stringLiteral value: String) { 27 | self.rawValue = value 28 | } 29 | 30 | public let rawValue: String 31 | 32 | public var description: String { rawValue } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/TextMarkupKit.docc/TextMarkupKit.md: -------------------------------------------------------------------------------- 1 | # TextMarkupKit 2 | 3 | Many iOS applications give you the ability to write plain text in a `UITextView` and format that text based upon simple rules. TextMarkupKit makes it easy to add "format as you type" capabilities to any iOS application. 4 | 5 | 6 | It consists of several interrelated components: 7 | 8 | 1. One set of components let you write a [Parsing Expression Grammar](https://en.wikipedia.org/wiki/Parsing_expression_grammar) to define how to parse the user's input. Because writing grammars is hard, TextMarkupKit lets you design "composable grammars." If there is an existing grammar that *almost* provides what you want, you can extend it with additional rules rather than write a new grammar from scratch. TextMarkupKit provides a grammar for a subset of Markdown syntax called *MiniMarkdown*. 9 | 2. An implementation of Dubroy & Warth's [Incremental Packrat Parsing](https://ohmlang.github.io/pubs/sle2017/incremental-packrat-parsing.pdf) algorithm to efficiently re-parse text content as the user types in the `UITextView`. 10 | 3. A system to format an `NSAttributedString` based upon the parse tree for its `-string` contents. TextMarkupKit's formatting support was designed around the needs of lightweight "human markup languages" like Markdown instead of syntax highlighting of programming languages. In addition to changing the attributes associated with text, TextMarkupKit's formatting rules let you transform the displayed text itself. For example, you may choose to change a space to a tab when formatting a list, or not show the special formatting delimiters in some modes, or replace an image markup sequence with an actual image attachment. TextMarkupKit supports all of these modes. 11 | 4. A way to efficiently integrate the formatted `NSAttributedString` with TextKit so it can be used with a `UITextView`. 12 | 13 | 14 | ## Overview of the code 15 | 16 | I'm still building out the documentation for TextMarkupKit. In the meantime, this is an overview of the important areas of code. 17 | 18 | ### Parsing 19 | 20 | - `ParsingRule` is an abstract base class. The job of a parsing rule is to evaluate the input text at specific offset and produce a `ParsingResult`, which is a struct that indicates: 21 | - If the parsing rule succeeded at that location 22 | - If the rule succeeded, how much of the input string is *consumed* by the rule. Parsing continues after the consumed input. 23 | - How much of the input string the `ParsingRule` had to look at at to make its success/fail decision. 24 | - `PackratGrammar` is a protocol something that defines a complete grammar through a graph of `ParsingRule`s. `PackratGrammar` exposes a single rule, `start`, that will be used when attempting to parse a string. 25 | - `MemoizationTable` implements the core incremental packrat parsing algorithm. 26 | 27 | Additionally, `ParsingRule.swift` defines many simple rules that you can combine to build much more complex rules for constructing your grammar. 28 | 29 | - `DotRule` matches any character. 30 | - `Characters` matches any character defined by a `CharacterSet`. 31 | - `Literal` matches a string literal. 32 | - `InOrder` takes an array of child rules and succeeds if **every** one of the child rules succeeds in sequence. 33 | - `Choice` also takes an array of child rules, but matches the **first** of the child rules in the array. 34 | - `AssertionRule` takes a single child rule. It succeeds if its child rule succeeds **but** it does not consume the input. 35 | - `NotAssertionRule`, like `AssertionRule`, takes a single child rule. It will succeed if its child rule fails and vice versa, and never consumes input. 36 | - `RangeRule` takes a single child rule and will try repeatedly match the rule to the input. It succeeds if the number of successful repetitions of the child rule falls within a specified range. 37 | 38 | ### TextStorage 39 | 40 | - `PieceTable` implements the [piece table](https://darrenburns.net/posts/piece-table/) data structure for efficient text editing. 41 | - `PieceTableString` is a subclass of `NSMutableString` that uses a `PieceTable` for its internal storage. 42 | - `ParsedAttributedString` is a subclass of `NSMutableAttributedString` that: 43 | 1. Uses a `PieceTableString` for character storage; 44 | 2. Uses a `MemoizationTable` to parse the string *and* incrementally re-parse the string on each change; 45 | 3. Applies a set of formatting rules based upon the parsed syntax tree to determine the formatting of the string. 46 | - `ObjectiveCTextStorageWrapper` is an `NSTextStorage` implementation that lets you use a `ParsedAttributedString` as the backing storage for `TextKit`, like `UITextView`. 47 | -------------------------------------------------------------------------------- /Sources/TextMarkupKit/TraceBuffer.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | 20 | /// Used for tracing the execution of rules. 21 | public final class TraceBuffer: CustomStringConvertible { 22 | /// Singleton. This is ugly but I can get rid of it if I move to regular code generation. 23 | public static let shared = TraceBuffer() 24 | 25 | /// All completed test entries. 26 | public var traceEntries: [Entry] = [] 27 | 28 | /// Start working on an entry. 29 | public func pushEntry(_ entry: Entry) { 30 | entryStack.append(entry) 31 | } 32 | 33 | /// Finish the most recent entry 34 | public func popEntry() { 35 | guard let popped = entryStack.popLast() else { 36 | assertionFailure() 37 | return 38 | } 39 | if let last = entryStack.last { 40 | last.subentries.append(popped) 41 | } else { 42 | traceEntries.append(popped) 43 | } 44 | } 45 | 46 | public func entry(at indexPath: IndexPath) -> Entry { 47 | precondition(!indexPath.isEmpty) 48 | return traceEntries[indexPath.first!].entry(at: indexPath.dropFirst()) 49 | } 50 | 51 | /// All in-progress entries 52 | private var entryStack: [Entry] = [] 53 | 54 | public var description: String { 55 | traceEntries.enumerated().map { index, entry in 56 | "\(index):\n\(entry)\n\n" 57 | }.joined() 58 | } 59 | 60 | public final class Entry: CustomStringConvertible { 61 | public init(rule: ParsingRule, index: Int, locationHint: String) { 62 | self.rule = rule 63 | self.index = index 64 | self.locationHint = locationHint 65 | } 66 | 67 | public let rule: ParsingRule 68 | public let index: Int 69 | public let locationHint: String 70 | public var result: ParsingResult? 71 | public var subentries: [Entry] = [] 72 | 73 | public var description: String { 74 | var buffer = "" 75 | writeRecursiveDescription(to: &buffer, indexPath: [], maxLevel: 2) 76 | return buffer 77 | } 78 | 79 | public var fullDescription: String { 80 | var buffer = "" 81 | writeRecursiveDescription(to: &buffer, indexPath: []) 82 | return buffer 83 | } 84 | 85 | public func entry(at indexPath: IndexPath) -> Entry { 86 | if indexPath.isEmpty { return self } 87 | return subentries[indexPath.first!].entry(at: indexPath.dropFirst()) 88 | } 89 | 90 | private func writeRecursiveDescription(to buffer: inout String, indexPath: IndexPath, maxLevel: Int = Int.max) { 91 | let indentLevel = indexPath.count 92 | guard indentLevel <= maxLevel else { return } 93 | let space = String(repeating: "| ", count: indentLevel) 94 | buffer.append("\(space)+ \(rule)@\(index): \(locationHint) \(indexPath)\n") 95 | for (index, subentry) in subentries.enumerated() { 96 | subentry.writeRecursiveDescription(to: &buffer, indexPath: indexPath.appending(index), maxLevel: maxLevel) 97 | } 98 | let resultString = result.map(String.init(describing:)) ?? "nil" 99 | buffer.append("\(space)= \(rule)@\(index): \(resultString)\n") 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import XCTest 19 | 20 | import TextMarkupKitTests 21 | 22 | var tests = [XCTestCaseEntry]() 23 | tests += TextMarkupKitTests.allTests() 24 | XCTMain(tests) 25 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/MiniMarkdownGrammarTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | @testable import TextMarkupKit 20 | import XCTest 21 | 22 | final class ParsingRuleTests: XCTestCase { 23 | func testDotMatchesEverything() { 24 | XCTAssertNil(DotRule().possibleOpeningCharacters) 25 | } 26 | 27 | func testTraceDotMatchesEverything() { 28 | XCTAssertNil(DotRule().trace().possibleOpeningCharacters) 29 | } 30 | 31 | func testNotOptionalSequence() { 32 | let chars = InOrder( 33 | Literal("A"), 34 | Literal("B") 35 | ).possibleOpeningCharacters 36 | XCTAssertEqual(chars, CharacterSet(charactersIn: "A")) 37 | } 38 | 39 | func testOptionalSequence() { 40 | let chars = InOrder( 41 | Literal("A").zeroOrOne(), 42 | Literal("B") 43 | ).possibleOpeningCharacters 44 | XCTAssertEqual(chars, CharacterSet(charactersIn: "AB")) 45 | } 46 | 47 | func testChoice() { 48 | let chars = Choice( 49 | Literal("A"), 50 | Literal("B") 51 | ).possibleOpeningCharacters 52 | XCTAssertEqual(chars, CharacterSet(charactersIn: "AB")) 53 | } 54 | 55 | func testInOrderFiltering() { 56 | let chars = InOrder( 57 | Literal("!").assert(), 58 | DotRule() 59 | ).possibleOpeningCharacters 60 | let expected = CharacterSet(charactersIn: "!") 61 | assertSameAnswers(chars, expected) 62 | } 63 | 64 | func testTraceModifications() { 65 | let grammar = MiniMarkdownGrammar() 66 | let unmodified = grammar.paragraph.possibleOpeningCharacters 67 | let trace = grammar.paragraph.trace().possibleOpeningCharacters 68 | assertSameAnswers(unmodified, trace) 69 | } 70 | 71 | let testString = "#abc123!?xABC\n \t." 72 | 73 | func assertSameAnswers(_ set1: CharacterSet?, _ set2: CharacterSet?, file: StaticString = #file, line: UInt = #line) { 74 | let matched1 = set1.matchedCharacters(from: testString) 75 | let matched2 = set2.matchedCharacters(from: testString) 76 | 77 | if matched1 != matched2 { 78 | let difference = matched1.symmetricDifference(matched2) 79 | XCTFail("Got different answers with the following characters: \(difference)", file: file, line: line) 80 | } 81 | } 82 | } 83 | 84 | extension Optional where Wrapped == CharacterSet { 85 | func matchedCharacters(from str: String) -> Set { 86 | switch self { 87 | case .none: 88 | return str.scalars(matching: { _ in true }) 89 | case .some(let set): 90 | return str.scalars(matching: { set.contains($0) }) 91 | } 92 | } 93 | } 94 | 95 | extension String { 96 | func scalars(matching predicate: (UnicodeScalar) -> Bool) -> Set { 97 | var results = Set() 98 | for scalar in unicodeScalars where predicate(scalar) { 99 | results.insert(scalar) 100 | } 101 | return results 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/MiniMarkdownParsingTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import TextMarkupKit 20 | import XCTest 21 | 22 | final class MiniMarkdownParsingTests: XCTestCase { 23 | func testNothingButText() { 24 | parseText("Just text.", expectedStructure: "(document (paragraph text))") 25 | } 26 | 27 | func testHeaderAndBody() { 28 | let markdown = """ 29 | # This is a header 30 | 31 | And this is a body. 32 | The two lines are part of the same paragraph. 33 | 34 | The line break indicates a new paragraph. 35 | 36 | """ 37 | parseText( 38 | markdown, 39 | expectedStructure: "(document (header delimiter tab text) blank_line (paragraph text) blank_line (paragraph text))" 40 | ) 41 | } 42 | 43 | func testJustEmphasis() { 44 | parseText( 45 | "*This is emphasized text.*", 46 | expectedStructure: "(document (paragraph (emphasis delimiter text delimiter)))" 47 | ) 48 | } 49 | 50 | func testTextWithEmphasis() { 51 | parseText( 52 | "This is text with *emphasis.*", 53 | expectedStructure: "(document (paragraph text (emphasis delimiter text delimiter)))" 54 | ) 55 | } 56 | 57 | func testTextWithSingleCharacterEmphasis() { 58 | parseText( 59 | "Emphasize just the letter *e* in this sentence", 60 | expectedStructure: "(document (paragraph text (emphasis delimiter text delimiter) text))" 61 | ) 62 | } 63 | 64 | func testEmphasisCannotBeEmpty() { 65 | parseText("Just ** two stars", expectedStructure: "(document (paragraph text))") 66 | } 67 | 68 | func testWithBold() { 69 | parseText( 70 | "This is text with **bold**.", 71 | expectedStructure: "(document (paragraph text (strong_emphasis delimiter text delimiter) text))" 72 | ) 73 | } 74 | 75 | func testTextAndHeader() { 76 | parseText( 77 | "Text\n# Heading", 78 | expectedStructure: "(document (paragraph text) (header delimiter tab text))" 79 | ) 80 | } 81 | 82 | func testTextAndCode() { 83 | parseText( 84 | "This is text with `code`.", 85 | expectedStructure: "(document (paragraph text (code delimiter text delimiter) text))" 86 | ) 87 | } 88 | 89 | func testParagraphs() { 90 | parseText( 91 | "Paragraph\n\nX", 92 | expectedStructure: "(document (paragraph text) blank_line (paragraph text))" 93 | ) 94 | } 95 | 96 | func testListWithMultipleItems() { 97 | let markdown = """ 98 | - Item one 99 | - Item two 100 | """ 101 | parseText(markdown, expectedStructure: "(document (list (list_item (list_delimiter unordered_list_opening tab) (paragraph text)) (list_item (list_delimiter unordered_list_opening tab) (paragraph text))))") 102 | } 103 | 104 | func testListItemWithStyling() { 105 | parseText( 106 | "- This is a list item with **strong emphasis**", 107 | expectedStructure: "(document (list (list_item (list_delimiter unordered_list_opening tab) (paragraph text (strong_emphasis delimiter text delimiter)))))" 108 | ) 109 | } 110 | 111 | func testEmphasisDoesNotSpanListItems() { 112 | let markdown = """ 113 | - Item *one 114 | - Item *two 115 | """ 116 | parseText(markdown, expectedStructure: "(document (list (list_item (list_delimiter unordered_list_opening tab) (paragraph text)) (list_item (list_delimiter unordered_list_opening tab) (paragraph text))))") 117 | } 118 | 119 | func testAllUnorderedListMarkers() { 120 | let example = """ 121 | - This is a list item. 122 | + So is this. 123 | * And so is this. 124 | 125 | """ 126 | let tree = parseText(example, expectedStructure: "(document (list (list_item (list_delimiter unordered_list_opening tab) (paragraph text)) (list_item (list_delimiter unordered_list_opening tab) (paragraph text)) (list_item (list_delimiter unordered_list_opening tab) (paragraph text))))") 127 | XCTAssertEqual(tree?.node(at: [0])?[ListTypeKey.self], .unordered) 128 | } 129 | 130 | func testOrderedListMarkers() { 131 | let example = """ 132 | 1. this is the first item 133 | 2. this is the second item 134 | 3) This is also legit. 135 | 136 | """ 137 | let tree = parseText(example, expectedStructure: "(document (list (list_item (list_delimiter ordered_list_number ordered_list_terminator tab) (paragraph text)) (list_item (list_delimiter ordered_list_number ordered_list_terminator tab) (paragraph text)) (list_item (list_delimiter ordered_list_number ordered_list_terminator tab) (paragraph text))))") 138 | XCTAssertEqual(tree?.node(at: [0])?[ListTypeKey.self], .ordered) 139 | } 140 | 141 | func testSingleLineBlockQuote() { 142 | let example = "> This is a quote with **bold** text." 143 | parseText(example, expectedStructure: "(document (blockquote (delimiter text tab) (paragraph text (strong_emphasis delimiter text delimiter) text)))") 144 | } 145 | 146 | func testOrderedMarkerCannotBeTenDigits() { 147 | let example = """ 148 | 12345678900) This isn't a list. 149 | """ 150 | parseText(example, expectedStructure: "(document (paragraph text))") 151 | } 152 | 153 | func testParseHashtag() { 154 | parseText("#hashtag\n", expectedStructure: "(document (paragraph (hashtag text) text))") 155 | } 156 | 157 | func testParseEmojiHashtag() { 158 | parseText("#hashtag/⭐️⭐️⭐️\n", expectedStructure: "(document (paragraph (hashtag text emoji) text))") 159 | parseText("#hashtag/😀\n", expectedStructure: "(document (paragraph (hashtag text emoji) text))") 160 | parseText("😀⭐️", expectedStructure: "(document (paragraph emoji))") 161 | } 162 | 163 | func testParseHashtagInText() { 164 | parseText("Paragraph with #hashtag\n", expectedStructure: "(document (paragraph text (hashtag text) text))") 165 | } 166 | 167 | func testHashtagCannotStartInTheMiddleOfAWord() { 168 | let example = "This paragraph does not contain a#hashtag because there is no space at the start." 169 | parseText(example, expectedStructure: "(document (paragraph text))") 170 | } 171 | 172 | func testParseEmoji() { 173 | parseText("Working code makes me feel 😀!", expectedStructure: "(document (paragraph text emoji text))") 174 | } 175 | 176 | func testParseImages() { 177 | let example = "This text has an image reference: ![xkcd](https://imgs.xkcd.com/comics/october_30th.png)" 178 | parseText(example, expectedStructure: "(document (paragraph text (image text link_alt_text text link_target text)))") 179 | } 180 | 181 | func testUnderlineEmphasis() { 182 | parseText("Underlines can do _emphasis_.", expectedStructure: "(document (paragraph text (emphasis delimiter text delimiter) text))") 183 | } 184 | 185 | func testLeftFlanking() { 186 | parseText( 187 | "This is * not* emphasis because the star doesn't hug", 188 | expectedStructure: "(document (paragraph text))" 189 | ) 190 | } 191 | 192 | func testRightFlanking() { 193 | parseText( 194 | "This is *not * emphasis because the star doesn't hug", 195 | expectedStructure: "(document (paragraph text))" 196 | ) 197 | } 198 | 199 | func testTypingBugText() { 200 | // Note that we currently coalesce consecutive blank_line nodes into a single blank_line node, 201 | // the same as with consecutive text nodes. This isn't obvious and I'm not sure I like it 202 | // but I'm going to let it be for now. 203 | parseText( 204 | "# Welcome to Scrap Paper.\n\n\n\n## Second heading\n\n", 205 | expectedStructure: "(document (header delimiter tab text) blank_line (header delimiter tab text) blank_line)" 206 | ) 207 | } 208 | 209 | func testHierarchicalHashtag() { 210 | parseText("#books/2020", expectedStructure: "(document (paragraph (hashtag text)))") 211 | } 212 | 213 | @MainActor func testFile() { 214 | let pieceTable = PieceTable(TestStrings.markdownCanonical) 215 | let memoizationTable = MemoizationTable(grammar: MiniMarkdownGrammar.shared) 216 | do { 217 | _ = try memoizationTable.parseBuffer(pieceTable) 218 | } catch { 219 | XCTFail("Unexpected error: \(error)") 220 | print(TraceBuffer.shared) 221 | } 222 | } 223 | } 224 | 225 | private extension MiniMarkdownParsingTests { 226 | /// Verify that parsed text matches expected structure. 227 | @discardableResult 228 | func parseText( 229 | _ text: String, 230 | expectedStructure: String, 231 | file: StaticString = #file, 232 | line: UInt = #line 233 | ) -> SyntaxTreeNode? { 234 | let parsedString = ParsedString(text, grammar: MiniMarkdownGrammar()) 235 | return verifyParsedStructure(of: parsedString, meets: expectedStructure, file: file, line: line) 236 | } 237 | 238 | @discardableResult 239 | func verifyParsedStructure( 240 | of text: ParsedString, 241 | meets expectedStructure: String, 242 | file: StaticString = #file, 243 | line: UInt = #line 244 | ) -> SyntaxTreeNode? { 245 | do { 246 | let tree = try text.result.get() 247 | if tree.length != text.count { 248 | let unparsedText = text[NSRange(location: tree.length, length: text.count - tree.length)] 249 | XCTFail("Test case \(name): Unparsed text = '\(unparsedText.debugDescription)'", file: file, line: line) 250 | } 251 | if expectedStructure != tree.compactStructure { 252 | print("### Failure: \(name)") 253 | print("Got: " + tree.compactStructure) 254 | print("Expected: " + expectedStructure) 255 | print("\n") 256 | print(tree.debugDescription(withContentsFrom: text)) 257 | print("\n\n\n") 258 | print(TraceBuffer.shared) 259 | } 260 | XCTAssertEqual(tree.compactStructure, expectedStructure, "Unexpected structure", file: file, line: line) 261 | return tree 262 | } catch { 263 | XCTFail("Unexpected error: \(error)", file: file, line: line) 264 | return nil 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/ParsedAttributedStringTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import TextMarkupKit 19 | import XCTest 20 | 21 | final class ParsedAttributedStringTests: XCTestCase { 22 | func testReplacementsAffectStringsButNotRawText() { 23 | let formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] = [ 24 | .emphasis: AnyParsedAttributedStringFormatter { $0.italic = true }, 25 | .header: AnyParsedAttributedStringFormatter { $0.fontSize = 24 }, 26 | .list: AnyParsedAttributedStringFormatter { $0.listLevel += 1 }, 27 | .strongEmphasis: AnyParsedAttributedStringFormatter { $0.bold = true }, 28 | .softTab: AnyParsedAttributedStringFormatter(substitution: "\t"), 29 | ] 30 | let defaultAttributes = AttributedStringAttributesDescriptor(textStyle: .body, color: .label, headIndent: 28, firstLineHeadIndent: 28) 31 | 32 | let textStorage = ParsedAttributedString( 33 | grammar: MiniMarkdownGrammar(), 34 | defaultAttributes: defaultAttributes, 35 | formatters: formatters 36 | ) 37 | 38 | textStorage.append(NSAttributedString(string: "# This is a heading\n\nAnd this is a paragraph")) 39 | XCTAssertEqual(textStorage.string, "#\tThis is a heading\n\nAnd this is a paragraph") 40 | XCTAssertEqual(textStorage.rawString.string, "# This is a heading\n\nAnd this is a paragraph") 41 | } 42 | 43 | func testVariableLengthReplacements() { 44 | let noDelimiterTextStorage = Self.makeNoDelimiterStorage() 45 | noDelimiterTextStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "#### This is a heading") 46 | XCTAssertEqual(noDelimiterTextStorage.string, "\tThis is a heading") 47 | XCTAssertEqual(noDelimiterTextStorage.rawStringRange(forRange: NSRange(location: 0, length: 18)), NSRange(location: 0, length: 22)) 48 | XCTAssertEqual(noDelimiterTextStorage.rawStringRange(forRange: NSRange(location: 0, length: 1)), NSRange(location: 0, length: 5)) 49 | XCTAssertEqual(noDelimiterTextStorage.range(forRawStringRange: NSRange(location: 0, length: 5)), NSRange(location: 0, length: 1)) 50 | XCTAssertEqual(noDelimiterTextStorage.range(forRawStringRange: NSRange(location: 5, length: 1)), NSRange(location: 1, length: 1)) 51 | 52 | // Walk through the string, attribute by attribute. We should end exactly at the end location. 53 | var location = 0 54 | var effectiveRange: NSRange = .init(location: 0, length: 0) 55 | while location < noDelimiterTextStorage.length { 56 | _ = noDelimiterTextStorage.attributes(at: location, effectiveRange: &effectiveRange) 57 | XCTAssert( 58 | location + effectiveRange.length <= noDelimiterTextStorage.string.utf16.count, 59 | "End of effective range (\(location + effectiveRange.length)) is beyond end-of-string \(noDelimiterTextStorage.string.utf16.count)" 60 | ) 61 | print(effectiveRange) 62 | location += effectiveRange.length 63 | } 64 | } 65 | 66 | func testListDelimiterRange() throws { 67 | let noDelimiterTextStorage = ParsedAttributedString(string: "* One\n* Two\n* ", style: MiniMarkdownGrammar.defaultEditingStyle()) 68 | XCTAssertEqual(noDelimiterTextStorage.string, "•\tOne\n•\tTwo\n•\t") 69 | let nodePath = try noDelimiterTextStorage.path(to: 13) 70 | guard let delimiter = nodePath.first(where: { $0.node.type == .listDelimiter }) else { 71 | XCTFail() 72 | return 73 | } 74 | XCTAssertEqual(delimiter.range, NSRange(location: 12, length: 2)) 75 | let visibleRange = noDelimiterTextStorage.range(forRawStringRange: delimiter.range) 76 | XCTAssertEqual(visibleRange, NSRange(location: 12, length: 2)) 77 | } 78 | 79 | func testQandACardWithReplacements() { 80 | let markdown = "Q: Can Q&A cards have *formatting*?\nA: **Yes!** Even `code`!" 81 | let noDelimiterTextStorage = Self.makeNoDelimiterStorage() 82 | noDelimiterTextStorage.append(NSAttributedString(string: markdown)) 83 | 84 | XCTAssertEqual(markdown.count - 8, noDelimiterTextStorage.length) 85 | } 86 | 87 | func testImageAndReplacements() { 88 | let markdown = """ 89 | # _Tom Kundig: Houses_: Dung Ngo, Tom Kundig, Steven Holl, Rick Joy, Billie Tsien (2006) 90 | 91 | ![](./288c09ac036eef237952e10cb8f62626441ee8f5.jpeg) 92 | 93 | """ 94 | 95 | let noDelimiterTextStorage = Self.makeNoDelimiterStorage() 96 | noDelimiterTextStorage.append(NSAttributedString(string: markdown)) 97 | noDelimiterTextStorage.append(NSAttributedString(string: "\n\n#b")) 98 | XCTAssertEqual(noDelimiterTextStorage.length, 93) 99 | } 100 | 101 | func testDeleteMultipleAttributeRuns() { 102 | let storage = ParsedAttributedString(string: "# Header\n\nParagraph\n\n> Quote\n\n", style: MiniMarkdownGrammar.defaultEditingStyle()) 103 | storage.replaceCharacters(in: NSRange(location: 2, length: 15), with: "") 104 | XCTAssertEqual(storage.string, "#\tph\n\n>\tQuote\n\n") 105 | } 106 | 107 | static func makeNoDelimiterStorage() -> ParsedAttributedString { 108 | let formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] = [ 109 | .emphasis: AnyParsedAttributedStringFormatter { $0.italic = true }, 110 | .header: AnyParsedAttributedStringFormatter { $0.fontSize = 24 }, 111 | .list: AnyParsedAttributedStringFormatter { $0.listLevel += 1 }, 112 | .strongEmphasis: AnyParsedAttributedStringFormatter { $0.bold = true }, 113 | .softTab: AnyParsedAttributedStringFormatter(substitution: "\t"), 114 | .image: AnyParsedAttributedStringFormatter(substitution: "\u{fffc}"), 115 | .delimiter: AnyParsedAttributedStringFormatter(substitution: ""), 116 | ] 117 | let defaultAttributes = AttributedStringAttributesDescriptor(textStyle: .body, color: .label, headIndent: 28, firstLineHeadIndent: 28) 118 | return ParsedAttributedString( 119 | grammar: MiniMarkdownGrammar(), 120 | defaultAttributes: defaultAttributes, 121 | formatters: formatters 122 | ) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/ParsedStringTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import TextMarkupKit 20 | import XCTest 21 | 22 | final class ParsedStringTests: XCTestCase { 23 | func testSimpleEdit() { 24 | let parser = ParsedString("Testing", grammar: MiniMarkdownGrammar()) 25 | parser.replaceCharacters(in: NSRange(location: 7, length: 0), with: ", testing") 26 | validateParser(parser, has: "(document (paragraph text))") 27 | } 28 | 29 | func testInsertBold() { 30 | let parser = ParsedString("Hello world", grammar: MiniMarkdownGrammar()) 31 | validateParser(parser, has: "(document (paragraph text))") 32 | parser.replaceCharacters(in: NSRange(location: 6, length: 0), with: "**awesome** ") 33 | validateParser(parser, has: "(document (paragraph text (strong_emphasis delimiter text delimiter) text))") 34 | } 35 | 36 | func testInsertCanChangeFormatting() { 37 | let parser = ParsedString("Hello **world*", grammar: MiniMarkdownGrammar()) 38 | validateParser(parser, has: "(document (paragraph text (emphasis delimiter text delimiter)))") 39 | TraceBuffer.shared.traceEntries.removeAll() 40 | parser.replaceCharacters(in: NSRange(location: 14, length: 0), with: "*") 41 | print(parser.utf16String) 42 | validateParser(parser, has: "(document (paragraph text (strong_emphasis delimiter text delimiter)))") 43 | } 44 | 45 | func testDeleteCanChangeFormatting() { 46 | let parser = ParsedString("Hello * world*", grammar: MiniMarkdownGrammar()) 47 | validateParser(parser, has: "(document (paragraph text))") 48 | TraceBuffer.shared.traceEntries.removeAll() 49 | parser.replaceCharacters(in: NSRange(location: 7, length: 1), with: "") 50 | XCTAssertEqual(parser.utf16String, parser.string) 51 | validateParser(parser, has: "(document (paragraph text (emphasis delimiter text delimiter)))") 52 | } 53 | 54 | func testDeleteCanChangeFormattingRightFlank() { 55 | let parser = ParsedString("Hello *world *", grammar: MiniMarkdownGrammar()) 56 | validateParser(parser, has: "(document (paragraph text))") 57 | TraceBuffer.shared.traceEntries.removeAll() 58 | parser.replaceCharacters(in: NSRange(location: 12, length: 1), with: "") 59 | XCTAssertEqual(parser.utf16String, parser.string) 60 | validateParser(parser, has: "(document (paragraph text (emphasis delimiter text delimiter)))") 61 | } 62 | 63 | func testIncrementalParsingReusesNodesWhenPossible() { 64 | let text = """ 65 | # Sample document 66 | 67 | I will be editing this **awesome** text and expect most nodes to be reused. 68 | """ 69 | let parser = ParsedString(text, grammar: MiniMarkdownGrammar()) 70 | guard let tree = validateParser(parser, has: "(document (header delimiter tab text) blank_line (paragraph text (strong_emphasis delimiter text delimiter) text))") else { 71 | XCTFail("Expected a tree") 72 | return 73 | } 74 | let emphasis = tree.node(at: [2, 1]) 75 | XCTAssertEqual(emphasis?.type, .strongEmphasis) 76 | parser.replaceCharacters(in: NSRange(location: text.utf16.count, length: 0), with: "Change paragraph!\n\nAnd add a new one.") 77 | guard let editedTree = validateParser(parser, has: "(document (header delimiter tab text) blank_line (paragraph text (strong_emphasis delimiter text delimiter) text) blank_line (paragraph text))") else { 78 | XCTFail("Expected a tree") 79 | return 80 | } 81 | let editedEmphasis = editedTree.node(at: [2, 1]) 82 | XCTAssertEqual(editedEmphasis?.type, .strongEmphasis) 83 | XCTAssert(emphasis === editedEmphasis) 84 | } 85 | 86 | func tooslow__testAddSentenceToLargeText() { 87 | let largeText = String(repeating: TestStrings.markdownCanonical, count: 10) 88 | let parser = ParsedString(largeText, grammar: MiniMarkdownGrammar()) 89 | let toInsert = "\n\nI'm adding some new text with *emphasis* to test incremental parsing.\n\n" 90 | measure { 91 | for (i, character) in toInsert.utf16.enumerated() { 92 | let str = String(utf16CodeUnits: [character], count: 1) 93 | parser.replaceCharacters(in: NSRange(location: 34 + i, length: 0), with: str) 94 | } 95 | } 96 | print("Inserted \(toInsert.utf16.count) characters, so remember to divide for per-character costs") 97 | } 98 | 99 | @MainActor func testReplacement() { 100 | let initialText = "#books #notreally #ijustwanttoreviewitwithbooks #books2019" 101 | let parsedString = ParsedString(initialText, grammar: MiniMarkdownGrammar.shared) 102 | XCTAssertTrue((try? parsedString.result.get()) != nil) 103 | let replacementRange = NSRange(parsedString.string.range(of: "#books2019")!, in: initialText) 104 | parsedString.replaceCharacters(in: replacementRange, with: "#books/2019") 105 | XCTAssertEqual(parsedString.string, "#books #notreally #ijustwanttoreviewitwithbooks #books/2019") 106 | } 107 | 108 | @MainActor func testPath() throws { 109 | let parsedString = ParsedString("* One", grammar: MiniMarkdownGrammar.shared) 110 | let nodeTypes = try parsedString.path(to: 4).map { $0.node.type } 111 | XCTAssertEqual(nodeTypes, [.document, .list, .listItem, .paragraph, .text]) 112 | let lastLocationNodeTypes = try parsedString.path(to: 5).map { $0.node.type } 113 | XCTAssertEqual(lastLocationNodeTypes, [.document, .list, .listItem, .paragraph, .text]) 114 | XCTAssertThrowsError(try parsedString.path(to: 6)) 115 | XCTAssertThrowsError(try parsedString.path(to: -1)) 116 | let startNodeTypes = try parsedString.path(to: 0).map { $0.node.type } 117 | XCTAssertEqual(startNodeTypes, [.document, .list, .listItem, .listDelimiter, .unorderedListOpening]) 118 | } 119 | 120 | @MainActor func testBlankLinePath() throws { 121 | let parsedString = ParsedString("# Header\n\nParagraph\n", grammar: MiniMarkdownGrammar.shared) 122 | let nodeTypes = try parsedString.path(to: 8).map { $0.node.type } 123 | XCTAssertEqual(nodeTypes, [.document, .header, .text]) 124 | let blankNodeTypes = try parsedString.path(to: 9).map { $0.node.type } 125 | XCTAssertEqual(blankNodeTypes, [.document, .blankLine]) 126 | } 127 | } 128 | 129 | // MARK: - Private 130 | 131 | private extension ParsedStringTests { 132 | @discardableResult 133 | func validateParser(_ parser: ParsedString, has expectedStructure: String, file: StaticString = #file, line: UInt = #line) -> SyntaxTreeNode? { 134 | do { 135 | let tree = try parser.result.get() 136 | if tree.length != parser.count { 137 | let unparsedText = parser[NSRange(location: tree.length, length: parser.count - tree.length)] 138 | XCTFail("Test case \(name): Unparsed text = '\(unparsedText.debugDescription)'", file: file, line: line) 139 | } 140 | if expectedStructure != tree.compactStructure { 141 | print("### Failure: \(name)") 142 | print("Got: " + tree.compactStructure) 143 | print("Expected: " + expectedStructure) 144 | print("\n") 145 | print(tree.debugDescription(withContentsFrom: parser)) 146 | print("\n\n\n") 147 | } 148 | XCTAssertEqual(tree.compactStructure, expectedStructure, "Unexpected structure", file: file, line: line) 149 | return tree 150 | } catch { 151 | XCTFail("Unexpected error: \(error)", file: file, line: line) 152 | return nil 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/ParsedTextStorageTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | import ObjectiveCTextStorageWrapper 20 | import TextMarkupKit 21 | import XCTest 22 | 23 | final class ParsedTextStorageTests: XCTestCase { 24 | var textStorage: ObjectiveCTextStorageWrapper! 25 | 26 | override func setUp() { 27 | super.setUp() 28 | let formatters: [SyntaxTreeNodeType: AnyParsedAttributedStringFormatter] = [ 29 | .emphasis: AnyParsedAttributedStringFormatter { $0.italic = true }, 30 | .header: AnyParsedAttributedStringFormatter { $0.fontSize = 24 }, 31 | .list: AnyParsedAttributedStringFormatter { $0.listLevel += 1 }, 32 | .strongEmphasis: AnyParsedAttributedStringFormatter { $0.bold = true }, 33 | .softTab: AnyParsedAttributedStringFormatter(substitution: "\t"), 34 | .image: AnyParsedAttributedStringFormatter(substitution: "\u{fffc}"), 35 | ] 36 | let defaultAttributes = AttributedStringAttributesDescriptor(textStyle: .body, color: .label, headIndent: 28, firstLineHeadIndent: 28) 37 | let storage = ParsedAttributedString( 38 | grammar: MiniMarkdownGrammar(), 39 | defaultAttributes: defaultAttributes, 40 | formatters: formatters 41 | ) 42 | textStorage = ObjectiveCTextStorageWrapper(storage: storage) 43 | } 44 | 45 | func testCanStoreAndRetrievePlainText() { 46 | textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "Hello, world!") 47 | XCTAssertEqual(textStorage.string, "Hello, world!") 48 | } 49 | 50 | func testAppendDelegateMessages() { 51 | assertDelegateMessages( 52 | for: [.append(text: "Hello, world")], 53 | are: DelegateMessage.messagePair(editedMask: [.editedCharacters, .editedAttributes], editedRange: NSRange(location: 0, length: 12), changeInLength: 12) 54 | ) 55 | } 56 | 57 | // TODO: With the new method of determining if attributes have changed, this is no longer an 58 | // effective test to ensure that incremental parsing is happening. 59 | func testEditMakesMinimumAttributeChange() { 60 | assertDelegateMessages( 61 | for: [ 62 | .append(text: "# Header\n\nParagraph with almost **bold*\n\nUnrelated"), 63 | .replace(range: NSRange(location: 39, length: 0), replacement: "*"), 64 | ], 65 | are: Array([ 66 | DelegateMessage.messagePair(editedMask: [.editedCharacters, .editedAttributes], editedRange: NSRange(location: 0, length: 50), changeInLength: 50), 67 | DelegateMessage.messagePair(editedMask: [.editedAttributes, .editedCharacters], editedRange: NSRange(location: 39, length: 1), changeInLength: 1), 68 | ].joined()) 69 | ) 70 | } 71 | 72 | func testTabSubstitutionHappens() { 73 | textStorage.append(NSAttributedString(string: "# This is a heading\n\nAnd this is a paragraph")) 74 | XCTAssertEqual(textStorage.string, "#\tThis is a heading\n\nAnd this is a paragraph") 75 | } 76 | 77 | func testCanAppendToAHeading() { 78 | assertDelegateMessages( 79 | for: [.append(text: "# Hello"), .append(text: ", world!\n\n")], 80 | are: Array([ 81 | DelegateMessage.messagePair(editedMask: [.editedCharacters, .editedAttributes], editedRange: NSRange(location: 0, length: 7), changeInLength: 7), 82 | DelegateMessage.messagePair(editedMask: [.editedCharacters, .editedAttributes], editedRange: NSRange(location: 7, length: 10), changeInLength: 10), 83 | ].joined()) 84 | ) 85 | } 86 | 87 | // TODO: Figure out a way to get access to the raw string contents 88 | // func testReplacementsAffectStringsButNotRawText() { 89 | // textStorage.append(NSAttributedString(string: "# This is a heading\n\nAnd this is a paragraph")) 90 | // XCTAssertEqual(textStorage.string, "#\tThis is a heading\n\nAnd this is a paragraph") 91 | // XCTAssertEqual(textStorage.storage.rawString, "# This is a heading\n\nAnd this is a paragraph") 92 | // } 93 | 94 | /// This used to crash because I was inproperly managing the `blank_line` nodes when coalescing them. It showed up when 95 | /// re-using memoized results. 96 | func testReproduceTypingBug() { 97 | let initialString = "# Welcome to Scrap Paper.\n\n\n\n##\n\n" 98 | textStorage.append(NSAttributedString(string: initialString)) 99 | let stringToInsert = " A second heading" 100 | var insertionPoint = initialString.utf16.count - 2 101 | for charToInsert in stringToInsert { 102 | let str = String(charToInsert) 103 | textStorage.replaceCharacters(in: NSRange(location: insertionPoint, length: 0), with: str) 104 | insertionPoint += 1 105 | } 106 | XCTAssertEqual(textStorage.string, "#\tWelcome to Scrap Paper.\n\n\n\n##\tA second heading\n\n") 107 | } 108 | 109 | func testEditsAroundImages() { 110 | let initialString = "Test ![](image.png) image" 111 | textStorage.append(NSAttributedString(string: initialString)) 112 | XCTAssertEqual(textStorage.string.count, 12) 113 | textStorage.replaceCharacters(in: NSRange(location: 5, length: 0), with: "x") 114 | // We should now have one more character than we did previously 115 | XCTAssertEqual(textStorage.string.count, 13) 116 | } 117 | 118 | func testDeleteEverything() { 119 | let initialString = "Test ![](image.png) image" 120 | textStorage.append(NSAttributedString(string: initialString)) 121 | XCTAssertEqual(textStorage.string.count, 12) 122 | textStorage.replaceCharacters(in: NSRange(location: 0, length: textStorage.string.utf16.count), with: "") 123 | XCTAssertEqual(textStorage.string.count, 0) 124 | } 125 | 126 | #if !os(macOS) 127 | /// Use the iOS convenience methods for manipulated AttributedStringAttributes to test that attributes are properly 128 | /// applied to ranges of the string. 129 | func testFormatting() { 130 | textStorage.append(NSAttributedString(string: "# Header\n\nParagraph with almost **bold*\n\nUnrelated")) 131 | var range = NSRange(location: NSNotFound, length: 0) 132 | let descriptor = textStorage.attributes(at: 0, effectiveRange: &range) 133 | var expectedAttributes: AttributedStringAttributes = [:] 134 | expectedAttributes.fontSize = 24 135 | XCTAssertEqual(expectedAttributes.font, descriptor.font) 136 | } 137 | #endif 138 | } 139 | 140 | // MARK: - Private 141 | 142 | private extension ParsedTextStorageTests { 143 | func assertDelegateMessages( 144 | for operations: [TextOperation], 145 | are expectedMessages: [DelegateMessage], 146 | file: StaticString = #file, 147 | line: UInt = #line 148 | ) { 149 | let textStorage = ObjectiveCTextStorageWrapper( 150 | storage: ParsedAttributedString( 151 | grammar: MiniMarkdownGrammar(), 152 | defaultAttributes: AttributedStringAttributesDescriptor(fontSize: 12), 153 | formatters: [.softTab: AnyParsedAttributedStringFormatter(substitution: "\t")] 154 | ) 155 | ) 156 | let miniMarkdownRecorder = TextStorageMessageRecorder() 157 | textStorage.delegate = miniMarkdownRecorder 158 | let plainTextStorage = NSTextStorage() 159 | for operation in operations { 160 | operation.apply(to: textStorage) 161 | operation.apply(to: plainTextStorage) 162 | } 163 | XCTAssertEqual( 164 | miniMarkdownRecorder.delegateMessages, 165 | expectedMessages, 166 | file: file, 167 | line: line 168 | ) 169 | if textStorage.string != plainTextStorage.string { 170 | print(textStorage.string.debugDescription) 171 | print(plainTextStorage.string.debugDescription) 172 | } 173 | } 174 | } 175 | 176 | private enum TextOperation { 177 | case append(text: String) 178 | case replace(range: NSRange, replacement: String) 179 | 180 | func apply(to textStorage: NSTextStorage) { 181 | switch self { 182 | case .append(let str): 183 | textStorage.append(NSAttributedString(string: str)) 184 | case .replace(let range, let replacement): 185 | textStorage.replaceCharacters(in: range, with: replacement) 186 | } 187 | } 188 | } 189 | 190 | #if !os(macOS) 191 | typealias EditActions = NSTextStorage.EditActions 192 | #else 193 | typealias EditActions = NSTextStorageEditActions 194 | #endif 195 | 196 | struct DelegateMessage: Equatable { 197 | let message: String 198 | let editedMask: EditActions 199 | let editedRange: NSRange 200 | let changeInLength: Int 201 | 202 | static func messagePair( 203 | editedMask: EditActions, 204 | editedRange: NSRange, 205 | changeInLength: Int 206 | ) -> [DelegateMessage] { 207 | return ["willProcessEditing", "didProcessEditing"].map { 208 | DelegateMessage( 209 | message: $0, 210 | editedMask: editedMask, 211 | editedRange: editedRange, 212 | changeInLength: changeInLength 213 | ) 214 | } 215 | } 216 | } 217 | 218 | final class TextStorageMessageRecorder: NSObject, NSTextStorageDelegate { 219 | public var delegateMessages: [DelegateMessage] = [] 220 | 221 | func textStorage( 222 | _ textStorage: NSTextStorage, 223 | willProcessEditing editedMask: EditActions, 224 | range editedRange: NSRange, 225 | changeInLength delta: Int 226 | ) { 227 | delegateMessages.append( 228 | DelegateMessage( 229 | message: "willProcessEditing", 230 | editedMask: editedMask, 231 | editedRange: editedRange, 232 | changeInLength: delta 233 | ) 234 | ) 235 | } 236 | 237 | func textStorage( 238 | _ textStorage: NSTextStorage, 239 | didProcessEditing editedMask: EditActions, 240 | range editedRange: NSRange, 241 | changeInLength delta: Int 242 | ) { 243 | delegateMessages.append( 244 | DelegateMessage( 245 | message: "didProcessEditing", 246 | editedMask: editedMask, 247 | editedRange: editedRange, 248 | changeInLength: delta 249 | ) 250 | ) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Tests/TextMarkupKitTests/PieceTableTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import Foundation 19 | @testable import TextMarkupKit 20 | import XCTest 21 | 22 | final class PieceTableTests: XCTestCase { 23 | func testOriginalLength() { 24 | let pieceTable = PieceTable("Hello, world") 25 | XCTAssertEqual(12, pieceTable.count) 26 | XCTAssertEqual("Hello, world", pieceTable.string) 27 | } 28 | 29 | func testAppendSingleCharacter() { 30 | var pieceTable = PieceTable("Hello, world") 31 | pieceTable.replaceCharacters(in: NSRange(location: 12, length: 0), with: "!") 32 | XCTAssertEqual("Hello, world!", pieceTable.string) 33 | } 34 | 35 | func testInsertCharacterInMiddle() { 36 | var pieceTable = PieceTable("Hello world") 37 | pieceTable.replaceCharacters(in: NSRange(location: 5, length: 0), with: ",") 38 | XCTAssertEqual("Hello, world", pieceTable.string) 39 | } 40 | 41 | func testDeleteCharacterInMiddle() { 42 | var pieceTable = PieceTable("Hello, world") 43 | pieceTable.replaceCharacters(in: NSRange(location: 5, length: 1), with: "") 44 | XCTAssertEqual("Hello world", pieceTable.string) 45 | } 46 | 47 | func testDeleteFromBeginning() { 48 | var pieceTable = PieceTable("_Hello, world") 49 | pieceTable.replaceCharacters(in: NSRange(location: 0, length: 1), with: "") 50 | XCTAssertEqual("Hello, world", pieceTable.string) 51 | } 52 | 53 | func testDeleteAtEnd() { 54 | var pieceTable = PieceTable() 55 | pieceTable.append(contentsOf: "Hello, world!?".utf16) 56 | let lastCharacterIndex = pieceTable.index(pieceTable.startIndex, offsetBy: pieceTable.count - 1) 57 | pieceTable.remove(at: lastCharacterIndex) 58 | XCTAssertEqual("Hello, world!", pieceTable.string) 59 | } 60 | 61 | func testInsertAtBeginning() { 62 | var pieceTable = PieceTable("Hello, world!") 63 | pieceTable.replaceCharacters(in: NSRange(location: 0, length: 0), with: "¡") 64 | XCTAssertEqual("¡Hello, world!", pieceTable.string) 65 | } 66 | 67 | func testLeftOverlappingEditRange() { 68 | var pieceTable = PieceTable("Hello, world!") 69 | pieceTable.replaceCharacters(in: NSRange(location: 7, length: 0), with: "zCRuel ") 70 | pieceTable.replaceCharacters(in: NSRange(location: 0, length: 10), with: "Goodbye, cr") 71 | XCTAssertEqual("Goodbye, cruel world!", pieceTable.string) 72 | } 73 | 74 | func testRightOverlappingEditRange() { 75 | var pieceTable = PieceTable("Hello, world!") 76 | pieceTable.replaceCharacters(in: NSRange(location: 4, length: 2), with: "a,") 77 | pieceTable.replaceCharacters(in: NSRange(location: 5, length: 2), with: "!! ") 78 | XCTAssertEqual("Hella!! world!", pieceTable.string) 79 | XCTAssertEqual(pieceTable.utf16String, pieceTable.string) 80 | } 81 | 82 | func testDeleteAddedOverlappingRange() { 83 | var pieceTable = PieceTable("Hello, world!") 84 | pieceTable.replaceCharacters(in: NSRange(location: 7, length: 0), with: "nutty ") 85 | pieceTable.replaceCharacters(in: NSRange(location: 5, length: 13), with: "") 86 | XCTAssertEqual("Hello!", pieceTable.string) 87 | } 88 | 89 | func testAppend() { 90 | var pieceTable = PieceTable("") 91 | pieceTable.replaceCharacters(in: NSRange(location: 0, length: 0), with: "Hello, world!") 92 | XCTAssertEqual(pieceTable.string, "Hello, world!") 93 | } 94 | 95 | func testRepeatedAppend() { 96 | var pieceTable = PieceTable() 97 | let expected = "Hello, world!!" 98 | for character in expected.utf16 { 99 | pieceTable.append(character) 100 | } 101 | XCTAssertEqual(pieceTable.string, expected) 102 | } 103 | 104 | func testAppendPerformance() { 105 | measure { 106 | var pieceTable = PieceTable("") 107 | for i in 0 ..< 1024 { 108 | pieceTable.replaceCharacters(in: NSRange(location: i, length: 0), with: ".") 109 | } 110 | } 111 | } 112 | 113 | /// This does two large "local" edits. First it puts 512 characters sequentially into the buffer. 114 | /// Then it puts another 512 characters sequentially into the middle. 115 | /// Logically this can be represented in 3 runs so manipulations should stay fast. 116 | func testLargeLocalEditPerformance() { 117 | let expected = String(repeating: "A", count: 256) + String(repeating: "B", count: 512) + String(repeating: "A", count: 256) 118 | measure { 119 | var pieceTable = PieceTable("") 120 | for i in 0 ..< 512 { 121 | pieceTable.replaceCharacters(in: NSRange(location: i, length: 0), with: "A") 122 | } 123 | for i in 0 ..< 512 { 124 | pieceTable.replaceCharacters(in: NSRange(location: 256 + i, length: 0), with: "B") 125 | } 126 | XCTAssertEqual(pieceTable.string, expected) 127 | } 128 | } 129 | 130 | let megabyteText = String(repeating: " ", count: 1024 * 1024) 131 | 132 | func testMegabytePieceTablePerformance() { 133 | measure { 134 | var pieceTable = PieceTable(megabyteText) 135 | for i in 0 ..< 50 * 1024 { 136 | pieceTable.replaceCharacters(in: NSRange(location: 1024 + i, length: 0), with: ".") 137 | } 138 | } 139 | } 140 | 141 | func TOO_SLOW_testMegabyteStringPerformance() { 142 | measure { 143 | var str = megabyteText 144 | var index = str.index(str.startIndex, offsetBy: 50 * 1024) 145 | for _ in 0 ..< 50 * 1024 { 146 | str.insert(".", at: index) 147 | index = str.index(after: index) 148 | } 149 | } 150 | } 151 | 152 | func testIndexMapping() { 153 | var pieceTable = PieceTable("# My *header* text") 154 | pieceTable.replaceSubrange(pieceTable.startIndex ..< pieceTable.index(pieceTable.startIndex, offsetBy: 2), with: Array("H1\t".utf16)) 155 | XCTAssertEqual(pieceTable.string, "H1\tMy *header* text") 156 | pieceTable.replaceSubrange(pieceTable.index(at: 13) ..< pieceTable.index(at: 14), with: []) 157 | pieceTable.replaceSubrange(pieceTable.index(at: 6) ..< pieceTable.index(at: 7), with: []) 158 | print(pieceTable) 159 | XCTAssertEqual(pieceTable.string, "H1\tMy header text") 160 | XCTAssertEqual(pieceTable.indexForOriginalOffset(0), .notFound(lowerBound: nil, upperBound: PieceTable.Index(pieceIndex: 1, contentIndex: 2))) 161 | XCTAssertEqual(pieceTable.indexForOriginalOffset(3), .found(at: PieceTable.Index(pieceIndex: 1, contentIndex: 3))) 162 | XCTAssertEqual(pieceTable.originalOffsetForIndex(PieceTable.Index(pieceIndex: 1, contentIndex: 3)), .found(at: 3)) 163 | XCTAssertEqual(pieceTable.originalOffsetForIndex(PieceTable.Index(pieceIndex: 0, contentIndex: 2)), .notFound(lowerBound: nil, upperBound: 2)) 164 | } 165 | 166 | /// This never finishes in a reasonable amount of time :-( 167 | func DISABLE_testMegabyteTextStoragePerformance() { 168 | measure { 169 | let textStorage = NSTextStorage(attributedString: NSAttributedString(string: megabyteText)) 170 | for i in 0 ..< 50 * 1024 { 171 | textStorage.replaceCharacters(in: NSRange(location: 1024 + i, length: 0), with: ".") 172 | } 173 | } 174 | } 175 | } 176 | 177 | // MARK: - Private 178 | 179 | extension SafeUnicodeBuffer { 180 | var utf16String: String { 181 | var chars = [unichar]() 182 | var i = 0 183 | while let character = utf16(at: i) { 184 | chars.append(character) 185 | i += 1 186 | } 187 | return String(utf16CodeUnits: chars, count: chars.count) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-log", 6 | "repositoryURL": "https://github.com/apple/swift-log.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 10 | "version": "1.4.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import SwiftUI 19 | import TextMarkupKit 20 | 21 | struct ContentView: View { 22 | @Binding var document: TextMarkupKitSampleDocument 23 | 24 | var body: some View { 25 | MarkupFormattedTextEditor(text: $document.text) 26 | } 27 | } 28 | 29 | struct ContentView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | ContentView(document: .constant(TextMarkupKitSampleDocument())) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDocumentTypes 8 | 9 | 10 | CFBundleTypeRole 11 | Viewer 12 | LSItemContentTypes 13 | 14 | com.example.plain-text 15 | 16 | NSUbiquitousDocumentUserActivityType 17 | $(PRODUCT_BUNDLE_IDENTIFIER).example-document 18 | 19 | 20 | CFBundleExecutable 21 | $(EXECUTABLE_NAME) 22 | CFBundleIdentifier 23 | $(PRODUCT_BUNDLE_IDENTIFIER) 24 | CFBundleInfoDictionaryVersion 25 | 6.0 26 | CFBundleName 27 | $(PRODUCT_NAME) 28 | CFBundlePackageType 29 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 30 | CFBundleShortVersionString 31 | 1.0 32 | CFBundleVersion 33 | 1 34 | LSRequiresIPhoneOS 35 | 36 | UIApplicationSceneManifest 37 | 38 | UIApplicationSupportsMultipleScenes 39 | 40 | 41 | UIApplicationSupportsIndirectInputEvents 42 | 43 | UILaunchScreen 44 | 45 | UIRequiredDeviceCapabilities 46 | 47 | armv7 48 | 49 | UISupportedInterfaceOrientations 50 | 51 | UIInterfaceOrientationPortrait 52 | UIInterfaceOrientationLandscapeLeft 53 | UIInterfaceOrientationLandscapeRight 54 | 55 | UISupportedInterfaceOrientations~ipad 56 | 57 | UIInterfaceOrientationPortrait 58 | UIInterfaceOrientationPortraitUpsideDown 59 | UIInterfaceOrientationLandscapeLeft 60 | UIInterfaceOrientationLandscapeRight 61 | 62 | UISupportsDocumentBrowser 63 | 64 | UTImportedTypeDeclarations 65 | 66 | 67 | UTTypeConformsTo 68 | 69 | public.plain-text 70 | 71 | UTTypeDescription 72 | Example Text 73 | UTTypeIdentifier 74 | com.example.plain-text 75 | UTTypeTagSpecification 76 | 77 | public.filename-extension 78 | 79 | exampletext 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/README.md: -------------------------------------------------------------------------------- 1 | # TextMarkupKitSample 2 | 3 | This is a sample SwiftUI application that loads and edits plain text files and uses `TextMarkupKit` to provide real-time formatting as you type. 4 | 5 | ![Screenshot of TextMarkupKitSample](../../assets/sample.png) 6 | 7 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/TextMarkupKitSampleApp.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import SwiftUI 19 | 20 | @main 21 | struct TextMarkupKitSampleApp: App { 22 | var body: some Scene { 23 | DocumentGroup(newDocument: TextMarkupKitSampleDocument()) { file in 24 | ContentView(document: file.$document) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSample/TextMarkupKitSampleDocument.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import SwiftUI 19 | import UniformTypeIdentifiers 20 | 21 | extension UTType { 22 | static var exampleText: UTType { 23 | UTType(importedAs: "com.example.plain-text") 24 | } 25 | } 26 | 27 | struct TextMarkupKitSampleDocument: FileDocument { 28 | var text: String 29 | 30 | init(text: String = welcomeContent) { 31 | self.text = text 32 | } 33 | 34 | static var readableContentTypes: [UTType] { [.exampleText] } 35 | 36 | init(configuration: ReadConfiguration) throws { 37 | guard let data = configuration.file.regularFileContents, 38 | let string = String(data: data, encoding: .utf8) 39 | else { 40 | throw CocoaError(.fileReadCorruptFile) 41 | } 42 | self.text = string 43 | } 44 | 45 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 46 | let data = text.data(using: .utf8)! 47 | return .init(regularFileWithContents: data) 48 | } 49 | } 50 | 51 | private let welcomeContent = """ 52 | # Welcome to TextMarkupKit! 53 | 54 | `TextMarkupKit` gives you the tools you need to provide a *format as you type* experience in iOS. Out of the box, it provides support for a subset of Markdown formatting, including: 55 | 56 | * **Bold** 57 | * *Italics* (also formatted _this way_) 58 | * `code` 59 | 60 | 1. Lists can be numbered, too. 61 | 62 | ## Now you try! 63 | 64 | Go ahead and edit this file! You will see the formatting adjust as you type. 65 | 66 | ## Learning more 67 | 68 | Checkout `README.md` inside TextMarkupKit. 69 | """ 70 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSampleTests/TextMarkupKitSampleTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | @testable import TextMarkupKitSample 19 | import XCTest 20 | 21 | class TextMarkupKitSampleTests: XCTestCase { 22 | override func setUpWithError() throws { 23 | // Put setup code here. This method is called before the invocation of each test method in the class. 24 | } 25 | 26 | override func tearDownWithError() throws { 27 | // Put teardown code here. This method is called after the invocation of each test method in the class. 28 | } 29 | 30 | func testExample() throws { 31 | // This is an example of a functional test case. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testPerformanceExample() throws { 36 | // This is an example of a performance test case. 37 | measure { 38 | // Put the code you want to measure the time of here. 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSampleUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /TextMarkupKitSample/TextMarkupKitSampleUITests/TextMarkupKitSampleUITests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the Apache Software Foundation (ASF) under one 2 | // or more contributor license agreements. See the NOTICE file 3 | // distributed with this work for additional information 4 | // regarding copyright ownership. The ASF licenses this file 5 | // to you under the Apache License, Version 2.0 (the 6 | // "License"); you may not use this file except in compliance 7 | // with the License. You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | import XCTest 19 | 20 | class TextMarkupKitSampleUITests: XCTestCase { 21 | override func setUpWithError() throws { 22 | // Put setup code here. This method is called before the invocation of each test method in the class. 23 | 24 | // In UI tests it is usually best to stop immediately when a failure occurs. 25 | continueAfterFailure = false 26 | 27 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 28 | } 29 | 30 | override func tearDownWithError() throws { 31 | // Put teardown code here. This method is called after the invocation of each test method in the class. 32 | } 33 | 34 | func testExample() throws { 35 | // UI tests must launch the application that they test. 36 | let app = XCUIApplication() 37 | app.launch() 38 | 39 | // Use recording to get started writing UI tests. 40 | // Use XCTAssert and related functions to verify your tests produce the correct results. 41 | } 42 | 43 | func testLaunchPerformance() throws { 44 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 45 | // This measures how long it takes to launch your application. 46 | measure(metrics: [XCTApplicationLaunchMetric()]) { 47 | XCUIApplication().launch() 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /assets/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdewey/TextMarkupKit/31198bae36a7d4f35d5086da907b6b3067687160/assets/sample.png --------------------------------------------------------------------------------