diff options
Diffstat (limited to 'Tests/NorgKitTests/Parsers/NorgParserTests.swift')
| -rw-r--r-- | Tests/NorgKitTests/Parsers/NorgParserTests.swift | 362 |
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) + } +} |