import Foundation import Testing @testable import OrgSwift /// Tests for the element tree: `source → OrgDocument → {HTML, AttributedString}`. /// /// The parse tests assert the tree carries structure faithfully. The renderer tests assert /// the point of the split — that a second output format is a walk over the same tree rather /// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`. struct ASTParseTests { @Test func headingCarriesTodoPriorityAndTags() { let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") guard case .heading(let heading) = doc.elements.first else { Issue.record("expected a heading"); return } #expect(heading.level == 1) #expect(heading.todo == "TODO") #expect(heading.priority == "A") #expect(heading.tags == ["work", "rust"]) #expect(OrgParser.plain(heading.title) == "Write the parser") } @Test func emphasisNestsRatherThanFlattening() { // The point of a tree: bold containing italic is structure, not a markup string. let objects = OrgParser.parseInline("*bold /inner/ rest*") guard case .bold(let children) = objects.first else { Issue.record("expected bold"); return } #expect(children.contains { if case .italic = $0 { return true } else { return false } }) } @Test func documentKeywordsAreMetadataNotContent() { let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.") #expect(doc.keyword("title") == "My Doc") #expect(doc.keyword("author") == "Someone") // Metadata does not appear as a body element. #expect(doc.elements.count == 1) guard case .paragraph = doc.elements.first else { Issue.record("expected a single paragraph"); return } } @Test func nestedListsBecomeNestedItems() { let doc = OrgParser.parse(""" - outer - inner - deepest - second """) guard case .list(let list) = doc.elements.first else { Issue.record("expected a list"); return } #expect(list.items.count == 2) guard case .list(let inner)? = list.items[0].content.last else { Issue.record("expected a nested list"); return } #expect(inner.items.count == 1) guard case .list(let deepest)? = inner.items[0].content.last else { Issue.record("expected a twice-nested list"); return } #expect(deepest.items.count == 1) } @Test func tableKeepsRuleRowAndAlignments() { let doc = OrgParser.parse(""" | Name | Score | |:------+------:| | alpha | 10 | """) guard case .table(let table) = doc.elements.first else { Issue.record("expected a table"); return } #expect(table.rows.count == 3) #expect(table.headerRowCount == 1) #expect(table.alignments == [.left, .right]) if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") } } @Test func timestampsAndLinksBecomeTypedObjects() { let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]") let hasTimestamp = objects.contains { if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" } return false } #expect(hasTimestamp) let hasIDLink = objects.contains { if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" } return false } #expect(hasIDLink) } } struct ASTRendererTests { /// The payoff: one parse, two output formats, neither re-deriving the other's work. @Test func oneParseFeedsTwoRenderers() { let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].") let html = OrgHTMLTreeRenderer().render(document) #expect(html.contains("bold")) #expect(html.contains("code")) #expect(html.contains(#"link"#)) let attributed = OrgAttributedStringRenderer().inline({ if case .paragraph(let objects) = document.elements[0] { return objects } return [] }()) // Same content, native representation: no markup, real attributes. let plain = String(attributed.characters) #expect(plain == "A bold claim with code and a link.") #expect(!plain.contains("<")) let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized } #expect(boldRun != nil) let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code } #expect(codeRun != nil) let linkRun = attributed.runs.first { $0.link != nil } #expect(linkRun?.link?.absoluteString == "https://example.com") } /// The native renderer resolves and sanitizes exactly as the HTML one does, so a README /// rendered natively gets the same working links rather than raw relative paths. @Test func attributedStringResolvesRelativeLinksAndRejectsUnsafeOnes() { let renderer = OrgAttributedStringRenderer(options: OrgRenderOptions( host: "gitbay.org", owner: "krz", repositoryName: "org-swift", ref: "HEAD", readmePath: "README.org" )) let relative = renderer.inline(OrgParser.parseInline("[[./ARCHITECTURE.md][notes]]")) #expect(relative.runs.first { $0.link != nil }?.link?.absoluteString == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md") // Without repository context there is nothing to resolve against, so an unsafe // scheme reaches the allowlist and the link degrades to its text — as in HTML. let unsafe = OrgAttributedStringRenderer().inline(OrgParser.parseInline("[[javascript:alert(1)][tap]]")) #expect(String(unsafe.characters) == "tap") #expect(unsafe.runs.allSatisfy { $0.link == nil }) #expect(!OrgRenderer.renderToHTML("[[javascript:alert(1)][tap]]").lowercased().contains("javascript:")) } /// Footnote numbering is document-wide and order-of-use, and now shared with HTML, so /// both renderers agree on which note is number 1. @Test func attributedStringNumbersFootnotesInReferenceOrder() { let doc = OrgParser.parse(""" First[fn:b] then second[fn:a] then first again[fn:b]. [fn:a] note a [fn:b] note b """) guard case .paragraph(let objects) = doc.elements[0] else { Issue.record("expected a paragraph"); return } var notes = OrgFootnotes(document: doc) let text = String(OrgAttributedStringRenderer().inline(objects, ¬es).characters) #expect(text == "First[1] then second[2] then first again[1].") #expect(notes.notes.map(\.label) == ["b", "a"]) } /// Emphasis nests in org, and the HTML renderer nests it (`b i`). /// Assigning the intent over the whole range instead of merging would flatten the inner /// one away. @Test func nestedEmphasisKeepsBothIntents() { let attributed = OrgAttributedStringRenderer().inline( OrgParser.parseInline("*bold /italic/ tail*") ) func intent(_ text: String) -> InlinePresentationIntent? { attributed.runs.first { String(attributed[$0.range].characters).contains(text) }? .inlinePresentationIntent } #expect(intent("bold")?.contains(.stronglyEmphasized) == true) #expect(intent("italic")?.contains(.stronglyEmphasized) == true) #expect(intent("italic")?.contains(.emphasized) == true) // The outer run is bold only. #expect(intent("bold")?.contains(.emphasized) == false) } @Test func attributedStringCarriesRolesForNonStandardIntents() { let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") let attributed = OrgAttributedStringRenderer().inline(objects) let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] } #expect(roles.contains(.superscript)) #expect(roles.contains(.timestamp)) } @Test func consecutiveListsOfDifferentKindsStaySeparate() { // An ordered list followed by a bullet list is two lists, not one with mixed items. let doc = OrgParser.parse(""" 1. first 2. second - [ ] todo - [X] done """) let lists = doc.elements.compactMap { element -> OrgList? in if case .list(let list) = element { return list } else { return nil } } #expect(lists.count == 2) #expect(lists.first?.kind == .ordered) #expect(lists.last?.kind == .unordered) #expect(lists.last?.items.first?.checkbox == .off) let html = OrgHTMLTreeRenderer().render(doc) #expect(html.contains("")) #expect(html.contains("