diff options
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 @@ -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) + } +} |