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.7.1
org-swift/Tests/OrgSwiftTests/OrgTreeTests.swift history · blame · raw

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