Commit b45a8e5a99
Verified · cmc
AST-PROTOTYPE.md added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | # AST split — prototype | |
| 2 | ||
| 3 | A working prototype of `source → OrgDocument → {renderers}`, on the `ast-prototype` branch. | |
| 4 | Nothing shipped changes: `OrgRenderer` (the single-pass source→HTML renderer hutch and | |
| 5 | gitbay-ios use) is untouched, and its 11/12 corpus conformance is unaffected. This is a | |
| 6 | parallel pipeline built to answer one question — *is a second output format a walk over a | |
| 7 | tree, or a second parser?* | |
| 8 | ||
| 9 | ## What's here | |
| 10 | ||
| 11 | ``` | |
| 12 | Sources/OrgSwift/AST/ | |
| 13 | OrgDocument.swift the element tree | |
| 14 | OrgParser.swift source → OrgDocument (blocks) | |
| 15 | OrgInlineParser.swift text → [OrgObject] (inlines) | |
| 16 | OrgHTMLTreeRenderer.swift OrgDocument → HTML | |
| 17 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString | |
| 18 | ``` | |
| 19 | ||
| 20 | The types deliberately mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's | |
| 21 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same | |
| 22 | `ListKind`/`Checkbox` vocabulary. That is what makes future *tree-level* conformance possible: | |
| 23 | today the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy | |
| 24 | for "do these two agree on structure". With matching trees, that question can be asked | |
| 25 | directly. | |
| 26 | ||
| 27 | ## The result | |
| 28 | ||
| 29 | Two renderers, one parse. `OrgAttributedStringRenderer` is **Foundation-only** — no SwiftUI — | |
| 30 | so it works server-side, in a CLI, anywhere, and a SwiftUI block renderer would sit on top of | |
| 31 | it for the inline runs inside each block. | |
| 32 | ||
| 33 | ```swift | |
| 34 | let document = OrgParser.parse(source) | |
| 35 | let html = OrgHTMLTreeRenderer().render(document) // markup | |
| 36 | let text = OrgAttributedStringRenderer().inline(objects) // native, real attributes | |
| 37 | ``` | |
| 38 | ||
| 39 | The AttributedString path carries `inlinePresentationIntent` (`.stronglyEmphasized`, | |
| 40 | `.emphasized`, `.code`) and real `link` attributes — no markup in the string. Constructs | |
| 41 | `AttributedString` has no portable attribute for (superscript, timestamps, footnote refs, | |
| 42 | images, underline, strikethrough — the last two live only in the UIKit/AppKit scopes) travel | |
| 43 | as an `OrgRole` custom attribute the UI layer reads to decide presentation, so the renderer | |
| 44 | never needs to know about fonts or colors. | |
| 45 | ||
| 46 | ## Conformance scorecard | |
| 47 | ||
| 48 | The tree-based HTML renderer, measured against the same `org-conformance` corpus and the same | |
| 49 | skeleton reduction the shipped renderer is held to: | |
| 50 | ||
| 51 | **8 / 12** — `blocks`, `elements`, `headings`, `images`, `lists`, `minimal`, `table`, `tblfm`. | |
| 52 | ||
| 53 | Reported, not gated: `ASTConformanceReportTests` prints the score and does not fail the suite, | |
| 54 | because the prototype is being measured rather than defended. Run with `ORG_DUMP=1` to print | |
| 55 | the first divergence per case. | |
| 56 | ||
| 57 | Remaining gaps, all small parser work rather than anything architectural: | |
| 58 | ||
| 59 | | Case | First divergence | | |
| 60 | |---|---| | |
| 61 | | `core` | consecutive ordered lists group into one `<ol>` | | |
| 62 | | `footnote` | inline footnotes (`[fn:x:text]`) lose their text — the definition needs to come from the reference | | |
| 63 | | `timestamps` | multi-day ranges (`<a>--<b>`) render as two stamps without the en-dash join | | |
| 64 | | `outofscope` | deliberately unsupported constructs; the shipped renderer diverges here too | | |
| 65 | ||
| 66 | Two gaps were closed while writing this, each ~10 lines, which is the useful signal about | |
| 67 | where the effort sits: property drawers (`:PROPERTIES:` … `:END:`) are dropped as heading | |
| 68 | metadata, and a bare image renders as `<p><img></p>` while a caption or `#+ATTR_HTML` | |
| 69 | promotes it to `<figure>`. | |
| 70 | ||
| 71 | ## What a migration would look like | |
| 72 | ||
| 73 | 1. Close the four gaps above until the tree renderer also scores 11/12. | |
| 74 | 2. Point `OrgRenderer.renderToHTML` at `parse` + `OrgHTMLTreeRenderer` internally, keeping the | |
| 75 | public API identical. The corpus test is the proof it is safe; consumers do not change. | |
| 76 | 3. Delete the single-pass renderer. | |
| 77 | 4. Add `OrgSwiftUI` as a **separate product** depending on the core, so the parser and HTML | |
| 78 | renderer stay Foundation-only and consumers who want HTML never import SwiftUI. | |
| 79 | ||
| 80 | Step 4 is where tables get built: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a | |
| 81 | horizontal `ScrollView` for phone-width overflow — the approach MarkdownUI uses, and the | |
| 82 | `OrgTable` node already carries the rows, the rule position, and per-column alignments it | |
| 83 | needs. | |
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Renders inline org content to `AttributedString` — the native counterpart to the HTML | |
| 4 | /// renderer, walking the same tree. | |
| 5 | /// | |
| 6 | /// This is the prototype's argument: a second output format is a walk over the parsed tree, | |
| 7 | /// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a | |
| 8 | /// SwiftUI block renderer would sit on top, using this for the inline runs inside each block. | |
| 9 | public struct OrgAttributedStringRenderer: Sendable { | |
| 10 | ||
| 11 | public init() {} | |
| 12 | ||
| 13 | /// Render one run of inline objects, carrying intents a UI layer can style. | |
| 14 | public func inline(_ objects: [OrgObject]) -> AttributedString { | |
| 15 | var result = AttributedString() | |
| 16 | for object in objects { | |
| 17 | switch object { | |
| 18 | case .text(let text): | |
| 19 | result += AttributedString(text) | |
| 20 | ||
| 21 | case .bold(let children): | |
| 22 | var part = inline(children) | |
| 23 | part.inlinePresentationIntent = .stronglyEmphasized | |
| 24 | result += part | |
| 25 | ||
| 26 | case .italic(let children): | |
| 27 | var part = inline(children) | |
| 28 | part.inlinePresentationIntent = .emphasized | |
| 29 | result += part | |
| 30 | ||
| 31 | // Underline and strikethrough have no Foundation-portable attribute (the | |
| 32 | // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they | |
| 33 | // travel as roles the UI layer applies. | |
| 34 | case .underline(let children): | |
| 35 | var part = inline(children) | |
| 36 | part.orgRole = .underline | |
| 37 | result += part | |
| 38 | ||
| 39 | case .strikeThrough(let children): | |
| 40 | var part = inline(children) | |
| 41 | part.orgRole = .strikeThrough | |
| 42 | result += part | |
| 43 | ||
| 44 | case .verbatim(let text), .code(let text): | |
| 45 | var part = AttributedString(text) | |
| 46 | part.inlinePresentationIntent = .code | |
| 47 | result += part | |
| 48 | ||
| 49 | case .superscript(let children): | |
| 50 | // No portable superscript attribute; mark it so a UI layer can raise it. | |
| 51 | var part = inline(children) | |
| 52 | part.orgRole = .superscript | |
| 53 | result += part | |
| 54 | ||
| 55 | case .lineBreak: | |
| 56 | result += AttributedString("\n") | |
| 57 | ||
| 58 | case .timestamp(let stamp): | |
| 59 | var part = AttributedString(stamp.displayValue) | |
| 60 | part.orgRole = .timestamp | |
| 61 | result += part | |
| 62 | ||
| 63 | case .footnoteRef(let label, _): | |
| 64 | var part = AttributedString("[\(label)]") | |
| 65 | part.orgRole = .footnoteReference | |
| 66 | result += part | |
| 67 | ||
| 68 | case .image(let figure): | |
| 69 | var part = AttributedString(figure.alt ?? figure.source) | |
| 70 | part.orgRole = .image | |
| 71 | result += part | |
| 72 | ||
| 73 | case .link(let link): | |
| 74 | var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target)) | |
| 75 | if let url = URL(string: hrefValue(link.target)) { | |
| 76 | part.link = url | |
| 77 | } | |
| 78 | result += part | |
| 79 | } | |
| 80 | } | |
| 81 | return result | |
| 82 | } | |
| 83 | ||
| 84 | /// Flatten a whole document to attributed paragraphs — a convenience for callers that | |
| 85 | /// want text without building block views (a share sheet, a plain-text export). | |
| 86 | public func paragraphs(_ document: OrgDocument) -> [AttributedString] { | |
| 87 | document.elements.compactMap { element in | |
| 88 | switch element { | |
| 89 | case .paragraph(let objects): return inline(objects) | |
| 90 | case .heading(let heading): return inline(heading.title) | |
| 91 | default: return nil | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | private func hrefValue(_ target: OrgLinkTarget) -> String { | |
| 97 | switch target { | |
| 98 | case .external(let url): return url | |
| 99 | case .file(let path): return path | |
| 100 | case .id(let identifier): return "#\(identifier)" | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | private func displayValue(_ target: OrgLinkTarget) -> String { | |
| 105 | switch target { | |
| 106 | case .external(let url): return url | |
| 107 | case .file(let path): return path | |
| 108 | case .id(let identifier): return identifier | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // MARK: - Custom attribute | |
| 114 | ||
| 115 | /// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to | |
| 116 | /// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable) | |
| 117 | /// without the renderer needing to know about fonts or colors. | |
| 118 | public enum OrgRole: String, Sendable, Codable { | |
| 119 | case superscript | |
| 120 | case timestamp | |
| 121 | case footnoteReference | |
| 122 | case image | |
| 123 | case underline | |
| 124 | case strikeThrough | |
| 125 | } | |
| 126 | ||
| 127 | public enum OrgRoleAttribute: AttributedStringKey { | |
| 128 | public typealias Value = OrgRole | |
| 129 | public static let name = "orgRole" | |
| 130 | } | |
| 131 | ||
| 132 | public extension AttributeScopes { | |
| 133 | struct OrgAttributes: AttributeScope { | |
| 134 | public let orgRole: OrgRoleAttribute | |
| 135 | } | |
| 136 | var org: OrgAttributes.Type { OrgAttributes.self } | |
| 137 | } | |
| 138 | ||
| 139 | public extension AttributedString { | |
| 140 | var orgRole: OrgRole? { | |
| 141 | get { self[OrgRoleAttribute.self] } | |
| 142 | set { self[OrgRoleAttribute.self] = newValue } | |
| 143 | } | |
| 144 | } | |
Sources/OrgSwift/AST/OrgDocument.swift added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // The org element tree — the document model, deliberately mirroring orgo's `model.rs` | |
| 4 | // (`Element`/`Object`, `TableRow::{Cells, Rule}`, `ListKind`, `Checkbox`) so the two can be | |
| 5 | // compared tree-to-tree later, instead of only through rendered HTML. | |
| 6 | // | |
| 7 | // Why a tree at all: the shipped renderer is a single pass from source straight to an HTML | |
| 8 | // string, so every new output format would mean re-deriving the parse. With a tree, parsing | |
| 9 | // happens once and each renderer is a walk — the HTML renderer stays the one the conformance | |
| 10 | // corpus measures, and a native renderer rides on the same proven parse. | |
| 11 | ||
| 12 | /// A parsed org document: metadata keywords plus the block elements of the body. | |
| 13 | public struct OrgDocument: Sendable, Equatable { | |
| 14 | /// `#+TITLE:`, `#+AUTHOR:`, `#+DATE:` and any other `#+KEY: value`, in source order. | |
| 15 | public var keywords: [(key: String, value: String)] | |
| 16 | public var elements: [OrgElement] | |
| 17 | ||
| 18 | public init(keywords: [(key: String, value: String)] = [], elements: [OrgElement] = []) { | |
| 19 | self.keywords = keywords | |
| 20 | self.elements = elements | |
| 21 | } | |
| 22 | ||
| 23 | public func keyword(_ name: String) -> String? { | |
| 24 | keywords.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value | |
| 25 | } | |
| 26 | ||
| 27 | public static func == (lhs: OrgDocument, rhs: OrgDocument) -> Bool { | |
| 28 | lhs.elements == rhs.elements | |
| 29 | && lhs.keywords.count == rhs.keywords.count | |
| 30 | && zip(lhs.keywords, rhs.keywords).allSatisfy { $0.key == $1.key && $0.value == $1.value } | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /// A block-level element. | |
| 35 | public indirect enum OrgElement: Sendable, Equatable { | |
| 36 | case heading(OrgHeading) | |
| 37 | case paragraph([OrgObject]) | |
| 38 | case list(OrgList) | |
| 39 | case table(OrgTable) | |
| 40 | case srcBlock(language: String?, code: String) | |
| 41 | case exampleBlock(String) | |
| 42 | case quoteBlock([OrgElement]) | |
| 43 | case centerBlock([OrgElement]) | |
| 44 | case verseBlock([[OrgObject]]) | |
| 45 | case specialBlock(name: String, content: [OrgElement]) | |
| 46 | case exportBlock(backend: String, raw: String) | |
| 47 | case figure(OrgFigure) | |
| 48 | case horizontalRule | |
| 49 | case footnoteDefinition(label: String, content: [OrgObject]) | |
| 50 | } | |
| 51 | ||
| 52 | public struct OrgHeading: Sendable, Equatable { | |
| 53 | /// Star count, before any render-time level offset. | |
| 54 | public var level: Int | |
| 55 | public var todo: String? | |
| 56 | public var priority: Character? | |
| 57 | public var title: [OrgObject] | |
| 58 | public var tags: [String] | |
| 59 | ||
| 60 | public init(level: Int, todo: String? = nil, priority: Character? = nil, | |
| 61 | title: [OrgObject], tags: [String] = []) { | |
| 62 | self.level = level | |
| 63 | self.todo = todo | |
| 64 | self.priority = priority | |
| 65 | self.title = title | |
| 66 | self.tags = tags | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | // MARK: - Lists | |
| 71 | ||
| 72 | public struct OrgList: Sendable, Equatable { | |
| 73 | public var kind: OrgListKind | |
| 74 | public var items: [OrgListItem] | |
| 75 | ||
| 76 | public init(kind: OrgListKind, items: [OrgListItem]) { | |
| 77 | self.kind = kind | |
| 78 | self.items = items | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | public enum OrgListKind: Sendable, Equatable { | |
| 83 | case unordered | |
| 84 | case ordered | |
| 85 | case description | |
| 86 | } | |
| 87 | ||
| 88 | public struct OrgListItem: Sendable, Equatable { | |
| 89 | public var checkbox: OrgCheckbox? | |
| 90 | /// The term of a description-list item (`term :: definition`). | |
| 91 | public var term: [OrgObject]? | |
| 92 | /// The item's own content: one paragraph normally, several for a multi-paragraph item. | |
| 93 | public var content: [[OrgObject]] | |
| 94 | /// A nested list, when the item has one. | |
| 95 | public var sublist: OrgList? | |
| 96 | ||
| 97 | public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil, | |
| 98 | content: [[OrgObject]], sublist: OrgList? = nil) { | |
| 99 | self.checkbox = checkbox | |
| 100 | self.term = term | |
| 101 | self.content = content | |
| 102 | self.sublist = sublist | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | public enum OrgCheckbox: Sendable, Equatable { | |
| 107 | case off | |
| 108 | case on | |
| 109 | case partial | |
| 110 | } | |
| 111 | ||
| 112 | // MARK: - Tables | |
| 113 | ||
| 114 | public struct OrgTable: Sendable, Equatable { | |
| 115 | public var rows: [OrgTableRow] | |
| 116 | /// Per-column alignment from the separator row, when it carries `:` markers. | |
| 117 | public var alignments: [OrgAlignment?] | |
| 118 | ||
| 119 | public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) { | |
| 120 | self.rows = rows | |
| 121 | self.alignments = alignments | |
| 122 | } | |
| 123 | ||
| 124 | /// A rule row separates the header band from the body, as in org. | |
| 125 | public var headerRowCount: Int { | |
| 126 | guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } }) | |
| 127 | else { return 0 } | |
| 128 | return ruleIndex | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | public enum OrgTableRow: Sendable, Equatable { | |
| 133 | case cells([[OrgObject]]) | |
| 134 | case rule | |
| 135 | } | |
| 136 | ||
| 137 | public enum OrgAlignment: String, Sendable, Equatable { | |
| 138 | case left, center, right | |
| 139 | } | |
| 140 | ||
| 141 | // MARK: - Figures | |
| 142 | ||
| 143 | public struct OrgFigure: Sendable, Equatable { | |
| 144 | public var source: String | |
| 145 | public var caption: [OrgObject]? | |
| 146 | /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native | |
| 147 | /// renderer can read the ones it understands (`:alt`, `:width`). | |
| 148 | public var attributes: [(key: String, value: String)] | |
| 149 | ||
| 150 | public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) { | |
| 151 | self.source = source | |
| 152 | self.caption = caption | |
| 153 | self.attributes = attributes | |
| 154 | } | |
| 155 | ||
| 156 | public var alt: String? { | |
| 157 | attributes.first { $0.key == "alt" }?.value | |
| 158 | } | |
| 159 | ||
| 160 | public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool { | |
| 161 | lhs.source == rhs.source && lhs.caption == rhs.caption | |
| 162 | && lhs.attributes.count == rhs.attributes.count | |
| 163 | && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value } | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | // MARK: - Inline objects | |
| 168 | ||
| 169 | /// An inline object. Text-bearing cases carry their own children so a renderer can nest | |
| 170 | /// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup. | |
| 171 | public indirect enum OrgObject: Sendable, Equatable { | |
| 172 | case text(String) | |
| 173 | case bold([OrgObject]) | |
| 174 | case italic([OrgObject]) | |
| 175 | case underline([OrgObject]) | |
| 176 | case strikeThrough([OrgObject]) | |
| 177 | /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text. | |
| 178 | case verbatim(String) | |
| 179 | case code(String) | |
| 180 | case link(OrgLink) | |
| 181 | case image(OrgFigure) | |
| 182 | case footnoteRef(label: String, number: Int) | |
| 183 | case timestamp(OrgTimestamp) | |
| 184 | case superscript([OrgObject]) | |
| 185 | case lineBreak | |
| 186 | } | |
| 187 | ||
| 188 | public struct OrgLink: Sendable, Equatable { | |
| 189 | public var target: OrgLinkTarget | |
| 190 | /// Nil description means the link shows its target. | |
| 191 | public var description: [OrgObject]? | |
| 192 | ||
| 193 | public init(target: OrgLinkTarget, description: [OrgObject]? = nil) { | |
| 194 | self.target = target | |
| 195 | self.description = description | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | public enum OrgLinkTarget: Sendable, Equatable { | |
| 200 | /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL. | |
| 201 | case external(String) | |
| 202 | /// A repository-relative path, from `[[file:…]]` or a bare relative target. | |
| 203 | case file(String) | |
| 204 | /// `[[id:…]]` — an in-page fragment. | |
| 205 | case id(String) | |
| 206 | } | |
| 207 | ||
| 208 | public struct OrgTimestamp: Sendable, Equatable { | |
| 209 | public var date: String | |
| 210 | public var time: String? | |
| 211 | public var endTime: String? | |
| 212 | public var active: Bool | |
| 213 | ||
| 214 | public init(date: String, time: String? = nil, endTime: String? = nil, active: Bool) { | |
| 215 | self.date = date | |
| 216 | self.time = time | |
| 217 | self.endTime = endTime | |
| 218 | self.active = active | |
| 219 | } | |
| 220 | ||
| 221 | /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`. | |
| 222 | public var machineValue: String { | |
| 223 | time.map { "\(date)T\($0)" } ?? date | |
| 224 | } | |
| 225 | ||
| 226 | public var displayValue: String { | |
| 227 | time.map { "\(date) \($0)" } ?? date | |
| 228 | } | |
| 229 | } | |
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift added +281
| @@ -0,0 +1,281 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Renders an ``OrgDocument`` to HTML by walking the tree. | |
| 4 | /// | |
| 5 | /// The point of the prototype: this produces the same shape of output as the shipped | |
| 6 | /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second | |
| 7 | /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it. | |
| 8 | public struct OrgHTMLTreeRenderer: Sendable { | |
| 9 | public var headingLevelOffset: Int | |
| 10 | public var highlighter: CodeHighlighter | |
| 11 | ||
| 12 | public init(headingLevelOffset: Int = 0, highlighter: CodeHighlighter = PlainCodeHighlighter()) { | |
| 13 | self.headingLevelOffset = headingLevelOffset | |
| 14 | self.highlighter = highlighter | |
| 15 | } | |
| 16 | ||
| 17 | public func render(_ document: OrgDocument) -> String { | |
| 18 | var footnotes = FootnoteNumbering(document: document) | |
| 19 | var html = document.elements.map { element( $0, &footnotes) }.joined() | |
| 20 | html += footnotes.renderSection(self) | |
| 21 | return html | |
| 22 | } | |
| 23 | ||
| 24 | // MARK: - Blocks | |
| 25 | ||
| 26 | private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { | |
| 27 | switch element { | |
| 28 | case .heading(let heading): | |
| 29 | let level = min(6, max(1, heading.level + headingLevelOffset)) | |
| 30 | var inner = "" | |
| 31 | if let todo = heading.todo { | |
| 32 | inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "# | |
| 33 | } | |
| 34 | if let priority = heading.priority { | |
| 35 | inner += #"<span class="priority">[#\#(priority)]</span> "# | |
| 36 | } | |
| 37 | inner += renderInline(heading.title, ¬es) | |
| 38 | for tag in heading.tags { | |
| 39 | inner += #" <span class="tag">\#(escapeHTML(tag))</span>"# | |
| 40 | } | |
| 41 | return "<h\(level)>\(inner)</h\(level)>\n" | |
| 42 | ||
| 43 | case .paragraph(let objects): | |
| 44 | return "<p>" + renderInline(objects, ¬es) + "</p>\n" | |
| 45 | ||
| 46 | case .list(let list): | |
| 47 | return renderList(list, ¬es) | |
| 48 | ||
| 49 | case .table(let table): | |
| 50 | return renderTable(table, ¬es) | |
| 51 | ||
| 52 | case .srcBlock(let language, let code): | |
| 53 | let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 54 | let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code) | |
| 55 | return "<pre><code\(classAttribute)>\(body)</code></pre>\n" | |
| 56 | ||
| 57 | case .exampleBlock(let text): | |
| 58 | return "<pre>\(escapeHTML(text))</pre>\n" | |
| 59 | ||
| 60 | case .quoteBlock(let children): | |
| 61 | return "<blockquote>\n" + children.map { self.element($0, ¬es) }.joined() + "</blockquote>\n" | |
| 62 | ||
| 63 | case .centerBlock(let children): | |
| 64 | return #"<div class="center">"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "</div>\n" | |
| 65 | ||
| 66 | case .verseBlock(let lines): | |
| 67 | let body = lines.map { renderInline($0, ¬es) }.joined(separator: "<br>\n") | |
| 68 | return #"<p class="verse">"# + "\n" + body + "\n</p>\n" | |
| 69 | ||
| 70 | case .specialBlock(let name, let children): | |
| 71 | return #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n" | |
| 72 | + children.map { self.element($0, ¬es) }.joined() + "</div>\n" | |
| 73 | ||
| 74 | case .exportBlock(let backend, let raw): | |
| 75 | return backend == "html" ? raw + "\n" : "" | |
| 76 | ||
| 77 | case .figure(let figure): | |
| 78 | // A bare image is a paragraph; a caption or explicit attributes promote it to a | |
| 79 | // <figure>, matching org's exporter. | |
| 80 | guard figure.caption != nil || !figure.attributes.isEmpty else { | |
| 81 | return "<p>" + imageTag(figure, caption: nil, ¬es) + "</p>\n" | |
| 82 | } | |
| 83 | var html = "<figure>" + imageTag(figure, caption: figure.caption, ¬es) | |
| 84 | if let caption = figure.caption { | |
| 85 | notes.figureNumber += 1 | |
| 86 | html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"# | |
| 87 | + renderInline(caption, ¬es) + "</figcaption>" | |
| 88 | } | |
| 89 | return html + "</figure>\n" | |
| 90 | ||
| 91 | case .horizontalRule: | |
| 92 | return "<hr>\n" | |
| 93 | ||
| 94 | case .footnoteDefinition: | |
| 95 | return "" // collected and emitted in the notes section | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String { | |
| 100 | if list.kind == .description { | |
| 101 | var html = "<dl>\n" | |
| 102 | for item in list.items { | |
| 103 | if let term = item.term { | |
| 104 | html += "<dt>" + renderInline(term, ¬es) + "</dt>\n" | |
| 105 | } | |
| 106 | if let first = item.content.first { | |
| 107 | html += "<dd>" + renderInline(first, ¬es) + "</dd>\n" | |
| 108 | } | |
| 109 | } | |
| 110 | return html + "</dl>\n" | |
| 111 | } | |
| 112 | ||
| 113 | let tag = list.kind == .ordered ? "ol" : "ul" | |
| 114 | var html = "<\(tag)>\n" | |
| 115 | for item in list.items { | |
| 116 | html += "<li>" | |
| 117 | if let checkbox = item.checkbox { | |
| 118 | switch checkbox { | |
| 119 | case .off: html += "<code>[ ]</code> " | |
| 120 | case .on: html += "<code>[X]</code> " | |
| 121 | case .partial: html += "<code>[-]</code> " | |
| 122 | } | |
| 123 | } | |
| 124 | if item.content.count <= 1 { | |
| 125 | html += renderInline(item.content.first ?? [], ¬es) | |
| 126 | } else { | |
| 127 | html += item.content.map { "<p>" + renderInline($0, ¬es) + "</p>" }.joined(separator: "\n") | |
| 128 | } | |
| 129 | if let sublist = item.sublist { | |
| 130 | html += "\n" + renderList(sublist, ¬es) | |
| 131 | } | |
| 132 | html += "</li>\n" | |
| 133 | } | |
| 134 | return html + "</\(tag)>\n" | |
| 135 | } | |
| 136 | ||
| 137 | private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String { | |
| 138 | var html = "<table>\n" | |
| 139 | var wroteHeader = false | |
| 140 | var inBody = false | |
| 141 | let headerCount = table.headerRowCount | |
| 142 | ||
| 143 | for (index, row) in table.rows.enumerated() { | |
| 144 | switch row { | |
| 145 | case .rule: | |
| 146 | if wroteHeader, !inBody { html += "</thead>\n<tbody>\n"; inBody = true } | |
| 147 | case .cells(let cells): | |
| 148 | let isHeader = headerCount > 0 && index < headerCount | |
| 149 | if isHeader, !wroteHeader { html += "<thead>\n"; wroteHeader = true } | |
| 150 | if !isHeader, !inBody { html += "<tbody>\n"; inBody = true } | |
| 151 | html += "<tr>\n" | |
| 152 | for (column, cell) in cells.enumerated() { | |
| 153 | let tag = isHeader ? "th" : "td" | |
| 154 | let alignment = column < table.alignments.count ? table.alignments[column] : nil | |
| 155 | let style = alignment.map { #" style="text-align: \#($0.rawValue);""# } ?? "" | |
| 156 | html += "<\(tag)\(style)>" + renderInline(cell, ¬es) + "</\(tag)>\n" | |
| 157 | } | |
| 158 | html += "</tr>\n" | |
| 159 | } | |
| 160 | } | |
| 161 | if inBody { html += "</tbody>\n" } | |
| 162 | return html + "</table>\n" | |
| 163 | } | |
| 164 | ||
| 165 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?, _ notes: inout FootnoteNumbering) -> String { | |
| 166 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" | |
| 167 | var html = #"<img src="\#(escapeHTMLAttribute(figure.source))" alt="\#(escapeHTMLAttribute(alt))""# | |
| 168 | for (key, value) in figure.attributes where key != "alt" { | |
| 169 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" | |
| 170 | } | |
| 171 | return html + ">" | |
| 172 | } | |
| 173 | ||
| 174 | // MARK: - Inline | |
| 175 | ||
| 176 | func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { | |
| 177 | var html = "" | |
| 178 | for object in objects { | |
| 179 | switch object { | |
| 180 | case .text(let text): html += escapeHTML(text) | |
| 181 | case .bold(let children): html += "<strong>" + renderInline(children, ¬es) + "</strong>" | |
| 182 | case .italic(let children): html += "<em>" + renderInline(children, ¬es) + "</em>" | |
| 183 | case .underline(let children): html += "<u>" + renderInline(children, ¬es) + "</u>" | |
| 184 | case .strikeThrough(let children): html += "<del>" + renderInline(children, ¬es) + "</del>" | |
| 185 | case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>" | |
| 186 | case .superscript(let children): html += "<sup>" + renderInline(children, ¬es) + "</sup>" | |
| 187 | case .lineBreak: html += "<br>" | |
| 188 | case .image(let figure): html += imageTag(figure, caption: nil, ¬es) | |
| 189 | case .timestamp(let stamp): | |
| 190 | let cssClass = stamp.active ? "timestamp" : "timestamp inactive" | |
| 191 | html += #"<time class="\#(cssClass)" datetime="\#(stamp.machineValue)">\#(stamp.displayValue)</time>"# | |
| 192 | case .footnoteRef(let label, _): | |
| 193 | let number = notes.number(for: label) | |
| 194 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## | |
| 195 | case .link(let link): | |
| 196 | let href = escapeHTMLAttribute(hrefValue(link.target)) | |
| 197 | let text = link.description.map { renderInline($0, ¬es) } ?? escapeHTML(displayValue(link.target)) | |
| 198 | html += #"<a href="\#(href)">\#(text)</a>"# | |
| 199 | } | |
| 200 | } | |
| 201 | return html | |
| 202 | } | |
| 203 | ||
| 204 | private func hrefValue(_ target: OrgLinkTarget) -> String { | |
| 205 | switch target { | |
| 206 | case .external(let url): return url | |
| 207 | case .file(let path): return path | |
| 208 | case .id(let identifier): return "#\(identifier)" | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | private func displayValue(_ target: OrgLinkTarget) -> String { | |
| 213 | switch target { | |
| 214 | case .external(let url): return url | |
| 215 | case .file(let path): return path | |
| 216 | case .id(let identifier): return identifier | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | /// Inline objects reduced to plain text, for an `alt` attribute. | |
| 221 | func plainText(_ objects: [OrgObject]) -> String { | |
| 222 | objects.map { object in | |
| 223 | switch object { | |
| 224 | case .text(let text): return text | |
| 225 | case .verbatim(let text), .code(let text): return text | |
| 226 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): | |
| 227 | return plainText(c) | |
| 228 | case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target) | |
| 229 | case .timestamp(let stamp): return stamp.displayValue | |
| 230 | case .image(let figure): return figure.alt ?? "" | |
| 231 | case .footnoteRef, .lineBreak: return "" | |
| 232 | } | |
| 233 | }.joined() | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | // MARK: - Footnote numbering | |
| 238 | ||
| 239 | /// Assigns footnote numbers in first-reference order and renders the notes section. | |
| 240 | struct FootnoteNumbering { | |
| 241 | private var numbers: [String: Int] = [:] | |
| 242 | private var order: [String] = [] | |
| 243 | private var definitions: [String: [OrgObject]] = [:] | |
| 244 | var figureNumber = 0 | |
| 245 | ||
| 246 | init(document: OrgDocument) { | |
| 247 | for element in document.elements { | |
| 248 | if case .footnoteDefinition(let label, let content) = element { | |
| 249 | definitions[label] = content | |
| 250 | } | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | mutating func number(for label: String) -> Int { | |
| 255 | if let existing = numbers[label] { return existing } | |
| 256 | let next = order.count + 1 | |
| 257 | numbers[label] = next | |
| 258 | order.append(label) | |
| 259 | return next | |
| 260 | } | |
| 261 | ||
| 262 | func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String { | |
| 263 | guard !order.isEmpty else { return "" } | |
| 264 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | |
| 265 | for label in order { | |
| 266 | let n = numbers[label] ?? 0 | |
| 267 | var copy = self | |
| 268 | let body = definitions[label].map { renderer.inlineForNotes($0, ©) } ?? "" | |
| 269 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | |
| 270 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | |
| 271 | } | |
| 272 | return html + "</ol>\n</section>\n" | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | extension OrgHTMLTreeRenderer { | |
| 277 | /// Renders a footnote definition's own inline content, reusing the body inline walk. | |
| 278 | func inlineForNotes(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { | |
| 279 | renderInline(objects, ¬es) | |
| 280 | } | |
| 281 | } | |
Sources/OrgSwift/AST/OrgInlineParser.swift added +271
| @@ -0,0 +1,271 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Inline parsing into `[OrgObject]`. | |
| 4 | // | |
| 5 | // The shipped renderer does inline work by regex-substituting HTML into an escaped string, | |
| 6 | // using placeholder tokens to protect what must not be re-scanned. That works for one output | |
| 7 | // format but bakes HTML into the parse. Here the same constructs become a tree, so `*bold | |
| 8 | // /italic/*` nests properly and every renderer decides its own representation. | |
| 9 | ||
| 10 | extension OrgParser { | |
| 11 | ||
| 12 | /// Inline objects reduced to their plain text — for alt text, previews, or assertions. | |
| 13 | public static func plain(_ objects: [OrgObject]) -> String { | |
| 14 | objects.map { object in | |
| 15 | switch object { | |
| 16 | case .text(let text): return text | |
| 17 | case .verbatim(let text), .code(let text): return text | |
| 18 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): | |
| 19 | return plain(c) | |
| 20 | case .link(let link): | |
| 21 | if let description = link.description { return plain(description) } | |
| 22 | switch link.target { | |
| 23 | case .external(let value), .file(let value), .id(let value): return value | |
| 24 | } | |
| 25 | case .image(let figure): return figure.alt ?? "" | |
| 26 | case .timestamp(let stamp): return stamp.displayValue | |
| 27 | case .footnoteRef, .lineBreak: return "" | |
| 28 | } | |
| 29 | }.joined() | |
| 30 | } | |
| 31 | ||
| 32 | /// Parse a run of inline org text into objects. | |
| 33 | public static func parseInline(_ text: String) -> [OrgObject] { | |
| 34 | var objects: [OrgObject] = [] | |
| 35 | var plain = "" | |
| 36 | let chars = Array(text) | |
| 37 | var i = 0 | |
| 38 | ||
| 39 | func flushPlain() { | |
| 40 | if !plain.isEmpty { objects.append(.text(plain)); plain = "" } | |
| 41 | } | |
| 42 | ||
| 43 | while i < chars.count { | |
| 44 | // Bracket links: [[target]] or [[target][description]] | |
| 45 | if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[", | |
| 46 | let link = scanLink(chars, from: i) { | |
| 47 | flushPlain() | |
| 48 | objects.append(link.object) | |
| 49 | i = link.next | |
| 50 | continue | |
| 51 | } | |
| 52 | ||
| 53 | // Footnote reference: [fn:label] or [fn:label:inline] | |
| 54 | if chars[i] == "[", let note = scanFootnote(chars, from: i) { | |
| 55 | flushPlain() | |
| 56 | objects.append(note.object) | |
| 57 | i = note.next | |
| 58 | continue | |
| 59 | } | |
| 60 | ||
| 61 | // Timestamps: <2024-01-15 Mon 10:30> or [2024-01-15 Mon] | |
| 62 | if chars[i] == "<" || chars[i] == "[", let stamp = scanTimestamp(chars, from: i) { | |
| 63 | flushPlain() | |
| 64 | objects.append(stamp.object) | |
| 65 | i = stamp.next | |
| 66 | continue | |
| 67 | } | |
| 68 | ||
| 69 | // Bare URL autolink. | |
| 70 | if chars[i] == "h", let url = scanBareURL(chars, from: i) { | |
| 71 | flushPlain() | |
| 72 | objects.append(url.object) | |
| 73 | i = url.next | |
| 74 | continue | |
| 75 | } | |
| 76 | ||
| 77 | // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~ | |
| 78 | if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i), | |
| 79 | let span = scanEmphasis(chars, from: i, marker: chars[i]) { | |
| 80 | flushPlain() | |
| 81 | switch marker { | |
| 82 | case .bold: objects.append(.bold(parseInline(span.body))) | |
| 83 | case .italic: objects.append(.italic(parseInline(span.body))) | |
| 84 | case .underline: objects.append(.underline(parseInline(span.body))) | |
| 85 | case .strike: objects.append(.strikeThrough(parseInline(span.body))) | |
| 86 | case .verbatim: objects.append(.verbatim(span.body)) | |
| 87 | case .code: objects.append(.code(span.body)) | |
| 88 | } | |
| 89 | i = span.next | |
| 90 | continue | |
| 91 | } | |
| 92 | ||
| 93 | // Superscript: x^2 or x^{group} | |
| 94 | if chars[i] == "^", i > 0, isWordCharacter(chars[i - 1]), | |
| 95 | let sup = scanSuperscript(chars, from: i) { | |
| 96 | flushPlain() | |
| 97 | objects.append(.superscript(parseInline(sup.body))) | |
| 98 | i = sup.next | |
| 99 | continue | |
| 100 | } | |
| 101 | ||
| 102 | plain.append(chars[i]) | |
| 103 | i += 1 | |
| 104 | } | |
| 105 | flushPlain() | |
| 106 | return objects | |
| 107 | } | |
| 108 | ||
| 109 | // MARK: - Scanners | |
| 110 | ||
| 111 | private enum Emphasis { case bold, italic, underline, strike, verbatim, code } | |
| 112 | ||
| 113 | private static func emphasisMarker(_ c: Character) -> Emphasis? { | |
| 114 | switch c { | |
| 115 | case "*": return .bold | |
| 116 | case "/": return .italic | |
| 117 | case "_": return .underline | |
| 118 | case "+": return .strike | |
| 119 | case "=": return .verbatim | |
| 120 | case "~": return .code | |
| 121 | default: return nil | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | /// org requires the opening marker to follow whitespace or start the run. | |
| 126 | private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool { | |
| 127 | i == 0 || chars[i - 1].isWhitespace || "([{'\"".contains(chars[i - 1]) | |
| 128 | } | |
| 129 | ||
| 130 | private static func isWordCharacter(_ c: Character) -> Bool { | |
| 131 | c.isLetter || c.isNumber | |
| 132 | } | |
| 133 | ||
| 134 | private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) | |
| 135 | -> (body: String, next: Int)? { | |
| 136 | var j = start + 1 | |
| 137 | var body = "" | |
| 138 | while j < chars.count { | |
| 139 | if chars[j] == marker { | |
| 140 | // The closer must end the run or be followed by space/punctuation. | |
| 141 | let after = j + 1 < chars.count ? chars[j + 1] : " " | |
| 142 | if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count { | |
| 143 | return (body, j + 1) | |
| 144 | } | |
| 145 | } | |
| 146 | if chars[j] == "\n" { return nil } | |
| 147 | body.append(chars[j]) | |
| 148 | j += 1 | |
| 149 | } | |
| 150 | return nil | |
| 151 | } | |
| 152 | ||
| 153 | private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | |
| 154 | var j = start + 2 | |
| 155 | var target = "" | |
| 156 | while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) { | |
| 157 | target.append(chars[j]); j += 1 | |
| 158 | } | |
| 159 | guard j < chars.count else { return nil } | |
| 160 | ||
| 161 | var description: String? | |
| 162 | if chars[j + 1] == "[" { | |
| 163 | j += 2 | |
| 164 | var text = "" | |
| 165 | var depth = 0 | |
| 166 | while j < chars.count { | |
| 167 | if chars[j] == "[" { depth += 1 } | |
| 168 | if chars[j] == "]" { | |
| 169 | if depth == 0 { break } | |
| 170 | depth -= 1 | |
| 171 | } | |
| 172 | text.append(chars[j]); j += 1 | |
| 173 | } | |
| 174 | description = text | |
| 175 | } | |
| 176 | // Consume the closing ]] | |
| 177 | while j < chars.count, chars[j] == "]" { j += 1 } | |
| 178 | ||
| 179 | let object = makeLinkObject(target: target, description: description) | |
| 180 | return (object, j) | |
| 181 | } | |
| 182 | ||
| 183 | private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject { | |
| 184 | let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget | |
| 185 | ||
| 186 | // A description that is itself an image makes the image the link's content. | |
| 187 | if let description { | |
| 188 | let inner = description.hasPrefix("[[") && description.hasSuffix("]]") | |
| 189 | ? String(description.dropFirst(2).dropLast(2)) : description | |
| 190 | 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://") { | |
| 193 | return .link(OrgLink(target: linkTarget(target), | |
| 194 | description: [.image(OrgFigure(source: imageSource))])) | |
| 195 | } | |
| 196 | return .link(OrgLink(target: linkTarget(target), description: parseInline(description))) | |
| 197 | } | |
| 198 | ||
| 199 | if isImagePath(target) { return .image(OrgFigure(source: target)) } | |
| 200 | return .link(OrgLink(target: linkTarget(target), description: nil)) | |
| 201 | } | |
| 202 | ||
| 203 | private static func linkTarget(_ target: String) -> OrgLinkTarget { | |
| 204 | if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) } | |
| 205 | if target.hasPrefix("http://") || target.hasPrefix("https://") | |
| 206 | || target.hasPrefix("mailto:") || target.hasPrefix("#") { | |
| 207 | return .external(target) | |
| 208 | } | |
| 209 | return .file(target) | |
| 210 | } | |
| 211 | ||
| 212 | private static func isImagePath(_ path: String) -> Bool { | |
| 213 | let lower = path.lowercased() | |
| 214 | return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"] | |
| 215 | .contains { lower.hasSuffix($0) } | |
| 216 | } | |
| 217 | ||
| 218 | private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | |
| 219 | let rest = String(chars[start...]) | |
| 220 | guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil } | |
| 221 | let label = String(match.1) | |
| 222 | let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound) | |
| 223 | // Numbering is assigned by the renderer, which sees the whole document; 0 is a placeholder. | |
| 224 | return (.footnoteRef(label: label, number: 0), start + consumed) | |
| 225 | } | |
| 226 | ||
| 227 | private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | |
| 228 | let active = chars[start] == "<" | |
| 229 | let closing: Character = active ? ">" : "]" | |
| 230 | var j = start + 1 | |
| 231 | var body = "" | |
| 232 | while j < chars.count, chars[j] != closing { | |
| 233 | if chars[j] == "\n" { return nil } | |
| 234 | body.append(chars[j]); j += 1 | |
| 235 | } | |
| 236 | guard j < chars.count else { return nil } | |
| 237 | guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil } | |
| 238 | ||
| 239 | let date = String(dateMatch.1) | |
| 240 | var time: String? | |
| 241 | var endTime: String? | |
| 242 | if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) { | |
| 243 | time = String(timeMatch.1) | |
| 244 | if let end = timeMatch.2 { endTime = String(end) } | |
| 245 | } | |
| 246 | return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime, active: active)), j + 1) | |
| 247 | } | |
| 248 | ||
| 249 | private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? { | |
| 250 | let rest = String(chars[start...]) | |
| 251 | guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil } | |
| 252 | var url = String(rest[match.range]) | |
| 253 | while let last = url.last, ".,;:!?".contains(last) { url.removeLast() } | |
| 254 | return (.link(OrgLink(target: .external(url), description: nil)), start + url.count) | |
| 255 | } | |
| 256 | ||
| 257 | private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? { | |
| 258 | var j = start + 1 | |
| 259 | guard j < chars.count else { return nil } | |
| 260 | if chars[j] == "{" { | |
| 261 | j += 1 | |
| 262 | var body = "" | |
| 263 | while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 } | |
| 264 | guard j < chars.count else { return nil } | |
| 265 | return (body, j + 1) | |
| 266 | } | |
| 267 | var body = "" | |
| 268 | while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 } | |
| 269 | return body.isEmpty ? nil : (body, j) | |
| 270 | } | |
| 271 | } | |
Sources/OrgSwift/AST/OrgParser.swift added +387
| @@ -0,0 +1,387 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Parses org source into an ``OrgDocument``. | |
| 4 | /// | |
| 5 | /// This is the prototype of the AST split: parsing happens once, here, and renderers walk the | |
| 6 | /// result. It is deliberately a separate pipeline from the shipped ``OrgRenderer`` (which goes | |
| 7 | /// straight from source to an HTML string) so the two can be compared before anything migrates. | |
| 8 | public enum OrgParser { | |
| 9 | ||
| 10 | public static func parse(_ source: String) -> OrgDocument { | |
| 11 | var document = OrgDocument() | |
| 12 | let lines = source | |
| 13 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 14 | .replacingOccurrences(of: "\r", with: "\n") | |
| 15 | .components(separatedBy: "\n") | |
| 16 | var index = 0 | |
| 17 | var pendingCaption: String? | |
| 18 | var pendingAttrs: [(key: String, value: String)] = [] | |
| 19 | ||
| 20 | func flushPending() { | |
| 21 | pendingCaption = nil | |
| 22 | pendingAttrs = [] | |
| 23 | } | |
| 24 | ||
| 25 | while index < lines.count { | |
| 26 | let line = lines[index] | |
| 27 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 28 | ||
| 29 | if trimmed.isEmpty { index += 1; continue } | |
| 30 | ||
| 31 | // Comments. | |
| 32 | if trimmed == "#" || trimmed.hasPrefix("# ") { index += 1; continue } | |
| 33 | ||
| 34 | // Property drawers are heading metadata; org's exporter drops them. | |
| 35 | if trimmed == ":PROPERTIES:" { | |
| 36 | index += 1 | |
| 37 | while index < lines.count, | |
| 38 | lines[index].trimmingCharacters(in: .whitespaces) != ":END:" { | |
| 39 | index += 1 | |
| 40 | } | |
| 41 | if index < lines.count { index += 1 } | |
| 42 | continue | |
| 43 | } | |
| 44 | ||
| 45 | // Affiliated keywords and document metadata. | |
| 46 | if let directive = orgKeywordDirective(in: trimmed) { | |
| 47 | switch directive.keyword { | |
| 48 | case "caption": pendingCaption = directive.value | |
| 49 | case "attr_html": pendingAttrs = parseAttributes(directive.value) | |
| 50 | default: document.keywords.append((directive.keyword, directive.value)) | |
| 51 | } | |
| 52 | index += 1 | |
| 53 | continue | |
| 54 | } | |
| 55 | ||
| 56 | // Blocks: #+begin_… / #+end_… | |
| 57 | if trimmed.lowercased().hasPrefix("#+begin_") { | |
| 58 | let (element, next) = parseBlock(lines, from: index) | |
| 59 | if let element { document.elements.append(element) } | |
| 60 | index = next | |
| 61 | flushPending() | |
| 62 | continue | |
| 63 | } | |
| 64 | ||
| 65 | // Heading. | |
| 66 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | |
| 67 | document.elements.append(.heading(parseHeading(stars: match.1.count, rest: String(match.2)))) | |
| 68 | index += 1 | |
| 69 | flushPending() | |
| 70 | continue | |
| 71 | } | |
| 72 | ||
| 73 | // Horizontal rule. | |
| 74 | if isOrgHorizontalRule(trimmed) { | |
| 75 | document.elements.append(.horizontalRule) | |
| 76 | index += 1 | |
| 77 | flushPending() | |
| 78 | continue | |
| 79 | } | |
| 80 | ||
| 81 | // Footnote definition. | |
| 82 | if let def = orgFootnoteDefinition(in: trimmed) { | |
| 83 | document.elements.append(.footnoteDefinition(label: def.label, content: parseInline(def.text))) | |
| 84 | index += 1 | |
| 85 | flushPending() | |
| 86 | continue | |
| 87 | } | |
| 88 | ||
| 89 | // A standalone image link, promoted to a figure by an affiliated caption/attrs. | |
| 90 | if let path = standaloneOrgImage(in: trimmed) { | |
| 91 | document.elements.append(.figure(OrgFigure( | |
| 92 | source: path, | |
| 93 | caption: pendingCaption.map(parseInline), | |
| 94 | attributes: pendingAttrs | |
| 95 | ))) | |
| 96 | index += 1 | |
| 97 | flushPending() | |
| 98 | continue | |
| 99 | } | |
| 100 | ||
| 101 | // Table. | |
| 102 | if isTableLine(trimmed) { | |
| 103 | let (table, next) = parseTable(lines, from: index) | |
| 104 | document.elements.append(.table(table)) | |
| 105 | index = next | |
| 106 | flushPending() | |
| 107 | continue | |
| 108 | } | |
| 109 | ||
| 110 | // List. | |
| 111 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { | |
| 112 | let (list, next) = parseList(lines, from: index) | |
| 113 | document.elements.append(.list(list)) | |
| 114 | index = next | |
| 115 | flushPending() | |
| 116 | continue | |
| 117 | } | |
| 118 | ||
| 119 | // Paragraph: consume until a blank line or a line that starts another construct. | |
| 120 | var paragraph: [String] = [] | |
| 121 | while index < lines.count { | |
| 122 | let candidate = lines[index] | |
| 123 | let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces) | |
| 124 | if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break } | |
| 125 | paragraph.append(candidateTrimmed) | |
| 126 | index += 1 | |
| 127 | } | |
| 128 | if !paragraph.isEmpty { | |
| 129 | document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " ")))) | |
| 130 | } | |
| 131 | flushPending() | |
| 132 | } | |
| 133 | ||
| 134 | return document | |
| 135 | } | |
| 136 | ||
| 137 | /// Would this line begin a construct other than the paragraph currently being consumed? | |
| 138 | private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool { | |
| 139 | if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true } | |
| 140 | if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true } | |
| 141 | if isOrgHorizontalRule(trimmed) { return true } | |
| 142 | if isTableLine(trimmed) { return true } | |
| 143 | if orgFootnoteDefinition(in: trimmed) != nil { return true } | |
| 144 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } | |
| 145 | return false | |
| 146 | } | |
| 147 | ||
| 148 | // MARK: - Heading | |
| 149 | ||
| 150 | private static func parseHeading(stars: Int, rest: String) -> OrgHeading { | |
| 151 | var body = rest | |
| 152 | var todo: String? | |
| 153 | var priority: Character? | |
| 154 | ||
| 155 | for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") { | |
| 156 | todo = keyword | |
| 157 | body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces) | |
| 158 | break | |
| 159 | } | |
| 160 | if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) { | |
| 161 | priority = Character(String(match.1)) | |
| 162 | body = String(body[match.range.upperBound...]) | |
| 163 | } | |
| 164 | let (title, tags) = splitHeadingTags(body) | |
| 165 | return OrgHeading(level: stars, todo: todo, priority: priority, | |
| 166 | title: parseInline(title), tags: tags) | |
| 167 | } | |
| 168 | ||
| 169 | // MARK: - Blocks | |
| 170 | ||
| 171 | private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) { | |
| 172 | let opener = lines[start].trimmingCharacters(in: .whitespaces) | |
| 173 | let lower = opener.lowercased() | |
| 174 | let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? "" | |
| 175 | let argument = opener | |
| 176 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 177 | .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) } | |
| 178 | ||
| 179 | var body: [String] = [] | |
| 180 | var index = start + 1 | |
| 181 | while index < lines.count { | |
| 182 | let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased() | |
| 183 | if trimmed == "#+end_\(name)" { index += 1; break } | |
| 184 | body.append(lines[index]) | |
| 185 | index += 1 | |
| 186 | } | |
| 187 | ||
| 188 | switch name { | |
| 189 | case "src": | |
| 190 | return (.srcBlock(language: argument?.isEmpty == false ? argument : nil, | |
| 191 | code: body.joined(separator: "\n")), index) | |
| 192 | case "example": | |
| 193 | return (.exampleBlock(body.joined(separator: "\n")), index) | |
| 194 | case "quote": | |
| 195 | return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index) | |
| 196 | case "center": | |
| 197 | return (.centerBlock(parse(body.joined(separator: "\n")).elements), index) | |
| 198 | case "verse": | |
| 199 | return (.verseBlock(body.map(parseInline)), index) | |
| 200 | case "export": | |
| 201 | return (.exportBlock(backend: (argument ?? "").lowercased(), | |
| 202 | raw: body.joined(separator: "\n")), index) | |
| 203 | default: | |
| 204 | return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index) | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | // MARK: - Table | |
| 209 | ||
| 210 | private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) { | |
| 211 | var rows: [OrgTableRow] = [] | |
| 212 | var alignments: [OrgAlignment?] = [] | |
| 213 | var index = start | |
| 214 | ||
| 215 | while index < lines.count { | |
| 216 | let trimmed = lines[index].trimmingCharacters(in: .whitespaces) | |
| 217 | guard isTableLine(trimmed) else { break } | |
| 218 | ||
| 219 | // A separator row's columns are divided by `+`, not `|`, so it needs its own | |
| 220 | // split — `|:---+---:|` is two columns, which splitting on `|` would miss. | |
| 221 | let separatorCells = parseOrgTableSeparatorRow(trimmed) | |
| 222 | let cells = separatorCells.allSatisfy(isTableSeparatorCell) | |
| 223 | ? separatorCells : parseTableRow(trimmed) | |
| 224 | if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) { | |
| 225 | rows.append(.rule) | |
| 226 | let parsed = cells.map { cell -> OrgAlignment? in | |
| 227 | switch tableAlignment(for: cell) { | |
| 228 | case "left": return .left | |
| 229 | case "center": return .center | |
| 230 | case "right": return .right | |
| 231 | default: return nil | |
| 232 | } | |
| 233 | } | |
| 234 | if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed } | |
| 235 | } else { | |
| 236 | rows.append(.cells(cells.map(parseInline))) | |
| 237 | } | |
| 238 | index += 1 | |
| 239 | } | |
| 240 | return (OrgTable(rows: rows, alignments: alignments), index) | |
| 241 | } | |
| 242 | ||
| 243 | // MARK: - List | |
| 244 | ||
| 245 | private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) { | |
| 246 | var block: [String] = [] | |
| 247 | var index = start | |
| 248 | var pendingBlanks: [String] = [] | |
| 249 | ||
| 250 | while index < lines.count { | |
| 251 | let line = lines[index] | |
| 252 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 253 | if trimmed.isEmpty { | |
| 254 | pendingBlanks.append(line); index += 1; continue | |
| 255 | } | |
| 256 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { | |
| 257 | block.append(contentsOf: pendingBlanks); pendingBlanks = [] | |
| 258 | block.append(line); index += 1; continue | |
| 259 | } | |
| 260 | if isIndentedContinuationLine(line) { | |
| 261 | block.append(contentsOf: pendingBlanks); pendingBlanks = [] | |
| 262 | block.append(line); index += 1; continue | |
| 263 | } | |
| 264 | break | |
| 265 | } | |
| 266 | return (buildList(block), index) | |
| 267 | } | |
| 268 | ||
| 269 | /// Group a list block's lines into items, recursing for nested lists. | |
| 270 | private static func buildList(_ lines: [String]) -> OrgList { | |
| 271 | let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 272 | .map(leadingWidth).min() ?? 0 | |
| 273 | let normalized = lines.map { dropLeading($0, base) } | |
| 274 | ||
| 275 | var groups: [[String]] = [] | |
| 276 | var current: [String] = [] | |
| 277 | for line in normalized { | |
| 278 | if isListMarkerLine(line) { | |
| 279 | if !current.isEmpty { groups.append(current) } | |
| 280 | current = [line] | |
| 281 | } else if !current.isEmpty { | |
| 282 | current.append(line) | |
| 283 | } | |
| 284 | } | |
| 285 | if !current.isEmpty { groups.append(current) } | |
| 286 | ||
| 287 | let firstMarker = groups.first?.first ?? "" | |
| 288 | var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered | |
| 289 | if stripMarker(firstMarker).contains(" :: ") { kind = .description } | |
| 290 | ||
| 291 | let items = groups.map { buildItem($0, kind: kind) } | |
| 292 | return OrgList(kind: kind, items: items) | |
| 293 | } | |
| 294 | ||
| 295 | private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem { | |
| 296 | var head = stripMarker(lines[0]) | |
| 297 | var checkbox: OrgCheckbox? | |
| 298 | if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) } | |
| 299 | else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } | |
| 300 | else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } | |
| 301 | ||
| 302 | let rest = Array(lines.dropFirst()) | |
| 303 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 304 | .map(leadingWidth).min() ?? 0 | |
| 305 | let outdented = rest.map { dropLeading($0, childIndent) } | |
| 306 | ||
| 307 | var paragraphs: [String] = [] | |
| 308 | var currentParagraph = [head] | |
| 309 | var sublistLines: [String] = [] | |
| 310 | var inSublist = false | |
| 311 | ||
| 312 | func flush() { | |
| 313 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) | |
| 314 | if !joined.isEmpty { paragraphs.append(joined) } | |
| 315 | currentParagraph = [] | |
| 316 | } | |
| 317 | ||
| 318 | for line in outdented { | |
| 319 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 320 | if isListMarkerLine(line) || inSublist { | |
| 321 | if !inSublist { flush() } | |
| 322 | inSublist = true | |
| 323 | sublistLines.append(line) | |
| 324 | } else if trimmed.isEmpty { | |
| 325 | flush() | |
| 326 | } else { | |
| 327 | currentParagraph.append(trimmed) | |
| 328 | } | |
| 329 | } | |
| 330 | flush() | |
| 331 | ||
| 332 | var term: [OrgObject]? | |
| 333 | var content = paragraphs | |
| 334 | if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") { | |
| 335 | term = parseInline(String(first[..<range.lowerBound])) | |
| 336 | content[0] = String(first[range.upperBound...]) | |
| 337 | } | |
| 338 | ||
| 339 | return OrgListItem( | |
| 340 | checkbox: checkbox, | |
| 341 | term: term, | |
| 342 | content: content.map(parseInline), | |
| 343 | sublist: sublistLines.isEmpty ? nil : buildList(sublistLines) | |
| 344 | ) | |
| 345 | } | |
| 346 | ||
| 347 | // MARK: - Helpers | |
| 348 | ||
| 349 | private static func parseAttributes(_ value: String) -> [(key: String, value: String)] { | |
| 350 | guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { | |
| 351 | return [] | |
| 352 | } | |
| 353 | let ns = value as NSString | |
| 354 | return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in | |
| 355 | var raw = ns.substring(with: m.range(at: 2)) | |
| 356 | if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) } | |
| 357 | return (ns.substring(with: m.range(at: 1)).lowercased(), raw) | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | private static func stripMarker(_ line: String) -> String { | |
| 362 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 363 | if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) } | |
| 364 | if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) } | |
| 365 | return trimmed | |
| 366 | } | |
| 367 | ||
| 368 | private static func leadingWidth(_ line: String) -> Int { | |
| 369 | var count = 0 | |
| 370 | for ch in line { | |
| 371 | if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break } | |
| 372 | } | |
| 373 | return count | |
| 374 | } | |
| 375 | ||
| 376 | private static func dropLeading(_ line: String, _ n: Int) -> String { | |
| 377 | var dropped = 0 | |
| 378 | var index = line.startIndex | |
| 379 | while index < line.endIndex, dropped < n { | |
| 380 | if line[index] == " " { dropped += 1 } | |
| 381 | else if line[index] == "\t" { dropped += 8 } | |
| 382 | else { break } | |
| 383 | index = line.index(after: index) | |
| 384 | } | |
| 385 | return String(line[index...]) | |
| 386 | } | |
| 387 | } | |
Sources/OrgSwift/Tables.swift +3 −3
| @@ -12,7 +12,7 @@ func parseTableRow(_ line: String) -> [String] { | ||
| 12 | 12 | .map { String($0).trimmingCharacters(in: .whitespaces) } |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |
| 15 | func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |
| 16 | 16 | var content = line.trimmingCharacters(in: .whitespaces) |
| 17 | 17 | if content.hasPrefix("|") { |
| 18 | 18 | content.removeFirst() |
| @@ -25,11 +25,11 @@ private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | ||
| 25 | 25 | .map { String($0).trimmingCharacters(in: .whitespaces) } |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | private func isTableSeparatorCell(_ cell: String) -> Bool { | |
| 28 | func isTableSeparatorCell(_ cell: String) -> Bool { | |
| 29 | 29 | tableAlignment(for: cell) != nil |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | private func tableAlignment(for cell: String) -> String? { | |
| 32 | func tableAlignment(for cell: String) -> String? { | |
| 33 | 33 | let trimmed = cell.trimmingCharacters(in: .whitespaces) |
| 34 | 34 | guard !trimmed.isEmpty else { return nil } |
| 35 | 35 | |
Tests/OrgSwiftTests/ASTTests.swift added +218
| @@ -0,0 +1,218 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgSwift | |
| 4 | ||
| 5 | /// Tests for the AST prototype: `source → OrgDocument → {HTML, AttributedString}`. | |
| 6 | /// | |
| 7 | /// Two things are being proven. First, that the tree carries the structure faithfully (the | |
| 8 | /// parse tests). Second — the actual argument for the split — that a second output format is | |
| 9 | /// a walk over the same tree rather than a second parser (the AttributedString tests), and | |
| 10 | /// that the tree-based HTML renderer can be held to the same conformance corpus as the | |
| 11 | /// shipped single-pass renderer. | |
| 12 | struct ASTParseTests { | |
| 13 | ||
| 14 | @Test | |
| 15 | func headingCarriesTodoPriorityAndTags() { | |
| 16 | let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") | |
| 17 | guard case .heading(let heading) = doc.elements.first else { | |
| 18 | Issue.record("expected a heading"); return | |
| 19 | } | |
| 20 | #expect(heading.level == 1) | |
| 21 | #expect(heading.todo == "TODO") | |
| 22 | #expect(heading.priority == "A") | |
| 23 | #expect(heading.tags == ["work", "rust"]) | |
| 24 | #expect(OrgParser.plain(heading.title) == "Write the parser") | |
| 25 | } | |
| 26 | ||
| 27 | @Test | |
| 28 | func emphasisNestsRatherThanFlattening() { | |
| 29 | // The point of a tree: bold containing italic is structure, not a markup string. | |
| 30 | let objects = OrgParser.parseInline("*bold /inner/ rest*") | |
| 31 | guard case .bold(let children) = objects.first else { | |
| 32 | Issue.record("expected bold"); return | |
| 33 | } | |
| 34 | #expect(children.contains { if case .italic = $0 { return true } else { return false } }) | |
| 35 | } | |
| 36 | ||
| 37 | @Test | |
| 38 | func documentKeywordsAreMetadataNotContent() { | |
| 39 | let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.") | |
| 40 | #expect(doc.keyword("title") == "My Doc") | |
| 41 | #expect(doc.keyword("author") == "Someone") | |
| 42 | // Metadata does not appear as a body element. | |
| 43 | #expect(doc.elements.count == 1) | |
| 44 | guard case .paragraph = doc.elements.first else { | |
| 45 | Issue.record("expected a single paragraph"); return | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | @Test | |
| 50 | func nestedListsBecomeNestedItems() { | |
| 51 | let doc = OrgParser.parse(""" | |
| 52 | - outer | |
| 53 | - inner | |
| 54 | - deepest | |
| 55 | - second | |
| 56 | """) | |
| 57 | guard case .list(let list) = doc.elements.first else { | |
| 58 | Issue.record("expected a list"); return | |
| 59 | } | |
| 60 | #expect(list.items.count == 2) | |
| 61 | let inner = list.items[0].sublist | |
| 62 | #expect(inner != nil) | |
| 63 | #expect(inner?.items.first?.sublist?.items.count == 1) | |
| 64 | } | |
| 65 | ||
| 66 | @Test | |
| 67 | func tableKeepsRuleRowAndAlignments() { | |
| 68 | let doc = OrgParser.parse(""" | |
| 69 | | Name | Score | | |
| 70 | |:------+------:| | |
| 71 | | alpha | 10 | | |
| 72 | """) | |
| 73 | guard case .table(let table) = doc.elements.first else { | |
| 74 | Issue.record("expected a table"); return | |
| 75 | } | |
| 76 | #expect(table.rows.count == 3) | |
| 77 | #expect(table.headerRowCount == 1) | |
| 78 | #expect(table.alignments == [.left, .right]) | |
| 79 | if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") } | |
| 80 | } | |
| 81 | ||
| 82 | @Test | |
| 83 | func timestampsAndLinksBecomeTypedObjects() { | |
| 84 | let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]") | |
| 85 | let hasTimestamp = objects.contains { | |
| 86 | if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" } | |
| 87 | return false | |
| 88 | } | |
| 89 | #expect(hasTimestamp) | |
| 90 | let hasIDLink = objects.contains { | |
| 91 | if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" } | |
| 92 | return false | |
| 93 | } | |
| 94 | #expect(hasIDLink) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | struct ASTRendererTests { | |
| 99 | ||
| 100 | /// The payoff: one parse, two output formats, neither re-deriving the other's work. | |
| 101 | @Test | |
| 102 | func oneParseFeedsTwoRenderers() { | |
| 103 | let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].") | |
| 104 | ||
| 105 | let html = OrgHTMLTreeRenderer().render(document) | |
| 106 | #expect(html.contains("<strong>bold</strong>")) | |
| 107 | #expect(html.contains("<code>code</code>")) | |
| 108 | #expect(html.contains(#"<a href="https://example.com">link</a>"#)) | |
| 109 | ||
| 110 | let attributed = OrgAttributedStringRenderer().inline({ | |
| 111 | if case .paragraph(let objects) = document.elements[0] { return objects } | |
| 112 | return [] | |
| 113 | }()) | |
| 114 | // Same content, native representation: no markup, real attributes. | |
| 115 | let plain = String(attributed.characters) | |
| 116 | #expect(plain == "A bold claim with code and a link.") | |
| 117 | #expect(!plain.contains("<")) | |
| 118 | ||
| 119 | let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized } | |
| 120 | #expect(boldRun != nil) | |
| 121 | let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code } | |
| 122 | #expect(codeRun != nil) | |
| 123 | let linkRun = attributed.runs.first { $0.link != nil } | |
| 124 | #expect(linkRun?.link?.absoluteString == "https://example.com") | |
| 125 | } | |
| 126 | ||
| 127 | @Test | |
| 128 | func attributedStringCarriesRolesForNonStandardIntents() { | |
| 129 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") | |
| 130 | let attributed = OrgAttributedStringRenderer().inline(objects) | |
| 131 | let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] } | |
| 132 | #expect(roles.contains(.superscript)) | |
| 133 | #expect(roles.contains(.timestamp)) | |
| 134 | } | |
| 135 | ||
| 136 | @Test | |
| 137 | func treeRendererProducesStructuralHTML() { | |
| 138 | let document = OrgParser.parse(""" | |
| 139 | * Heading | |
| 140 | ||
| 141 | | a | b | | |
| 142 | |---+---| | |
| 143 | | 1 | 2 | | |
| 144 | ||
| 145 | - [ ] todo | |
| 146 | - [X] done | |
| 147 | """) | |
| 148 | let html = OrgHTMLTreeRenderer().render(document) | |
| 149 | #expect(html.contains("<h1>Heading</h1>")) | |
| 150 | #expect(html.contains("<thead>")) | |
| 151 | #expect(html.contains("<th>a</th>")) | |
| 152 | #expect(html.contains("<td>1</td>")) | |
| 153 | #expect(html.contains("<code>[ ]</code> todo")) | |
| 154 | #expect(html.contains("<code>[X]</code> done")) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | /// How far the tree-based HTML renderer already agrees with orgo, measured with the same | |
| 159 | /// corpus and the same skeleton reduction the shipped renderer is held to. This is the | |
| 160 | /// prototype's honest scorecard — it is not expected to match the shipped renderer's 11/12 | |
| 161 | /// yet, and the number here is what a migration would have to close. | |
| 162 | struct ASTConformanceReportTests { | |
| 163 | ||
| 164 | @Test | |
| 165 | func reportCorpusAgreement() throws { | |
| 166 | guard let dir = astCorpusCasesDir() else { | |
| 167 | print("org-conformance corpus not found — skipping") | |
| 168 | return | |
| 169 | } | |
| 170 | let renderer = OrgHTMLTreeRenderer(headingLevelOffset: 1) | |
| 171 | var matched: [String] = [] | |
| 172 | var diverged: [String] = [] | |
| 173 | ||
| 174 | for name in astCaseNames(dir) { | |
| 175 | let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? "" | |
| 176 | let goldenRaw = (try? String(contentsOf: dir.appendingPathComponent("\(name).skeleton"), encoding: .utf8)) ?? "" | |
| 177 | let trimmed = goldenRaw.hasSuffix("\n") ? String(goldenRaw.dropLast()) : goldenRaw | |
| 178 | let golden = trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n") | |
| 179 | ||
| 180 | let html = renderer.render(OrgParser.parse(source)) | |
| 181 | let got = OrgSkeleton.skeleton(html) | |
| 182 | if got == golden { | |
| 183 | matched.append(name) | |
| 184 | } else { | |
| 185 | diverged.append(name) | |
| 186 | if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil { | |
| 187 | let firstDiff = (0..<max(got.count, golden.count)).first { | |
| 188 | ($0 < golden.count ? golden[$0] : "∅") != ($0 < got.count ? got[$0] : "∅") | |
| 189 | } | |
| 190 | if let k = firstDiff { | |
| 191 | print("AST-DIFF \(name) @\(k): orgo=\(k < golden.count ? golden[k] : "∅") ours=\(k < got.count ? got[k] : "∅")") | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 196 | print("AST-CONFORMANCE matched=\(matched.count)/\(matched.count + diverged.count) \(matched.sorted())") | |
| 197 | print("AST-CONFORMANCE diverged=\(diverged.sorted())") | |
| 198 | // The prototype is measured, not gated: this test reports, it does not fail. | |
| 199 | #expect(matched.count + diverged.count > 0) | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | private func astCorpusCasesDir() -> URL? { | |
| 204 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { | |
| 205 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") | |
| 206 | if FileManager.default.fileExists(atPath: cases.path) { return cases } | |
| 207 | } | |
| 208 | let pkgRoot = URL(fileURLWithPath: #filePath) | |
| 209 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | |
| 210 | let sibling = pkgRoot.deletingLastPathComponent() | |
| 211 | .appendingPathComponent("org-conformance").appendingPathComponent("cases") | |
| 212 | return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil | |
| 213 | } | |
| 214 | ||
| 215 | private func astCaseNames(_ dir: URL) -> [String] { | |
| 216 | let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] | |
| 217 | return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() | |
| 218 | } | |