krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Tests/OrgSwiftTests/OrgTreeTests.swift

v0.3.0
org-swift/Tests/OrgSwiftTests/OrgTreeTests.swift history · blame · raw

383 lines · 16292 bytes

  1import Foundation
  2import Testing
  3@testable import OrgSwift
  4
  5/// Tests for the element tree: `source → OrgDocument → {HTML, AttributedString}`.
  6///
  7/// The parse tests assert the tree carries structure faithfully. The renderer tests assert
  8/// the point of the split — that a second output format is a walk over the same tree rather
  9/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`.
 10struct ASTParseTests {
 11
 12    @Test
 13    func headingCarriesTodoPriorityAndTags() {
 14        let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")
 15        guard case .heading(let heading) = doc.elements.first else {
 16            Issue.record("expected a heading"); return
 17        }
 18        #expect(heading.level == 1)
 19        #expect(heading.todo == "TODO")
 20        #expect(heading.priority == "A")
 21        #expect(heading.tags == ["work", "rust"])
 22        #expect(OrgParser.plain(heading.title) == "Write the parser")
 23    }
 24
 25    @Test
 26    func emphasisNestsRatherThanFlattening() {
 27        // The point of a tree: bold containing italic is structure, not a markup string.
 28        let objects = OrgParser.parseInline("*bold /inner/ rest*")
 29        guard case .bold(let children) = objects.first else {
 30            Issue.record("expected bold"); return
 31        }
 32        #expect(children.contains { if case .italic = $0 { return true } else { return false } })
 33    }
 34
 35    @Test
 36    func documentKeywordsAreMetadataNotContent() {
 37        let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.")
 38        #expect(doc.keyword("title") == "My Doc")
 39        #expect(doc.keyword("author") == "Someone")
 40        // Metadata does not appear as a body element.
 41        #expect(doc.elements.count == 1)
 42        guard case .paragraph = doc.elements.first else {
 43            Issue.record("expected a single paragraph"); return
 44        }
 45    }
 46
 47    @Test
 48    func nestedListsBecomeNestedItems() {
 49        let doc = OrgParser.parse("""
 50        - outer
 51          - inner
 52            - deepest
 53        - second
 54        """)
 55        guard case .list(let list) = doc.elements.first else {
 56            Issue.record("expected a list"); return
 57        }
 58        #expect(list.items.count == 2)
 59        let inner = list.items[0].sublist
 60        #expect(inner != nil)
 61        #expect(inner?.items.first?.sublist?.items.count == 1)
 62    }
 63
 64    @Test
 65    func tableKeepsRuleRowAndAlignments() {
 66        let doc = OrgParser.parse("""
 67        | Name  | Score |
 68        |:------+------:|
 69        | alpha |    10 |
 70        """)
 71        guard case .table(let table) = doc.elements.first else {
 72            Issue.record("expected a table"); return
 73        }
 74        #expect(table.rows.count == 3)
 75        #expect(table.headerRowCount == 1)
 76        #expect(table.alignments == [.left, .right])
 77        if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") }
 78    }
 79
 80    @Test
 81    func timestampsAndLinksBecomeTypedObjects() {
 82        let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]")
 83        let hasTimestamp = objects.contains {
 84            if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" }
 85            return false
 86        }
 87        #expect(hasTimestamp)
 88        let hasIDLink = objects.contains {
 89            if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" }
 90            return false
 91        }
 92        #expect(hasIDLink)
 93    }
 94}
 95
 96struct ASTRendererTests {
 97
 98    /// The payoff: one parse, two output formats, neither re-deriving the other's work.
 99    @Test
100    func oneParseFeedsTwoRenderers() {
101        let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].")
102
103        let html = OrgHTMLTreeRenderer().render(document)
104        #expect(html.contains("<strong>bold</strong>"))
105        #expect(html.contains("<code>code</code>"))
106        #expect(html.contains(#"<a href="https://example.com">link</a>"#))
107
108        let attributed = OrgAttributedStringRenderer().inline({
109            if case .paragraph(let objects) = document.elements[0] { return objects }
110            return []
111        }())
112        // Same content, native representation: no markup, real attributes.
113        let plain = String(attributed.characters)
114        #expect(plain == "A bold claim with code and a link.")
115        #expect(!plain.contains("<"))
116
117        let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized }
118        #expect(boldRun != nil)
119        let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code }
120        #expect(codeRun != nil)
121        let linkRun = attributed.runs.first { $0.link != nil }
122        #expect(linkRun?.link?.absoluteString == "https://example.com")
123    }
124
125    /// The native renderer resolves and sanitizes exactly as the HTML one does, so a README
126    /// rendered natively gets the same working links rather than raw relative paths.
127    @Test
128    func attributedStringResolvesRelativeLinksAndRejectsUnsafeOnes() {
129        let renderer = OrgAttributedStringRenderer(options: OrgRenderOptions(
130            host: "gitbay.org",
131            owner: "krz",
132            repositoryName: "org-swift",
133            ref: "HEAD",
134            readmePath: "README.org"
135        ))
136
137        let relative = renderer.inline(OrgParser.parseInline("[[./ARCHITECTURE.md][notes]]"))
138        #expect(relative.runs.first { $0.link != nil }?.link?.absoluteString
139            == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md")
140
141        // Without repository context there is nothing to resolve against, so an unsafe
142        // scheme reaches the allowlist and the link degrades to its text — as in HTML.
143        let unsafe = OrgAttributedStringRenderer().inline(OrgParser.parseInline("[[javascript:alert(1)][tap]]"))
144        #expect(String(unsafe.characters) == "tap")
145        #expect(unsafe.runs.allSatisfy { $0.link == nil })
146        #expect(!OrgRenderer.renderToHTML("[[javascript:alert(1)][tap]]").lowercased().contains("javascript:"))
147    }
148
149    /// Footnote numbering is document-wide and order-of-use, and now shared with HTML, so
150    /// both renderers agree on which note is number 1.
151    @Test
152    func attributedStringNumbersFootnotesInReferenceOrder() {
153        let doc = OrgParser.parse("""
154        First[fn:b] then second[fn:a] then first again[fn:b].
155
156        [fn:a] note a
157        [fn:b] note b
158        """)
159        guard case .paragraph(let objects) = doc.elements[0] else {
160            Issue.record("expected a paragraph"); return
161        }
162        var notes = OrgFootnotes(document: doc)
163        let text = String(OrgAttributedStringRenderer().inline(objects, &notes).characters)
164        #expect(text == "First[1] then second[2] then first again[1].")
165        #expect(notes.notes.map(\.label) == ["b", "a"])
166    }
167
168    /// Emphasis nests in org, and the HTML renderer nests it (`<strong>b <em>i</em></strong>`).
169    /// Assigning the intent over the whole range instead of merging would flatten the inner
170    /// one away.
171    @Test
172    func nestedEmphasisKeepsBothIntents() {
173        let attributed = OrgAttributedStringRenderer().inline(
174            OrgParser.parseInline("*bold /italic/ tail*")
175        )
176        func intent(_ text: String) -> InlinePresentationIntent? {
177            attributed.runs.first { String(attributed[$0.range].characters).contains(text) }?
178                .inlinePresentationIntent
179        }
180        #expect(intent("bold")?.contains(.stronglyEmphasized) == true)
181        #expect(intent("italic")?.contains(.stronglyEmphasized) == true)
182        #expect(intent("italic")?.contains(.emphasized) == true)
183        // The outer run is bold only.
184        #expect(intent("bold")?.contains(.emphasized) == false)
185    }
186
187    @Test
188    func attributedStringCarriesRolesForNonStandardIntents() {
189        let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
190        let attributed = OrgAttributedStringRenderer().inline(objects)
191        let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] }
192        #expect(roles.contains(.superscript))
193        #expect(roles.contains(.timestamp))
194    }
195
196    @Test
197    func consecutiveListsOfDifferentKindsStaySeparate() {
198        // An ordered list followed by a bullet list is two lists, not one with mixed items.
199        let doc = OrgParser.parse("""
200        1. first
201        2. second
202
203        - [ ] todo
204        - [X] done
205        """)
206        let lists = doc.elements.compactMap { element -> OrgList? in
207            if case .list(let list) = element { return list } else { return nil }
208        }
209        #expect(lists.count == 2)
210        #expect(lists.first?.kind == .ordered)
211        #expect(lists.last?.kind == .unordered)
212        #expect(lists.last?.items.first?.checkbox == .off)
213
214        let html = OrgHTMLTreeRenderer().render(doc)
215        #expect(html.contains("</ol>"))
216        #expect(html.contains("<ul>"))
217    }
218
219    @Test
220    func inlineFootnoteDefinesItsNoteAtTheReference() {
221        let doc = OrgParser.parse("A claim.[fn:x:defined right here]")
222        let html = OrgHTMLTreeRenderer().render(doc)
223        #expect(html.contains(##"href="#fn-1">1</a>"##))
224        // Inline note text sits directly in the item; only reference-style notes get a <p>.
225        #expect(html.contains(#"<li id="fn-1">defined right here "#))
226        #expect(!html.contains(#"<li id="fn-1"><p>"#))
227    }
228
229    @Test
230    func referenceStyleFootnoteKeepsItsParagraph() {
231        let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.")
232        let html = OrgHTMLTreeRenderer().render(doc)
233        #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#))
234    }
235
236    @Test
237    func timestampRangesRenderAsTwoTimeElements() {
238        // Same-day: the end shows only its time, since the start carries the date.
239        let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>."))
240        #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#))
241        #expect(sameDay.contains("&#8211;"))
242        #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#))
243
244        // Multi-day: one timestamp carrying an end date, rendered as two stamps.
245        let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>."))
246        #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#))
247        #expect(multiDay.contains("&#8211;"))
248        #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#))
249        // The `--` join is consumed, not left as stray text.
250        #expect(!multiDay.contains("--"))
251    }
252
253    @Test
254    func treeRendererProducesStructuralHTML() {
255        let document = OrgParser.parse("""
256        * Heading
257
258        | a | b |
259        |---+---|
260        | 1 | 2 |
261
262        - [ ] todo
263        - [X] done
264        """)
265        let html = OrgHTMLTreeRenderer().render(document)
266        #expect(html.contains("<h1>Heading</h1>"))
267        #expect(html.contains("<thead>"))
268        #expect(html.contains("<th>a</th>"))
269        #expect(html.contains("<td>1</td>"))
270        #expect(html.contains("<code>[&nbsp;]</code> todo"))
271        #expect(html.contains("<code>[X]</code> done"))
272    }
273}
274
275/// The render options, which the conformance corpus does not exercise because it renders with
276/// no repository context. These were written as shipped-vs-tree equivalence checks during the
277/// migration; now that the tree *is* the renderer, they assert the behaviour directly.
278struct OrgRenderOptionsTests {
279
280    /// gitbay's configuration: images resolve against `raw`, links against `blob`.
281    private static let repositoryOptions = OrgRenderOptions(
282        host: "gitbay.org",
283        owner: "krz",
284        repositoryName: "gitbay",
285        ref: "HEAD",
286        readmePath: "README.org",
287        imagePathSegment: "raw",
288        linkPathSegment: "blob"
289    )
290
291    @Test
292    func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() {
293        let html = OrgRenderer.renderToHTML("""
294        A relative link to [[docs/DESIGN.org][the design]] and an absolute one to
295        [[https://example.org][elsewhere]].
296
297        [[file:docs/logo.png]]
298        """, options: Self.repositoryOptions)
299
300        #expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png"))
301        #expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org"))
302        #expect(html.contains("https://example.org"))
303    }
304
305    @Test
306    func leavesRelativeTargetsAloneWithoutRepositoryContext() {
307        let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]")
308        #expect(html.contains(#"href="docs/DESIGN.org""#))
309        #expect(html.contains(#"src="logo.png""#))
310    }
311
312    @Test
313    func emitsTheMetadataHeaderOnlyWhenAsked() {
314        let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody."
315
316        let withHeader = OrgRenderer.renderToHTML(source)
317        #expect(withHeader.contains(#"<h1 class="org-title">My Doc</h1>"#))
318        #expect(withHeader.contains(#"<p class="org-author">Someone</p>"#))
319
320        let without = OrgRenderer.renderToHTML(source, options: OrgRenderOptions(metadataHeader: false))
321        #expect(!without.contains("org-title"))
322        #expect(!without.contains("My Doc"))
323        #expect(without.contains("<p>Body.</p>"))
324    }
325
326    /// org's border rule: an emphasis body may not begin or end with whitespace. Without the
327    /// trailing half, `=ssh.mode = "system"=` closed at the middle `=` and rendered as two
328    /// verbatim spans with the quoted value loose between them.
329    @Test
330    func emphasisBodyCannotBeginOrEndWithWhitespace() {
331        let objects = OrgParser.parseInline(#"=ssh.mode = "system"= to run"#)
332        let verbatims = objects.compactMap { object -> String? in
333            if case .verbatim(let text) = object { return text } else { return nil }
334        }
335        #expect(verbatims == [#"ssh.mode = "system""#])
336
337        // A leading space means the marker never opens.
338        #expect(OrgParser.parseInline("= not verbatim=") == [.text("= not verbatim=")])
339
340        // The ordinary cases still work.
341        #expect(OrgParser.parseInline("=plain=") == [.verbatim("plain")])
342        let bold = OrgParser.parseInline("*bold* and *two words*")
343        #expect(bold.contains(.bold([.text("bold")])))
344        #expect(bold.contains(.bold([.text("two words")])))
345    }
346
347    @Test
348    func rejectsUnsafeSchemes() {
349        let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]")
350        #expect(!html.lowercased().contains("javascript:"))
351        // The link degrades to its text rather than becoming a bad anchor.
352        #expect(html.contains("click"))
353    }
354
355    /// A range whose halves are inactive timestamps is still a range. orgo applies the `--`
356    /// rule to both bracket kinds, requiring only that the halves agree on activeness; the
357    /// renderer this replaced joined active ranges only, so this is the one behaviour the
358    /// swap deliberately changed.
359    @Test
360    func joinsInactiveTimestampRanges() {
361        let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]")
362        #expect(!html.contains("--"))
363        #expect(html.contains("&#8211;"))
364        #expect(html.contains(#"class="timestamp inactive""#))
365    }
366}
367
368private func astCorpusCasesDir() -> URL? {
369    if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] {
370        let cases = URL(fileURLWithPath: env).appendingPathComponent("cases")
371        if FileManager.default.fileExists(atPath: cases.path) { return cases }
372    }
373    let pkgRoot = URL(fileURLWithPath: #filePath)
374        .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
375    let sibling = pkgRoot.deletingLastPathComponent()
376        .appendingPathComponent("org-conformance").appendingPathComponent("cases")
377    return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil
378}
379
380private func astCaseNames(_ dir: URL) -> [String] {
381    let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
382    return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted()
383}