aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-06-16 12:08:21 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-06-16 12:22:47 +0200
commit501fdce29e4d2c46c4708ad7f44056878e0db3fa (patch)
treedb923b77037db9f5b37600a6e34c3bb2e8a971be
Initial extraction from Norganize1.0.0
-rw-r--r--.gitignore9
-rw-r--r--.swiftlint.yml36
-rw-r--r--Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift95
-rw-r--r--Documentation/example.norg106
-rw-r--r--Documentation/huge.norg2476
-rw-r--r--Justfile27
-rw-r--r--LICENSE661
-rw-r--r--Package.resolved78
-rw-r--r--Package.swift58
-rw-r--r--README.md168
-rw-r--r--Sources/NorgKit/Extensions/Array+InlineSpan.swift7
-rw-r--r--Sources/NorgKit/Helpers/ASCIIByteSet.swift30
-rw-r--r--Sources/NorgKit/Helpers/ASCIIHelper.swift5
-rw-r--r--Sources/NorgKit/Helpers/TextHelper.swift95
-rw-r--r--Sources/NorgKit/Models/DetachedModifier.swift61
-rw-r--r--Sources/NorgKit/Models/InlineLink.swift15
-rw-r--r--Sources/NorgKit/Models/InlineSpan.swift12
-rw-r--r--Sources/NorgKit/Models/InlineStyle.swift28
-rw-r--r--Sources/NorgKit/Models/NorgBlock.swift108
-rw-r--r--Sources/NorgKit/Models/NorgDocument.swift16
-rw-r--r--Sources/NorgKit/Models/NorgNode.swift10
-rw-r--r--Sources/NorgKit/Models/NorgTask.swift20
-rw-r--r--Sources/NorgKit/Models/TaskStatus.swift33
-rw-r--r--Sources/NorgKit/Parsers/NorgInlineParser.swift248
-rw-r--r--Sources/NorgKit/Parsers/NorgParser.swift286
-rw-r--r--Sources/NorgKit/Parsers/TaskScanner.swift52
-rw-r--r--Sources/NorgKit/TreeFolder.swift82
-rw-r--r--Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift19
-rw-r--r--Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift42
-rw-r--r--Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift22
-rw-r--r--Tests/NorgKitTests/Helpers/TextHelperTests.swift71
-rw-r--r--Tests/NorgKitTests/Models/DetachedModifierTests.swift61
-rw-r--r--Tests/NorgKitTests/Models/InlineLinkTests.swift32
-rw-r--r--Tests/NorgKitTests/Models/InlineSpanTests.swift35
-rw-r--r--Tests/NorgKitTests/Models/InlineStyleTests.swift35
-rw-r--r--Tests/NorgKitTests/Models/NorgBlockTests.swift127
-rw-r--r--Tests/NorgKitTests/Models/NorgDocumentTests.swift32
-rw-r--r--Tests/NorgKitTests/Models/NorgNodeTests.swift31
-rw-r--r--Tests/NorgKitTests/Models/NorgTaskTests.swift33
-rw-r--r--Tests/NorgKitTests/Models/TaskStatusTests.swift54
-rw-r--r--Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift141
-rw-r--r--Tests/NorgKitTests/Parsers/NorgParserTests.swift362
-rw-r--r--Tests/NorgKitTests/Parsers/TaskScannerTests.swift110
-rw-r--r--Tests/NorgKitTests/TreeFolderTests.swift145
44 files changed, 6174 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d2c5c89
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
+*.profraw
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..51b479a
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,36 @@
+# SwiftLint configuration for NorgKit.
+# Tuned to the existing style rather than forcing churn: the parsers use short,
+# conventional cursor names (i, j, lo, hi, c, s, m) and the recogniser functions
+# are intentionally long, flat state machines.
+
+included:
+ - Sources
+ - Tests
+ - Benchmarks
+
+identifier_name:
+ # Allow short loop/cursor names that read clearly in tight parsing loops.
+ min_length:
+ warning: 1
+ error: 1
+
+line_length:
+ warning: 120
+ error: 160
+ ignores_comments: true
+ ignores_urls: true
+
+function_body_length:
+ warning: 80
+ error: 120
+
+cyclomatic_complexity:
+ warning: 15
+ error: 25
+
+disabled_rules:
+ # "TODO" appears in doc comments describing Norg's TODO-status syntax.
+ - todo
+ # Trailing commas are used selectively (e.g. the modifier tables); leave the
+ # choice to the author rather than enforcing one way.
+ - trailing_comma
diff --git a/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift b/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift
new file mode 100644
index 0000000..91e1d38
--- /dev/null
+++ b/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift
@@ -0,0 +1,95 @@
+import Benchmark
+import Foundation
+import NorgKit
+
+let benchmarks: @Sendable () -> Benchmark? = {
+ // Load the norg files from the Documentation directory.
+ let fileManager = FileManager.default
+ let currentDirectory = fileManager.currentDirectoryPath
+ let documentationPath = (currentDirectory as NSString).appendingPathComponent("Documentation")
+
+ let examplePath = (documentationPath as NSString).appendingPathComponent("example.norg")
+ let hugePath = (documentationPath as NSString).appendingPathComponent("huge.norg")
+
+ let exampleSource: String
+ let hugeSource: String
+
+ do {
+ exampleSource = try String(contentsOfFile: examplePath, encoding: .utf8)
+ } catch {
+ print("Current directory: \(currentDirectory)")
+ print("Failed to load example.norg from: \(examplePath)")
+ print("Error: \(error)")
+ fatalError("Failed to load example.norg")
+ }
+
+ do {
+ hugeSource = try String(contentsOfFile: hugePath, encoding: .utf8)
+ } catch {
+ print("Failed to load huge.norg from: \(hugePath)")
+ print("Error: \(error)")
+ fatalError("Failed to load huge.norg")
+ }
+
+ Benchmark.defaultConfiguration.maxDuration = .seconds(3)
+ Benchmark.defaultConfiguration.maxIterations = 500
+
+ let fileURL = URL(fileURLWithPath: "/vault/today.norg")
+
+ Benchmark("parse example") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(NorgParser.parse(exampleSource))
+ }
+ }
+
+ Benchmark("parse huge") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(NorgParser.parse(hugeSource))
+ }
+ }
+
+ // Same parse, plus the tree-folding pass — the delta against "parse …" above
+ // is the cost of building the hierarchy.
+ Benchmark("parse tree example") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(NorgParser.parseTree(exampleSource))
+ }
+ }
+
+ Benchmark("parse tree huge") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(NorgParser.parseTree(hugeSource))
+ }
+ }
+
+ // Just the fold, over an already-parsed document, to isolate its cost.
+ let hugeDocument = NorgParser.parse(hugeSource)
+ Benchmark("fold huge") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(hugeDocument.tree())
+ }
+ }
+
+ // Task scanning is meant to be cheap enough to run across an entire vault, so
+ // it bypasses the full block parser.
+ Benchmark("scan tasks example") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(TaskScanner.scan(content: exampleSource, fileURL: fileURL))
+ }
+ }
+
+ Benchmark("scan tasks huge") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(TaskScanner.scan(content: hugeSource, fileURL: fileURL))
+ }
+ }
+
+ // Rewriting a single status marker — the write-back path for toggling a task.
+ // Line 17 (zero-based) is the first task in huge.norg, so this exercises the
+ // full split / replace / re-join path rather than bailing out early.
+ return Benchmark("update task status huge") { benchmark in
+ for _ in benchmark.scaledIterations {
+ blackHole(TaskScanner.updatedContent(hugeSource, line: 17, to: .done))
+ }
+ }
+}
diff --git a/Documentation/example.norg b/Documentation/example.norg
new file mode 100644
index 0000000..b986520
--- /dev/null
+++ b/Documentation/example.norg
@@ -0,0 +1,106 @@
+@document.meta
+title: NorgKit Example
+authors: [rbdr]
+categories: documentation
+version: 1.0
+@end
+
+* NorgKit
+
+NorgKit is a pure-/Foundation/ parser for *Norg* documents. This file exercises
+a representative spread of the syntax so it can double as a parser benchmark and
+a quick visual reference.
+
+** Inline markup
+
+A paragraph can mix *bold*, /italic/, _underline_ and -strikethrough- runs, plus
+`verbatim` spans and $f(x) = x^2$ math. Modifiers /can be _nested_/ where it makes
+sense, and a {https://github.com/nvim-neorg/neorg}[link] points elsewhere while a
+bare {* NorgKit} link references a heading. Escapes like \*these\* stay literal.
+
+** Tasks
+
+- (x) Parse headings and their TODO status
+- (x) Parse ordered and unordered lists
+- ( ) Parse `@code` blocks with a language
+- (-) Wire up the benchmark target
+- (=) Investigate a streaming backend
+- (?) Decide on a tree-sitter bridge
+-- ( ) Nested subtask one
+-- (x) Nested subtask two
+- (!) An urgent follow up
+- (+) A recurring chore
+- (_) A cancelled idea
+
+** Ordered steps
+
+~ Read the document into memory
+~ Split it into lines
+~ Recognise each block
+~~ Detached modifiers first
+~~ Then ranged tags
+~ Emit the block list
+
+** Quotes
+
+> A language that doesn't affect the way you think about programming is not worth
+> knowing.
+> (x) Even a quote can carry a settled status.
+
+** Code
+
+@code swift
+import NorgKit
+
+let document = NorgParser.parse(source)
+for block in document.blocks {
+ print(block.line, block.status as Any)
+}
+@end
+
+@code
+plain verbatim block without a language
+@end
+
+** Other ranged tags
+
+@math
+\int_0^1 x^2 \, dx = \frac{1}{3}
+@end
+
+@image.png diagram alt text
+/assets/architecture.png
+@end
+
+** Definitions and footnotes
+
+$ Norg
+A structured plain-text format for note-taking and task management.
+
+$$ Ranged definition
+This form scans every block until the closing modifier, so it can hold
+- a list,
+- and more than one paragraph.
+$$
+
+^ source
+The reference lives in the Neorg specification repository.
+
+** Prose
+
+This final section is a longer paragraph intended to be soft-wrapped across
+several source lines so the paragraph-merging path is exercised. The parser
+should join these physical lines into a single logical paragraph, preserving the
+inline styling such as *emphasis* and `code` while collapsing the line breaks
+into spaces. It keeps going for a few more lines to give the merge loop some
+real work to do during the benchmark run.
+
+---
+
+A weak delimiter precedes this paragraph and a horizontal rule follows it.
+
+___
+
+* Closing heading
+
+That's the end of the example document.
diff --git a/Documentation/huge.norg b/Documentation/huge.norg
new file mode 100644
index 0000000..f5321b2
--- /dev/null
+++ b/Documentation/huge.norg
@@ -0,0 +1,2476 @@
+@document.meta
+title: NorgKit Huge Benchmark Document
+authors: [rbdr]
+categories: benchmark
+version: 1.0
+@end
+
+* NorgKit Stress Document
+
+A generated document used to benchmark the parser against a large input.
+
+** Section 1
+
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+
+- (x) Task 1.0 for the agenda scanner
+-- ( ) nested item under 1.0
+- (?) Task 1.1 for the agenda scanner
+- (!) Task 1.2 for the agenda scanner
+- (+) Task 1.3 for the agenda scanner
+-- (!) nested item under 1.3
+- (-) Task 1.4 for the agenda scanner
+- (=) Task 1.5 for the agenda scanner
+
+~ Ordered step 0 of section 1
+~ Ordered step 1 of section 1
+~ Ordered step 2 of section 1
+~ Ordered step 3 of section 1
+
+> Quoted wisdom number 1 about parsing performance.
+
+** Section 2
+
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+
+- (?) Task 2.0 for the agenda scanner
+-- ( ) nested item under 2.0
+- (!) Task 2.1 for the agenda scanner
+- (+) Task 2.2 for the agenda scanner
+- (-) Task 2.3 for the agenda scanner
+-- (!) nested item under 2.3
+- (=) Task 2.4 for the agenda scanner
+- (_) Task 2.5 for the agenda scanner
+
+~ Ordered step 0 of section 2
+~ Ordered step 1 of section 2
+~ Ordered step 2 of section 2
+~ Ordered step 3 of section 2
+
+> Quoted wisdom number 2 about parsing performance.
+
+** Section 3
+
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+
+- (!) Task 3.0 for the agenda scanner
+-- ( ) nested item under 3.0
+- (+) Task 3.1 for the agenda scanner
+- (-) Task 3.2 for the agenda scanner
+- (=) Task 3.3 for the agenda scanner
+-- (!) nested item under 3.3
+- (_) Task 3.4 for the agenda scanner
+- ( ) Task 3.5 for the agenda scanner
+
+~ Ordered step 0 of section 3
+~ Ordered step 1 of section 3
+~ Ordered step 2 of section 3
+~ Ordered step 3 of section 3
+
+> Quoted wisdom number 3 about parsing performance.
+
+** Section 4
+
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+
+- (+) Task 4.0 for the agenda scanner
+-- ( ) nested item under 4.0
+- (-) Task 4.1 for the agenda scanner
+- (=) Task 4.2 for the agenda scanner
+- (_) Task 4.3 for the agenda scanner
+-- (!) nested item under 4.3
+- ( ) Task 4.4 for the agenda scanner
+- (x) Task 4.5 for the agenda scanner
+
+~ Ordered step 0 of section 4
+~ Ordered step 1 of section 4
+~ Ordered step 2 of section 4
+~ Ordered step 3 of section 4
+
+> Quoted wisdom number 4 about parsing performance.
+
+@code swift
+let section4 = NorgParser.parse(source) // 4
+print(section4.blocks.count)
+@end
+
+** Section 5
+
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+
+- (-) Task 5.0 for the agenda scanner
+-- ( ) nested item under 5.0
+- (=) Task 5.1 for the agenda scanner
+- (_) Task 5.2 for the agenda scanner
+- ( ) Task 5.3 for the agenda scanner
+-- (!) nested item under 5.3
+- (x) Task 5.4 for the agenda scanner
+- (?) Task 5.5 for the agenda scanner
+
+~ Ordered step 0 of section 5
+~ Ordered step 1 of section 5
+~ Ordered step 2 of section 5
+~ Ordered step 3 of section 5
+
+> Quoted wisdom number 5 about parsing performance.
+
+___
+
+** Section 6
+
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+dog while parsing norg blocks with bold italic
+
+- (=) Task 6.0 for the agenda scanner
+-- ( ) nested item under 6.0
+- (_) Task 6.1 for the agenda scanner
+- ( ) Task 6.2 for the agenda scanner
+- (x) Task 6.3 for the agenda scanner
+-- (!) nested item under 6.3
+- (?) Task 6.4 for the agenda scanner
+- (!) Task 6.5 for the agenda scanner
+
+~ Ordered step 0 of section 6
+~ Ordered step 1 of section 6
+~ Ordered step 2 of section 6
+~ Ordered step 3 of section 6
+
+> Quoted wisdom number 6 about parsing performance.
+
+** Section 7
+
+lazy dog while parsing norg blocks with *bold*
+dog while parsing norg blocks with bold italic
+while parsing norg blocks with bold italic and
+
+- (_) Task 7.0 for the agenda scanner
+-- ( ) nested item under 7.0
+- ( ) Task 7.1 for the agenda scanner
+- (x) Task 7.2 for the agenda scanner
+- (?) Task 7.3 for the agenda scanner
+-- (!) nested item under 7.3
+- (!) Task 7.4 for the agenda scanner
+- (+) Task 7.5 for the agenda scanner
+
+~ Ordered step 0 of section 7
+~ Ordered step 1 of section 7
+~ Ordered step 2 of section 7
+~ Ordered step 3 of section 7
+
+> Quoted wisdom number 7 about parsing performance.
+
+@math
+\sum_{i=0}^{7} i = \frac{7(7+1)}{2}
+@end
+
+** Section 8
+
+dog while parsing norg blocks with *bold* /italic/
+while parsing norg blocks with bold italic and
+parsing norg blocks with bold italic and verbatim
+
+- ( ) Task 8.0 for the agenda scanner
+-- ( ) nested item under 8.0
+- (x) Task 8.1 for the agenda scanner
+- (?) Task 8.2 for the agenda scanner
+- (!) Task 8.3 for the agenda scanner
+-- (!) nested item under 8.3
+- (+) Task 8.4 for the agenda scanner
+- (-) Task 8.5 for the agenda scanner
+
+~ Ordered step 0 of section 8
+~ Ordered step 1 of section 8
+~ Ordered step 2 of section 8
+~ Ordered step 3 of section 8
+
+> Quoted wisdom number 8 about parsing performance.
+
+@code swift
+let section8 = NorgParser.parse(source) // 8
+print(section8.blocks.count)
+@end
+
+** Section 9
+
+while parsing norg blocks with *bold* /italic/ and
+parsing norg blocks with bold italic and verbatim
+norg blocks with bold italic and verbatim runs
+
+- (x) Task 9.0 for the agenda scanner
+-- ( ) nested item under 9.0
+- (?) Task 9.1 for the agenda scanner
+- (!) Task 9.2 for the agenda scanner
+- (+) Task 9.3 for the agenda scanner
+-- (!) nested item under 9.3
+- (-) Task 9.4 for the agenda scanner
+- (=) Task 9.5 for the agenda scanner
+
+~ Ordered step 0 of section 9
+~ Ordered step 1 of section 9
+~ Ordered step 2 of section 9
+~ Ordered step 3 of section 9
+
+> Quoted wisdom number 9 about parsing performance.
+
+** Section 10
+
+parsing norg blocks with *bold* /italic/ and `verbatim`
+norg blocks with bold italic and verbatim runs
+blocks with bold italic and verbatim runs scattered
+
+- (?) Task 10.0 for the agenda scanner
+-- ( ) nested item under 10.0
+- (!) Task 10.1 for the agenda scanner
+- (+) Task 10.2 for the agenda scanner
+- (-) Task 10.3 for the agenda scanner
+-- (!) nested item under 10.3
+- (=) Task 10.4 for the agenda scanner
+- (_) Task 10.5 for the agenda scanner
+
+~ Ordered step 0 of section 10
+~ Ordered step 1 of section 10
+~ Ordered step 2 of section 10
+~ Ordered step 3 of section 10
+
+> Quoted wisdom number 10 about parsing performance.
+
+___
+
+** Section 11
+
+norg blocks with *bold* /italic/ and `verbatim` runs
+blocks with bold italic and verbatim runs scattered
+with bold italic and verbatim runs scattered throughout
+
+- (!) Task 11.0 for the agenda scanner
+-- ( ) nested item under 11.0
+- (+) Task 11.1 for the agenda scanner
+- (-) Task 11.2 for the agenda scanner
+- (=) Task 11.3 for the agenda scanner
+-- (!) nested item under 11.3
+- (_) Task 11.4 for the agenda scanner
+- ( ) Task 11.5 for the agenda scanner
+
+~ Ordered step 0 of section 11
+~ Ordered step 1 of section 11
+~ Ordered step 2 of section 11
+~ Ordered step 3 of section 11
+
+> Quoted wisdom number 11 about parsing performance.
+
+** Section 12
+
+blocks with *bold* /italic/ and `verbatim` runs scattered
+with bold italic and verbatim runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+
+- (+) Task 12.0 for the agenda scanner
+-- ( ) nested item under 12.0
+- (-) Task 12.1 for the agenda scanner
+- (=) Task 12.2 for the agenda scanner
+- (_) Task 12.3 for the agenda scanner
+-- (!) nested item under 12.3
+- ( ) Task 12.4 for the agenda scanner
+- (x) Task 12.5 for the agenda scanner
+
+~ Ordered step 0 of section 12
+~ Ordered step 1 of section 12
+~ Ordered step 2 of section 12
+~ Ordered step 3 of section 12
+
+> Quoted wisdom number 12 about parsing performance.
+
+@code swift
+let section12 = NorgParser.parse(source) // 12
+print(section12.blocks.count)
+@end
+
+** Section 13
+
+with *bold* /italic/ and `verbatim` runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+
+- (-) Task 13.0 for the agenda scanner
+-- ( ) nested item under 13.0
+- (=) Task 13.1 for the agenda scanner
+- (_) Task 13.2 for the agenda scanner
+- ( ) Task 13.3 for the agenda scanner
+-- (!) nested item under 13.3
+- (x) Task 13.4 for the agenda scanner
+- (?) Task 13.5 for the agenda scanner
+
+~ Ordered step 0 of section 13
+~ Ordered step 1 of section 13
+~ Ordered step 2 of section 13
+~ Ordered step 3 of section 13
+
+> Quoted wisdom number 13 about parsing performance.
+
+** Section 14
+
+*bold* /italic/ and `verbatim` runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+
+- (=) Task 14.0 for the agenda scanner
+-- ( ) nested item under 14.0
+- (_) Task 14.1 for the agenda scanner
+- ( ) Task 14.2 for the agenda scanner
+- (x) Task 14.3 for the agenda scanner
+-- (!) nested item under 14.3
+- (?) Task 14.4 for the agenda scanner
+- (!) Task 14.5 for the agenda scanner
+
+~ Ordered step 0 of section 14
+~ Ordered step 1 of section 14
+~ Ordered step 2 of section 14
+~ Ordered step 3 of section 14
+
+> Quoted wisdom number 14 about parsing performance.
+
+@math
+\sum_{i=0}^{14} i = \frac{14(14+1)}{2}
+@end
+
+** Section 15
+
+/italic/ and `verbatim` runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+
+- (_) Task 15.0 for the agenda scanner
+-- ( ) nested item under 15.0
+- ( ) Task 15.1 for the agenda scanner
+- (x) Task 15.2 for the agenda scanner
+- (?) Task 15.3 for the agenda scanner
+-- (!) nested item under 15.3
+- (!) Task 15.4 for the agenda scanner
+- (+) Task 15.5 for the agenda scanner
+
+~ Ordered step 0 of section 15
+~ Ordered step 1 of section 15
+~ Ordered step 2 of section 15
+~ Ordered step 3 of section 15
+
+> Quoted wisdom number 15 about parsing performance.
+
+___
+
+** Section 16
+
+and `verbatim` runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+runs scattered throughout the prose
+
+- ( ) Task 16.0 for the agenda scanner
+-- ( ) nested item under 16.0
+- (x) Task 16.1 for the agenda scanner
+- (?) Task 16.2 for the agenda scanner
+- (!) Task 16.3 for the agenda scanner
+-- (!) nested item under 16.3
+- (+) Task 16.4 for the agenda scanner
+- (-) Task 16.5 for the agenda scanner
+
+~ Ordered step 0 of section 16
+~ Ordered step 1 of section 16
+~ Ordered step 2 of section 16
+~ Ordered step 3 of section 16
+
+> Quoted wisdom number 16 about parsing performance.
+
+@code swift
+let section16 = NorgParser.parse(source) // 16
+print(section16.blocks.count)
+@end
+
+** Section 17
+
+`verbatim` runs scattered throughout the prose
+runs scattered throughout the prose
+scattered throughout the prose
+
+- (x) Task 17.0 for the agenda scanner
+-- ( ) nested item under 17.0
+- (?) Task 17.1 for the agenda scanner
+- (!) Task 17.2 for the agenda scanner
+- (+) Task 17.3 for the agenda scanner
+-- (!) nested item under 17.3
+- (-) Task 17.4 for the agenda scanner
+- (=) Task 17.5 for the agenda scanner
+
+~ Ordered step 0 of section 17
+~ Ordered step 1 of section 17
+~ Ordered step 2 of section 17
+~ Ordered step 3 of section 17
+
+> Quoted wisdom number 17 about parsing performance.
+
+** Section 18
+
+runs scattered throughout the prose
+scattered throughout the prose
+throughout the prose
+
+- (?) Task 18.0 for the agenda scanner
+-- ( ) nested item under 18.0
+- (!) Task 18.1 for the agenda scanner
+- (+) Task 18.2 for the agenda scanner
+- (-) Task 18.3 for the agenda scanner
+-- (!) nested item under 18.3
+- (=) Task 18.4 for the agenda scanner
+- (_) Task 18.5 for the agenda scanner
+
+~ Ordered step 0 of section 18
+~ Ordered step 1 of section 18
+~ Ordered step 2 of section 18
+~ Ordered step 3 of section 18
+
+> Quoted wisdom number 18 about parsing performance.
+
+** Section 19
+
+scattered throughout the prose
+throughout the prose
+the prose
+
+- (!) Task 19.0 for the agenda scanner
+-- ( ) nested item under 19.0
+- (+) Task 19.1 for the agenda scanner
+- (-) Task 19.2 for the agenda scanner
+- (=) Task 19.3 for the agenda scanner
+-- (!) nested item under 19.3
+- (_) Task 19.4 for the agenda scanner
+- ( ) Task 19.5 for the agenda scanner
+
+~ Ordered step 0 of section 19
+~ Ordered step 1 of section 19
+~ Ordered step 2 of section 19
+~ Ordered step 3 of section 19
+
+> Quoted wisdom number 19 about parsing performance.
+
+** Section 20
+
+throughout the prose
+the prose
+prose
+
+- (+) Task 20.0 for the agenda scanner
+-- ( ) nested item under 20.0
+- (-) Task 20.1 for the agenda scanner
+- (=) Task 20.2 for the agenda scanner
+- (_) Task 20.3 for the agenda scanner
+-- (!) nested item under 20.3
+- ( ) Task 20.4 for the agenda scanner
+- (x) Task 20.5 for the agenda scanner
+
+~ Ordered step 0 of section 20
+~ Ordered step 1 of section 20
+~ Ordered step 2 of section 20
+~ Ordered step 3 of section 20
+
+> Quoted wisdom number 20 about parsing performance.
+
+@code swift
+let section20 = NorgParser.parse(source) // 20
+print(section20.blocks.count)
+@end
+
+___
+
+** Section 21
+
+the prose
+prose
+the quick brown fox jumps over a lazy
+
+- (-) Task 21.0 for the agenda scanner
+-- ( ) nested item under 21.0
+- (=) Task 21.1 for the agenda scanner
+- (_) Task 21.2 for the agenda scanner
+- ( ) Task 21.3 for the agenda scanner
+-- (!) nested item under 21.3
+- (x) Task 21.4 for the agenda scanner
+- (?) Task 21.5 for the agenda scanner
+
+~ Ordered step 0 of section 21
+~ Ordered step 1 of section 21
+~ Ordered step 2 of section 21
+~ Ordered step 3 of section 21
+
+> Quoted wisdom number 21 about parsing performance.
+
+@math
+\sum_{i=0}^{21} i = \frac{21(21+1)}{2}
+@end
+
+** Section 22
+
+prose
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+
+- (=) Task 22.0 for the agenda scanner
+-- ( ) nested item under 22.0
+- (_) Task 22.1 for the agenda scanner
+- ( ) Task 22.2 for the agenda scanner
+- (x) Task 22.3 for the agenda scanner
+-- (!) nested item under 22.3
+- (?) Task 22.4 for the agenda scanner
+- (!) Task 22.5 for the agenda scanner
+
+~ Ordered step 0 of section 22
+~ Ordered step 1 of section 22
+~ Ordered step 2 of section 22
+~ Ordered step 3 of section 22
+
+> Quoted wisdom number 22 about parsing performance.
+
+** Section 23
+
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+
+- (_) Task 23.0 for the agenda scanner
+-- ( ) nested item under 23.0
+- ( ) Task 23.1 for the agenda scanner
+- (x) Task 23.2 for the agenda scanner
+- (?) Task 23.3 for the agenda scanner
+-- (!) nested item under 23.3
+- (!) Task 23.4 for the agenda scanner
+- (+) Task 23.5 for the agenda scanner
+
+~ Ordered step 0 of section 23
+~ Ordered step 1 of section 23
+~ Ordered step 2 of section 23
+~ Ordered step 3 of section 23
+
+> Quoted wisdom number 23 about parsing performance.
+
+** Section 24
+
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+
+- ( ) Task 24.0 for the agenda scanner
+-- ( ) nested item under 24.0
+- (x) Task 24.1 for the agenda scanner
+- (?) Task 24.2 for the agenda scanner
+- (!) Task 24.3 for the agenda scanner
+-- (!) nested item under 24.3
+- (+) Task 24.4 for the agenda scanner
+- (-) Task 24.5 for the agenda scanner
+
+~ Ordered step 0 of section 24
+~ Ordered step 1 of section 24
+~ Ordered step 2 of section 24
+~ Ordered step 3 of section 24
+
+> Quoted wisdom number 24 about parsing performance.
+
+@code swift
+let section24 = NorgParser.parse(source) // 24
+print(section24.blocks.count)
+@end
+
+** Section 25
+
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+
+- (x) Task 25.0 for the agenda scanner
+-- ( ) nested item under 25.0
+- (?) Task 25.1 for the agenda scanner
+- (!) Task 25.2 for the agenda scanner
+- (+) Task 25.3 for the agenda scanner
+-- (!) nested item under 25.3
+- (-) Task 25.4 for the agenda scanner
+- (=) Task 25.5 for the agenda scanner
+
+~ Ordered step 0 of section 25
+~ Ordered step 1 of section 25
+~ Ordered step 2 of section 25
+~ Ordered step 3 of section 25
+
+> Quoted wisdom number 25 about parsing performance.
+
+___
+
+** Section 26
+
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+
+- (?) Task 26.0 for the agenda scanner
+-- ( ) nested item under 26.0
+- (!) Task 26.1 for the agenda scanner
+- (+) Task 26.2 for the agenda scanner
+- (-) Task 26.3 for the agenda scanner
+-- (!) nested item under 26.3
+- (=) Task 26.4 for the agenda scanner
+- (_) Task 26.5 for the agenda scanner
+
+~ Ordered step 0 of section 26
+~ Ordered step 1 of section 26
+~ Ordered step 2 of section 26
+~ Ordered step 3 of section 26
+
+> Quoted wisdom number 26 about parsing performance.
+
+** Section 27
+
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+
+- (!) Task 27.0 for the agenda scanner
+-- ( ) nested item under 27.0
+- (+) Task 27.1 for the agenda scanner
+- (-) Task 27.2 for the agenda scanner
+- (=) Task 27.3 for the agenda scanner
+-- (!) nested item under 27.3
+- (_) Task 27.4 for the agenda scanner
+- ( ) Task 27.5 for the agenda scanner
+
+~ Ordered step 0 of section 27
+~ Ordered step 1 of section 27
+~ Ordered step 2 of section 27
+~ Ordered step 3 of section 27
+
+> Quoted wisdom number 27 about parsing performance.
+
+** Section 28
+
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+
+- (+) Task 28.0 for the agenda scanner
+-- ( ) nested item under 28.0
+- (-) Task 28.1 for the agenda scanner
+- (=) Task 28.2 for the agenda scanner
+- (_) Task 28.3 for the agenda scanner
+-- (!) nested item under 28.3
+- ( ) Task 28.4 for the agenda scanner
+- (x) Task 28.5 for the agenda scanner
+
+~ Ordered step 0 of section 28
+~ Ordered step 1 of section 28
+~ Ordered step 2 of section 28
+~ Ordered step 3 of section 28
+
+> Quoted wisdom number 28 about parsing performance.
+
+@code swift
+let section28 = NorgParser.parse(source) // 28
+print(section28.blocks.count)
+@end
+
+@math
+\sum_{i=0}^{28} i = \frac{28(28+1)}{2}
+@end
+
+** Section 29
+
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+dog while parsing norg blocks with bold italic
+
+- (-) Task 29.0 for the agenda scanner
+-- ( ) nested item under 29.0
+- (=) Task 29.1 for the agenda scanner
+- (_) Task 29.2 for the agenda scanner
+- ( ) Task 29.3 for the agenda scanner
+-- (!) nested item under 29.3
+- (x) Task 29.4 for the agenda scanner
+- (?) Task 29.5 for the agenda scanner
+
+~ Ordered step 0 of section 29
+~ Ordered step 1 of section 29
+~ Ordered step 2 of section 29
+~ Ordered step 3 of section 29
+
+> Quoted wisdom number 29 about parsing performance.
+
+** Section 30
+
+lazy dog while parsing norg blocks with *bold*
+dog while parsing norg blocks with bold italic
+while parsing norg blocks with bold italic and
+
+- (=) Task 30.0 for the agenda scanner
+-- ( ) nested item under 30.0
+- (_) Task 30.1 for the agenda scanner
+- ( ) Task 30.2 for the agenda scanner
+- (x) Task 30.3 for the agenda scanner
+-- (!) nested item under 30.3
+- (?) Task 30.4 for the agenda scanner
+- (!) Task 30.5 for the agenda scanner
+
+~ Ordered step 0 of section 30
+~ Ordered step 1 of section 30
+~ Ordered step 2 of section 30
+~ Ordered step 3 of section 30
+
+> Quoted wisdom number 30 about parsing performance.
+
+___
+
+** Section 31
+
+dog while parsing norg blocks with *bold* /italic/
+while parsing norg blocks with bold italic and
+parsing norg blocks with bold italic and verbatim
+
+- (_) Task 31.0 for the agenda scanner
+-- ( ) nested item under 31.0
+- ( ) Task 31.1 for the agenda scanner
+- (x) Task 31.2 for the agenda scanner
+- (?) Task 31.3 for the agenda scanner
+-- (!) nested item under 31.3
+- (!) Task 31.4 for the agenda scanner
+- (+) Task 31.5 for the agenda scanner
+
+~ Ordered step 0 of section 31
+~ Ordered step 1 of section 31
+~ Ordered step 2 of section 31
+~ Ordered step 3 of section 31
+
+> Quoted wisdom number 31 about parsing performance.
+
+** Section 32
+
+while parsing norg blocks with *bold* /italic/ and
+parsing norg blocks with bold italic and verbatim
+norg blocks with bold italic and verbatim runs
+
+- ( ) Task 32.0 for the agenda scanner
+-- ( ) nested item under 32.0
+- (x) Task 32.1 for the agenda scanner
+- (?) Task 32.2 for the agenda scanner
+- (!) Task 32.3 for the agenda scanner
+-- (!) nested item under 32.3
+- (+) Task 32.4 for the agenda scanner
+- (-) Task 32.5 for the agenda scanner
+
+~ Ordered step 0 of section 32
+~ Ordered step 1 of section 32
+~ Ordered step 2 of section 32
+~ Ordered step 3 of section 32
+
+> Quoted wisdom number 32 about parsing performance.
+
+@code swift
+let section32 = NorgParser.parse(source) // 32
+print(section32.blocks.count)
+@end
+
+** Section 33
+
+parsing norg blocks with *bold* /italic/ and `verbatim`
+norg blocks with bold italic and verbatim runs
+blocks with bold italic and verbatim runs scattered
+
+- (x) Task 33.0 for the agenda scanner
+-- ( ) nested item under 33.0
+- (?) Task 33.1 for the agenda scanner
+- (!) Task 33.2 for the agenda scanner
+- (+) Task 33.3 for the agenda scanner
+-- (!) nested item under 33.3
+- (-) Task 33.4 for the agenda scanner
+- (=) Task 33.5 for the agenda scanner
+
+~ Ordered step 0 of section 33
+~ Ordered step 1 of section 33
+~ Ordered step 2 of section 33
+~ Ordered step 3 of section 33
+
+> Quoted wisdom number 33 about parsing performance.
+
+** Section 34
+
+norg blocks with *bold* /italic/ and `verbatim` runs
+blocks with bold italic and verbatim runs scattered
+with bold italic and verbatim runs scattered throughout
+
+- (?) Task 34.0 for the agenda scanner
+-- ( ) nested item under 34.0
+- (!) Task 34.1 for the agenda scanner
+- (+) Task 34.2 for the agenda scanner
+- (-) Task 34.3 for the agenda scanner
+-- (!) nested item under 34.3
+- (=) Task 34.4 for the agenda scanner
+- (_) Task 34.5 for the agenda scanner
+
+~ Ordered step 0 of section 34
+~ Ordered step 1 of section 34
+~ Ordered step 2 of section 34
+~ Ordered step 3 of section 34
+
+> Quoted wisdom number 34 about parsing performance.
+
+** Section 35
+
+blocks with *bold* /italic/ and `verbatim` runs scattered
+with bold italic and verbatim runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+
+- (!) Task 35.0 for the agenda scanner
+-- ( ) nested item under 35.0
+- (+) Task 35.1 for the agenda scanner
+- (-) Task 35.2 for the agenda scanner
+- (=) Task 35.3 for the agenda scanner
+-- (!) nested item under 35.3
+- (_) Task 35.4 for the agenda scanner
+- ( ) Task 35.5 for the agenda scanner
+
+~ Ordered step 0 of section 35
+~ Ordered step 1 of section 35
+~ Ordered step 2 of section 35
+~ Ordered step 3 of section 35
+
+> Quoted wisdom number 35 about parsing performance.
+
+@math
+\sum_{i=0}^{35} i = \frac{35(35+1)}{2}
+@end
+
+___
+
+** Section 36
+
+with *bold* /italic/ and `verbatim` runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+
+- (+) Task 36.0 for the agenda scanner
+-- ( ) nested item under 36.0
+- (-) Task 36.1 for the agenda scanner
+- (=) Task 36.2 for the agenda scanner
+- (_) Task 36.3 for the agenda scanner
+-- (!) nested item under 36.3
+- ( ) Task 36.4 for the agenda scanner
+- (x) Task 36.5 for the agenda scanner
+
+~ Ordered step 0 of section 36
+~ Ordered step 1 of section 36
+~ Ordered step 2 of section 36
+~ Ordered step 3 of section 36
+
+> Quoted wisdom number 36 about parsing performance.
+
+@code swift
+let section36 = NorgParser.parse(source) // 36
+print(section36.blocks.count)
+@end
+
+** Section 37
+
+*bold* /italic/ and `verbatim` runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+
+- (-) Task 37.0 for the agenda scanner
+-- ( ) nested item under 37.0
+- (=) Task 37.1 for the agenda scanner
+- (_) Task 37.2 for the agenda scanner
+- ( ) Task 37.3 for the agenda scanner
+-- (!) nested item under 37.3
+- (x) Task 37.4 for the agenda scanner
+- (?) Task 37.5 for the agenda scanner
+
+~ Ordered step 0 of section 37
+~ Ordered step 1 of section 37
+~ Ordered step 2 of section 37
+~ Ordered step 3 of section 37
+
+> Quoted wisdom number 37 about parsing performance.
+
+** Section 38
+
+/italic/ and `verbatim` runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+
+- (=) Task 38.0 for the agenda scanner
+-- ( ) nested item under 38.0
+- (_) Task 38.1 for the agenda scanner
+- ( ) Task 38.2 for the agenda scanner
+- (x) Task 38.3 for the agenda scanner
+-- (!) nested item under 38.3
+- (?) Task 38.4 for the agenda scanner
+- (!) Task 38.5 for the agenda scanner
+
+~ Ordered step 0 of section 38
+~ Ordered step 1 of section 38
+~ Ordered step 2 of section 38
+~ Ordered step 3 of section 38
+
+> Quoted wisdom number 38 about parsing performance.
+
+** Section 39
+
+and `verbatim` runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+runs scattered throughout the prose
+
+- (_) Task 39.0 for the agenda scanner
+-- ( ) nested item under 39.0
+- ( ) Task 39.1 for the agenda scanner
+- (x) Task 39.2 for the agenda scanner
+- (?) Task 39.3 for the agenda scanner
+-- (!) nested item under 39.3
+- (!) Task 39.4 for the agenda scanner
+- (+) Task 39.5 for the agenda scanner
+
+~ Ordered step 0 of section 39
+~ Ordered step 1 of section 39
+~ Ordered step 2 of section 39
+~ Ordered step 3 of section 39
+
+> Quoted wisdom number 39 about parsing performance.
+
+** Section 40
+
+`verbatim` runs scattered throughout the prose
+runs scattered throughout the prose
+scattered throughout the prose
+
+- ( ) Task 40.0 for the agenda scanner
+-- ( ) nested item under 40.0
+- (x) Task 40.1 for the agenda scanner
+- (?) Task 40.2 for the agenda scanner
+- (!) Task 40.3 for the agenda scanner
+-- (!) nested item under 40.3
+- (+) Task 40.4 for the agenda scanner
+- (-) Task 40.5 for the agenda scanner
+
+~ Ordered step 0 of section 40
+~ Ordered step 1 of section 40
+~ Ordered step 2 of section 40
+~ Ordered step 3 of section 40
+
+> Quoted wisdom number 40 about parsing performance.
+
+@code swift
+let section40 = NorgParser.parse(source) // 40
+print(section40.blocks.count)
+@end
+
+___
+
+** Section 41
+
+runs scattered throughout the prose
+scattered throughout the prose
+throughout the prose
+
+- (x) Task 41.0 for the agenda scanner
+-- ( ) nested item under 41.0
+- (?) Task 41.1 for the agenda scanner
+- (!) Task 41.2 for the agenda scanner
+- (+) Task 41.3 for the agenda scanner
+-- (!) nested item under 41.3
+- (-) Task 41.4 for the agenda scanner
+- (=) Task 41.5 for the agenda scanner
+
+~ Ordered step 0 of section 41
+~ Ordered step 1 of section 41
+~ Ordered step 2 of section 41
+~ Ordered step 3 of section 41
+
+> Quoted wisdom number 41 about parsing performance.
+
+** Section 42
+
+scattered throughout the prose
+throughout the prose
+the prose
+
+- (?) Task 42.0 for the agenda scanner
+-- ( ) nested item under 42.0
+- (!) Task 42.1 for the agenda scanner
+- (+) Task 42.2 for the agenda scanner
+- (-) Task 42.3 for the agenda scanner
+-- (!) nested item under 42.3
+- (=) Task 42.4 for the agenda scanner
+- (_) Task 42.5 for the agenda scanner
+
+~ Ordered step 0 of section 42
+~ Ordered step 1 of section 42
+~ Ordered step 2 of section 42
+~ Ordered step 3 of section 42
+
+> Quoted wisdom number 42 about parsing performance.
+
+@math
+\sum_{i=0}^{42} i = \frac{42(42+1)}{2}
+@end
+
+** Section 43
+
+throughout the prose
+the prose
+prose
+
+- (!) Task 43.0 for the agenda scanner
+-- ( ) nested item under 43.0
+- (+) Task 43.1 for the agenda scanner
+- (-) Task 43.2 for the agenda scanner
+- (=) Task 43.3 for the agenda scanner
+-- (!) nested item under 43.3
+- (_) Task 43.4 for the agenda scanner
+- ( ) Task 43.5 for the agenda scanner
+
+~ Ordered step 0 of section 43
+~ Ordered step 1 of section 43
+~ Ordered step 2 of section 43
+~ Ordered step 3 of section 43
+
+> Quoted wisdom number 43 about parsing performance.
+
+** Section 44
+
+the prose
+prose
+the quick brown fox jumps over a lazy
+
+- (+) Task 44.0 for the agenda scanner
+-- ( ) nested item under 44.0
+- (-) Task 44.1 for the agenda scanner
+- (=) Task 44.2 for the agenda scanner
+- (_) Task 44.3 for the agenda scanner
+-- (!) nested item under 44.3
+- ( ) Task 44.4 for the agenda scanner
+- (x) Task 44.5 for the agenda scanner
+
+~ Ordered step 0 of section 44
+~ Ordered step 1 of section 44
+~ Ordered step 2 of section 44
+~ Ordered step 3 of section 44
+
+> Quoted wisdom number 44 about parsing performance.
+
+@code swift
+let section44 = NorgParser.parse(source) // 44
+print(section44.blocks.count)
+@end
+
+** Section 45
+
+prose
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+
+- (-) Task 45.0 for the agenda scanner
+-- ( ) nested item under 45.0
+- (=) Task 45.1 for the agenda scanner
+- (_) Task 45.2 for the agenda scanner
+- ( ) Task 45.3 for the agenda scanner
+-- (!) nested item under 45.3
+- (x) Task 45.4 for the agenda scanner
+- (?) Task 45.5 for the agenda scanner
+
+~ Ordered step 0 of section 45
+~ Ordered step 1 of section 45
+~ Ordered step 2 of section 45
+~ Ordered step 3 of section 45
+
+> Quoted wisdom number 45 about parsing performance.
+
+___
+
+** Section 46
+
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+
+- (=) Task 46.0 for the agenda scanner
+-- ( ) nested item under 46.0
+- (_) Task 46.1 for the agenda scanner
+- ( ) Task 46.2 for the agenda scanner
+- (x) Task 46.3 for the agenda scanner
+-- (!) nested item under 46.3
+- (?) Task 46.4 for the agenda scanner
+- (!) Task 46.5 for the agenda scanner
+
+~ Ordered step 0 of section 46
+~ Ordered step 1 of section 46
+~ Ordered step 2 of section 46
+~ Ordered step 3 of section 46
+
+> Quoted wisdom number 46 about parsing performance.
+
+** Section 47
+
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+
+- (_) Task 47.0 for the agenda scanner
+-- ( ) nested item under 47.0
+- ( ) Task 47.1 for the agenda scanner
+- (x) Task 47.2 for the agenda scanner
+- (?) Task 47.3 for the agenda scanner
+-- (!) nested item under 47.3
+- (!) Task 47.4 for the agenda scanner
+- (+) Task 47.5 for the agenda scanner
+
+~ Ordered step 0 of section 47
+~ Ordered step 1 of section 47
+~ Ordered step 2 of section 47
+~ Ordered step 3 of section 47
+
+> Quoted wisdom number 47 about parsing performance.
+
+** Section 48
+
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+
+- ( ) Task 48.0 for the agenda scanner
+-- ( ) nested item under 48.0
+- (x) Task 48.1 for the agenda scanner
+- (?) Task 48.2 for the agenda scanner
+- (!) Task 48.3 for the agenda scanner
+-- (!) nested item under 48.3
+- (+) Task 48.4 for the agenda scanner
+- (-) Task 48.5 for the agenda scanner
+
+~ Ordered step 0 of section 48
+~ Ordered step 1 of section 48
+~ Ordered step 2 of section 48
+~ Ordered step 3 of section 48
+
+> Quoted wisdom number 48 about parsing performance.
+
+@code swift
+let section48 = NorgParser.parse(source) // 48
+print(section48.blocks.count)
+@end
+
+** Section 49
+
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+
+- (x) Task 49.0 for the agenda scanner
+-- ( ) nested item under 49.0
+- (?) Task 49.1 for the agenda scanner
+- (!) Task 49.2 for the agenda scanner
+- (+) Task 49.3 for the agenda scanner
+-- (!) nested item under 49.3
+- (-) Task 49.4 for the agenda scanner
+- (=) Task 49.5 for the agenda scanner
+
+~ Ordered step 0 of section 49
+~ Ordered step 1 of section 49
+~ Ordered step 2 of section 49
+~ Ordered step 3 of section 49
+
+> Quoted wisdom number 49 about parsing performance.
+
+@math
+\sum_{i=0}^{49} i = \frac{49(49+1)}{2}
+@end
+
+** Section 50
+
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+
+- (?) Task 50.0 for the agenda scanner
+-- ( ) nested item under 50.0
+- (!) Task 50.1 for the agenda scanner
+- (+) Task 50.2 for the agenda scanner
+- (-) Task 50.3 for the agenda scanner
+-- (!) nested item under 50.3
+- (=) Task 50.4 for the agenda scanner
+- (_) Task 50.5 for the agenda scanner
+
+~ Ordered step 0 of section 50
+~ Ordered step 1 of section 50
+~ Ordered step 2 of section 50
+~ Ordered step 3 of section 50
+
+> Quoted wisdom number 50 about parsing performance.
+
+___
+
+** Section 51
+
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+
+- (!) Task 51.0 for the agenda scanner
+-- ( ) nested item under 51.0
+- (+) Task 51.1 for the agenda scanner
+- (-) Task 51.2 for the agenda scanner
+- (=) Task 51.3 for the agenda scanner
+-- (!) nested item under 51.3
+- (_) Task 51.4 for the agenda scanner
+- ( ) Task 51.5 for the agenda scanner
+
+~ Ordered step 0 of section 51
+~ Ordered step 1 of section 51
+~ Ordered step 2 of section 51
+~ Ordered step 3 of section 51
+
+> Quoted wisdom number 51 about parsing performance.
+
+** Section 52
+
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+dog while parsing norg blocks with bold italic
+
+- (+) Task 52.0 for the agenda scanner
+-- ( ) nested item under 52.0
+- (-) Task 52.1 for the agenda scanner
+- (=) Task 52.2 for the agenda scanner
+- (_) Task 52.3 for the agenda scanner
+-- (!) nested item under 52.3
+- ( ) Task 52.4 for the agenda scanner
+- (x) Task 52.5 for the agenda scanner
+
+~ Ordered step 0 of section 52
+~ Ordered step 1 of section 52
+~ Ordered step 2 of section 52
+~ Ordered step 3 of section 52
+
+> Quoted wisdom number 52 about parsing performance.
+
+@code swift
+let section52 = NorgParser.parse(source) // 52
+print(section52.blocks.count)
+@end
+
+** Section 53
+
+lazy dog while parsing norg blocks with *bold*
+dog while parsing norg blocks with bold italic
+while parsing norg blocks with bold italic and
+
+- (-) Task 53.0 for the agenda scanner
+-- ( ) nested item under 53.0
+- (=) Task 53.1 for the agenda scanner
+- (_) Task 53.2 for the agenda scanner
+- ( ) Task 53.3 for the agenda scanner
+-- (!) nested item under 53.3
+- (x) Task 53.4 for the agenda scanner
+- (?) Task 53.5 for the agenda scanner
+
+~ Ordered step 0 of section 53
+~ Ordered step 1 of section 53
+~ Ordered step 2 of section 53
+~ Ordered step 3 of section 53
+
+> Quoted wisdom number 53 about parsing performance.
+
+** Section 54
+
+dog while parsing norg blocks with *bold* /italic/
+while parsing norg blocks with bold italic and
+parsing norg blocks with bold italic and verbatim
+
+- (=) Task 54.0 for the agenda scanner
+-- ( ) nested item under 54.0
+- (_) Task 54.1 for the agenda scanner
+- ( ) Task 54.2 for the agenda scanner
+- (x) Task 54.3 for the agenda scanner
+-- (!) nested item under 54.3
+- (?) Task 54.4 for the agenda scanner
+- (!) Task 54.5 for the agenda scanner
+
+~ Ordered step 0 of section 54
+~ Ordered step 1 of section 54
+~ Ordered step 2 of section 54
+~ Ordered step 3 of section 54
+
+> Quoted wisdom number 54 about parsing performance.
+
+** Section 55
+
+while parsing norg blocks with *bold* /italic/ and
+parsing norg blocks with bold italic and verbatim
+norg blocks with bold italic and verbatim runs
+
+- (_) Task 55.0 for the agenda scanner
+-- ( ) nested item under 55.0
+- ( ) Task 55.1 for the agenda scanner
+- (x) Task 55.2 for the agenda scanner
+- (?) Task 55.3 for the agenda scanner
+-- (!) nested item under 55.3
+- (!) Task 55.4 for the agenda scanner
+- (+) Task 55.5 for the agenda scanner
+
+~ Ordered step 0 of section 55
+~ Ordered step 1 of section 55
+~ Ordered step 2 of section 55
+~ Ordered step 3 of section 55
+
+> Quoted wisdom number 55 about parsing performance.
+
+___
+
+** Section 56
+
+parsing norg blocks with *bold* /italic/ and `verbatim`
+norg blocks with bold italic and verbatim runs
+blocks with bold italic and verbatim runs scattered
+
+- ( ) Task 56.0 for the agenda scanner
+-- ( ) nested item under 56.0
+- (x) Task 56.1 for the agenda scanner
+- (?) Task 56.2 for the agenda scanner
+- (!) Task 56.3 for the agenda scanner
+-- (!) nested item under 56.3
+- (+) Task 56.4 for the agenda scanner
+- (-) Task 56.5 for the agenda scanner
+
+~ Ordered step 0 of section 56
+~ Ordered step 1 of section 56
+~ Ordered step 2 of section 56
+~ Ordered step 3 of section 56
+
+> Quoted wisdom number 56 about parsing performance.
+
+@code swift
+let section56 = NorgParser.parse(source) // 56
+print(section56.blocks.count)
+@end
+
+@math
+\sum_{i=0}^{56} i = \frac{56(56+1)}{2}
+@end
+
+** Section 57
+
+norg blocks with *bold* /italic/ and `verbatim` runs
+blocks with bold italic and verbatim runs scattered
+with bold italic and verbatim runs scattered throughout
+
+- (x) Task 57.0 for the agenda scanner
+-- ( ) nested item under 57.0
+- (?) Task 57.1 for the agenda scanner
+- (!) Task 57.2 for the agenda scanner
+- (+) Task 57.3 for the agenda scanner
+-- (!) nested item under 57.3
+- (-) Task 57.4 for the agenda scanner
+- (=) Task 57.5 for the agenda scanner
+
+~ Ordered step 0 of section 57
+~ Ordered step 1 of section 57
+~ Ordered step 2 of section 57
+~ Ordered step 3 of section 57
+
+> Quoted wisdom number 57 about parsing performance.
+
+** Section 58
+
+blocks with *bold* /italic/ and `verbatim` runs scattered
+with bold italic and verbatim runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+
+- (?) Task 58.0 for the agenda scanner
+-- ( ) nested item under 58.0
+- (!) Task 58.1 for the agenda scanner
+- (+) Task 58.2 for the agenda scanner
+- (-) Task 58.3 for the agenda scanner
+-- (!) nested item under 58.3
+- (=) Task 58.4 for the agenda scanner
+- (_) Task 58.5 for the agenda scanner
+
+~ Ordered step 0 of section 58
+~ Ordered step 1 of section 58
+~ Ordered step 2 of section 58
+~ Ordered step 3 of section 58
+
+> Quoted wisdom number 58 about parsing performance.
+
+** Section 59
+
+with *bold* /italic/ and `verbatim` runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+
+- (!) Task 59.0 for the agenda scanner
+-- ( ) nested item under 59.0
+- (+) Task 59.1 for the agenda scanner
+- (-) Task 59.2 for the agenda scanner
+- (=) Task 59.3 for the agenda scanner
+-- (!) nested item under 59.3
+- (_) Task 59.4 for the agenda scanner
+- ( ) Task 59.5 for the agenda scanner
+
+~ Ordered step 0 of section 59
+~ Ordered step 1 of section 59
+~ Ordered step 2 of section 59
+~ Ordered step 3 of section 59
+
+> Quoted wisdom number 59 about parsing performance.
+
+** Section 60
+
+*bold* /italic/ and `verbatim` runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+
+- (+) Task 60.0 for the agenda scanner
+-- ( ) nested item under 60.0
+- (-) Task 60.1 for the agenda scanner
+- (=) Task 60.2 for the agenda scanner
+- (_) Task 60.3 for the agenda scanner
+-- (!) nested item under 60.3
+- ( ) Task 60.4 for the agenda scanner
+- (x) Task 60.5 for the agenda scanner
+
+~ Ordered step 0 of section 60
+~ Ordered step 1 of section 60
+~ Ordered step 2 of section 60
+~ Ordered step 3 of section 60
+
+> Quoted wisdom number 60 about parsing performance.
+
+@code swift
+let section60 = NorgParser.parse(source) // 60
+print(section60.blocks.count)
+@end
+
+___
+
+** Section 61
+
+/italic/ and `verbatim` runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+
+- (-) Task 61.0 for the agenda scanner
+-- ( ) nested item under 61.0
+- (=) Task 61.1 for the agenda scanner
+- (_) Task 61.2 for the agenda scanner
+- ( ) Task 61.3 for the agenda scanner
+-- (!) nested item under 61.3
+- (x) Task 61.4 for the agenda scanner
+- (?) Task 61.5 for the agenda scanner
+
+~ Ordered step 0 of section 61
+~ Ordered step 1 of section 61
+~ Ordered step 2 of section 61
+~ Ordered step 3 of section 61
+
+> Quoted wisdom number 61 about parsing performance.
+
+** Section 62
+
+and `verbatim` runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+runs scattered throughout the prose
+
+- (=) Task 62.0 for the agenda scanner
+-- ( ) nested item under 62.0
+- (_) Task 62.1 for the agenda scanner
+- ( ) Task 62.2 for the agenda scanner
+- (x) Task 62.3 for the agenda scanner
+-- (!) nested item under 62.3
+- (?) Task 62.4 for the agenda scanner
+- (!) Task 62.5 for the agenda scanner
+
+~ Ordered step 0 of section 62
+~ Ordered step 1 of section 62
+~ Ordered step 2 of section 62
+~ Ordered step 3 of section 62
+
+> Quoted wisdom number 62 about parsing performance.
+
+** Section 63
+
+`verbatim` runs scattered throughout the prose
+runs scattered throughout the prose
+scattered throughout the prose
+
+- (_) Task 63.0 for the agenda scanner
+-- ( ) nested item under 63.0
+- ( ) Task 63.1 for the agenda scanner
+- (x) Task 63.2 for the agenda scanner
+- (?) Task 63.3 for the agenda scanner
+-- (!) nested item under 63.3
+- (!) Task 63.4 for the agenda scanner
+- (+) Task 63.5 for the agenda scanner
+
+~ Ordered step 0 of section 63
+~ Ordered step 1 of section 63
+~ Ordered step 2 of section 63
+~ Ordered step 3 of section 63
+
+> Quoted wisdom number 63 about parsing performance.
+
+@math
+\sum_{i=0}^{63} i = \frac{63(63+1)}{2}
+@end
+
+** Section 64
+
+runs scattered throughout the prose
+scattered throughout the prose
+throughout the prose
+
+- ( ) Task 64.0 for the agenda scanner
+-- ( ) nested item under 64.0
+- (x) Task 64.1 for the agenda scanner
+- (?) Task 64.2 for the agenda scanner
+- (!) Task 64.3 for the agenda scanner
+-- (!) nested item under 64.3
+- (+) Task 64.4 for the agenda scanner
+- (-) Task 64.5 for the agenda scanner
+
+~ Ordered step 0 of section 64
+~ Ordered step 1 of section 64
+~ Ordered step 2 of section 64
+~ Ordered step 3 of section 64
+
+> Quoted wisdom number 64 about parsing performance.
+
+@code swift
+let section64 = NorgParser.parse(source) // 64
+print(section64.blocks.count)
+@end
+
+** Section 65
+
+scattered throughout the prose
+throughout the prose
+the prose
+
+- (x) Task 65.0 for the agenda scanner
+-- ( ) nested item under 65.0
+- (?) Task 65.1 for the agenda scanner
+- (!) Task 65.2 for the agenda scanner
+- (+) Task 65.3 for the agenda scanner
+-- (!) nested item under 65.3
+- (-) Task 65.4 for the agenda scanner
+- (=) Task 65.5 for the agenda scanner
+
+~ Ordered step 0 of section 65
+~ Ordered step 1 of section 65
+~ Ordered step 2 of section 65
+~ Ordered step 3 of section 65
+
+> Quoted wisdom number 65 about parsing performance.
+
+___
+
+** Section 66
+
+throughout the prose
+the prose
+prose
+
+- (?) Task 66.0 for the agenda scanner
+-- ( ) nested item under 66.0
+- (!) Task 66.1 for the agenda scanner
+- (+) Task 66.2 for the agenda scanner
+- (-) Task 66.3 for the agenda scanner
+-- (!) nested item under 66.3
+- (=) Task 66.4 for the agenda scanner
+- (_) Task 66.5 for the agenda scanner
+
+~ Ordered step 0 of section 66
+~ Ordered step 1 of section 66
+~ Ordered step 2 of section 66
+~ Ordered step 3 of section 66
+
+> Quoted wisdom number 66 about parsing performance.
+
+** Section 67
+
+the prose
+prose
+the quick brown fox jumps over a lazy
+
+- (!) Task 67.0 for the agenda scanner
+-- ( ) nested item under 67.0
+- (+) Task 67.1 for the agenda scanner
+- (-) Task 67.2 for the agenda scanner
+- (=) Task 67.3 for the agenda scanner
+-- (!) nested item under 67.3
+- (_) Task 67.4 for the agenda scanner
+- ( ) Task 67.5 for the agenda scanner
+
+~ Ordered step 0 of section 67
+~ Ordered step 1 of section 67
+~ Ordered step 2 of section 67
+~ Ordered step 3 of section 67
+
+> Quoted wisdom number 67 about parsing performance.
+
+** Section 68
+
+prose
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+
+- (+) Task 68.0 for the agenda scanner
+-- ( ) nested item under 68.0
+- (-) Task 68.1 for the agenda scanner
+- (=) Task 68.2 for the agenda scanner
+- (_) Task 68.3 for the agenda scanner
+-- (!) nested item under 68.3
+- ( ) Task 68.4 for the agenda scanner
+- (x) Task 68.5 for the agenda scanner
+
+~ Ordered step 0 of section 68
+~ Ordered step 1 of section 68
+~ Ordered step 2 of section 68
+~ Ordered step 3 of section 68
+
+> Quoted wisdom number 68 about parsing performance.
+
+@code swift
+let section68 = NorgParser.parse(source) // 68
+print(section68.blocks.count)
+@end
+
+** Section 69
+
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+
+- (-) Task 69.0 for the agenda scanner
+-- ( ) nested item under 69.0
+- (=) Task 69.1 for the agenda scanner
+- (_) Task 69.2 for the agenda scanner
+- ( ) Task 69.3 for the agenda scanner
+-- (!) nested item under 69.3
+- (x) Task 69.4 for the agenda scanner
+- (?) Task 69.5 for the agenda scanner
+
+~ Ordered step 0 of section 69
+~ Ordered step 1 of section 69
+~ Ordered step 2 of section 69
+~ Ordered step 3 of section 69
+
+> Quoted wisdom number 69 about parsing performance.
+
+** Section 70
+
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+
+- (=) Task 70.0 for the agenda scanner
+-- ( ) nested item under 70.0
+- (_) Task 70.1 for the agenda scanner
+- ( ) Task 70.2 for the agenda scanner
+- (x) Task 70.3 for the agenda scanner
+-- (!) nested item under 70.3
+- (?) Task 70.4 for the agenda scanner
+- (!) Task 70.5 for the agenda scanner
+
+~ Ordered step 0 of section 70
+~ Ordered step 1 of section 70
+~ Ordered step 2 of section 70
+~ Ordered step 3 of section 70
+
+> Quoted wisdom number 70 about parsing performance.
+
+@math
+\sum_{i=0}^{70} i = \frac{70(70+1)}{2}
+@end
+
+___
+
+** Section 71
+
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+
+- (_) Task 71.0 for the agenda scanner
+-- ( ) nested item under 71.0
+- ( ) Task 71.1 for the agenda scanner
+- (x) Task 71.2 for the agenda scanner
+- (?) Task 71.3 for the agenda scanner
+-- (!) nested item under 71.3
+- (!) Task 71.4 for the agenda scanner
+- (+) Task 71.5 for the agenda scanner
+
+~ Ordered step 0 of section 71
+~ Ordered step 1 of section 71
+~ Ordered step 2 of section 71
+~ Ordered step 3 of section 71
+
+> Quoted wisdom number 71 about parsing performance.
+
+** Section 72
+
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+
+- ( ) Task 72.0 for the agenda scanner
+-- ( ) nested item under 72.0
+- (x) Task 72.1 for the agenda scanner
+- (?) Task 72.2 for the agenda scanner
+- (!) Task 72.3 for the agenda scanner
+-- (!) nested item under 72.3
+- (+) Task 72.4 for the agenda scanner
+- (-) Task 72.5 for the agenda scanner
+
+~ Ordered step 0 of section 72
+~ Ordered step 1 of section 72
+~ Ordered step 2 of section 72
+~ Ordered step 3 of section 72
+
+> Quoted wisdom number 72 about parsing performance.
+
+@code swift
+let section72 = NorgParser.parse(source) // 72
+print(section72.blocks.count)
+@end
+
+** Section 73
+
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+
+- (x) Task 73.0 for the agenda scanner
+-- ( ) nested item under 73.0
+- (?) Task 73.1 for the agenda scanner
+- (!) Task 73.2 for the agenda scanner
+- (+) Task 73.3 for the agenda scanner
+-- (!) nested item under 73.3
+- (-) Task 73.4 for the agenda scanner
+- (=) Task 73.5 for the agenda scanner
+
+~ Ordered step 0 of section 73
+~ Ordered step 1 of section 73
+~ Ordered step 2 of section 73
+~ Ordered step 3 of section 73
+
+> Quoted wisdom number 73 about parsing performance.
+
+** Section 74
+
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+
+- (?) Task 74.0 for the agenda scanner
+-- ( ) nested item under 74.0
+- (!) Task 74.1 for the agenda scanner
+- (+) Task 74.2 for the agenda scanner
+- (-) Task 74.3 for the agenda scanner
+-- (!) nested item under 74.3
+- (=) Task 74.4 for the agenda scanner
+- (_) Task 74.5 for the agenda scanner
+
+~ Ordered step 0 of section 74
+~ Ordered step 1 of section 74
+~ Ordered step 2 of section 74
+~ Ordered step 3 of section 74
+
+> Quoted wisdom number 74 about parsing performance.
+
+** Section 75
+
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+dog while parsing norg blocks with bold italic
+
+- (!) Task 75.0 for the agenda scanner
+-- ( ) nested item under 75.0
+- (+) Task 75.1 for the agenda scanner
+- (-) Task 75.2 for the agenda scanner
+- (=) Task 75.3 for the agenda scanner
+-- (!) nested item under 75.3
+- (_) Task 75.4 for the agenda scanner
+- ( ) Task 75.5 for the agenda scanner
+
+~ Ordered step 0 of section 75
+~ Ordered step 1 of section 75
+~ Ordered step 2 of section 75
+~ Ordered step 3 of section 75
+
+> Quoted wisdom number 75 about parsing performance.
+
+___
+
+** Section 76
+
+lazy dog while parsing norg blocks with *bold*
+dog while parsing norg blocks with bold italic
+while parsing norg blocks with bold italic and
+
+- (+) Task 76.0 for the agenda scanner
+-- ( ) nested item under 76.0
+- (-) Task 76.1 for the agenda scanner
+- (=) Task 76.2 for the agenda scanner
+- (_) Task 76.3 for the agenda scanner
+-- (!) nested item under 76.3
+- ( ) Task 76.4 for the agenda scanner
+- (x) Task 76.5 for the agenda scanner
+
+~ Ordered step 0 of section 76
+~ Ordered step 1 of section 76
+~ Ordered step 2 of section 76
+~ Ordered step 3 of section 76
+
+> Quoted wisdom number 76 about parsing performance.
+
+@code swift
+let section76 = NorgParser.parse(source) // 76
+print(section76.blocks.count)
+@end
+
+** Section 77
+
+dog while parsing norg blocks with *bold* /italic/
+while parsing norg blocks with bold italic and
+parsing norg blocks with bold italic and verbatim
+
+- (-) Task 77.0 for the agenda scanner
+-- ( ) nested item under 77.0
+- (=) Task 77.1 for the agenda scanner
+- (_) Task 77.2 for the agenda scanner
+- ( ) Task 77.3 for the agenda scanner
+-- (!) nested item under 77.3
+- (x) Task 77.4 for the agenda scanner
+- (?) Task 77.5 for the agenda scanner
+
+~ Ordered step 0 of section 77
+~ Ordered step 1 of section 77
+~ Ordered step 2 of section 77
+~ Ordered step 3 of section 77
+
+> Quoted wisdom number 77 about parsing performance.
+
+@math
+\sum_{i=0}^{77} i = \frac{77(77+1)}{2}
+@end
+
+** Section 78
+
+while parsing norg blocks with *bold* /italic/ and
+parsing norg blocks with bold italic and verbatim
+norg blocks with bold italic and verbatim runs
+
+- (=) Task 78.0 for the agenda scanner
+-- ( ) nested item under 78.0
+- (_) Task 78.1 for the agenda scanner
+- ( ) Task 78.2 for the agenda scanner
+- (x) Task 78.3 for the agenda scanner
+-- (!) nested item under 78.3
+- (?) Task 78.4 for the agenda scanner
+- (!) Task 78.5 for the agenda scanner
+
+~ Ordered step 0 of section 78
+~ Ordered step 1 of section 78
+~ Ordered step 2 of section 78
+~ Ordered step 3 of section 78
+
+> Quoted wisdom number 78 about parsing performance.
+
+** Section 79
+
+parsing norg blocks with *bold* /italic/ and `verbatim`
+norg blocks with bold italic and verbatim runs
+blocks with bold italic and verbatim runs scattered
+
+- (_) Task 79.0 for the agenda scanner
+-- ( ) nested item under 79.0
+- ( ) Task 79.1 for the agenda scanner
+- (x) Task 79.2 for the agenda scanner
+- (?) Task 79.3 for the agenda scanner
+-- (!) nested item under 79.3
+- (!) Task 79.4 for the agenda scanner
+- (+) Task 79.5 for the agenda scanner
+
+~ Ordered step 0 of section 79
+~ Ordered step 1 of section 79
+~ Ordered step 2 of section 79
+~ Ordered step 3 of section 79
+
+> Quoted wisdom number 79 about parsing performance.
+
+** Section 80
+
+norg blocks with *bold* /italic/ and `verbatim` runs
+blocks with bold italic and verbatim runs scattered
+with bold italic and verbatim runs scattered throughout
+
+- ( ) Task 80.0 for the agenda scanner
+-- ( ) nested item under 80.0
+- (x) Task 80.1 for the agenda scanner
+- (?) Task 80.2 for the agenda scanner
+- (!) Task 80.3 for the agenda scanner
+-- (!) nested item under 80.3
+- (+) Task 80.4 for the agenda scanner
+- (-) Task 80.5 for the agenda scanner
+
+~ Ordered step 0 of section 80
+~ Ordered step 1 of section 80
+~ Ordered step 2 of section 80
+~ Ordered step 3 of section 80
+
+> Quoted wisdom number 80 about parsing performance.
+
+@code swift
+let section80 = NorgParser.parse(source) // 80
+print(section80.blocks.count)
+@end
+
+___
+
+** Section 81
+
+blocks with *bold* /italic/ and `verbatim` runs scattered
+with bold italic and verbatim runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+
+- (x) Task 81.0 for the agenda scanner
+-- ( ) nested item under 81.0
+- (?) Task 81.1 for the agenda scanner
+- (!) Task 81.2 for the agenda scanner
+- (+) Task 81.3 for the agenda scanner
+-- (!) nested item under 81.3
+- (-) Task 81.4 for the agenda scanner
+- (=) Task 81.5 for the agenda scanner
+
+~ Ordered step 0 of section 81
+~ Ordered step 1 of section 81
+~ Ordered step 2 of section 81
+~ Ordered step 3 of section 81
+
+> Quoted wisdom number 81 about parsing performance.
+
+** Section 82
+
+with *bold* /italic/ and `verbatim` runs scattered throughout
+bold italic and verbatim runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+
+- (?) Task 82.0 for the agenda scanner
+-- ( ) nested item under 82.0
+- (!) Task 82.1 for the agenda scanner
+- (+) Task 82.2 for the agenda scanner
+- (-) Task 82.3 for the agenda scanner
+-- (!) nested item under 82.3
+- (=) Task 82.4 for the agenda scanner
+- (_) Task 82.5 for the agenda scanner
+
+~ Ordered step 0 of section 82
+~ Ordered step 1 of section 82
+~ Ordered step 2 of section 82
+~ Ordered step 3 of section 82
+
+> Quoted wisdom number 82 about parsing performance.
+
+** Section 83
+
+*bold* /italic/ and `verbatim` runs scattered throughout the
+italic and verbatim runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+
+- (!) Task 83.0 for the agenda scanner
+-- ( ) nested item under 83.0
+- (+) Task 83.1 for the agenda scanner
+- (-) Task 83.2 for the agenda scanner
+- (=) Task 83.3 for the agenda scanner
+-- (!) nested item under 83.3
+- (_) Task 83.4 for the agenda scanner
+- ( ) Task 83.5 for the agenda scanner
+
+~ Ordered step 0 of section 83
+~ Ordered step 1 of section 83
+~ Ordered step 2 of section 83
+~ Ordered step 3 of section 83
+
+> Quoted wisdom number 83 about parsing performance.
+
+** Section 84
+
+/italic/ and `verbatim` runs scattered throughout the prose
+and verbatim runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+
+- (+) Task 84.0 for the agenda scanner
+-- ( ) nested item under 84.0
+- (-) Task 84.1 for the agenda scanner
+- (=) Task 84.2 for the agenda scanner
+- (_) Task 84.3 for the agenda scanner
+-- (!) nested item under 84.3
+- ( ) Task 84.4 for the agenda scanner
+- (x) Task 84.5 for the agenda scanner
+
+~ Ordered step 0 of section 84
+~ Ordered step 1 of section 84
+~ Ordered step 2 of section 84
+~ Ordered step 3 of section 84
+
+> Quoted wisdom number 84 about parsing performance.
+
+@code swift
+let section84 = NorgParser.parse(source) // 84
+print(section84.blocks.count)
+@end
+
+@math
+\sum_{i=0}^{84} i = \frac{84(84+1)}{2}
+@end
+
+** Section 85
+
+and `verbatim` runs scattered throughout the prose
+verbatim runs scattered throughout the prose
+runs scattered throughout the prose
+
+- (-) Task 85.0 for the agenda scanner
+-- ( ) nested item under 85.0
+- (=) Task 85.1 for the agenda scanner
+- (_) Task 85.2 for the agenda scanner
+- ( ) Task 85.3 for the agenda scanner
+-- (!) nested item under 85.3
+- (x) Task 85.4 for the agenda scanner
+- (?) Task 85.5 for the agenda scanner
+
+~ Ordered step 0 of section 85
+~ Ordered step 1 of section 85
+~ Ordered step 2 of section 85
+~ Ordered step 3 of section 85
+
+> Quoted wisdom number 85 about parsing performance.
+
+___
+
+** Section 86
+
+`verbatim` runs scattered throughout the prose
+runs scattered throughout the prose
+scattered throughout the prose
+
+- (=) Task 86.0 for the agenda scanner
+-- ( ) nested item under 86.0
+- (_) Task 86.1 for the agenda scanner
+- ( ) Task 86.2 for the agenda scanner
+- (x) Task 86.3 for the agenda scanner
+-- (!) nested item under 86.3
+- (?) Task 86.4 for the agenda scanner
+- (!) Task 86.5 for the agenda scanner
+
+~ Ordered step 0 of section 86
+~ Ordered step 1 of section 86
+~ Ordered step 2 of section 86
+~ Ordered step 3 of section 86
+
+> Quoted wisdom number 86 about parsing performance.
+
+** Section 87
+
+runs scattered throughout the prose
+scattered throughout the prose
+throughout the prose
+
+- (_) Task 87.0 for the agenda scanner
+-- ( ) nested item under 87.0
+- ( ) Task 87.1 for the agenda scanner
+- (x) Task 87.2 for the agenda scanner
+- (?) Task 87.3 for the agenda scanner
+-- (!) nested item under 87.3
+- (!) Task 87.4 for the agenda scanner
+- (+) Task 87.5 for the agenda scanner
+
+~ Ordered step 0 of section 87
+~ Ordered step 1 of section 87
+~ Ordered step 2 of section 87
+~ Ordered step 3 of section 87
+
+> Quoted wisdom number 87 about parsing performance.
+
+** Section 88
+
+scattered throughout the prose
+throughout the prose
+the prose
+
+- ( ) Task 88.0 for the agenda scanner
+-- ( ) nested item under 88.0
+- (x) Task 88.1 for the agenda scanner
+- (?) Task 88.2 for the agenda scanner
+- (!) Task 88.3 for the agenda scanner
+-- (!) nested item under 88.3
+- (+) Task 88.4 for the agenda scanner
+- (-) Task 88.5 for the agenda scanner
+
+~ Ordered step 0 of section 88
+~ Ordered step 1 of section 88
+~ Ordered step 2 of section 88
+~ Ordered step 3 of section 88
+
+> Quoted wisdom number 88 about parsing performance.
+
+@code swift
+let section88 = NorgParser.parse(source) // 88
+print(section88.blocks.count)
+@end
+
+** Section 89
+
+throughout the prose
+the prose
+prose
+
+- (x) Task 89.0 for the agenda scanner
+-- ( ) nested item under 89.0
+- (?) Task 89.1 for the agenda scanner
+- (!) Task 89.2 for the agenda scanner
+- (+) Task 89.3 for the agenda scanner
+-- (!) nested item under 89.3
+- (-) Task 89.4 for the agenda scanner
+- (=) Task 89.5 for the agenda scanner
+
+~ Ordered step 0 of section 89
+~ Ordered step 1 of section 89
+~ Ordered step 2 of section 89
+~ Ordered step 3 of section 89
+
+> Quoted wisdom number 89 about parsing performance.
+
+** Section 90
+
+the prose
+prose
+the quick brown fox jumps over a lazy
+
+- (?) Task 90.0 for the agenda scanner
+-- ( ) nested item under 90.0
+- (!) Task 90.1 for the agenda scanner
+- (+) Task 90.2 for the agenda scanner
+- (-) Task 90.3 for the agenda scanner
+-- (!) nested item under 90.3
+- (=) Task 90.4 for the agenda scanner
+- (_) Task 90.5 for the agenda scanner
+
+~ Ordered step 0 of section 90
+~ Ordered step 1 of section 90
+~ Ordered step 2 of section 90
+~ Ordered step 3 of section 90
+
+> Quoted wisdom number 90 about parsing performance.
+
+___
+
+** Section 91
+
+prose
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+
+- (!) Task 91.0 for the agenda scanner
+-- ( ) nested item under 91.0
+- (+) Task 91.1 for the agenda scanner
+- (-) Task 91.2 for the agenda scanner
+- (=) Task 91.3 for the agenda scanner
+-- (!) nested item under 91.3
+- (_) Task 91.4 for the agenda scanner
+- ( ) Task 91.5 for the agenda scanner
+
+~ Ordered step 0 of section 91
+~ Ordered step 1 of section 91
+~ Ordered step 2 of section 91
+~ Ordered step 3 of section 91
+
+> Quoted wisdom number 91 about parsing performance.
+
+@math
+\sum_{i=0}^{91} i = \frac{91(91+1)}{2}
+@end
+
+** Section 92
+
+the quick brown fox jumps over a lazy
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+
+- (+) Task 92.0 for the agenda scanner
+-- ( ) nested item under 92.0
+- (-) Task 92.1 for the agenda scanner
+- (=) Task 92.2 for the agenda scanner
+- (_) Task 92.3 for the agenda scanner
+-- (!) nested item under 92.3
+- ( ) Task 92.4 for the agenda scanner
+- (x) Task 92.5 for the agenda scanner
+
+~ Ordered step 0 of section 92
+~ Ordered step 1 of section 92
+~ Ordered step 2 of section 92
+~ Ordered step 3 of section 92
+
+> Quoted wisdom number 92 about parsing performance.
+
+@code swift
+let section92 = NorgParser.parse(source) // 92
+print(section92.blocks.count)
+@end
+
+** Section 93
+
+quick brown fox jumps over a lazy dog
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+
+- (-) Task 93.0 for the agenda scanner
+-- ( ) nested item under 93.0
+- (=) Task 93.1 for the agenda scanner
+- (_) Task 93.2 for the agenda scanner
+- ( ) Task 93.3 for the agenda scanner
+-- (!) nested item under 93.3
+- (x) Task 93.4 for the agenda scanner
+- (?) Task 93.5 for the agenda scanner
+
+~ Ordered step 0 of section 93
+~ Ordered step 1 of section 93
+~ Ordered step 2 of section 93
+~ Ordered step 3 of section 93
+
+> Quoted wisdom number 93 about parsing performance.
+
+** Section 94
+
+brown fox jumps over a lazy dog while
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+
+- (=) Task 94.0 for the agenda scanner
+-- ( ) nested item under 94.0
+- (_) Task 94.1 for the agenda scanner
+- ( ) Task 94.2 for the agenda scanner
+- (x) Task 94.3 for the agenda scanner
+-- (!) nested item under 94.3
+- (?) Task 94.4 for the agenda scanner
+- (!) Task 94.5 for the agenda scanner
+
+~ Ordered step 0 of section 94
+~ Ordered step 1 of section 94
+~ Ordered step 2 of section 94
+~ Ordered step 3 of section 94
+
+> Quoted wisdom number 94 about parsing performance.
+
+** Section 95
+
+fox jumps over a lazy dog while parsing
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+
+- (_) Task 95.0 for the agenda scanner
+-- ( ) nested item under 95.0
+- ( ) Task 95.1 for the agenda scanner
+- (x) Task 95.2 for the agenda scanner
+- (?) Task 95.3 for the agenda scanner
+-- (!) nested item under 95.3
+- (!) Task 95.4 for the agenda scanner
+- (+) Task 95.5 for the agenda scanner
+
+~ Ordered step 0 of section 95
+~ Ordered step 1 of section 95
+~ Ordered step 2 of section 95
+~ Ordered step 3 of section 95
+
+> Quoted wisdom number 95 about parsing performance.
+
+___
+
+** Section 96
+
+jumps over a lazy dog while parsing norg
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+
+- ( ) Task 96.0 for the agenda scanner
+-- ( ) nested item under 96.0
+- (x) Task 96.1 for the agenda scanner
+- (?) Task 96.2 for the agenda scanner
+- (!) Task 96.3 for the agenda scanner
+-- (!) nested item under 96.3
+- (+) Task 96.4 for the agenda scanner
+- (-) Task 96.5 for the agenda scanner
+
+~ Ordered step 0 of section 96
+~ Ordered step 1 of section 96
+~ Ordered step 2 of section 96
+~ Ordered step 3 of section 96
+
+> Quoted wisdom number 96 about parsing performance.
+
+@code swift
+let section96 = NorgParser.parse(source) // 96
+print(section96.blocks.count)
+@end
+
+** Section 97
+
+over a lazy dog while parsing norg blocks
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+
+- (x) Task 97.0 for the agenda scanner
+-- ( ) nested item under 97.0
+- (?) Task 97.1 for the agenda scanner
+- (!) Task 97.2 for the agenda scanner
+- (+) Task 97.3 for the agenda scanner
+-- (!) nested item under 97.3
+- (-) Task 97.4 for the agenda scanner
+- (=) Task 97.5 for the agenda scanner
+
+~ Ordered step 0 of section 97
+~ Ordered step 1 of section 97
+~ Ordered step 2 of section 97
+~ Ordered step 3 of section 97
+
+> Quoted wisdom number 97 about parsing performance.
+
+** Section 98
+
+a lazy dog while parsing norg blocks with
+lazy dog while parsing norg blocks with bold
+dog while parsing norg blocks with bold italic
+
+- (?) Task 98.0 for the agenda scanner
+-- ( ) nested item under 98.0
+- (!) Task 98.1 for the agenda scanner
+- (+) Task 98.2 for the agenda scanner
+- (-) Task 98.3 for the agenda scanner
+-- (!) nested item under 98.3
+- (=) Task 98.4 for the agenda scanner
+- (_) Task 98.5 for the agenda scanner
+
+~ Ordered step 0 of section 98
+~ Ordered step 1 of section 98
+~ Ordered step 2 of section 98
+~ Ordered step 3 of section 98
+
+> Quoted wisdom number 98 about parsing performance.
+
+@math
+\sum_{i=0}^{98} i = \frac{98(98+1)}{2}
+@end
+
+** Section 99
+
+lazy dog while parsing norg blocks with *bold*
+dog while parsing norg blocks with bold italic
+while parsing norg blocks with bold italic and
+
+- (!) Task 99.0 for the agenda scanner
+-- ( ) nested item under 99.0
+- (+) Task 99.1 for the agenda scanner
+- (-) Task 99.2 for the agenda scanner
+- (=) Task 99.3 for the agenda scanner
+-- (!) nested item under 99.3
+- (_) Task 99.4 for the agenda scanner
+- ( ) Task 99.5 for the agenda scanner
+
+~ Ordered step 0 of section 99
+~ Ordered step 1 of section 99
+~ Ordered step 2 of section 99
+~ Ordered step 3 of section 99
+
+> Quoted wisdom number 99 about parsing performance.
+
+** Section 100
+
+dog while parsing norg blocks with *bold* /italic/
+while parsing norg blocks with bold italic and
+parsing norg blocks with bold italic and verbatim
+
+- (+) Task 100.0 for the agenda scanner
+-- ( ) nested item under 100.0
+- (-) Task 100.1 for the agenda scanner
+- (=) Task 100.2 for the agenda scanner
+- (_) Task 100.3 for the agenda scanner
+-- (!) nested item under 100.3
+- ( ) Task 100.4 for the agenda scanner
+- (x) Task 100.5 for the agenda scanner
+
+~ Ordered step 0 of section 100
+~ Ordered step 1 of section 100
+~ Ordered step 2 of section 100
+~ Ordered step 3 of section 100
+
+> Quoted wisdom number 100 about parsing performance.
+
+@code swift
+let section100 = NorgParser.parse(source) // 100
+print(section100.blocks.count)
+@end
+
+___
+
+** Section 101
+
+while parsing norg blocks with *bold* /italic/ and
+parsing norg blocks with bold italic and verbatim
+norg blocks with bold italic and verbatim runs
+
+- (-) Task 101.0 for the agenda scanner
+-- ( ) nested item under 101.0
+- (=) Task 101.1 for the agenda scanner
+- (_) Task 101.2 for the agenda scanner
+- ( ) Task 101.3 for the agenda scanner
+-- (!) nested item under 101.3
+- (x) Task 101.4 for the agenda scanner
+- (?) Task 101.5 for the agenda scanner
+
+~ Ordered step 0 of section 101
+~ Ordered step 1 of section 101
+~ Ordered step 2 of section 101
+~ Ordered step 3 of section 101
+
+> Quoted wisdom number 101 about parsing performance.
+
+** Section 102
+
+parsing norg blocks with *bold* /italic/ and `verbatim`
+norg blocks with bold italic and verbatim runs
+blocks with bold italic and verbatim runs scattered
+
+- (=) Task 102.0 for the agenda scanner
+-- ( ) nested item under 102.0
+- (_) Task 102.1 for the agenda scanner
+- ( ) Task 102.2 for the agenda scanner
+- (x) Task 102.3 for the agenda scanner
+-- (!) nested item under 102.3
+- (?) Task 102.4 for the agenda scanner
+- (!) Task 102.5 for the agenda scanner
+
+~ Ordered step 0 of section 102
+~ Ordered step 1 of section 102
+~ Ordered step 2 of section 102
+~ Ordered step 3 of section 102
+
+> Quoted wisdom number 102 about parsing performance.
+
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..d02c5f2
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,27 @@
+profile := "debug"
+
+default: build
+
+build:
+ swift build -c {{profile}}
+
+test:
+ swift test
+
+coverage:
+ swift test --enable-code-coverage
+ xcrun llvm-cov report \
+ .build/debug/NorgKitTests.xctest/Contents/MacOS/NorgKitTests \
+ -instr-profile=.build/debug/codecov/default.profdata
+
+benchmark:
+ swift package benchmark --target ParseBenchmarkTarget
+
+format:
+ swift-format --in-place --recursive Sources Tests
+ swiftlint --fix Sources Tests
+
+lint:
+ swiftlint Sources Tests
+
+ci: lint test
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fcdcf78
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ NorgKit, A swift library to parse and work with .norg / neorg files
+ Copyright (C) 2026 Ruben Beltran del Rio
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..d6b8bd4
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,78 @@
+{
+ "originHash" : "ff3ecf9d13fc6404cf8625409b930c01b972a9b74abb5355a9f68844b7e592fe",
+ "pins" : [
+ {
+ "identity" : "benchmark",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ordo-one/benchmark",
+ "state" : {
+ "revision" : "595d8db7d9d612ecf256ebf659c20aeec338c5ca",
+ "version" : "1.34.1"
+ }
+ },
+ {
+ "identity" : "hdrhistogram-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/HdrHistogram/hdrhistogram-swift.git",
+ "state" : {
+ "revision" : "c2e1210df04b4fff47e53f2f9dad9cc45ae15d63",
+ "version" : "0.2.0"
+ }
+ },
+ {
+ "identity" : "package-jemalloc",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ordo-one/package-jemalloc.git",
+ "state" : {
+ "revision" : "e8a5db026963f5bfeac842d9d3f2cc8cde323b49",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
+ "version" : "1.8.2"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
+ "version" : "1.7.2"
+ }
+ },
+ {
+ "identity" : "texttable",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ordo-one/TextTable.git",
+ "state" : {
+ "revision" : "a27a07300cf4ae322e0079ca0a475c5583dd575f",
+ "version" : "0.0.2"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..41a842e
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,58 @@
+// swift-tools-version: 6.4
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "NorgKit",
+ platforms: [
+ .macOS(.v13),
+ .iOS(.v16),
+ .macCatalyst(.v16),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, making them visible to other packages.
+ .library(
+ name: "NorgKit",
+ targets: ["NorgKit"]
+ ),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/ordo-one/benchmark", .upToNextMajor(from: "1.0.0")),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets can depend on other targets in this package and products from dependencies.
+ .target(
+ name: "NorgKit",
+ swiftSettings: [
+ .enableUpcomingFeature("ApproachableConcurrency"),
+ .defaultIsolation(nil),
+ ],
+ ),
+ .testTarget(
+ name: "NorgKitTests",
+ dependencies: ["NorgKit"],
+ swiftSettings: [
+ .enableUpcomingFeature("ApproachableConcurrency"),
+ .defaultIsolation(nil),
+ ],
+ ),
+ ],
+ swiftLanguageModes: [.v6]
+)
+
+// Benchmark of ParseBenchmarkTarget
+package.targets += [
+ .executableTarget(
+ name: "ParseBenchmarkTarget",
+ dependencies: [
+ .product(name: "Benchmark", package: "benchmark"),
+ "NorgKit",
+ ],
+ path: "Benchmarks/ParseBenchmarkTarget",
+ plugins: [
+ .plugin(name: "BenchmarkPlugin", package: "benchmark"),
+ ]
+ ),
+]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..04b711f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,168 @@
+# NorgKit
+
+NorgKit is a Swift parser and library for `.norg` / [Neorg](https://github.com/nvim-neorg/neorg) documents.
+
+* Depends only on `Foundation`, so usable outside of iOS/ipadOS/macOS apps.
+* Concurrency safe and sendable.
+* Types are `Hashable` and `Codable` for caching and serialization.
+* Reasonably Fast.
+
+## Usage
+
+`NorgParser.parse` outputs a flat list of blocks you can iterate over.
+
+```swift
+import NorgKit
+
+let document = NorgParser.parse("""
+* (x) Ship NorgKit
+Some *bold* and /italic/ prose, plus a {https://example.com}[link].
+
+@code swift
+let answer = 42
+@end
+""")
+
+for block in document.blocks {
+ switch block {
+ case let .heading(level, status, content, line):
+ print("h\(level) @\(line): \(content.plainText) [\(status?.rawValue ?? "no status")]")
+ case let .paragraph(content, _):
+ print(content.plainText)
+ case let .codeBlock(language, code, _):
+ print("```\(language ?? "")\n\(code)\n```")
+ default:
+ break
+ }
+}
+```
+
+### Hierarchical parsing
+
+When you need the document's structure as a tree (eg. for folding), you can
+call either `.parseTree()`, or `.tree()` on a `NorgDocument`
+
+```swift
+// Parse straight to a tree...
+let nodes = NorgParser.parseTree(source) // [NorgNode]
+
+// ...or fold an existing flat parse.
+let document = NorgParser.parse(source)
+let tree = document.tree()
+
+func render(_ node: NorgNode) {
+ emit(node.block) // a NorgBlock
+ node.children.forEach(render)
+}
+tree.forEach(render)
+```
+
+
+### Inline-only parsing
+
+`NorgInlineParser.parse` parses a single line of inline markup into
+`[InlineSpan]`, useful when you only need intra-paragraph styling:
+
+```swift
+let spans = NorgInlineParser.parse("a `verbatim` and *bold* run")
+```
+
+### Scanning and updating tasks
+
+Besides the parser, `NorgKit` contains `TaskScanner`, which extracts tasks
+across a file and can rewrite status markers in place. It's faster than a
+parse; fast enough to run on a whole vault! (Eg. to look for all open tasks)
+
+```swift
+let url = URL(filePath: "/vault/today.norg")
+let tasks = TaskScanner.scan(content: contents, fileURL: url)
+
+// Toggle the task on line 3 to done, preserving indentation.
+if let updated = TaskScanner.updatedContent(contents, line: 3, to: .done) {
+ try updated.write(to: url, atomically: true, encoding: .utf8)
+}
+```
+
+
+## Types Exported
+
+- **`NorgDocument`**: a parsed document. Has `blocks: [NorgBlock]` and a
+ `tree()` method that folds them into `[NorgNode]`.
+- **`NorgBlock`**: a block-level element, including its `line`, and an attached
+ `status` that denotes its task status if any, and can be copied to a new
+ status (or have it cleared, with `nil`) via `settingStatus(_:)`. Its inline
+ text is available as `content` and the nested blocks of a range-able block as
+ `body`. Can be one of:
+ - `heading`
+ - `paragraph`
+ - `unorderedListItem`
+ - `orderedListItem`
+ - `quote`
+ - `codeBlock`
+ - `definition` (`$ Term` / ranged `$$ … $$`), with a `title` and `body`,
+ - `footnote` (`^ Name` / ranged `^^ … ^^`), with a `title` and `body`,
+ - `tableCell` (`: Address` / ranged `:: … ::`), with a `title` and `body`,
+ - `rangedTag`,
+ - `horizontalRule` (`___`),
+ - `weakDelimiter` (`---`),
+ - `strongDelimiter` (`===`).
+- **`NorgNode`**: a node in the hierarchical view from `tree()` / `parseTree`.
+ Wraps a `block: NorgBlock` and its nested `children: [NorgNode]`.
+- **`InlineSpan`** — a run of text with `styles: InlineStyle` and an optional
+ `link: InlineLink?`. `Array<InlineSpan>.plainText` concatenates the text,
+ ignoring styling.
+- **`InlineLink`** — a linkable: a `kind` (`.link` for `{location}`, `.anchor`
+ for `[name]`) and an optional `target`. The `target` is `nil` for a bare
+ anchor declaration whose location is defined elsewhere in the document.
+- **`InlineStyle`** — an `OptionSet` of inline modifiers:
+ - `.bold`
+ - `.italic`
+ - `.underline`
+ - `.strikethrough`
+ - `.verbatim`
+ - `.superscript`
+ - `.subscript`
+ - `.spoiler`
+ - `.math`
+- **`TaskStatus`** — a Norg TODO status, one of:
+ - `.undone`
+ - `.done`
+ - `.needsInput`
+ - `.urgent`
+ - `.recurring`
+ - `.pending`
+ - `.onHold`
+ - `.cancelled`
+- **`NorgTask`** — a task located in a file, with its `fileURL`, `line`,
+ `status`, and `text`.
+
+## Reasonably Fast
+
+(All measures p90)
+
+Benchmarked on an M1 Pro. A document with around 40 blocks parses in ~150µs.
+While a larger document with roughly 1,600 blocks (about 2,500 lines) does so
+in ~2ms.
+
+Folding costs ~100 microseconds, so the benchmarks above apply whether you fold
+or not.
+
+Scanning tasks is faster and meant to be run over whole vaults. The same
+~2,500-line document (around 800 tasks) takes ~420µs.
+
+You can run the benchmarks by using:
+
+```bash
+just benchmark
+```
+
+## Development
+
+A `Justfile` is provided to run common tasks:
+
+```sh
+just build # swift build
+just test # swift test
+just lint # swiftlint
+just format # swift-format + swiftlint --fix
+```
diff --git a/Sources/NorgKit/Extensions/Array+InlineSpan.swift b/Sources/NorgKit/Extensions/Array+InlineSpan.swift
new file mode 100644
index 0000000..167bfce
--- /dev/null
+++ b/Sources/NorgKit/Extensions/Array+InlineSpan.swift
@@ -0,0 +1,7 @@
+/// Extends behavior of InlineSpan arrays.
+extension Array where Element == InlineSpan {
+ /// The concatenated plain text of all spans, ignoring styling.
+ public var plainText: String {
+ map(\.text).joined()
+ }
+}
diff --git a/Sources/NorgKit/Helpers/ASCIIByteSet.swift b/Sources/NorgKit/Helpers/ASCIIByteSet.swift
new file mode 100644
index 0000000..59efbe7
--- /dev/null
+++ b/Sources/NorgKit/Helpers/ASCIIByteSet.swift
@@ -0,0 +1,30 @@
+/// A membership test over the ASCII range (bytes `0`–`127`)
+/// We use this for performance, since markers are always ASCII, and this this
+/// faster than using Character.
+struct ASCIIByteSet {
+ private let low: UInt64
+ private let high: UInt64
+
+ /// Builds a set from the ASCII values.
+ init(_ characters: String) {
+ var lo: UInt64 = 0
+ var hi: UInt64 = 0
+ for byte in characters.utf8 {
+ if byte < 64 {
+ lo |= 1 << UInt64(byte)
+ } else if byte < 128 {
+ hi |= 1 << UInt64(byte - 64)
+ }
+ }
+ low = lo
+ high = hi
+ }
+
+ /// Whether `byte` is in the set.
+ @inline(__always)
+ func contains(_ byte: UInt8) -> Bool {
+ if byte < 64 { return low & (1 << UInt64(byte)) != 0 }
+ if byte < 128 { return high & (1 << UInt64(byte &- 64)) != 0 }
+ return false
+ }
+}
diff --git a/Sources/NorgKit/Helpers/ASCIIHelper.swift b/Sources/NorgKit/Helpers/ASCIIHelper.swift
new file mode 100644
index 0000000..98cce42
--- /dev/null
+++ b/Sources/NorgKit/Helpers/ASCIIHelper.swift
@@ -0,0 +1,5 @@
+struct ASCIIHelper {
+ static func isWhitespace(_ b: UInt8) -> Bool {
+ b == 0x20 || (0x09...0x0D).contains(b)
+ }
+}
diff --git a/Sources/NorgKit/Helpers/TextHelper.swift b/Sources/NorgKit/Helpers/TextHelper.swift
new file mode 100644
index 0000000..b0d45fe
--- /dev/null
+++ b/Sources/NorgKit/Helpers/TextHelper.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// Text helpers that avoid `CharacterSet` and allocations for performance.
+enum TextHelper {
+
+ /// Splits text into lines as slices.
+ static func lineSlices(_ text: String) -> [Substring] {
+ var result: [Substring] = []
+ enumerateLines(in: text) { line, _ in result.append(line) }
+ return result
+ }
+
+ /// Invokes `body` once per line, passing a `Substring` view and its index.
+ static func enumerateLines(in text: String, _ body: (Substring, Int) -> Void) {
+ let utf8 = text.utf8
+ let end = utf8.endIndex
+ var lineStart = utf8.startIndex
+ var i = utf8.startIndex
+ var index = 0
+ while i < end {
+ if utf8[i] == 0x0A {
+ body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)], index)
+ index += 1
+ lineStart = utf8.index(after: i)
+ }
+ i = utf8.index(after: i)
+ }
+ body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)], index)
+ }
+
+ /// The end index of a line `[start, newline)`, backed up by one when the last
+ /// byte is a carriage return. Operating on the UTF-8 view avoids the
+ /// grapheme decode that `Substring.last` would pay on every line.
+ private static func strippedLineEnd(
+ _ utf8: String.UTF8View, from start: String.Index, to newline: String.Index
+ ) -> String.Index {
+ guard newline > start else { return newline }
+ let last = utf8.index(before: newline)
+ return utf8[last] == 0x0D ? last : newline
+ }
+
+ /// Returns the line at `index` (zero-based) as a `Substring`, or `nil` when
+ /// there is no such line. Trailing `\r` is stripped and line counting matches
+ /// ``lines(_:)`` / ``enumerateLines(in:_:)``. Stops as soon as the line is
+ /// found, so locating an early line in a large document is cheap.
+ static func line(in text: String, at index: Int) -> Substring? {
+ guard index >= 0 else { return nil }
+ let utf8 = text.utf8
+ let end = utf8.endIndex
+ var lineStart = utf8.startIndex
+ var i = utf8.startIndex
+ var current = 0
+ while i < end {
+ if utf8[i] == 0x0A {
+ if current == index {
+ return text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)]
+ }
+ current += 1
+ lineStart = utf8.index(after: i)
+ }
+ i = utf8.index(after: i)
+ }
+ return current == index
+ ? text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)] : nil
+ }
+
+ /// Trims leading and trailing whitespace from a slice using
+ /// `Character.isWhitespace`, avoiding the `CharacterSet` bridging cost that
+ /// `trimmingCharacters(in:)` pays per call. The result is a slice of the
+ /// input, so nothing is copied. This is the single trimming primitive used
+ /// across the parser, scanner, and detached-modifier recogniser.
+ static func whitespaceTrimmed(_ s: Substring) -> Substring {
+ // Trim over the UTF-8 view: Norg indentation and trailing space is always
+ // ASCII whitespace, whose bytes are all `< 0x80` and so never part of a
+ // multi-byte scalar — the trimmed bounds stay on scalar boundaries. This
+ // avoids the grapheme decode `Character.isWhitespace` pays on every line.
+ let utf8 = s.utf8
+ var start = utf8.startIndex
+ var end = utf8.endIndex
+ while start < end, ASCIIHelper.isWhitespace(utf8[start]) { start = utf8.index(after: start) }
+ while start < end {
+ let prev = utf8.index(before: end)
+ guard ASCIIHelper.isWhitespace(utf8[prev]) else { break }
+ end = prev
+ }
+ return s[start..<end]
+ }
+
+ /// Trims leading and trailing whitespace.
+ static func whitespaceTrimmed(_ s: String) -> String {
+ let trimmed = whitespaceTrimmed(s[...])
+ return trimmed.startIndex == s.startIndex && trimmed.endIndex == s.endIndex
+ ? s : String(trimmed)
+ }
+}
diff --git a/Sources/NorgKit/Models/DetachedModifier.swift b/Sources/NorgKit/Models/DetachedModifier.swift
new file mode 100644
index 0000000..1f28c36
--- /dev/null
+++ b/Sources/NorgKit/Models/DetachedModifier.swift
@@ -0,0 +1,61 @@
+/// A detached modifier with its marker, nestling level, task status (if set),
+/// and content.
+struct DetachedModifier {
+ let marker: Character
+ let level: Int
+ let status: TaskStatus?
+ let statusIndex: String.Index?
+ let content: Substring
+
+ /// Finds and parses a detached modifier if present. The accepting markers
+ /// is used to reduce the markers being parsed.
+ static func parse(_ line: Substring, accepting markers: ASCIIByteSet) -> DetachedModifier? {
+
+ let utf8 = line.utf8
+ let end = utf8.endIndex
+ var i = utf8.startIndex
+
+ while i < end, ASCIIHelper.isWhitespace(utf8[i]) { i = utf8.index(after: i) }
+ guard i < end else { return nil }
+
+ let markerByte = utf8[i]
+ guard markers.contains(markerByte) else { return nil }
+
+ var level = 0
+ var run = i
+ while run < end, utf8[run] == markerByte {
+ level += 1
+ run = utf8.index(after: run)
+ }
+
+ guard run < end, ASCIIHelper.isWhitespace(utf8[run]) else { return nil }
+
+ var rest = run
+ while rest < end, ASCIIHelper.isWhitespace(utf8[rest]) { rest = utf8.index(after: rest) }
+
+ var status: TaskStatus?
+ var statusIndex: String.Index?
+ var contentStart = rest
+ if rest < end, utf8[rest] == UInt8(ascii: "(") {
+ let markerPosition = utf8.index(after: rest)
+ if markerPosition < end {
+ let closePosition = utf8.index(after: markerPosition)
+ let statusByte = utf8[markerPosition]
+ if closePosition < end, utf8[closePosition] == UInt8(ascii: ")"),
+ let parsed = TaskStatus(markerByte: statusByte) {
+ status = parsed
+ statusIndex = markerPosition
+ contentStart = utf8.index(after: closePosition)
+ }
+ }
+ }
+
+ return DetachedModifier(
+ marker: Character(Unicode.Scalar(markerByte)),
+ level: level,
+ status: status,
+ statusIndex: statusIndex,
+ content: TextHelper.whitespaceTrimmed(line[contentStart...])
+ )
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineLink.swift b/Sources/NorgKit/Models/InlineLink.swift
new file mode 100644
index 0000000..b2f6a15
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineLink.swift
@@ -0,0 +1,15 @@
+/// A link or anchor.
+public struct InlineLink: Equatable, Hashable, Sendable, Codable {
+ public enum Kind: Equatable, Hashable, Sendable, Codable {
+ case link
+ case anchor
+ }
+
+ public var kind: Kind
+ public var target: String?
+
+ public init(kind: Kind, target: String?) {
+ self.kind = kind
+ self.target = target
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineSpan.swift b/Sources/NorgKit/Models/InlineSpan.swift
new file mode 100644
index 0000000..bebe60b
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineSpan.swift
@@ -0,0 +1,12 @@
+/// A contiguous run of text sharing the same style and link.
+public struct InlineSpan: Equatable, Hashable, Sendable, Codable {
+ public var text: String
+ public var styles: InlineStyle
+ public var link: InlineLink?
+
+ public init(text: String, styles: InlineStyle = [], link: InlineLink? = nil) {
+ self.text = text
+ self.styles = styles
+ self.link = link
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineStyle.swift b/Sources/NorgKit/Models/InlineStyle.swift
new file mode 100644
index 0000000..807de7b
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineStyle.swift
@@ -0,0 +1,28 @@
+/// Composable inline styles that may be applied to a single run of text.
+public struct InlineStyle: OptionSet, Hashable, Sendable, Codable {
+ public let rawValue: Int
+
+ public init(rawValue: Int) {
+ self.rawValue = rawValue
+ }
+
+ public static let bold = InlineStyle(rawValue: 1 << 0)
+ public static let italic = InlineStyle(rawValue: 1 << 1)
+ public static let underline = InlineStyle(rawValue: 1 << 2)
+ public static let strikethrough = InlineStyle(rawValue: 1 << 3)
+ public static let verbatim = InlineStyle(rawValue: 1 << 4)
+ public static let superscript = InlineStyle(rawValue: 1 << 5)
+ public static let `subscript` = InlineStyle(rawValue: 1 << 6)
+ public static let spoiler = InlineStyle(rawValue: 1 << 7)
+ public static let math = InlineStyle(rawValue: 1 << 8)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ self.init(rawValue: try container.decode(Int.self))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgBlock.swift b/Sources/NorgKit/Models/NorgBlock.swift
new file mode 100644
index 0000000..32e4eeb
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgBlock.swift
@@ -0,0 +1,108 @@
+/// A block-level element of a Norg document, including its line position in
+/// the source.
+public enum NorgBlock: Equatable, Hashable, Sendable, Codable {
+ case heading(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case paragraph(content: [InlineSpan], line: Int)
+ case unorderedListItem(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case orderedListItem(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case quote(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case codeBlock(language: String?, code: String, line: Int)
+ case definition(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case footnote(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case tableCell(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case rangedTag(name: [String], parameters: [String], content: String, line: Int)
+ case horizontalRule(line: Int)
+ case weakDelimiter(line: Int)
+ case strongDelimiter(line: Int)
+
+ /// The source line on which the block begins.
+ public var line: Int {
+ switch self {
+ case .heading(_, _, _, let line),
+ .paragraph(_, let line),
+ .unorderedListItem(_, _, _, let line),
+ .orderedListItem(_, _, _, let line),
+ .quote(_, _, _, let line),
+ .codeBlock(_, _, let line),
+ .definition(_, _, _, let line),
+ .footnote(_, _, _, let line),
+ .tableCell(_, _, _, let line),
+ .rangedTag(_, _, _, let line),
+ .horizontalRule(let line),
+ .weakDelimiter(let line),
+ .strongDelimiter(let line):
+ return line
+ }
+ }
+
+ /// The task status attached to the block, if any.
+ public var status: TaskStatus? {
+ switch self {
+ case .heading(_, let status, _, _),
+ .unorderedListItem(_, let status, _, _),
+ .orderedListItem(_, let status, _, _),
+ .quote(_, let status, _, _),
+ .definition(_, let status, _, _),
+ .footnote(_, let status, _, _),
+ .tableCell(_, let status, _, _):
+ return status
+ default:
+ return nil
+ }
+ }
+
+ /// The block's inline styled content (eg. text of a heading/paragraph, or
+ /// title of a definition/footnote).
+ public var content: [InlineSpan]? {
+ switch self {
+ case .heading(_, _, let content, _),
+ .paragraph(let content, _),
+ .unorderedListItem(_, _, let content, _),
+ .orderedListItem(_, _, let content, _),
+ .quote(_, _, let content, _):
+ return content
+ case .definition(let title, _, _, _),
+ .footnote(let title, _, _, _),
+ .tableCell(let title, _, _, _):
+ return title
+ default:
+ return nil
+ }
+ }
+
+ /// The nested blocks owned by a range-able block (definition, footnote, or
+ /// table cell).
+ public var body: [NorgBlock]? {
+ switch self {
+ case .definition(_, _, let body, _),
+ .footnote(_, _, let body, _),
+ .tableCell(_, _, let body, _):
+ return body
+ default:
+ return nil
+ }
+ }
+
+ /// Returns a copy of the block with its task status replaced (pass `nil` to
+ /// clear it). Blocks that cannot carry a status are returned unchanged.
+ public func settingStatus(_ status: TaskStatus?) -> NorgBlock {
+ switch self {
+ case .heading(let level, _, let content, let line):
+ return .heading(level: level, status: status, content: content, line: line)
+ case .unorderedListItem(let level, _, let content, let line):
+ return .unorderedListItem(level: level, status: status, content: content, line: line)
+ case .orderedListItem(let level, _, let content, let line):
+ return .orderedListItem(level: level, status: status, content: content, line: line)
+ case .quote(let level, _, let content, let line):
+ return .quote(level: level, status: status, content: content, line: line)
+ case .definition(let title, _, let body, let line):
+ return .definition(title: title, status: status, body: body, line: line)
+ case .footnote(let title, _, let body, let line):
+ return .footnote(title: title, status: status, body: body, line: line)
+ case .tableCell(let title, _, let body, let line):
+ return .tableCell(title: title, status: status, body: body, line: line)
+ default:
+ return self
+ }
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgDocument.swift b/Sources/NorgKit/Models/NorgDocument.swift
new file mode 100644
index 0000000..73a3ed6
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgDocument.swift
@@ -0,0 +1,16 @@
+/// A fully parsed Norg document.
+public struct NorgDocument: Equatable, Hashable, Sendable, Codable {
+
+ /// The parsed blocks, as a flat list.
+ public var blocks: [NorgBlock]
+
+ /// Given an array of parsed blocks, create a document.
+ public init(blocks: [NorgBlock] = []) {
+ self.blocks = blocks
+ }
+
+ /// Converts the flat document into a tree.
+ public func tree() -> [NorgNode] {
+ TreeFolder(blocks).fold()
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgNode.swift b/Sources/NorgKit/Models/NorgNode.swift
new file mode 100644
index 0000000..99ae1f1
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgNode.swift
@@ -0,0 +1,10 @@
+/// A node in a tree view of a Norg document. Includes its block and children.
+public struct NorgNode: Equatable, Hashable, Sendable, Codable {
+ public var block: NorgBlock
+ public var children: [NorgNode]
+
+ public init(block: NorgBlock, children: [NorgNode] = []) {
+ self.block = block
+ self.children = children
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgTask.swift b/Sources/NorgKit/Models/NorgTask.swift
new file mode 100644
index 0000000..7eb5c53
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgTask.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// A single norg task / TODO.
+public struct NorgTask: Identifiable, Equatable, Hashable, Sendable, Codable {
+
+ public let fileURL: URL
+ /// This is zero-indexed
+ public let line: Int
+ public var status: TaskStatus
+ public let text: String
+
+ public init(fileURL: URL, line: Int, status: TaskStatus, text: String) {
+ self.fileURL = fileURL
+ self.line = line
+ self.status = status
+ self.text = text
+ }
+
+ public var id: String { "\(fileURL.absoluteString):\(line)" }
+}
diff --git a/Sources/NorgKit/Models/TaskStatus.swift b/Sources/NorgKit/Models/TaskStatus.swift
new file mode 100644
index 0000000..74badf3
--- /dev/null
+++ b/Sources/NorgKit/Models/TaskStatus.swift
@@ -0,0 +1,33 @@
+/// A Norg TODO status.
+public enum TaskStatus: String, CaseIterable, Identifiable, Sendable, Hashable, Codable {
+ case undone = " "
+ case done = "x"
+ case needsInput = "?"
+ case urgent = "!"
+ case recurring = "+"
+ case pending = "-"
+ case onHold = "="
+ case cancelled = "_"
+
+ /// Identifier, corresponds to its character.
+ public var id: String { rawValue }
+
+ /// Creates a TaskStatus based on a UTF-8 character.
+ public init?(marker: Character) {
+ self.init(rawValue: String(marker))
+ }
+
+ init?(markerByte byte: UInt8) {
+ switch byte {
+ case UInt8(ascii: " "): self = .undone
+ case UInt8(ascii: "x"): self = .done
+ case UInt8(ascii: "?"): self = .needsInput
+ case UInt8(ascii: "!"): self = .urgent
+ case UInt8(ascii: "+"): self = .recurring
+ case UInt8(ascii: "-"): self = .pending
+ case UInt8(ascii: "="): self = .onHold
+ case UInt8(ascii: "_"): self = .cancelled
+ default: return nil
+ }
+ }
+}
diff --git a/Sources/NorgKit/Parsers/NorgInlineParser.swift b/Sources/NorgKit/Parsers/NorgInlineParser.swift
new file mode 100644
index 0000000..1f19c43
--- /dev/null
+++ b/Sources/NorgKit/Parsers/NorgInlineParser.swift
@@ -0,0 +1,248 @@
+import Foundation
+
+/// Converts inline Norg markup into a list of `InlineSpan`s.
+public enum NorgInlineParser {
+
+ /// Attached modifiers whose content is parsed recursively for nesting.
+ private static let modifiers: [Unicode.Scalar: InlineStyle] = [
+ "*": .bold,
+ "/": .italic,
+ "_": .underline,
+ "-": .strikethrough,
+ "^": .superscript,
+ ",": .subscript,
+ "!": .spoiler,
+ ]
+
+ /// Modifiers whose content is taken verbatim.
+ private static let literalModifiers: [Unicode.Scalar: InlineStyle] = [
+ "`": .verbatim,
+ "$": .math,
+ ]
+
+ /// Bytes that can begin an inline object. If none are found, it's plain
+ /// text.
+ private static let significant = ASCIIByteSet("*/_-^,!\u{60}$%{[\\")
+
+ /// Parses a string to a list of `InlineSpan`s
+ public static func parse(_ text: String) -> [InlineSpan] {
+ if text.isEmpty { return [] }
+ if !text.utf8.contains(where: significant.contains) {
+ return [InlineSpan(text: text, styles: [])]
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: [])
+ }
+
+ /// Renders the text as plain text, without styling or markup.
+ static func plainText(_ text: Substring) -> String {
+ if text.isEmpty { return "" }
+ if !text.utf8.contains(where: significant.contains) {
+ return String(text)
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: []).plainText
+ }
+
+ private static func parse(_ chars: [Unicode.Scalar], from lo: Int, to hi: Int, base: InlineStyle)
+ -> [InlineSpan] {
+ var spans: [InlineSpan] = []
+ var buffer = String.UnicodeScalarView()
+
+ func flush() {
+ guard !buffer.isEmpty else { return }
+ spans.append(InlineSpan(text: String(buffer), styles: base))
+ buffer = String.UnicodeScalarView()
+ }
+
+ var i = lo
+ while i < hi {
+ let c = chars[i]
+ // `prev`/`next` deliberately peek outside `[lo, hi)`: within a nested
+ // range the enclosing modifier (e.g. the `_` around `_/x/_`) is a
+ // valid boundary, so boundary detection uses the whole line.
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+
+ // Escapes: the next scalar is taken literally.
+ if c == "\\" {
+ if i + 1 < hi {
+ buffer.append(chars[i + 1])
+ i += 2
+ } else {
+ i += 1
+ }
+ continue
+ }
+
+ // Comments are dropped from the rendered output.
+ if c == "%", isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: "%") {
+ flush()
+ i = close + 1
+ continue
+ }
+
+ // Verbatim / math objects: literal inner content.
+ if let style = literalModifiers[c], isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: c) {
+ flush()
+ spans.append(InlineSpan(text: slice(chars, i + 1, close), styles: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ // Links: {location} optionally followed by [description].
+ if c == "{", let close = bracketClose(chars, from: i + 1, to: hi, char: "}") {
+ let target = slice(chars, i + 1, close)
+ var j = close + 1
+ var label = linkLabel(target)
+ if j < hi, chars[j] == "[", let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ // An explicit description replaces the derived label verbatim.
+ label = slice(chars, j + 1, dclose)
+ j = dclose + 1
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: target)))
+ i = j
+ continue
+ }
+
+ // Anchors: [name] (declaration), [name]{location} (definition), or
+ // [name][description] (declaration with a custom description).
+ if c == "[", let close = bracketClose(chars, from: i + 1, to: hi, char: "]") {
+ let name = slice(chars, i + 1, close)
+ var j = close + 1
+ var label = name
+ var target: String?
+ if j < hi, chars[j] == "{", let tclose = bracketClose(chars, from: j + 1, to: hi, char: "}") {
+ target = slice(chars, j + 1, tclose)
+ j = tclose + 1
+ } else if j < hi, chars[j] == "[",
+ let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ label = slice(chars, j + 1, dclose)
+ j = dclose + 1
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
+ i = j
+ continue
+ }
+
+ // Attached modifiers with recursively parsed content.
+ if let style = modifiers[c], isOpener(prev: prev, next: next),
+ let close = modifierClose(chars, from: i + 1, to: hi, char: c) {
+ flush()
+ spans.append(contentsOf: parse(chars, from: i + 1, to: close, base: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ buffer.append(c)
+ i += 1
+ }
+
+ flush()
+ return spans
+ }
+
+ // MARK: - Boundary helpers
+
+ /// Builds a `String` from a half-open scalar range `[from, to)`.
+ private static func slice(_ chars: [Unicode.Scalar], _ from: Int, _ to: Int) -> String {
+ String(String.UnicodeScalarView(chars[from..<to]))
+ }
+
+ private static func isSpace(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ return c.properties.isWhitespace
+ }
+
+ /// Whether a scalar (or the absence of one, at a line edge) counts as a
+ /// modifier boundary: whitespace, punctuation, or a symbol.
+ private static func isBoundary(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ if c.properties.isWhitespace { return true }
+ switch c.properties.generalCategory {
+ case .connectorPunctuation, .dashPunctuation, .openPunctuation,
+ .closePunctuation, .initialPunctuation, .finalPunctuation, .otherPunctuation,
+ .mathSymbol, .currencySymbol, .modifierSymbol, .otherSymbol:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// A valid opener is preceded by a boundary and followed by non-whitespace.
+ private static func isOpener(prev: Unicode.Scalar?, next: Unicode.Scalar?) -> Bool {
+ isBoundary(prev) && !isSpace(next)
+ }
+
+ /// Finds the closing modifier of the same character: preceded by
+ /// non-whitespace and followed by a boundary. Honours escapes.
+ private static func modifierClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+ if !isSpace(prev) && isBoundary(next) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Finds the closing character for verbatim/comment content. No escapes.
+ private static func literalClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ if !isSpace(prev) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Finds a matching closing bracket, honouring escapes.
+ private static func bracketClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char { return i }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Produces display text for a link target that has no explicit description
+ /// by stripping the leading location prefix (`*`, `#`, `/`, `$`, `:file:`).
+ private static func linkLabel(_ target: String) -> String {
+ var s = Substring(target)
+ // Strip a leading `:path:` file specifier.
+ if s.first == ":", let end = s.dropFirst().firstIndex(of: ":") {
+ s = s[s.index(after: end)...]
+ }
+ s = s.drop { "*#/$ ".contains($0) }
+ return s.isEmpty ? target : String(s)
+ }
+}
diff --git a/Sources/NorgKit/Parsers/NorgParser.swift b/Sources/NorgKit/Parsers/NorgParser.swift
new file mode 100644
index 0000000..aad66f4
--- /dev/null
+++ b/Sources/NorgKit/Parsers/NorgParser.swift
@@ -0,0 +1,286 @@
+/// Parses a Norg document into a list of `NorgBlock`s.
+public enum NorgParser {
+
+ private enum Delimiter { case weak, strong, rule }
+
+ private static let blockMarkers = ASCIIByteSet("*-~>")
+ private static let rangeableMarkers = ASCIIByteSet("$^:")
+
+ private struct Source {
+ let raw: [Substring]
+ let trimmed: [Substring]
+ }
+
+ /// Parses as a list
+ public static func parse(_ text: String) -> NorgDocument {
+ let raw = TextHelper.lineSlices(text)
+ let source = Source(raw: raw, trimmed: raw.map(TextHelper.whitespaceTrimmed))
+ return NorgDocument(blocks: parseBlocks(in: source, from: 0, to: raw.count))
+ }
+
+ /// Parses as a tree
+ public static func parseTree(_ text: String) -> [NorgNode] {
+ parse(text).tree()
+ }
+
+ /// Parses the half-open line range `[lo, hi)` into blocks. Line numbers are
+ /// the absolute indices into the source, so the recursively parsed body of a
+ /// range-able block keeps the true source line of every nested block: the
+ /// line arrays are shared, never re-sliced.
+ private static func parseBlocks(in source: Source, from lo: Int, to hi: Int) -> [NorgBlock] {
+ var blocks: [NorgBlock] = []
+ var i = lo
+
+ while i < hi {
+ let lineNo = i
+ let line = source.trimmed[i]
+
+ if line.isEmpty {
+ i += 1
+ continue
+ }
+
+ // Verbatim ranged tags: @code … @end, @document.meta … @end, etc.
+ if line.hasPrefix("@") {
+ let (block, next) = verbatimTagBlock(at: i, in: source, to: hi)
+ if let block { blocks.append(block) }
+ i = next
+ continue
+ }
+
+ // Delimiting modifiers (lines of two or more identical -, = or _).
+ // All three are emitted as blocks: `___` renders as a rule, while
+ // `---`/`===` are reset signals the tree fold consumes.
+ if let delimiter = delimiter(line) {
+ switch delimiter {
+ case .rule: blocks.append(.horizontalRule(line: lineNo))
+ case .weak: blocks.append(.weakDelimiter(line: lineNo))
+ case .strong: blocks.append(.strongDelimiter(line: lineNo))
+ }
+ i += 1
+ continue
+ }
+
+ // Range-able detached modifiers: definitions, footnotes, table cells.
+ if let m = DetachedModifier.parse(line[...], accepting: rangeableMarkers) {
+ let (block, next) = rangeableBlock(m, at: i, in: source, to: hi, line: lineNo)
+ if let block { blocks.append(block) }
+ i = next
+ continue
+ }
+
+ // Structural (headings) and nestable (lists, quotes) modifiers.
+ if let m = DetachedModifier.parse(line[...], accepting: blockMarkers) {
+ let (block, next) = detachedBlock(m, at: i, in: source, to: hi, line: lineNo)
+ if let block { blocks.append(block) }
+ i = next
+ continue
+ }
+
+ let (paragraph, next) = paragraphBlock(at: i, in: source, to: hi, line: lineNo)
+ blocks.append(paragraph)
+ i = next
+ }
+
+ return blocks
+ }
+
+ // MARK: - Block recognisers
+
+ /// Builds a verbatim ranged tag (`@name … @end`) and returns the next line.
+ /// A stray `@end` with no matching opener is a no-op, not a tag named "end"
+ /// that would otherwise swallow the rest of the document.
+ private static func verbatimTagBlock(at i: Int, in source: Source, to hi: Int) -> (
+ block: NorgBlock?, next: Int
+ ) {
+ let header = TextHelper.whitespaceTrimmed(String(source.trimmed[i].dropFirst()))
+ if header == "end" { return (nil, i + 1) }
+ var body: [String] = []
+ var j = i + 1
+ while j < hi, source.trimmed[j] != "@end" {
+ body.append(String(source.raw[j]))
+ j += 1
+ }
+ return (rangedTagBlock(header: header, body: body, line: i), (j < hi) ? j + 1 : j)
+ }
+
+ /// Merges consecutive soft-wrapped lines into a single paragraph block.
+ private static func paragraphBlock(
+ at i: Int, in source: Source, to hi: Int, line lineNo: Int
+ ) -> (block: NorgBlock, next: Int) {
+ var paragraph: [Substring] = [source.trimmed[i]]
+ var j = i + 1
+ while j < hi {
+ let next = source.trimmed[j]
+ if next.isEmpty || isBlockStart(next) { break }
+ paragraph.append(next)
+ j += 1
+ }
+ return (
+ .paragraph(content: NorgInlineParser.parse(paragraph.joined(separator: " ")), line: lineNo), j
+ )
+ }
+
+ /// Builds the block for a recognised detached modifier, merging soft-wrapped
+ /// continuation lines into its content, and returns the next line to parse.
+ ///
+ /// Headings are *structural*: they take only a single paragraph segment as
+ /// their title. The *nestable* modifiers (lists, quotes) consume a whole
+ /// paragraph as content, so continuation lines are merged in until a
+ /// paragraph break or a new block — see Norg 1.0 §"Structural"/"Nestable
+ /// Detached Modifiers".
+ private static func detachedBlock(
+ _ m: DetachedModifier, at i: Int, in source: Source, to hi: Int, line lineNo: Int
+ ) -> (block: NorgBlock?, next: Int) {
+ var text = String(m.content)
+ var j = i + 1
+ if m.marker != "*" {
+ while j < hi {
+ let next = source.trimmed[j]
+ if next.isEmpty || isBlockStart(next) { break }
+ if !text.isEmpty { text += " " }
+ text += next
+ j += 1
+ }
+ }
+ return (block(m, content: NorgInlineParser.parse(text), line: lineNo), j)
+ }
+
+ /// Builds a range-able block (definition, footnote, or table cell) and
+ /// returns the next line to parse.
+ ///
+ /// The single form (`$ Term`) takes its title from the marker line and the
+ /// immediately following paragraph as its body. The ranged form (`$$ Term`)
+ /// takes every block up to the matching closer — the doubled marker alone on
+ /// a line — tracking nested openers of the same marker so an inner range does
+ /// not close the outer one. The body is parsed by recursing over the shared
+ /// line arrays, which preserves absolute source line numbers.
+ private static func rangeableBlock(
+ _ m: DetachedModifier, at i: Int, in source: Source, to hi: Int, line lineNo: Int
+ ) -> (block: NorgBlock?, next: Int) {
+ let title = NorgInlineParser.parse(String(m.content))
+ let bodyEnd: Int
+ let next: Int
+
+ if m.level >= 2 {
+ let closer = String(repeating: m.marker, count: m.level)
+ var depth = 1
+ var j = i + 1
+ while j < hi {
+ let t = source.trimmed[j]
+ if t == closer {
+ depth -= 1
+ if depth == 0 { break }
+ } else if isRangedOpener(t, marker: m.marker) {
+ depth += 1
+ }
+ j += 1
+ }
+ bodyEnd = j
+ next = (j < hi) ? j + 1 : j // skip the closer line itself
+ } else {
+ var j = i + 1
+ while j < hi {
+ let t = source.trimmed[j]
+ if t.isEmpty || isBlockStart(t) { break }
+ j += 1
+ }
+ bodyEnd = j
+ next = j
+ }
+
+ let body = parseBlocks(in: source, from: i + 1, to: bodyEnd)
+ return (rangeable(m.marker, title: title, status: m.status, body: body, line: lineNo), next)
+ }
+
+ private static func rangeable(
+ _ marker: Character, title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int
+ ) -> NorgBlock? {
+ switch marker {
+ case "$": return .definition(title: title, status: status, body: body, line: line)
+ case "^": return .footnote(title: title, status: status, body: body, line: line)
+ case ":": return .tableCell(title: title, status: status, body: body, line: line)
+ default: return nil
+ }
+ }
+
+ /// Whether `line` opens a ranged detached modifier with the given `marker`
+ /// (two or more leading markers), used to balance nested ranges.
+ private static func isRangedOpener(_ line: Substring, marker: Character) -> Bool {
+ guard let m = DetachedModifier.parse(line[...], accepting: ASCIIByteSet(String(marker))) else {
+ return false
+ }
+ return m.level >= 2
+ }
+
+ /// Maps a recognised detached modifier and its content to the corresponding block.
+ private static func block(_ m: DetachedModifier, content: [InlineSpan], line lineNo: Int)
+ -> NorgBlock? {
+ switch m.marker {
+ case "*": return .heading(level: m.level, status: m.status, content: content, line: lineNo)
+ case "-":
+ return .unorderedListItem(level: m.level, status: m.status, content: content, line: lineNo)
+ case "~":
+ return .orderedListItem(level: m.level, status: m.status, content: content, line: lineNo)
+ case ">": return .quote(level: m.level, status: m.status, content: content, line: lineNo)
+ default: return nil
+ }
+ }
+
+ private static func rangedTagBlock(header: String, body: [String], line: Int) -> NorgBlock? {
+ // header e.g. "code swift", "document.meta", "math". Name and parameters are
+ // whitespace-separated; the name is dot-split as in the reference parser.
+ let fields = header.split(maxSplits: 1, whereSeparator: \.isWhitespace)
+ let nameField = fields.first.map(String.init) ?? ""
+
+ // Hidden metadata/comment tags produce no rendered block.
+ if nameField == "document.meta" || nameField == "comment" { return nil }
+
+ let name = nameField.split(separator: ".").map(String.init)
+ let parameters =
+ fields.count > 1
+ ? fields[1].split(whereSeparator: \.isWhitespace).map(String.init)
+ : []
+ let content = dedent(body)
+
+ if name.first == "code" {
+ return .codeBlock(language: parameters.first, code: content, line: line)
+ }
+ return .rangedTag(name: name, parameters: parameters, content: content, line: line)
+ }
+
+ /// Joins a verbatim body and strips the common leading indentation shared by
+ /// all non-blank lines, matching rust-norg's `textwrap::dedent`.
+ private static func dedent(_ body: [String]) -> String {
+ let indents = body.compactMap { line -> Int? in
+ line.allSatisfy(\.isWhitespace) ? nil : line.prefix(while: \.isWhitespace).count
+ }
+ let common = indents.min() ?? 0
+ guard common > 0 else { return body.joined(separator: "\n") }
+ return body.map { line in
+ line.allSatisfy(\.isWhitespace) ? line : String(line.dropFirst(common))
+ }.joined(separator: "\n")
+ }
+
+ // MARK: - Helpers
+
+ /// Identifies a delimiting-modifier line (two or more identical `-`, `=`, `_`).
+ private static func delimiter(_ s: Substring) -> Delimiter? {
+ guard s.count >= 2, let first = s.first, "-=_".contains(first),
+ s.allSatisfy({ $0 == first })
+ else { return nil }
+ switch first {
+ case "-": return .weak
+ case "=": return .strong
+ default: return .rule
+ }
+ }
+
+ /// Whether a trimmed line starts a new block (used to break paragraphs).
+ private static func isBlockStart(_ trimmed: Substring) -> Bool {
+ if trimmed.hasPrefix("@") { return true }
+ if delimiter(trimmed) != nil { return true }
+ if DetachedModifier.parse(trimmed[...], accepting: blockMarkers) != nil { return true }
+ return DetachedModifier.parse(trimmed[...], accepting: rangeableMarkers) != nil
+ }
+}
diff --git a/Sources/NorgKit/Parsers/TaskScanner.swift b/Sources/NorgKit/Parsers/TaskScanner.swift
new file mode 100644
index 0000000..39c16b5
--- /dev/null
+++ b/Sources/NorgKit/Parsers/TaskScanner.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+/// Scans Norg source for tasks and rewrites their status markers.
+/// Handy to extract TODOs quicker than the parser can.
+public enum TaskScanner {
+
+ private static let markers = ASCIIByteSet("*-~>$^:")
+
+ /// Extracts every task in `content`, attributing each to `fileURL`.
+ public static func scan(content: String, fileURL: URL) -> [NorgTask] {
+ var tasks: [NorgTask] = []
+ TextHelper.enumerateLines(in: content) { line, index in
+ if let task = task(in: line, fileURL: fileURL, line: index) {
+ tasks.append(task)
+ }
+ }
+ return tasks
+ }
+
+ /// Parses a single line into a task, if it carries a status extension.
+ public static func task(in raw: String, fileURL: URL, line: Int) -> NorgTask? {
+ task(in: raw[...], fileURL: fileURL, line: line)
+ }
+
+ /// Span-free fast path over a line slice.
+ static func task(in line: Substring, fileURL: URL, line index: Int) -> NorgTask? {
+ guard let m = DetachedModifier.parse(line, accepting: markers), let status = m.status else {
+ return nil
+ }
+ return NorgTask(
+ fileURL: fileURL,
+ line: index,
+ status: status,
+ text: NorgInlineParser.plainText(m.content)
+ )
+ }
+
+ /// Returns `content` with the status marker on `line` replaced by `status`,
+ /// or `nil` if the line carries no recognisable task marker. Line endings are
+ /// preserved, so a CRLF file round-trips unchanged.
+ public static func updatedContent(_ content: String, line index: Int, to status: TaskStatus)
+ -> String? {
+ guard let line = TextHelper.line(in: content, at: index),
+ let m = DetachedModifier.parse(line, accepting: markers),
+ let statusIndex = m.statusIndex
+ else { return nil }
+
+ var updated = content
+ updated.replaceSubrange(statusIndex...statusIndex, with: status.rawValue)
+ return updated
+ }
+}
diff --git a/Sources/NorgKit/TreeFolder.swift b/Sources/NorgKit/TreeFolder.swift
new file mode 100644
index 0000000..944d98f
--- /dev/null
+++ b/Sources/NorgKit/TreeFolder.swift
@@ -0,0 +1,82 @@
+/// Folds a flat list into a tree.
+final class TreeFolder {
+ private enum NestableKind { case unordered, ordered, quote }
+
+ private let blocks: [NorgBlock]
+ private var index = 0
+ private var strongReset = false
+
+ init(_ blocks: [NorgBlock]) {
+ self.blocks = blocks
+ }
+
+ func fold() -> [NorgNode] {
+ foldStructural(level: 0)
+ }
+
+ /// Folds structural items, that can have any block under them.
+ private func foldStructural(level: Int) -> [NorgNode] {
+ var nodes: [NorgNode] = []
+ while index < blocks.count {
+ let block = blocks[index]
+ switch block {
+ case .heading(let headingLevel, _, _, _):
+
+ // A heading of the same or higher level belongs to an ancestor;
+ // leave it for the caller.
+ if headingLevel <= level { return nodes }
+ index += 1
+ let children = foldStructural(level: headingLevel)
+ nodes.append(NorgNode(block: block, children: children))
+ if strongReset {
+ if level == 0 { strongReset = false } else { return nodes }
+ }
+
+ case .unorderedListItem(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .unordered, level: itemLevel))
+ case .orderedListItem(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .ordered, level: itemLevel))
+ case .quote(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .quote, level: itemLevel))
+
+ case .weakDelimiter:
+ index += 1
+ if level > 0 { return nodes }
+
+ case .strongDelimiter:
+ index += 1
+ if level > 0 {
+ strongReset = true
+ return nodes
+ }
+
+ default:
+ index += 1
+ nodes.append(NorgNode(block: block, children: []))
+ }
+ }
+ return nodes
+ }
+
+ /// Folds nestable items that can only consume more of their kind.
+ private func foldNestableItem(_ block: NorgBlock, kind: NestableKind, level: Int) -> NorgNode {
+ index += 1
+ var children: [NorgNode] = []
+ while index < blocks.count, let child = nestableDescriptor(blocks[index]),
+ child.kind == kind, child.level > level {
+ children.append(foldNestableItem(blocks[index], kind: child.kind, level: child.level))
+ }
+ return NorgNode(block: block, children: children)
+ }
+
+ /// The kind and nesting level of a nestable block, or `nil` if `block` is not
+ /// a nestable item.
+ private func nestableDescriptor(_ block: NorgBlock) -> (kind: NestableKind, level: Int)? {
+ switch block {
+ case .unorderedListItem(let level, _, _, _): return (.unordered, level)
+ case .orderedListItem(let level, _, _, _): return (.ordered, level)
+ case .quote(let level, _, _, _): return (.quote, level)
+ default: return nil
+ }
+ }
+}
diff --git a/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift b/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift
new file mode 100644
index 0000000..4f23feb
--- /dev/null
+++ b/Tests/NorgKitTests/Extensions/Array+InlineSpanTests.swift
@@ -0,0 +1,19 @@
+import Testing
+
+@testable import NorgKit
+
+struct ArrayInlineSpanTests {
+
+ @Test func plainTextConcatenatesSpanText() {
+ let spans = [
+ InlineSpan(text: "Hello, ", styles: .bold),
+ InlineSpan(text: "world", styles: .italic),
+ InlineSpan(text: "!"),
+ ]
+ #expect(spans.plainText == "Hello, world!")
+ }
+
+ @Test func plainTextOfEmptyArrayIsEmpty() {
+ #expect([InlineSpan]().plainText.isEmpty)
+ }
+}
diff --git a/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift b/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift
new file mode 100644
index 0000000..eb29ecb
--- /dev/null
+++ b/Tests/NorgKitTests/Helpers/ASCIIByteSetTests.swift
@@ -0,0 +1,42 @@
+import Testing
+
+@testable import NorgKit
+
+struct ASCIIByteSetTests {
+
+ @Test func containsLowBytes() {
+ // `*` (0x2A) and `-` (0x2D) live in the low 64-bit word.
+ let set = ASCIIByteSet("*-")
+ #expect(set.contains(UInt8(ascii: "*")))
+ #expect(set.contains(UInt8(ascii: "-")))
+ #expect(!set.contains(UInt8(ascii: "/")))
+ }
+
+ @Test func containsHighBytes() {
+ // `~` (0x7E) and `_` (0x5F) live in the high 64-bit word.
+ let set = ASCIIByteSet("~_")
+ #expect(set.contains(UInt8(ascii: "~")))
+ #expect(set.contains(UInt8(ascii: "_")))
+ #expect(!set.contains(UInt8(ascii: "x")))
+ }
+
+ @Test func spansBothWords() {
+ let set = ASCIIByteSet("*~")
+ #expect(set.contains(UInt8(ascii: "*")))
+ #expect(set.contains(UInt8(ascii: "~")))
+ }
+
+ @Test func rejectsNonAsciiAndOutOfSetBytes() {
+ let set = ASCIIByteSet("*")
+ #expect(!set.contains(0)) // NUL, low word, unset
+ #expect(!set.contains(200)) // beyond ASCII, never stored
+ #expect(!set.contains(127)) // high word, unset
+ }
+
+ @Test func emptySetContainsNothing() {
+ let set = ASCIIByteSet("")
+ #expect(!set.contains(UInt8(ascii: "*")))
+ #expect(!set.contains(0))
+ #expect(!set.contains(127))
+ }
+}
diff --git a/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift b/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift
new file mode 100644
index 0000000..8a807fe
--- /dev/null
+++ b/Tests/NorgKitTests/Helpers/ASCIIHelperTests.swift
@@ -0,0 +1,22 @@
+import Testing
+
+@testable import NorgKit
+
+struct ASCIIHelperTests {
+ @Test func detectsSpaces() {
+ #expect(ASCIIHelper.isWhitespace(0x20) == true)
+ #expect(ASCIIHelper.isWhitespace(0x09) == true)
+ #expect(ASCIIHelper.isWhitespace(0x0A) == true)
+ #expect(ASCIIHelper.isWhitespace(0x0B) == true)
+ #expect(ASCIIHelper.isWhitespace(0x0C) == true)
+ #expect(ASCIIHelper.isWhitespace(0x0D) == true)
+ }
+
+ @Test func doesNotDetectNonSpaces() {
+ #expect(ASCIIHelper.isWhitespace(0x19) == false)
+ #expect(ASCIIHelper.isWhitespace(0x21) == false)
+ #expect(ASCIIHelper.isWhitespace(0x0E) == false)
+ #expect(ASCIIHelper.isWhitespace(0x08) == false)
+ #expect(ASCIIHelper.isWhitespace(0x67) == false)
+ }
+}
diff --git a/Tests/NorgKitTests/Helpers/TextHelperTests.swift b/Tests/NorgKitTests/Helpers/TextHelperTests.swift
new file mode 100644
index 0000000..9e236ff
--- /dev/null
+++ b/Tests/NorgKitTests/Helpers/TextHelperTests.swift
@@ -0,0 +1,71 @@
+import Testing
+
+@testable import NorgKit
+
+struct TextHelperTests {
+
+ @Test func lineSlicesSplitsOnNewlines() {
+ #expect(TextHelper.lineSlices("a\nb\nc").map(String.init) == ["a", "b", "c"])
+ }
+
+ @Test func lineSlicesKeepsTrailingEmptyLine() {
+ #expect(TextHelper.lineSlices("a\n").map(String.init) == ["a", ""])
+ }
+
+ @Test func lineSlicesHandlesEmptyString() {
+ #expect(TextHelper.lineSlices("").map(String.init) == [""])
+ }
+
+ @Test func enumerateLinesProvidesIndices() {
+ var seen: [(String, Int)] = []
+ TextHelper.enumerateLines(in: "one\ntwo") { line, index in
+ seen.append((String(line), index))
+ }
+ #expect(seen.map(\.0) == ["one", "two"])
+ #expect(seen.map(\.1) == [0, 1])
+ }
+
+ @Test func enumerateLinesStripsCarriageReturns() {
+ var lines: [String] = []
+ TextHelper.enumerateLines(in: "a\r\nb\r\n") { line, _ in lines.append(String(line)) }
+ #expect(lines == ["a", "b", ""])
+ }
+
+ @Test func lineReturnsRequestedLine() {
+ #expect(TextHelper.line(in: "a\nb\nc", at: 1).map(String.init) == "b")
+ }
+
+ @Test func lineReturnsLastLineWithoutTrailingNewline() {
+ #expect(TextHelper.line(in: "a\nb", at: 1).map(String.init) == "b")
+ }
+
+ @Test func lineStripsCarriageReturn() {
+ #expect(TextHelper.line(in: "a\r\nb", at: 0).map(String.init) == "a")
+ }
+
+ @Test func lineReturnsNilForOutOfRange() {
+ #expect(TextHelper.line(in: "a\nb", at: 5) == nil)
+ }
+
+ @Test func lineReturnsNilForNegativeIndex() {
+ #expect(TextHelper.line(in: "a", at: -1) == nil)
+ }
+
+ @Test func whitespaceTrimmedSubstringTrimsBothEnds() {
+ let trimmed = TextHelper.whitespaceTrimmed(" \t hi \t "[...])
+ #expect(trimmed == "hi")
+ }
+
+ @Test func whitespaceTrimmedSubstringOfAllWhitespaceIsEmpty() {
+ #expect(TextHelper.whitespaceTrimmed(" \t "[...]).isEmpty)
+ }
+
+ @Test func whitespaceTrimmedStringTrimsWhenNeeded() {
+ #expect(TextHelper.whitespaceTrimmed(" hi ") == "hi")
+ }
+
+ @Test func whitespaceTrimmedStringReturnsSameValueWhenAlreadyTrimmed() {
+ let input = "already-clean"
+ #expect(TextHelper.whitespaceTrimmed(input) == input)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/DetachedModifierTests.swift b/Tests/NorgKitTests/Models/DetachedModifierTests.swift
new file mode 100644
index 0000000..7ef1479
--- /dev/null
+++ b/Tests/NorgKitTests/Models/DetachedModifierTests.swift
@@ -0,0 +1,61 @@
+import Testing
+
+@testable import NorgKit
+
+struct DetachedModifierTests {
+
+ private let markers = ASCIIByteSet("*-~>$^:")
+
+ @Test func parsesMarkerLevelAndContent() {
+ let m = DetachedModifier.parse("** heading"[...], accepting: markers)
+ #expect(m?.marker == "*")
+ #expect(m?.level == 2)
+ #expect(m?.content == "heading")
+ #expect(m?.status == nil)
+ }
+
+ @Test func skipsLeadingWhitespace() {
+ let m = DetachedModifier.parse(" - item"[...], accepting: markers)
+ #expect(m?.marker == "-")
+ #expect(m?.level == 1)
+ #expect(m?.content == "item")
+ }
+
+ @Test func parsesStatusAndStatusIndex() {
+ let line = "- (x) done"
+ let m = DetachedModifier.parse(line[...], accepting: markers)
+ #expect(m?.status == .done)
+ #expect(m?.content == "done")
+
+ if let index = m?.statusIndex {
+ #expect(line[index] == "x")
+ } else {
+ Issue.record("Expected a status index")
+ }
+ }
+
+ @Test func returnsNilForUnacceptedMarker() {
+ #expect(DetachedModifier.parse("+ item"[...], accepting: markers) == nil)
+ }
+
+ @Test func returnsNilWithoutWhitespaceAfterMarker() {
+ #expect(DetachedModifier.parse("*bold*"[...], accepting: markers) == nil)
+ }
+
+ @Test func returnsNilForEmptyAndBlankLines() {
+ #expect(DetachedModifier.parse(""[...], accepting: markers) == nil)
+ #expect(DetachedModifier.parse(" "[...], accepting: markers) == nil)
+ }
+
+ @Test func unterminatedStatusParenIsTreatedAsContent() {
+ let m = DetachedModifier.parse("- (x oops"[...], accepting: markers)
+ #expect(m?.status == nil)
+ #expect(m?.content == "(x oops")
+ }
+
+ @Test func unknownStatusCharInParensIsContent() {
+ let m = DetachedModifier.parse("- (z) text"[...], accepting: markers)
+ #expect(m?.status == nil)
+ #expect(m?.content == "(z) text")
+ }
+}
diff --git a/Tests/NorgKitTests/Models/InlineLinkTests.swift b/Tests/NorgKitTests/Models/InlineLinkTests.swift
new file mode 100644
index 0000000..9cf2cce
--- /dev/null
+++ b/Tests/NorgKitTests/Models/InlineLinkTests.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct InlineLinkTests {
+
+ @Test func storesKindAndTarget() {
+ let link = InlineLink(kind: .link, target: "https://example.com")
+ #expect(link.kind == .link)
+ #expect(link.target == "https://example.com")
+ }
+
+ @Test func anchorMayHaveNoTarget() {
+ let anchor = InlineLink(kind: .anchor, target: nil)
+ #expect(anchor.kind == .anchor)
+ #expect(anchor.target == nil)
+ }
+
+ @Test func equatableDistinguishesKindAndTarget() {
+ #expect(InlineLink(kind: .link, target: "a") == InlineLink(kind: .link, target: "a"))
+ #expect(InlineLink(kind: .link, target: "a") != InlineLink(kind: .anchor, target: "a"))
+ #expect(InlineLink(kind: .link, target: "a") != InlineLink(kind: .link, target: "b"))
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ for link in [InlineLink(kind: .link, target: "x"), InlineLink(kind: .anchor, target: nil)] {
+ let data = try JSONEncoder().encode(link)
+ #expect(try JSONDecoder().decode(InlineLink.self, from: data) == link)
+ }
+ }
+}
diff --git a/Tests/NorgKitTests/Models/InlineSpanTests.swift b/Tests/NorgKitTests/Models/InlineSpanTests.swift
new file mode 100644
index 0000000..24531e4
--- /dev/null
+++ b/Tests/NorgKitTests/Models/InlineSpanTests.swift
@@ -0,0 +1,35 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct InlineSpanTests {
+
+ @Test func defaultsToNoStyleAndNoLink() {
+ let span = InlineSpan(text: "hi")
+ #expect(span.styles.isEmpty)
+ #expect(span.link == nil)
+ }
+
+ @Test func storesStyleAndLink() {
+ let link = InlineLink(kind: .link, target: "https://example.com")
+ let span = InlineSpan(text: "hi", styles: .bold, link: link)
+ #expect(span.styles.contains(.bold))
+ #expect(span.link == link)
+ }
+
+ @Test func equatableDistinguishesFields() {
+ #expect(InlineSpan(text: "a") == InlineSpan(text: "a"))
+ #expect(InlineSpan(text: "a") != InlineSpan(text: "b"))
+ #expect(InlineSpan(text: "a", styles: .bold) != InlineSpan(text: "a"))
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let span = InlineSpan(
+ text: "hi", styles: [.bold, .italic],
+ link: InlineLink(kind: .anchor, target: nil)
+ )
+ let data = try JSONEncoder().encode(span)
+ #expect(try JSONDecoder().decode(InlineSpan.self, from: data) == span)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/InlineStyleTests.swift b/Tests/NorgKitTests/Models/InlineStyleTests.swift
new file mode 100644
index 0000000..4eafc12
--- /dev/null
+++ b/Tests/NorgKitTests/Models/InlineStyleTests.swift
@@ -0,0 +1,35 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct InlineStyleTests {
+
+ @Test func combinesAndContains() {
+ let style: InlineStyle = [.bold, .italic]
+ #expect(style.contains(.bold))
+ #expect(style.contains(.italic))
+ #expect(!style.contains(.underline))
+ }
+
+ @Test func distinctRawValues() {
+ let all: [InlineStyle] = [
+ .bold, .italic, .underline, .strikethrough,
+ .verbatim, .superscript, .subscript, .spoiler, .math,
+ ]
+ #expect(Set(all.map(\.rawValue)).count == all.count)
+ }
+
+ @Test func encodesAsSingleInteger() throws {
+ let style: InlineStyle = [.bold, .verbatim]
+ let data = try JSONEncoder().encode(style)
+ #expect(String(data: data, encoding: .utf8) == "\(style.rawValue)")
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let style: InlineStyle = [.italic, .math, .spoiler]
+ let data = try JSONEncoder().encode(style)
+ let decoded = try JSONDecoder().decode(InlineStyle.self, from: data)
+ #expect(decoded == style)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/NorgBlockTests.swift b/Tests/NorgKitTests/Models/NorgBlockTests.swift
new file mode 100644
index 0000000..d9d18c2
--- /dev/null
+++ b/Tests/NorgKitTests/Models/NorgBlockTests.swift
@@ -0,0 +1,127 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct NorgBlockTests {
+
+ private let span = [InlineSpan(text: "x")]
+ private let title = [InlineSpan(text: "t")]
+ private let body: [NorgBlock] = [.paragraph(content: [InlineSpan(text: "b")], line: 1)]
+
+ private var allBlocks: [NorgBlock] {
+ [
+ .heading(level: 1, status: .done, content: span, line: 0),
+ .paragraph(content: span, line: 1),
+ .unorderedListItem(level: 1, status: .undone, content: span, line: 2),
+ .orderedListItem(level: 1, status: .pending, content: span, line: 3),
+ .quote(level: 1, status: .urgent, content: span, line: 4),
+ .codeBlock(language: "swift", code: "c", line: 5),
+ .definition(title: title, status: .needsInput, body: body, line: 6),
+ .footnote(title: title, status: .onHold, body: body, line: 7),
+ .tableCell(title: title, status: .recurring, body: body, line: 8),
+ .rangedTag(name: ["img"], parameters: ["a"], content: "c", line: 9),
+ .horizontalRule(line: 10),
+ .weakDelimiter(line: 11),
+ .strongDelimiter(line: 12),
+ ]
+ }
+
+ @Test func lineReportsSourceLineForEveryCase() {
+ for (expected, block) in allBlocks.enumerated() {
+ #expect(block.line == expected)
+ }
+ }
+
+ @Test func statusReturnsValueForStatusBearingBlocks() {
+ #expect(NorgBlock.heading(level: 1, status: .done, content: span, line: 0).status == .done)
+ #expect(
+ NorgBlock.unorderedListItem(level: 1, status: .undone, content: span, line: 0).status
+ == .undone)
+ #expect(
+ NorgBlock.orderedListItem(level: 1, status: .pending, content: span, line: 0).status
+ == .pending)
+ #expect(NorgBlock.quote(level: 1, status: .urgent, content: span, line: 0).status == .urgent)
+ #expect(
+ NorgBlock.definition(title: title, status: .needsInput, body: body, line: 0).status
+ == .needsInput)
+ #expect(
+ NorgBlock.footnote(title: title, status: .onHold, body: body, line: 0).status == .onHold)
+ #expect(
+ NorgBlock.tableCell(title: title, status: .recurring, body: body, line: 0).status
+ == .recurring)
+ }
+
+ @Test func statusIsNilForBlocksThatCannotCarryOne() {
+ #expect(NorgBlock.paragraph(content: span, line: 0).status == nil)
+ #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).status == nil)
+ #expect(NorgBlock.rangedTag(name: ["x"], parameters: [], content: "c", line: 0).status == nil)
+ #expect(NorgBlock.horizontalRule(line: 0).status == nil)
+ #expect(NorgBlock.weakDelimiter(line: 0).status == nil)
+ #expect(NorgBlock.strongDelimiter(line: 0).status == nil)
+ }
+
+ @Test func contentReturnsInlineSpansOrTitle() {
+ #expect(NorgBlock.heading(level: 1, status: nil, content: span, line: 0).content == span)
+ #expect(NorgBlock.paragraph(content: span, line: 0).content == span)
+ #expect(
+ NorgBlock.unorderedListItem(level: 1, status: nil, content: span, line: 0).content == span)
+ #expect(
+ NorgBlock.orderedListItem(level: 1, status: nil, content: span, line: 0).content == span)
+ #expect(NorgBlock.quote(level: 1, status: nil, content: span, line: 0).content == span)
+ #expect(NorgBlock.definition(title: title, status: nil, body: body, line: 0).content == title)
+ #expect(NorgBlock.footnote(title: title, status: nil, body: body, line: 0).content == title)
+ #expect(NorgBlock.tableCell(title: title, status: nil, body: body, line: 0).content == title)
+ }
+
+ @Test func contentIsNilForContentlessBlocks() {
+ #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).content == nil)
+ #expect(NorgBlock.rangedTag(name: ["x"], parameters: [], content: "c", line: 0).content == nil)
+ #expect(NorgBlock.horizontalRule(line: 0).content == nil)
+ #expect(NorgBlock.weakDelimiter(line: 0).content == nil)
+ #expect(NorgBlock.strongDelimiter(line: 0).content == nil)
+ }
+
+ @Test func bodyReturnsNestedBlocksForRangeableBlocks() {
+ #expect(NorgBlock.definition(title: title, status: nil, body: body, line: 0).body == body)
+ #expect(NorgBlock.footnote(title: title, status: nil, body: body, line: 0).body == body)
+ #expect(NorgBlock.tableCell(title: title, status: nil, body: body, line: 0).body == body)
+ }
+
+ @Test func bodyIsNilForNonRangeableBlocks() {
+ #expect(NorgBlock.heading(level: 1, status: nil, content: span, line: 0).body == nil)
+ #expect(NorgBlock.paragraph(content: span, line: 0).body == nil)
+ #expect(NorgBlock.codeBlock(language: nil, code: "c", line: 0).body == nil)
+ #expect(NorgBlock.horizontalRule(line: 0).body == nil)
+ }
+
+ @Test func settingStatusReplacesStatusOnSupportingBlocks() {
+ let cases: [NorgBlock] = [
+ .heading(level: 2, status: nil, content: span, line: 0),
+ .unorderedListItem(level: 1, status: nil, content: span, line: 0),
+ .orderedListItem(level: 1, status: nil, content: span, line: 0),
+ .quote(level: 1, status: nil, content: span, line: 0),
+ .definition(title: title, status: nil, body: body, line: 0),
+ .footnote(title: title, status: nil, body: body, line: 0),
+ .tableCell(title: title, status: nil, body: body, line: 0),
+ ]
+ for block in cases {
+ #expect(block.status == nil)
+ let set = block.settingStatus(.done)
+ #expect(set.status == .done)
+ #expect(set.line == block.line)
+ #expect(set.content == block.content)
+ #expect(set.body == block.body)
+ #expect(set.settingStatus(nil).status == nil)
+ }
+ }
+
+ @Test func settingStatusLeavesUnsupportedBlocksUnchanged() {
+ let paragraph = NorgBlock.paragraph(content: span, line: 0)
+ #expect(paragraph.settingStatus(.done) == paragraph)
+ let rule = NorgBlock.horizontalRule(line: 0)
+ #expect(rule.settingStatus(.done) == rule)
+ let code = NorgBlock.codeBlock(language: "swift", code: "c", line: 0)
+ #expect(code.settingStatus(.urgent) == code)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/NorgDocumentTests.swift b/Tests/NorgKitTests/Models/NorgDocumentTests.swift
new file mode 100644
index 0000000..13bad67
--- /dev/null
+++ b/Tests/NorgKitTests/Models/NorgDocumentTests.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct NorgDocumentTests {
+
+ @Test func defaultsToEmptyBlocks() {
+ #expect(NorgDocument().blocks.isEmpty)
+ }
+
+ @Test func treeFoldsBlocksIntoNodes() {
+ let doc = NorgDocument(blocks: [
+ .heading(level: 1, status: nil, content: [InlineSpan(text: "H")], line: 0),
+ .paragraph(content: [InlineSpan(text: "body")], line: 1),
+ ])
+ let tree = doc.tree()
+ #expect(tree.count == 1)
+ #expect(tree.first?.children.count == 1)
+ }
+
+ @Test func treeMatchesTreeFolderOutput() {
+ let doc = NorgParser.parse("* H\n- a\n-- b")
+ #expect(doc.tree() == TreeFolder(doc.blocks).fold())
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let doc = NorgParser.parse("* H\nbody\n- ( ) task")
+ let data = try JSONEncoder().encode(doc)
+ #expect(try JSONDecoder().decode(NorgDocument.self, from: data) == doc)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/NorgNodeTests.swift b/Tests/NorgKitTests/Models/NorgNodeTests.swift
new file mode 100644
index 0000000..5e60014
--- /dev/null
+++ b/Tests/NorgKitTests/Models/NorgNodeTests.swift
@@ -0,0 +1,31 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct NorgNodeTests {
+
+ private let block = NorgBlock.paragraph(content: [InlineSpan(text: "x")], line: 0)
+
+ @Test func defaultsToNoChildren() {
+ #expect(NorgNode(block: block).children.isEmpty)
+ }
+
+ @Test func storesBlockAndChildren() {
+ let child = NorgNode(block: block)
+ let node = NorgNode(block: block, children: [child])
+ #expect(node.block == block)
+ #expect(node.children == [child])
+ }
+
+ @Test func equatableComparesBlockAndChildren() {
+ #expect(NorgNode(block: block) == NorgNode(block: block))
+ #expect(NorgNode(block: block, children: [NorgNode(block: block)]) != NorgNode(block: block))
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let node = NorgNode(block: block, children: [NorgNode(block: block)])
+ let data = try JSONEncoder().encode(node)
+ #expect(try JSONDecoder().decode(NorgNode.self, from: data) == node)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/NorgTaskTests.swift b/Tests/NorgKitTests/Models/NorgTaskTests.swift
new file mode 100644
index 0000000..e8707a1
--- /dev/null
+++ b/Tests/NorgKitTests/Models/NorgTaskTests.swift
@@ -0,0 +1,33 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct NorgTaskTests {
+
+ private let file = URL(filePath: "/tmp/notes.norg")
+
+ @Test func idCombinesFileURLAndLine() {
+ let task = NorgTask(fileURL: file, line: 7, status: .undone, text: "do it")
+ #expect(task.id == "\(file.absoluteString):7")
+ }
+
+ @Test func idIsStableForSameFileAndLine() {
+ let a = NorgTask(fileURL: file, line: 3, status: .done, text: "a")
+ let b = NorgTask(fileURL: file, line: 3, status: .urgent, text: "b")
+ #expect(a.id == b.id)
+ }
+
+ @Test func differentLinesProduceDifferentIDs() {
+ let a = NorgTask(fileURL: file, line: 1, status: .done, text: "a")
+ let b = NorgTask(fileURL: file, line: 2, status: .done, text: "a")
+ #expect(a.id != b.id)
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let task = NorgTask(fileURL: file, line: 4, status: .pending, text: "keep")
+ let data = try JSONEncoder().encode(task)
+ let decoded = try JSONDecoder().decode(NorgTask.self, from: data)
+ #expect(decoded == task)
+ }
+}
diff --git a/Tests/NorgKitTests/Models/TaskStatusTests.swift b/Tests/NorgKitTests/Models/TaskStatusTests.swift
new file mode 100644
index 0000000..a00e50c
--- /dev/null
+++ b/Tests/NorgKitTests/Models/TaskStatusTests.swift
@@ -0,0 +1,54 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct TaskStatusTests {
+
+ @Test func parsesEveryMarker() {
+ #expect(TaskStatus(marker: " ") == .undone)
+ #expect(TaskStatus(marker: "x") == .done)
+ #expect(TaskStatus(marker: "?") == .needsInput)
+ #expect(TaskStatus(marker: "!") == .urgent)
+ #expect(TaskStatus(marker: "+") == .recurring)
+ #expect(TaskStatus(marker: "-") == .pending)
+ #expect(TaskStatus(marker: "=") == .onHold)
+ #expect(TaskStatus(marker: "_") == .cancelled)
+ }
+
+ @Test func rejectsUnknownMarker() {
+ #expect(TaskStatus(marker: "z") == nil)
+ }
+
+ @Test func parsesEveryMarkerByte() {
+ #expect(TaskStatus(markerByte: UInt8(ascii: " ")) == .undone)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "x")) == .done)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "?")) == .needsInput)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "!")) == .urgent)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "+")) == .recurring)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "-")) == .pending)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "=")) == .onHold)
+ #expect(TaskStatus(markerByte: UInt8(ascii: "_")) == .cancelled)
+ }
+
+ @Test func rejectsUnknownMarkerByte() {
+ #expect(TaskStatus(markerByte: UInt8(ascii: "z")) == nil)
+ }
+
+ @Test func idEqualsRawValue() {
+ for status in TaskStatus.allCases {
+ #expect(status.id == status.rawValue)
+ }
+ }
+
+ @Test func rawValuesMatchMarkers() {
+ #expect(TaskStatus.undone.rawValue == " ")
+ #expect(TaskStatus.done.rawValue == "x")
+ #expect(TaskStatus.allCases.count == 8)
+ }
+
+ @Test func roundTripsThroughJSON() throws {
+ let data = try JSONEncoder().encode(TaskStatus.urgent)
+ #expect(try JSONDecoder().decode(TaskStatus.self, from: data) == .urgent)
+ }
+}
diff --git a/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift b/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift
new file mode 100644
index 0000000..d59fc35
--- /dev/null
+++ b/Tests/NorgKitTests/Parsers/NorgInlineParserTests.swift
@@ -0,0 +1,141 @@
+import Testing
+
+@testable import NorgKit
+
+struct NorgInlineParserTests {
+
+ private func span(_ spans: [InlineSpan], withText text: String) -> InlineSpan? {
+ spans.first { $0.text == text }
+ }
+
+ @Test func parsesBold() {
+ let spans = NorgInlineParser.parse("This is *bold*.")
+ #expect(spans.plainText == "This is bold.")
+ #expect(span(spans, withText: "bold")?.styles.contains(.bold) == true)
+ }
+
+ @Test func parsesMixedStyles() {
+ let spans = NorgInlineParser.parse("a /italic/ and _underline_ and -strike-")
+ #expect(span(spans, withText: "italic")?.styles.contains(.italic) == true)
+ #expect(span(spans, withText: "underline")?.styles.contains(.underline) == true)
+ #expect(span(spans, withText: "strike")?.styles.contains(.strikethrough) == true)
+ }
+
+ @Test func parsesNestedStyles() {
+ let spans = NorgInlineParser.parse("_under /italic/_")
+ let inner = span(spans, withText: "italic")
+ #expect(inner?.styles.contains(.italic) == true)
+ #expect(inner?.styles.contains(.underline) == true)
+ }
+
+ @Test func verbatimIsLiteral() {
+ let spans = NorgInlineParser.parse("a `b*c*d` e")
+ let verbatim = span(spans, withText: "b*c*d")
+ #expect(verbatim?.styles.contains(.verbatim) == true)
+ #expect(spans.plainText == "a b*c*d e")
+ }
+
+ @Test func dropsComments() {
+ let spans = NorgInlineParser.parse("visible %hidden% text")
+ #expect(!spans.plainText.contains("hidden"))
+ #expect(spans.plainText.contains("visible"))
+ #expect(spans.plainText.contains("text"))
+ }
+
+ @Test func escapesModifier() {
+ let spans = NorgInlineParser.parse("\\*not bold\\*")
+ #expect(spans.plainText == "*not bold*")
+ #expect(spans.allSatisfy { !$0.styles.contains(.bold) })
+ }
+
+ @Test func doesNotMatchMidWordHyphen() {
+ let spans = NorgInlineParser.parse("well-known value")
+ #expect(spans.plainText == "well-known value")
+ #expect(spans.allSatisfy { !$0.styles.contains(.strikethrough) })
+ }
+
+ @Test func parsesLinkWithDescription() {
+ let spans = NorgInlineParser.parse("see {https://example.com}[the site] now")
+ let link = span(spans, withText: "the site")
+ #expect(link?.link == InlineLink(kind: .link, target: "https://example.com"))
+ }
+
+ @Test func linkDescriptionIsLiteralNotPrefixStripped() {
+ // A `*`-prefixed description must not be mistaken for a location prefix.
+ let spans = NorgInlineParser.parse("{https://example.com}[* keep me]")
+ #expect(spans.first?.text == "* keep me")
+ #expect(spans.first?.link?.target == "https://example.com")
+ }
+
+ @Test func stripsLocationPrefixFromBareLink() {
+ let spans = NorgInlineParser.parse("{* Some Heading}")
+ #expect(spans.first?.text == "Some Heading")
+ #expect(spans.first?.link == InlineLink(kind: .link, target: "* Some Heading"))
+ }
+
+ @Test func parsesAnchorDefinition() {
+ let spans = NorgInlineParser.parse("[my label]{https://example.com}")
+ #expect(spans.first?.text == "my label")
+ #expect(spans.first?.link == InlineLink(kind: .anchor, target: "https://example.com"))
+ }
+
+ @Test func bareAnchorDeclarationHasNoInlineTarget() {
+ // `[Neorg]` references a target defined elsewhere; its target is unknown
+ // at the declaration site, so it must not echo the label as the target.
+ let spans = NorgInlineParser.parse("I like [Neorg] a lot")
+ let anchor = span(spans, withText: "Neorg")
+ #expect(anchor?.link == InlineLink(kind: .anchor, target: nil))
+ }
+
+ @Test func anchorDeclarationWithDescription() {
+ let spans = NorgInlineParser.parse("[anchor name][shown text]")
+ #expect(spans.first?.text == "shown text")
+ #expect(spans.first?.link == InlineLink(kind: .anchor, target: nil))
+ }
+
+ @Test func unterminatedModifierIsLiteral() {
+ let spans = NorgInlineParser.parse("a * b c")
+ #expect(spans.plainText == "a * b c")
+ }
+
+ @Test func unterminatedAttachedModifierWithOpenerIsLiteral() {
+ // A valid opener (`*` followed by non-space) with no closing modifier
+ // falls through to plain text rather than styling the rest of the line.
+ let spans = NorgInlineParser.parse("*bold but never closed")
+ #expect(spans.plainText == "*bold but never closed")
+ #expect(spans.allSatisfy { !$0.styles.contains(.bold) })
+ }
+
+ @Test func unterminatedVerbatimIsLiteral() {
+ let spans = NorgInlineParser.parse("a `b c d")
+ #expect(spans.plainText == "a `b c d")
+ #expect(spans.allSatisfy { !$0.styles.contains(.verbatim) })
+ }
+
+ @Test func unmatchedLinkBraceIsLiteral() {
+ let spans = NorgInlineParser.parse("see {never closed here")
+ #expect(spans.plainText == "see {never closed here")
+ #expect(spans.allSatisfy { $0.link == nil })
+ }
+
+ @Test func unmatchedAnchorBracketIsLiteral() {
+ let spans = NorgInlineParser.parse("see [never closed here")
+ #expect(spans.plainText == "see [never closed here")
+ #expect(spans.allSatisfy { $0.link == nil })
+ }
+
+ @Test func stripsFileSpecifierAndLocationPrefixFromBareLink() {
+ // `{:path:* Heading}` carries a `:file:` specifier before the location
+ // prefix; both are stripped to derive the display label.
+ let spans = NorgInlineParser.parse("{:notes.norg:* Some Heading}")
+ #expect(spans.first?.text == "Some Heading")
+ #expect(spans.first?.link == InlineLink(kind: .link, target: ":notes.norg:* Some Heading"))
+ }
+
+ @Test func plainTextHelperReturnsUnstyledText() {
+ // Exercises the static plainText(_:) entry used by the task scanner.
+ #expect(NorgInlineParser.plainText("just *bold* here") == "just bold here")
+ #expect(NorgInlineParser.plainText("") == "")
+ #expect(NorgInlineParser.plainText("no markup at all") == "no markup at all")
+ }
+}
diff --git a/Tests/NorgKitTests/Parsers/NorgParserTests.swift b/Tests/NorgKitTests/Parsers/NorgParserTests.swift
new file mode 100644
index 0000000..00ee0ea
--- /dev/null
+++ b/Tests/NorgKitTests/Parsers/NorgParserTests.swift
@@ -0,0 +1,362 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct NorgParserTests {
+
+ @Test func parsesHeadingLevelAndStatus() {
+ let doc = NorgParser.parse("** (x) Ship it")
+ guard case .heading(let level, let status, let content, let line) = doc.blocks.first else {
+ Issue.record("Expected a heading")
+ return
+ }
+ #expect(level == 2)
+ #expect(status == .done)
+ #expect(content.plainText == "Ship it")
+ #expect(line == 0)
+ }
+
+ @Test func parsesPlainHeading() {
+ let doc = NorgParser.parse("* Title")
+ guard case .heading(_, let status, let content, _) = doc.blocks.first else {
+ Issue.record("Expected a heading")
+ return
+ }
+ #expect(status == nil)
+ #expect(content.plainText == "Title")
+ }
+
+ @Test func parsesUnorderedAndNestedList() {
+ let doc = NorgParser.parse("- ( ) todo\n-- nested")
+ guard case .unorderedListItem(let level1, let status, _, _) = doc.blocks.first else {
+ Issue.record("Expected list item")
+ return
+ }
+ #expect(level1 == 1)
+ #expect(status == .undone)
+ guard case .unorderedListItem(let level2, _, let content, _) = doc.blocks.last else {
+ Issue.record("Expected nested list item")
+ return
+ }
+ #expect(level2 == 2)
+ #expect(content.plainText == "nested")
+ }
+
+ @Test func parsesOrderedList() {
+ let doc = NorgParser.parse("~ first\n~ second")
+ #expect(doc.blocks.count == 2)
+ if case .orderedListItem = doc.blocks.first {} else { Issue.record("Expected ordered item") }
+ }
+
+ @Test func parsesQuote() {
+ let doc = NorgParser.parse("> a wise quote")
+ guard case .quote(let level, let status, let content, _) = doc.blocks.first else {
+ Issue.record("Expected quote")
+ return
+ }
+ #expect(level == 1)
+ #expect(status == nil)
+ #expect(content.plainText == "a wise quote")
+ }
+
+ @Test func quoteCarriesStatus() {
+ let doc = NorgParser.parse("> (x) a settled matter")
+ guard case .quote(_, let status, let content, _) = doc.blocks.first else {
+ Issue.record("Expected quote")
+ return
+ }
+ #expect(status == .done)
+ #expect(content.plainText == "a settled matter")
+ }
+
+ @Test func parsesUnknownRangedTagGenerically() {
+ let doc = NorgParser.parse("@math\nx^2 + y^2 = z^2\n@end")
+ guard case .rangedTag(let name, let parameters, let content, _) = doc.blocks.first else {
+ Issue.record("Expected ranged tag")
+ return
+ }
+ #expect(name == ["math"])
+ #expect(parameters.isEmpty)
+ #expect(content == "x^2 + y^2 = z^2")
+ }
+
+ @Test func rangedTagSplitsNameAndParameters() {
+ let doc = NorgParser.parse("@image.png alt text\n/img/cat.png\n@end")
+ guard case .rangedTag(let name, let parameters, _, _) = doc.blocks.first else {
+ Issue.record("Expected ranged tag")
+ return
+ }
+ #expect(name == ["image", "png"])
+ #expect(parameters == ["alt", "text"])
+ }
+
+ @Test func parsesCrlfDocument() {
+ let doc = NorgParser.parse("* One\r\n\r\n- (x) two\r\n@code swift\r\nlet x = 1\r\n@end\r\n")
+ #expect(doc.blocks.count == 3)
+ guard case .heading(_, _, let headingContent, _) = doc.blocks[0] else {
+ Issue.record("Expected heading")
+ return
+ }
+ #expect(headingContent.plainText == "One")
+ guard case .unorderedListItem(_, let status, let listContent, let line) = doc.blocks[1] else {
+ Issue.record("Expected list item")
+ return
+ }
+ #expect(status == .done)
+ #expect(listContent.plainText == "two")
+ #expect(line == 2)
+ guard case .codeBlock(let language, let code, _) = doc.blocks[2] else {
+ Issue.record("Expected code block")
+ return
+ }
+ #expect(language == "swift")
+ #expect(code == "let x = 1")
+ }
+
+ @Test func parsesCodeBlockWithLanguage() {
+ let doc = NorgParser.parse("@code swift\nlet x = 1\nlet y = 2\n@end")
+ guard case .codeBlock(let language, let code, _) = doc.blocks.first else {
+ Issue.record("Expected code block")
+ return
+ }
+ #expect(language == "swift")
+ #expect(code == "let x = 1\nlet y = 2")
+ }
+
+ @Test func emptyVerbatimBodyProducesEmptyCode() {
+ // No body lines means no common indentation to compute.
+ let doc = NorgParser.parse("@code\n@end")
+ guard case .codeBlock(_, let code, _) = doc.blocks.first else {
+ Issue.record("Expected code block")
+ return
+ }
+ #expect(code.isEmpty)
+ }
+
+ @Test func tagWithEmptyHeaderParsesWithEmptyName() {
+ // A bare `@` opener has no name field; it still forms a ranged tag.
+ let doc = NorgParser.parse("@\nbody\n@end")
+ guard case .rangedTag(let name, let parameters, let content, _) = doc.blocks.first else {
+ Issue.record("Expected ranged tag")
+ return
+ }
+ #expect(name.isEmpty)
+ #expect(parameters.isEmpty)
+ #expect(content == "body")
+ }
+
+ @Test func dedentsCommonLeadingIndentationInVerbatimBody() {
+ // All body lines share four leading spaces, which are stripped while the
+ // relative indentation of the nested line is preserved. A blank line does
+ // not contribute to the common indent.
+ let doc = NorgParser.parse("@code\n one\n\n two\n three\n@end")
+ guard case .codeBlock(_, let code, _) = doc.blocks.first else {
+ Issue.record("Expected code block")
+ return
+ }
+ #expect(code == "one\n\n two\nthree")
+ }
+
+ @Test func strayEndTagIsNoOpAndDoesNotSwallowDocument() {
+ let doc = NorgParser.parse("* One\n@end\n* Two")
+ #expect(doc.blocks.count == 2)
+ if case .heading = doc.blocks.first {} else { Issue.record("Expected first heading") }
+ if case .heading = doc.blocks.last {} else { Issue.record("Expected second heading") }
+ }
+
+ @Test func hidesDocumentMetadata() {
+ let doc = NorgParser.parse("@document.meta\ntitle: Test\n@end\n* Heading")
+ #expect(doc.blocks.count == 1)
+ if case .heading = doc.blocks.first {} else { Issue.record("Expected only the heading") }
+ }
+}
+
+// MARK: - Delimiters, paragraphs, and range-able blocks
+
+extension NorgParserTests {
+
+ @Test func parsesDelimitingModifiers() {
+ if case .horizontalRule = NorgParser.parse("___").blocks.first {
+ } else {
+ Issue.record("Expected horizontal rule")
+ }
+ if case .weakDelimiter = NorgParser.parse("---").blocks.first {
+ } else {
+ Issue.record("Expected weak delimiter")
+ }
+ if case .strongDelimiter = NorgParser.parse("===").blocks.first {
+ } else {
+ Issue.record("Expected strong delimiter")
+ }
+ }
+
+ @Test func mergesSoftWrappedParagraph() {
+ let doc = NorgParser.parse("line one\nline two")
+ #expect(doc.blocks.count == 1)
+ guard case .paragraph(let content, _) = doc.blocks.first else {
+ Issue.record("Expected paragraph")
+ return
+ }
+ #expect(content.plainText == "line one line two")
+ }
+
+ @Test func boldAtLineStartIsParagraphNotHeading() {
+ let doc = NorgParser.parse("*bold* word")
+ guard case .paragraph(let content, _) = doc.blocks.first else {
+ Issue.record("Expected paragraph")
+ return
+ }
+ #expect(content.plainText == "bold word")
+ }
+
+ @Test func tracksLineNumbersAcrossBlankLines() {
+ let doc = NorgParser.parse("* One\n\n* Two")
+ #expect(doc.blocks.count == 2)
+ #expect(doc.blocks[1].line == 2)
+ }
+
+ @Test func listItemMergesSoftWrappedContinuation() {
+ // Nestable detached modifiers consume a whole paragraph as content, so a
+ // continuation line with no marker joins the item (Norg 1.0 §"Nestable
+ // Detached Modifiers").
+ let doc = NorgParser.parse("- ( ) buy milk\n and eggs")
+ #expect(doc.blocks.count == 1)
+ guard case .unorderedListItem(_, let status, let content, _) = doc.blocks.first else {
+ Issue.record("Expected a single list item")
+ return
+ }
+ #expect(status == .undone)
+ #expect(content.plainText == "buy milk and eggs")
+ }
+
+ @Test func quoteMergesSoftWrappedContinuation() {
+ let doc = NorgParser.parse("> a wise quote\nthat spans lines")
+ #expect(doc.blocks.count == 1)
+ guard case .quote(_, _, let content, _) = doc.blocks.first else {
+ Issue.record("Expected a single quote")
+ return
+ }
+ #expect(content.plainText == "a wise quote that spans lines")
+ }
+
+ @Test func headingTitleDoesNotMergeContinuation() {
+ // Headings are structural: the title is a single paragraph segment, and
+ // the following line becomes its own block.
+ let doc = NorgParser.parse("* A heading\nbody text below")
+ #expect(doc.blocks.count == 2)
+ guard case .heading(_, _, let content, _) = doc.blocks.first else {
+ Issue.record("Expected heading")
+ return
+ }
+ #expect(content.plainText == "A heading")
+ guard case .paragraph(let body, let line) = doc.blocks.last else {
+ Issue.record("Expected paragraph")
+ return
+ }
+ #expect(body.plainText == "body text below")
+ #expect(line == 1)
+ }
+
+ @Test func blankLineTerminatesListItemContent() {
+ let doc = NorgParser.parse("- item one\n\nseparate paragraph")
+ #expect(doc.blocks.count == 2)
+ guard case .unorderedListItem(_, _, let content, _) = doc.blocks.first else {
+ Issue.record("Expected list item")
+ return
+ }
+ #expect(content.plainText == "item one")
+ if case .paragraph = doc.blocks.last {} else { Issue.record("Expected paragraph") }
+ }
+
+ @Test func nextListItemBreaksContinuation() {
+ let doc = NorgParser.parse("- first\n- second")
+ #expect(doc.blocks.count == 2)
+ }
+
+ @Test func parsesSingleDefinition() {
+ let doc = NorgParser.parse("$ Norg\nA structured note format.\nplain follow-up")
+ // The definition owns only the immediately following paragraph; the
+ // blank-free continuation merges into that one body paragraph.
+ guard case .definition(let title, let status, let body, let line) = doc.blocks.first else {
+ Issue.record("Expected a definition")
+ return
+ }
+ #expect(title.plainText == "Norg")
+ #expect(status == nil)
+ #expect(line == 0)
+ #expect(body.count == 1)
+ guard case .paragraph(let content, let bodyLine) = body.first else {
+ Issue.record("Expected a paragraph body")
+ return
+ }
+ #expect(content.plainText == "A structured note format. plain follow-up")
+ #expect(bodyLine == 1) // absolute line number is preserved
+ }
+
+ @Test func parsesRangedDefinitionWithNestedBlocks() {
+ let doc = NorgParser.parse("$$ Term\n- one\n- two\n$$\nafter")
+ #expect(doc.blocks.count == 2)
+ guard case .definition(let title, _, let body, _) = doc.blocks.first else {
+ Issue.record("Expected a definition")
+ return
+ }
+ #expect(title.plainText == "Term")
+ #expect(body.count == 2)
+ if case .unorderedListItem = body.first {} else { Issue.record("Expected a list body") }
+ // The block after the closer is back at the top level on its own line.
+ guard case .paragraph(let after, let line) = doc.blocks.last else {
+ Issue.record("Expected trailing paragraph")
+ return
+ }
+ #expect(after.plainText == "after")
+ #expect(line == 4)
+ }
+
+ @Test func nestedRangeDoesNotCloseOuter() {
+ // An inner `$$ … $$` must not be mistaken for the outer range's closer.
+ let doc = NorgParser.parse("$$ outer\n$$ inner\nbody\n$$\nstill outer\n$$")
+ #expect(doc.blocks.count == 1)
+ guard case .definition(_, _, let body, _) = doc.blocks.first else {
+ Issue.record("Expected outer definition")
+ return
+ }
+ // Body holds the inner definition and the trailing paragraph.
+ #expect(body.count == 2)
+ if case .definition = body.first {} else { Issue.record("Expected nested definition") }
+ #expect(body.last?.content?.plainText == "still outer")
+ }
+
+ @Test func parsesFootnoteAndTableCell() {
+ let doc = NorgParser.parse("^ source\nthe reference\n: A1\nthe cell")
+ guard case .footnote(let footTitle, _, _, _) = doc.blocks.first else {
+ Issue.record("Expected a footnote")
+ return
+ }
+ #expect(footTitle.plainText == "source")
+ guard case .tableCell(let cellTitle, _, _, _) = doc.blocks.last else {
+ Issue.record("Expected a table cell")
+ return
+ }
+ #expect(cellTitle.plainText == "A1")
+ }
+
+ @Test func definitionCarriesStatus() {
+ let doc = NorgParser.parse("$ (x) Settled term\nits meaning")
+ guard case .definition(_, let status, _, _) = doc.blocks.first else {
+ Issue.record("Expected a definition")
+ return
+ }
+ #expect(status == .done)
+ }
+
+ @Test func documentRoundTripsThroughJSON() throws {
+ // Includes a ranged definition so the recursive `body` encodes/decodes.
+ let doc = NorgParser.parse(
+ "* (x) Done\n- ( ) todo {https://example.com}[link]\n$$ Term\n- nested\n$$\n@code swift\nlet x = 1\n@end"
+ )
+ let data = try JSONEncoder().encode(doc)
+ let decoded = try JSONDecoder().decode(NorgDocument.self, from: data)
+ #expect(decoded == doc)
+ }
+}
diff --git a/Tests/NorgKitTests/Parsers/TaskScannerTests.swift b/Tests/NorgKitTests/Parsers/TaskScannerTests.swift
new file mode 100644
index 0000000..58226fd
--- /dev/null
+++ b/Tests/NorgKitTests/Parsers/TaskScannerTests.swift
@@ -0,0 +1,110 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct TaskScannerTests {
+
+ private let file = URL(filePath: "/tmp/notes.norg")
+
+ @Test func scansTasksWithLineNumbers() {
+ let content = """
+ * Project
+ - ( ) first task
+ - (x) done task
+ plain text
+ ~ (-) ordered pending
+ """
+ let tasks = TaskScanner.scan(content: content, fileURL: file)
+ #expect(tasks.count == 3)
+ #expect(tasks[0].line == 1)
+ #expect(tasks[0].status == .undone)
+ #expect(tasks[0].text == "first task")
+ #expect(tasks[1].status == .done)
+ #expect(tasks[2].status == .pending)
+ }
+
+ @Test func ignoresNonTaskLines() {
+ let tasks = TaskScanner.scan(content: "- just a list item\n* heading", fileURL: file)
+ #expect(tasks.isEmpty)
+ }
+
+ @Test func parsesSingleLineIntoTask() {
+ let task = TaskScanner.task(in: "- (!) urgent thing", fileURL: file, line: 9)
+ #expect(task?.line == 9)
+ #expect(task?.status == .urgent)
+ #expect(task?.text == "urgent thing")
+ #expect(task?.fileURL == file)
+ }
+
+ @Test func parsesSingleLineReturnsNilForNonTask() {
+ #expect(TaskScanner.task(in: "- plain item", fileURL: file, line: 0) == nil)
+ }
+
+ @Test func scansEveryStatusBearingModifier() {
+ // The Norg spec allows any detached modifier to carry a TODO status. We
+ // model the structural, nestable, and range-able modifiers (`%`
+ // attributes are intentionally not treated as tasks).
+ let content = """
+ * (x) heading task
+ - ( ) list task
+ ~ (-) ordered task
+ > (!) quote task
+ $ (?) definition task
+ ^ (=) footnote task
+ : (+) table cell task
+ """
+ let tasks = TaskScanner.scan(content: content, fileURL: file)
+ #expect(tasks.count == 7)
+ #expect(
+ tasks.map(\.status) == [
+ .done, .undone, .pending, .urgent, .needsInput, .onHold, .recurring,
+ ])
+ #expect(tasks[3].text == "quote task")
+ #expect(tasks[4].text == "definition task")
+ }
+
+ @Test func stripsInlineMarkupFromTaskText() {
+ let tasks = TaskScanner.scan(content: "- ( ) buy *milk*", fileURL: file)
+ #expect(tasks.first?.text == "buy milk")
+ }
+
+ @Test func updatesStatusMarker() {
+ let content = "- ( ) toggle me"
+ let updated = TaskScanner.updatedContent(content, line: 0, to: .done)
+ #expect(updated == "- (x) toggle me")
+ }
+
+ @Test func updatesStatusPreservingIndentation() {
+ let content = "* Heading\n -- (x) nested"
+ let updated = TaskScanner.updatedContent(content, line: 1, to: .cancelled)
+ #expect(updated == "* Heading\n -- (_) nested")
+ }
+
+ @Test func updateReturnsNilWhenNoMarker() {
+ #expect(TaskScanner.updatedContent("plain line", line: 0, to: .done) == nil)
+ }
+
+ @Test func updateReturnsNilForOutOfRangeLine() {
+ #expect(TaskScanner.updatedContent("- ( ) a", line: 5, to: .done) == nil)
+ }
+
+ @Test func updatesStatusOnLaterLineLeavingRestIntact() {
+ let content = "* heading\n> ( ) quote task\n- plain item"
+ let updated = TaskScanner.updatedContent(content, line: 1, to: .urgent)
+ #expect(updated == "* heading\n> (!) quote task\n- plain item")
+ }
+
+ @Test func scansCrlfContent() {
+ let tasks = TaskScanner.scan(content: "- ( ) first\r\n- (x) second\r\n", fileURL: file)
+ #expect(tasks.count == 2)
+ #expect(tasks[0].text == "first")
+ #expect(tasks[1].status == .done)
+ }
+
+ @Test func updatePreservesCrlfLineEndings() {
+ let content = "- ( ) a\r\n- ( ) b"
+ let updated = TaskScanner.updatedContent(content, line: 1, to: .done)
+ #expect(updated == "- ( ) a\r\n- (x) b")
+ }
+}
diff --git a/Tests/NorgKitTests/TreeFolderTests.swift b/Tests/NorgKitTests/TreeFolderTests.swift
new file mode 100644
index 0000000..f3fe0a3
--- /dev/null
+++ b/Tests/NorgKitTests/TreeFolderTests.swift
@@ -0,0 +1,145 @@
+import Foundation
+import Testing
+
+@testable import NorgKit
+
+struct TreeFolderTests {
+
+ private func headingLevel(_ node: NorgNode?) -> Int? {
+ guard case .heading(let level, _, _, _)? = node?.block else { return nil }
+ return level
+ }
+
+ private func plainText(_ node: NorgNode?) -> String? {
+ switch node?.block {
+ case .heading(_, _, let content, _),
+ .paragraph(let content, _),
+ .unorderedListItem(_, _, let content, _),
+ .quote(_, _, let content, _):
+ return content.plainText
+ default:
+ return nil
+ }
+ }
+
+ @Test func headingOwnsFollowingParagraph() {
+ let tree = NorgParser.parseTree("* Title\nbody paragraph")
+ #expect(tree.count == 1)
+ #expect(headingLevel(tree.first) == 1)
+ #expect(tree.first?.children.count == 1)
+ #expect(plainText(tree.first?.children.first) == "body paragraph")
+ }
+
+ @Test func subHeadingNestsUnderParent() {
+ let tree = NorgParser.parseTree("* H1\npara one\n** H2\npara two")
+ #expect(tree.count == 1)
+ let h1 = tree.first
+ #expect(headingLevel(h1) == 1)
+ #expect(h1?.children.count == 2)
+ #expect(plainText(h1?.children.first) == "para one")
+ let h2 = h1?.children.last
+ #expect(headingLevel(h2) == 2)
+ #expect(plainText(h2?.children.first) == "para two")
+ }
+
+ @Test func sameOrHigherHeadingClosesScope() {
+ let tree = NorgParser.parseTree("* H1\nbody\n* H2")
+ #expect(tree.count == 2)
+ #expect(headingLevel(tree.first) == 1)
+ #expect(headingLevel(tree.last) == 1)
+ #expect(tree.first?.children.count == 1)
+ #expect(tree.last?.children.isEmpty == true)
+ }
+
+ @Test func weakDelimiterClosesOneLevel() {
+ let tree = NorgParser.parseTree("* H1\n** H2\ninner\n*** H3\ninnerinner\n---\nback in h1")
+ #expect(tree.count == 1)
+ let h1 = tree.first
+ #expect(h1?.children.count == 1)
+
+ let h2 = h1?.children.first
+ #expect(h2?.children.count == 3)
+ #expect(headingLevel(h2) == 2)
+ #expect(plainText(h2?.children.first) == "inner")
+
+ let h3 = h2?.children[1]
+ #expect(headingLevel(h3) == 3)
+ #expect(plainText(h3?.children.first) == "innerinner")
+ #expect(h3?.children.count == 1)
+
+ #expect(plainText(h2?.children.last) == "back in h1")
+ #expect(
+ h1?.children.allSatisfy {
+ if case .weakDelimiter = $0.block { return false }
+ return true
+ } == true)
+ }
+
+ @Test func strongDelimiterClosesAllLevels() {
+ let tree = NorgParser.parseTree("* H1\n** H2\ninner\n*** H3\ninnerinner\n===\nat root")
+ #expect(tree.count == 2)
+ #expect(headingLevel(tree.first) == 1)
+ #expect(plainText(tree.last) == "at root")
+ }
+
+ @Test func nestableItemsNestByLevelAndType() {
+ let tree = NorgParser.parseTree("- one\n-- nested\n--- deeper\n- two")
+ #expect(tree.count == 2)
+ let one = tree.first
+ #expect(plainText(one) == "one")
+ #expect(one?.children.count == 1)
+ let nested = one?.children.first
+ #expect(plainText(nested) == "nested")
+ #expect(plainText(nested?.children.first) == "deeper")
+ #expect(plainText(tree.last) == "two")
+ }
+
+ @Test func differentNestableTypeDoesNotNest() {
+ let tree = NorgParser.parseTree("- item\n~ ordered")
+ #expect(tree.count == 2)
+ #expect(tree.first?.children.isEmpty == true)
+ }
+
+ @Test func quotesNestByLevel() {
+ let tree = NorgParser.parseTree("> one\n>> nested\n> two")
+ #expect(tree.count == 2)
+ let one = tree.first
+ #expect(plainText(one) == "one")
+ #expect(one?.children.count == 1)
+ #expect(plainText(one?.children.first) == "nested")
+ #expect(plainText(tree.last) == "two")
+ }
+
+ @Test func listNestsUnderHeading() {
+ let tree = NorgParser.parseTree("* Tasks\n- ( ) a\n-- ( ) sub")
+ let heading = tree.first
+ #expect(headingLevel(heading) == 1)
+ #expect(heading?.children.count == 1)
+ let item = heading?.children.first
+ #expect(plainText(item) == "a")
+ #expect(plainText(item?.children.first) == "sub")
+ }
+
+ @Test func horizontalRuleStaysALeaf() {
+ let tree = NorgParser.parseTree("* H\n___\ntext")
+ let heading = tree.first
+ #expect(heading?.children.count == 2)
+ if case .horizontalRule = heading?.children.first?.block {
+ } else {
+ Issue.record("Expected horizontal rule leaf")
+ }
+ #expect(heading?.children.first?.children.isEmpty == true)
+ }
+
+ @Test func parseTreeMatchesParseThenFold() {
+ let source = "* H1\npara\n** H2\n- list\n-- sub\n===\nroot para"
+ #expect(NorgParser.parseTree(source) == NorgParser.parse(source).tree())
+ }
+
+ @Test func treeRoundTripsThroughJSON() throws {
+ let tree = NorgParser.parseTree("* H1\nbody\n** H2\n- ( ) task")
+ let data = try JSONEncoder().encode(tree)
+ let decoded = try JSONDecoder().decode([NorgNode].self, from: data)
+ #expect(decoded == tree)
+ }
+}