├── .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 | 
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: "
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 | 
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"
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"
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 | 
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
--------------------------------------------------------------------------------