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