Tests/OrgSwiftTests/OrgTreeTests.swift
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, ¬es).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("–"))
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("–"))
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>[ ]</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("–"))
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}