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

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