aboutsummaryrefslogtreecommitdiff
path: root/Tests/NorgKitTests/Parsers/NorgParserTests.swift
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-06-16 12:08:21 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-06-16 12:22:47 +0200
commit501fdce29e4d2c46c4708ad7f44056878e0db3fa (patch)
treedb923b77037db9f5b37600a6e34c3bb2e8a971be /Tests/NorgKitTests/Parsers/NorgParserTests.swift
Initial extraction from Norganize1.0.0
Diffstat (limited to 'Tests/NorgKitTests/Parsers/NorgParserTests.swift')
-rw-r--r--Tests/NorgKitTests/Parsers/NorgParserTests.swift362
1 files changed, 362 insertions, 0 deletions
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)
+ }
+}