Commit 59a47caccb
Verified · cmc
AST-PROTOTYPE.md +35 −9
| @@ -78,19 +78,45 @@ to a particular output: | ||
| 78 | 78 | - `OrgTimestamp` carries `endTime` and `endDate`, so a range is *one* timestamp with an end — |
| 79 | 79 | matching how orgo models it — rather than two stamps with punctuation between them. |
| 80 | 80 | |
| 81 | ## What a migration would look like | |
| 81 | ## Options are reconciled — the swap is ready | |
| 82 | 82 | |
| 83 | The conformance gap is closed, so the remaining steps are integration rather than parsing: | |
| 83 | `OrgHTMLTreeRenderer` now takes the same `OrgRenderOptions` the shipped renderer takes, and | |
| 84 | implements all of it: `metadataHeader`, `headingLevelOffset`, repository-relative resolution | |
| 85 | (`host`, `owner`, `repositoryName`, `ref`, `readmePath`, `imagePathSegment`/`linkPathSegment`) | |
| 86 | and URL sanitising. | |
| 87 | ||
| 88 | Resolution came out *simpler* on the tree. Because targets stay typed — `.external`, `.file`, | |
| 89 | `.id` — it applies to the `.file` case in the renderer, so no resolver closure has to be | |
| 90 | threaded through the parse. An unsafe or unresolvable target degrades to its text instead of | |
| 91 | becoming a bad anchor, as before. | |
| 92 | ||
| 93 | `ASTShippedEquivalenceTests` is the evidence, comparing shipped against tree by semantic | |
| 94 | skeleton (which keeps `href`/`src`, so resolved URLs are genuinely checked): | |
| 95 | ||
| 96 | every corpus case, in the corpus configuration; | |
| 97 | a source exercising the options, under repository options, with metadata off, and with no | |
| 98 | repository context at all; | |
| 99 | identical URL resolution (image → `raw`, link → `blob`); | |
| 100 | unsafe schemes rejected by both; | |
| 101 | a 28-construct battery drawn from the shipped renderer's own test inputs. | |
| 102 | ||
| 103 | That battery found three real gaps, since closed: `#+CAPTION:`/`#+NAME:` on a non-image block | |
| 104 | now wraps it in `<figure class="org-block">` (a new `.captioned` element), the nested | |
| 105 | `[[dest][[img]]]` badge form parses again, and bare email addresses autolink. | |
| 106 | ||
| 107 | **One intentional difference remains.** A range whose halves are *inactive* timestamps, | |
| 108 | `[a]--[b]`, is joined by the tree and left as `--` by the shipped renderer. orgo joins both | |
| 109 | bracket kinds, so the tree is the more correct of the two; it is asserted directly in | |
| 110 | `joinsInactiveTimestampRangesWhereShippedDoesNot` rather than degraded to match. It shows up | |
| 111 | in the corpus only inside `outofscope`'s LOGBOOK drawer. | |
| 112 | ||
| 113 | ## Remaining steps | |
| 84 | 114 | |
| 85 | 115 | 1. ~~Close the gaps until the tree renderer also scores 11/12.~~ **Done.** |
| 86 | 2. Reconcile the render-time options the shipped renderer carries and this one does not yet: | |
| 87 | `metadataHeader`, the repository-relative link/image resolution (`host`, `owner`, | |
| 88 | `imagePathSegment`/`linkPathSegment`), and URL sanitising. The tree keeps link targets | |
| 89 | typed (`.external` / `.file` / `.id`), so resolution becomes a renderer concern applied to | |
| 90 | `.file` targets rather than a closure threaded through the parse — a simplification, but | |
| 91 | it is the real work left before a swap. | |
| 116 | 2. ~~Reconcile the render options.~~ **Done.** | |
| 92 | 117 | 3. Point `OrgRenderer.renderToHTML` at `parse` + `OrgHTMLTreeRenderer` internally, keeping the |
| 93 | public API identical. The corpus test is the proof it is safe; consumers do not change. | |
| 118 | public API identical — a one-line body change, with the equivalence tests as the proof it | |
| 119 | is safe. Consumers do not change. | |
| 94 | 120 | 4. Delete the single-pass renderer. |
| 95 | 121 | 5. Add `OrgSwiftUI` as a **separate product** depending on the core, so the parser and HTML |
| 96 | 122 | renderer stay Foundation-only and consumers who want HTML never import SwiftUI. |
Sources/OrgSwift/AST/OrgDocument.swift +4
| @@ -45,6 +45,10 @@ public indirect enum OrgElement: Sendable, Equatable { | ||
| 45 | 45 | case specialBlock(name: String, content: [OrgElement]) |
| 46 | 46 | case exportBlock(backend: String, raw: String) |
| 47 | 47 | case figure(OrgFigure) |
| 48 | /// A block carrying an affiliated `#+CAPTION:` / `#+NAME:`, which org's exporter wraps in | |
| 49 | /// a `<figure>`. Images take the `.figure` case instead; this is for everything else | |
| 50 | /// (a captioned source block, example, or table). | |
| 51 | case captioned(name: String?, caption: [OrgObject]?, content: OrgElement) | |
| 48 | 52 | case horizontalRule |
| 49 | 53 | case footnoteDefinition(label: String, content: [OrgObject]) |
| 50 | 54 | } |
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +75 −21
| @@ -6,27 +6,68 @@ import Foundation | ||
| 6 | 6 | /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second |
| 7 | 7 | /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it. |
| 8 | 8 | public struct OrgHTMLTreeRenderer: Sendable { |
| 9 | public var headingLevelOffset: Int | |
| 9 | /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it. | |
| 10 | public var options: OrgRenderOptions | |
| 10 | 11 | public var highlighter: CodeHighlighter |
| 11 | 12 | |
| 12 | public init(headingLevelOffset: Int = 0, highlighter: CodeHighlighter = PlainCodeHighlighter()) { | |
| 13 | self.headingLevelOffset = headingLevelOffset | |
| 13 | public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) { | |
| 14 | self.options = options | |
| 14 | 15 | self.highlighter = highlighter |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | 18 | public func render(_ document: OrgDocument) -> String { |
| 18 | 19 | var footnotes = FootnoteNumbering(document: document) |
| 19 | var html = document.elements.map { element( $0, &footnotes) }.joined() | |
| 20 | var html = metadataHeader(document) | |
| 21 | html += document.elements.map { element($0, &footnotes) }.joined() | |
| 20 | 22 | html += footnotes.renderSection(self) |
| 21 | 23 | return html |
| 22 | 24 | } |
| 23 | 25 | |
| 26 | /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats | |
| 27 | /// these as document metadata carried by a page template, so the corpus renders with this | |
| 28 | /// off; an app showing a README title turns it on. | |
| 29 | private func metadataHeader(_ document: OrgDocument) -> String { | |
| 30 | guard options.metadataHeader else { return "" } | |
| 31 | let title = document.keyword("title") | |
| 32 | let author = document.keyword("author") | |
| 33 | let date = document.keyword("date") | |
| 34 | guard title != nil || author != nil || date != nil else { return "" } | |
| 35 | ||
| 36 | var html = "<div class=\"org-metadata\">\n" | |
| 37 | if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" } | |
| 38 | if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" } | |
| 39 | if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" } | |
| 40 | return html + "</div>\n" | |
| 41 | } | |
| 42 | ||
| 43 | // MARK: - URL resolution | |
| 44 | ||
| 45 | /// Resolve a link target to a safe `href`, or nil when it cannot be made safe. | |
| 46 | /// | |
| 47 | /// Because the tree keeps targets typed, resolution is a renderer concern applied to the | |
| 48 | /// `.file` case only — no resolver closure has to be threaded through the parse. | |
| 49 | private func href(for target: OrgLinkTarget) -> String? { | |
| 50 | switch target { | |
| 51 | case .id(let identifier): | |
| 52 | return sanitizedReadmeLinkURLString("#\(identifier)") | |
| 53 | case .external(let url): | |
| 54 | return sanitizedReadmeLinkURLString(url) | |
| 55 | case .file(let path): | |
| 56 | return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /// Resolve an image source to a safe `src`, or nil when it cannot be made safe. | |
| 61 | private func imageSource(_ source: String) -> String? { | |
| 62 | sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source) | |
| 63 | } | |
| 64 | ||
| 24 | 65 | // MARK: - Blocks |
| 25 | 66 | |
| 26 | 67 | private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { |
| 27 | 68 | switch element { |
| 28 | 69 | case .heading(let heading): |
| 29 | let level = min(6, max(1, heading.level + headingLevelOffset)) | |
| 70 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) | |
| 30 | 71 | var inner = "" |
| 31 | 72 | if let todo = heading.todo { |
| 32 | 73 | inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "# |
| @@ -75,12 +116,15 @@ public struct OrgHTMLTreeRenderer: Sendable { | ||
| 75 | 116 | return backend == "html" ? raw + "\n" : "" |
| 76 | 117 | |
| 77 | 118 | case .figure(let figure): |
| 119 | guard let tag = imageTag(figure, caption: figure.caption) else { | |
| 120 | return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n" | |
| 121 | } | |
| 78 | 122 | // A bare image is a paragraph; a caption or explicit attributes promote it to a |
| 79 | 123 | // <figure>, matching org's exporter. |
| 80 | 124 | guard figure.caption != nil || !figure.attributes.isEmpty else { |
| 81 | return "<p>" + imageTag(figure, caption: nil, ¬es) + "</p>\n" | |
| 125 | return "<p>" + tag + "</p>\n" | |
| 82 | 126 | } |
| 83 | var html = "<figure>" + imageTag(figure, caption: figure.caption, ¬es) | |
| 127 | var html = "<figure>" + tag | |
| 84 | 128 | if let caption = figure.caption { |
| 85 | 129 | notes.figureNumber += 1 |
| 86 | 130 | html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"# |
| @@ -88,6 +132,15 @@ public struct OrgHTMLTreeRenderer: Sendable { | ||
| 88 | 132 | } |
| 89 | 133 | return html + "</figure>\n" |
| 90 | 134 | |
| 135 | case .captioned(let name, let caption, let content): | |
| 136 | let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 137 | var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n" | |
| 138 | html += self.element(content, ¬es) | |
| 139 | if let caption { | |
| 140 | html += "<figcaption>" + renderInline(caption, ¬es) + "</figcaption>\n" | |
| 141 | } | |
| 142 | return html + "</figure>\n" | |
| 143 | ||
| 91 | 144 | case .horizontalRule: |
| 92 | 145 | return "<hr>\n" |
| 93 | 146 | |
| @@ -162,9 +215,12 @@ public struct OrgHTMLTreeRenderer: Sendable { | ||
| 162 | 215 | return html + "</table>\n" |
| 163 | 216 | } |
| 164 | 217 | |
| 165 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?, _ notes: inout FootnoteNumbering) -> String { | |
| 218 | /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — | |
| 219 | /// in which case the caller falls back to text rather than pointing at something unsafe. | |
| 220 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { | |
| 221 | guard let source = imageSource(figure.source) else { return nil } | |
| 166 | 222 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" |
| 167 | var html = #"<img src="\#(escapeHTMLAttribute(figure.source))" alt="\#(escapeHTMLAttribute(alt))""# | |
| 223 | var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# | |
| 168 | 224 | for (key, value) in figure.attributes where key != "alt" { |
| 169 | 225 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" |
| 170 | 226 | } |
| @@ -185,7 +241,8 @@ public struct OrgHTMLTreeRenderer: Sendable { | ||
| 185 | 241 | case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>" |
| 186 | 242 | case .superscript(let children): html += "<sup>" + renderInline(children, ¬es) + "</sup>" |
| 187 | 243 | case .lineBreak: html += "<br>" |
| 188 | case .image(let figure): html += imageTag(figure, caption: nil, ¬es) | |
| 244 | case .image(let figure): | |
| 245 | html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source) | |
| 189 | 246 | case .timestamp(let stamp): |
| 190 | 247 | let cssClass = stamp.active ? "timestamp" : "timestamp inactive" |
| 191 | 248 | func time(_ machine: String, _ display: String) -> String { |
| @@ -200,22 +257,19 @@ public struct OrgHTMLTreeRenderer: Sendable { | ||
| 200 | 257 | let number = notes.number(for: label, inline: inline) |
| 201 | 258 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## |
| 202 | 259 | case .link(let link): |
| 203 | let href = escapeHTMLAttribute(hrefValue(link.target)) | |
| 204 | let text = link.description.map { renderInline($0, ¬es) } ?? escapeHTML(displayValue(link.target)) | |
| 205 | html += #"<a href="\#(href)">\#(text)</a>"# | |
| 260 | let text = link.description.map { renderInline($0, ¬es) } | |
| 261 | ?? escapeHTML(displayValue(link.target)) | |
| 262 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. | |
| 263 | if let href = href(for: link.target) { | |
| 264 | html += #"<a href="\#(href)">\#(text)</a>"# | |
| 265 | } else { | |
| 266 | html += text | |
| 267 | } | |
| 206 | 268 | } |
| 207 | 269 | } |
| 208 | 270 | return html |
| 209 | 271 | } |
| 210 | 272 | |
| 211 | private func hrefValue(_ target: OrgLinkTarget) -> String { | |
| 212 | switch target { | |
| 213 | case .external(let url): return url | |
| 214 | case .file(let path): return path | |
| 215 | case .id(let identifier): return "#\(identifier)" | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | 273 | private func displayValue(_ target: OrgLinkTarget) -> String { |
| 220 | 274 | switch target { |
| 221 | 275 | case .external(let url): return url |
Sources/OrgSwift/AST/OrgInlineParser.swift +37 −5
| @@ -74,6 +74,15 @@ extension OrgParser { | ||
| 74 | 74 | continue |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token. | |
| 78 | if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]), | |
| 79 | let email = scanEmail(chars, from: i) { | |
| 80 | flushPlain() | |
| 81 | objects.append(email.object) | |
| 82 | i = email.next | |
| 83 | continue | |
| 84 | } | |
| 85 | ||
| 77 | 86 | // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~ |
| 78 | 87 | if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i), |
| 79 | 88 | let span = scanEmphasis(chars, from: i, marker: chars[i]) { |
| @@ -183,13 +192,18 @@ extension OrgParser { | ||
| 183 | 192 | private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject { |
| 184 | 193 | let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget |
| 185 | 194 | |
| 186 | // A description that is itself an image makes the image the link's content. | |
| 195 | // A description that is itself an image makes the image the link's content — the | |
| 196 | // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any | |
| 197 | // balanced brackets before deciding. | |
| 187 | 198 | if let description { |
| 188 | let inner = description.hasPrefix("[[") && description.hasSuffix("]]") | |
| 189 | ? String(description.dropFirst(2).dropLast(2)) : description | |
| 199 | var inner = description | |
| 200 | while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 { | |
| 201 | inner = String(inner.dropFirst().dropLast()) | |
| 202 | } | |
| 203 | let wasWrapped = inner != description | |
| 190 | 204 | let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner |
| 191 | if isImagePath(imageSource), inner != description || inner.hasPrefix("file:") | |
| 192 | || inner.hasPrefix("http://") || inner.hasPrefix("https://") { | |
| 205 | if isImagePath(imageSource), | |
| 206 | wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") { | |
| 193 | 207 | return .link(OrgLink(target: linkTarget(target), |
| 194 | 208 | description: [.image(OrgFigure(source: imageSource))])) |
| 195 | 209 | } |
| @@ -272,6 +286,24 @@ extension OrgParser { | ||
| 272 | 286 | return (.link(OrgLink(target: .external(url), description: nil)), start + url.count) |
| 273 | 287 | } |
| 274 | 288 | |
| 289 | /// Characters that keep an email from starting here, mirroring the shipped renderer's | |
| 290 | /// `(?<![\w.%+\-])` guard. | |
| 291 | private static func isEmailBoundaryCharacter(_ c: Character) -> Bool { | |
| 292 | c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-" | |
| 293 | } | |
| 294 | ||
| 295 | private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | |
| 296 | let rest = String(chars[start...]) | |
| 297 | guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else { | |
| 298 | return nil | |
| 299 | } | |
| 300 | let address = String(rest[match.range]) | |
| 301 | // A trailing character that cannot end an address belongs to the sentence, not the link. | |
| 302 | guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil } | |
| 303 | return (.link(OrgLink(target: .external("mailto:\(address)"), | |
| 304 | description: [.text(address)])), start + address.count) | |
| 305 | } | |
| 306 | ||
| 275 | 307 | private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? { |
| 276 | 308 | var j = start + 1 |
| 277 | 309 | guard j < chars.count else { return nil } |
Sources/OrgSwift/AST/OrgParser.swift +19 −2
| @@ -15,13 +15,29 @@ public enum OrgParser { | ||
| 15 | 15 | .components(separatedBy: "\n") |
| 16 | 16 | var index = 0 |
| 17 | 17 | var pendingCaption: String? |
| 18 | var pendingName: String? | |
| 18 | 19 | var pendingAttrs: [(key: String, value: String)] = [] |
| 19 | 20 | |
| 20 | 21 | func flushPending() { |
| 21 | 22 | pendingCaption = nil |
| 23 | pendingName = nil | |
| 22 | 24 | pendingAttrs = [] |
| 23 | 25 | } |
| 24 | 26 | |
| 27 | /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or | |
| 28 | /// `#+NAME:` precedes it — org's exporter turns that pairing into a `<figure>`. | |
| 29 | func append(_ element: OrgElement) { | |
| 30 | if pendingCaption != nil || pendingName != nil { | |
| 31 | document.elements.append(.captioned( | |
| 32 | name: pendingName, | |
| 33 | caption: pendingCaption.map(parseInline), | |
| 34 | content: element | |
| 35 | )) | |
| 36 | } else { | |
| 37 | document.elements.append(element) | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 25 | 41 | while index < lines.count { |
| 26 | 42 | let line = lines[index] |
| 27 | 43 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| @@ -46,6 +62,7 @@ public enum OrgParser { | ||
| 46 | 62 | if let directive = orgKeywordDirective(in: trimmed) { |
| 47 | 63 | switch directive.keyword { |
| 48 | 64 | case "caption": pendingCaption = directive.value |
| 65 | case "name": pendingName = directive.value | |
| 49 | 66 | case "attr_html": pendingAttrs = parseAttributes(directive.value) |
| 50 | 67 | default: document.keywords.append((directive.keyword, directive.value)) |
| 51 | 68 | } |
| @@ -56,7 +73,7 @@ public enum OrgParser { | ||
| 56 | 73 | // Blocks: #+begin_… / #+end_… |
| 57 | 74 | if trimmed.lowercased().hasPrefix("#+begin_") { |
| 58 | 75 | let (element, next) = parseBlock(lines, from: index) |
| 59 | if let element { document.elements.append(element) } | |
| 76 | if let element { append(element) } | |
| 60 | 77 | index = next |
| 61 | 78 | flushPending() |
| 62 | 79 | continue |
| @@ -101,7 +118,7 @@ public enum OrgParser { | ||
| 101 | 118 | // Table. |
| 102 | 119 | if isTableLine(trimmed) { |
| 103 | 120 | let (table, next) = parseTable(lines, from: index) |
| 104 | document.elements.append(.table(table)) | |
| 121 | append(.table(table)) | |
| 105 | 122 | index = next |
| 106 | 123 | flushPending() |
| 107 | 124 | continue |
Tests/OrgSwiftTests/ASTTests.swift +206 −1
| @@ -232,7 +232,11 @@ struct ASTConformanceTests { | ||
| 232 | 232 | print("org-conformance corpus not found — skipping") |
| 233 | 233 | return |
| 234 | 234 | } |
| 235 | let renderer = OrgHTMLTreeRenderer(headingLevelOffset: 1) | |
| 235 | // The same configuration the shipped renderer is measured in: body content only, | |
| 236 | // with `*` rendering as <h2> because orgo's title owns <h1>. | |
| 237 | let renderer = OrgHTMLTreeRenderer( | |
| 238 | options: OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1) | |
| 239 | ) | |
| 236 | 240 | var matched: [String] = [] |
| 237 | 241 | var diverged: [String] = [] |
| 238 | 242 | |
| @@ -266,6 +270,207 @@ struct ASTConformanceTests { | ||
| 266 | 270 | } |
| 267 | 271 | } |
| 268 | 272 | |
| 273 | /// Equivalence with the shipped renderer under the render options, which the corpus does not | |
| 274 | /// exercise (it renders with no repository context). This is the evidence that pointing | |
| 275 | /// `OrgRenderer.renderToHTML` at the tree would be a no-op for callers. | |
| 276 | /// | |
| 277 | /// Comparison is by semantic skeleton, which keeps `href` and `src` — so resolved URLs are | |
| 278 | /// actually checked — while ignoring the class-name and whitespace differences that carry no | |
| 279 | /// meaning. | |
| 280 | struct ASTShippedEquivalenceTests { | |
| 281 | ||
| 282 | /// gitbay's configuration: images resolve against `raw`, links against `blob`. | |
| 283 | private static let repositoryOptions = OrgRenderOptions( | |
| 284 | host: "gitbay.org", | |
| 285 | owner: "krz", | |
| 286 | repositoryName: "gitbay", | |
| 287 | ref: "HEAD", | |
| 288 | readmePath: "README.org", | |
| 289 | metadataHeader: true, | |
| 290 | headingLevelOffset: 0, | |
| 291 | imagePathSegment: "raw", | |
| 292 | linkPathSegment: "blob" | |
| 293 | ) | |
| 294 | ||
| 295 | private static let source = """ | |
| 296 | #+TITLE: My Doc | |
| 297 | #+AUTHOR: Someone | |
| 298 | #+DATE: 2026-08-28 | |
| 299 | ||
| 300 | * Heading | |
| 301 | ||
| 302 | A paragraph with *bold*, ~code~, and a bare URL https://example.com here. | |
| 303 | ||
| 304 | A relative link to [[docs/DESIGN.org][the design]] and an absolute one to | |
| 305 | [[https://example.org][elsewhere]], plus an in-page [[id:anchor]] link. | |
| 306 | ||
| 307 | [[file:docs/logo.png]] | |
| 308 | ||
| 309 | | Name | Score | | |
| 310 | |:------+------:| | |
| 311 | | alpha | 10 | | |
| 312 | """ | |
| 313 | ||
| 314 | private func skeletons(_ options: OrgRenderOptions) -> (shipped: [String], tree: [String]) { | |
| 315 | let shipped = OrgRenderer.renderToHTML(Self.source, options: options) | |
| 316 | let tree = OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(Self.source)) | |
| 317 | return (OrgSkeleton.skeleton(shipped), OrgSkeleton.skeleton(tree)) | |
| 318 | } | |
| 319 | ||
| 320 | @Test | |
| 321 | func agreesWithShippedRendererUnderRepositoryOptions() { | |
| 322 | let (shipped, tree) = skeletons(Self.repositoryOptions) | |
| 323 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 324 | } | |
| 325 | ||
| 326 | @Test | |
| 327 | func agreesWithShippedRendererWithMetadataOff() { | |
| 328 | var options = Self.repositoryOptions | |
| 329 | options.metadataHeader = false | |
| 330 | options.headingLevelOffset = 1 | |
| 331 | let (shipped, tree) = skeletons(options) | |
| 332 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 333 | } | |
| 334 | ||
| 335 | @Test | |
| 336 | func agreesWithShippedRendererWithNoRepositoryContext() { | |
| 337 | // owner/repo nil disables resolution: relative targets must survive untouched in both. | |
| 338 | let (shipped, tree) = skeletons(OrgRenderOptions()) | |
| 339 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 340 | } | |
| 341 | ||
| 342 | /// Both renderers must resolve repository-relative targets to the same URLs, against the | |
| 343 | /// segment each kind belongs to. | |
| 344 | @Test | |
| 345 | func resolvesRelativeURLsIdenticallyToShipped() { | |
| 346 | let shipped = OrgRenderer.renderToHTML(Self.source, options: Self.repositoryOptions) | |
| 347 | let tree = OrgHTMLTreeRenderer(options: Self.repositoryOptions).render(OrgParser.parse(Self.source)) | |
| 348 | for expected in [ | |
| 349 | "https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png", // image → raw | |
| 350 | "https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org", // link → blob | |
| 351 | "https://example.org", | |
| 352 | ] { | |
| 353 | #expect(shipped.contains(expected), "shipped lost \(expected)") | |
| 354 | #expect(tree.contains(expected), "tree lost \(expected)") | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | /// An unsafe scheme must not become an anchor in either renderer. | |
| 359 | @Test | |
| 360 | func rejectsUnsafeSchemesLikeShipped() { | |
| 361 | let hostile = "[[javascript:alert(1)][click]]" | |
| 362 | let shipped = OrgRenderer.renderToHTML(hostile) | |
| 363 | let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(hostile)) | |
| 364 | #expect(!shipped.lowercased().contains("javascript:")) | |
| 365 | #expect(!tree.lowercased().contains("javascript:")) | |
| 366 | #expect(tree.contains("click")) | |
| 367 | } | |
| 368 | ||
| 369 | /// Shipped and tree must agree on every corpus case, so swapping does not change what a | |
| 370 | /// consumer renders — including `outofscope`, where both diverge from orgo but must still | |
| 371 | /// diverge the same way. | |
| 372 | /// | |
| 373 | /// One documented exception: `outofscope` contains a LOGBOOK clock entry whose halves are | |
| 374 | /// *inactive* timestamps, `[…]--[…]`. The tree joins that into one range (orgo does the | |
| 375 | /// same — `try_timestamp` applies the `--` rule to both bracket kinds, requiring only that | |
| 376 | /// the halves agree on activeness), while the shipped renderer joins active ranges only | |
| 377 | /// and leaves `--` as text. The tree is the more correct of the two, so this is recorded | |
| 378 | /// as the single intentional behaviour change a swap would introduce rather than degraded | |
| 379 | /// to match. | |
| 380 | @Test | |
| 381 | func agreesWithShippedRendererAcrossEveryCorpusCase() throws { | |
| 382 | guard let dir = astCorpusCasesDir() else { | |
| 383 | print("org-conformance corpus not found — skipping") | |
| 384 | return | |
| 385 | } | |
| 386 | let knownDifferences = ["outofscope"] | |
| 387 | let options = OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1) | |
| 388 | var mismatched: [String] = [] | |
| 389 | for name in astCaseNames(dir) where !knownDifferences.contains(name) { | |
| 390 | let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? "" | |
| 391 | let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source, options: options)) | |
| 392 | let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(source))) | |
| 393 | if shipped != tree { | |
| 394 | mismatched.append("\(name) [\(firstDifference(shipped, tree))]") | |
| 395 | } | |
| 396 | } | |
| 397 | #expect(mismatched.isEmpty, "shipped and tree disagree on: \(mismatched.joined(separator: "; "))") | |
| 398 | } | |
| 399 | ||
| 400 | /// The inactive-range difference above, asserted directly so it cannot change unnoticed. | |
| 401 | @Test | |
| 402 | func joinsInactiveTimestampRangesWhereShippedDoesNot() { | |
| 403 | let source = "CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]" | |
| 404 | let shipped = OrgRenderer.renderToHTML(source) | |
| 405 | let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(source)) | |
| 406 | #expect(shipped.contains("--")) | |
| 407 | #expect(!tree.contains("--")) | |
| 408 | #expect(tree.contains("–")) | |
| 409 | } | |
| 410 | ||
| 411 | /// A battery drawn from the shipped renderer's own test inputs, to catch behaviour the | |
| 412 | /// corpus does not reach. Every case must render identically, or the swap would regress a | |
| 413 | /// consumer. | |
| 414 | @Test | |
| 415 | func agreesWithShippedRendererAcrossConstructBattery() { | |
| 416 | let cases: [(name: String, source: String)] = [ | |
| 417 | ("comment", "# This is a comment\nNormal text"), | |
| 418 | ("strike", "+deleted text+"), | |
| 419 | ("example", "#+begin_example\nhello world\n#+end_example"), | |
| 420 | ("bare image link", "[[https://example.com/path/to/file.png]]"), | |
| 421 | ("wrapped bullet", "- First line\n continues here"), | |
| 422 | ("directives", "#+OPTIONS: toc:nil\n#+PROPERTY: header-args :results output\nBody text"), | |
| 423 | ("verse", "#+begin_verse\nThere is a line.\n And an indented line.\n#+end_verse"), | |
| 424 | ("named block", "#+CAPTION: Build output\n#+NAME: build-log\n#+begin_example\nhello world\n#+end_example"), | |
| 425 | ("linked image", "[[https://example.com][[https://img.example.net/a.jpg]]]"), | |
| 426 | ("table alignment", "| Left | Center | Right |\n|:-----+:-----:+------:|\n| a | b | c |"), | |
| 427 | ("nested list", "- Bullet with nested list\n - Nested child one\n - Nested child two"), | |
| 428 | ("timestamps", "An active <2024-01-15 Mon> and inactive [2024-01-15 Mon]."), | |
| 429 | ("time", "At <2024-01-15 Mon 10:30>."), | |
| 430 | ("same-day range", "Range <2024-01-15 Mon 10:00-11:45>."), | |
| 431 | ("multi-day range", "Span <2024-01-15 Mon>--<2024-01-20 Sat>."), | |
| 432 | ("repeater", "Repeats <2024-01-15 Mon +1w>."), | |
| 433 | ("non-timestamp", "Compare 3 < 4 and [not a stamp]."), | |
| 434 | ("inline footnote", "See here.[fn:x:defined inline]"), | |
| 435 | ("footnotes", "A claim.[fn:1] Another.[fn:2]\n\n[fn:1] First note.\n[fn:2] Second with /emphasis/."), | |
| 436 | ("heading tags", "* Write the parser :work:rust:"), | |
| 437 | ("bare url", "See https://example.com now."), | |
| 438 | ("email", "Mail someone@example.com today."), | |
| 439 | ("deep heading", "****** Deep"), | |
| 440 | ("checkboxes", "- [ ] todo\n- [X] done\n- [-] partial"), | |
| 441 | ("description list", "- term one :: the first definition"), | |
| 442 | ("quote", "#+begin_quote\nA quoted paragraph.\n#+end_quote"), | |
| 443 | ("src", "#+begin_src swift\nlet x = 1\n#+end_src"), | |
| 444 | ("superscript", "N^2 and H^{2O}."), | |
| 445 | ] | |
| 446 | ||
| 447 | var mismatched: [String] = [] | |
| 448 | for (name, source) in cases { | |
| 449 | let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source)) | |
| 450 | let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer().render(OrgParser.parse(source))) | |
| 451 | if shipped != tree { | |
| 452 | mismatched.append(name) | |
| 453 | if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil { | |
| 454 | print("BATTERY-DIFF \(name): \(firstDifference(shipped, tree))") | |
| 455 | } | |
| 456 | } | |
| 457 | } | |
| 458 | #expect(mismatched.isEmpty, "tree diverges from shipped on: \(mismatched.joined(separator: ", "))") | |
| 459 | } | |
| 460 | ||
| 461 | private func firstDifference(_ shipped: [String], _ tree: [String]) -> Comment { | |
| 462 | let index = (0..<max(shipped.count, tree.count)).first { | |
| 463 | ($0 < shipped.count ? shipped[$0] : "∅") != ($0 < tree.count ? tree[$0] : "∅") | |
| 464 | } | |
| 465 | guard let index else { return "identical" } | |
| 466 | return """ | |
| 467 | diverges at \(index): \ | |
| 468 | shipped=\(index < shipped.count ? shipped[index] : "∅") \ | |
| 469 | tree=\(index < tree.count ? tree[index] : "∅") | |
| 470 | """ | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 269 | 474 | private func astCorpusCasesDir() -> URL? { |
| 270 | 475 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { |
| 271 | 476 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") |