krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 0cc0e6b91b

0cc0e6b91be5b70851d7bda71f5e3182695ff02e

parent: 791ada4f3c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 03:10 UTC

Parser: citations, export snippets, inline calls, diary sexps, table.el, item tags

The parser now reads, as org-element does in Org 9.8.7:
- citations ([cite/style:prefix; @key…; suffix]) with their references and keys;
- export snippets (@@backend:value@@);
- inline Babel calls (call_name[header](arguments)[header]);
- diary sexps (%%(…) at the start of a line);
- table.el tables (+--+ rules first and last), which the table commands leave alone;
- item counters ([@3], [@start:3]) and description tags (TAG :: on -, + and * items).

HTML and Markdown export: HTML snippets pass through, others are dropped;
description lists come from the tags, with (no term) and the checkbox in
the term as ox-html writes them; ordered counters set <li value>; table.el
tables are preformatted; diary sexps aren't exported. The editor styles
the new objects, and incremental reparsing widens for item first lines
that could gain a counter, checkbox or tag.

Layout: unified · split

Sources/OrgCore/Commands/Footnotes.swift +2 −1
@@ -127,7 +127,8 @@ extension EmacsBuffer {
127 return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true 127 return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true
128 } 128 }
129 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp, 129 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp,
130 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget] 130 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget,
131 .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag]
131 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer] 132 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer]
132 let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) } 133 let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) }
133 switch element?.kind { 134 switch element?.kind {
Sources/OrgCore/Commands/Narrowing.swift +2 −1
@@ -32,7 +32,8 @@ public enum Narrowing {
32 probe = buffer.lineStart(buffer.point) 32 probe = buffer.lineStart(buffer.point)
33 } 33 }
34 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp, 34 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp,
35 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget] 35 .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget,
36 .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag]
36 var chain: [SyntaxNode] = [] 37 var chain: [SyntaxNode] = []
37 var node = tree.root 38 var node = tree.root
38 while let child = node.child(containing: probe) { 39 while let child = node.child(containing: probe) {
Sources/OrgCore/Export/HTMLExport.swift +22 −20
@@ -260,6 +260,9 @@ final class HTMLRenderer {
260 return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0)) 260 return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
261 } 261 }
262 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" 262 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
263 case .tableEl:
264 let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") }
265 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
263 case .dynamicBlock: 266 case .dynamicBlock:
264 let lines = node.text.components(separatedBy: "\n") 267 let lines = node.text.components(separatedBy: "\n")
265 return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n")) 268 return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
@@ -434,7 +437,8 @@ final class HTMLRenderer {
434 private func list(_ items: [SyntaxNode]) -> String { 437 private func list(_ items: [SyntaxNode]) -> String {
435 guard let first = items.first else { return "" } 438 guard let first = items.first else { return "" }
436 let ordered = isOrdered(first) 439 let ordered = isOrdered(first)
437 let description = !ordered && firstLine(first).contains(" :: ") 440 // `org-list-get-list-type`: descriptive when the first item has a tag.
441 let description = !ordered && first.firstChild(.itemTag) != nil
438 if description { 442 if description {
439 var out = "<dl>\n" 443 var out = "<dl>\n"
440 for item in items { 444 for item in items {
@@ -449,32 +453,25 @@ final class HTMLRenderer {
449 return out + "</\(tag)>\n" 453 return out + "</\(tag)>\n"
450 } 454 }
451 455
452 private func firstLine(_ item: SyntaxNode) -> String { 456 /// A description item's term, `(no term)` without one, and the rest of the item.
453 item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? ""
454 }
455
456 private func descriptionParts(_ item: SyntaxNode) -> (String, String) { 457 private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
457 guard let paragraph = item.firstChild(.paragraph) else { return ("", "") } 458 var term = item.firstChild(.itemTag).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? "(no term)"
458 let text = paragraph.text 459 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
459 guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) } 460 term = "<code>\(box == "[ ]" ? "[&nbsp;]" : box.uppercased())</code> " + term
460 let term = inlineString(String(text[..<separator.lowerBound])) 461 }
461 var rest = inlineParagraphText(String(text[separator.upperBound...])) 462 var rest = ""
462 for child in item.children where child.kind != .paragraph || child.range != paragraph.range { 463 for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
463 rest += element(child) 464 rest += index == 0 && child.kind == .paragraph ? inline(child).trimmingCharacters(in: .whitespacesAndNewlines) : element(child)
464 } 465 }
465 return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines)) 466 return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
466 } 467 }
467 468
468 /// Paragraph text, possibly spanning lines, rendered inline.
469 private func inlineParagraphText(_ text: String) -> String {
470 let tree = OrgParser.parse(text, defaults: settings)
471 let paragraphs = tree.root.descendants().filter { $0.kind == .paragraph }
472 guard !paragraphs.isEmpty else { return Self.escape(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
473 return paragraphs.map { inline($0).trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\n")
474 }
475
476 private func listItem(_ item: SyntaxNode) -> String { 469 private func listItem(_ item: SyntaxNode) -> String {
470 // ox-html gives an ordered item's counter as its value.
477 var open = "<li>" 471 var open = "<li>"
472 if isOrdered(item), let counter = item.tokens.first(where: { $0.kind == .counter })?.text.firstMatch(of: /([0-9]+|[A-Za-z])\]/) {
473 open = "<li value=\"\(counter.1)\">"
474 }
478 var prefix = "" 475 var prefix = ""
479 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text { 476 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
480 switch box { 477 switch box {
@@ -680,6 +677,11 @@ final class HTMLRenderer {
680 hasMath = true 677 hasMath = true
681 return Self.escape(node.text) 678 return Self.escape(node.text)
682 case .statisticsCookie: return Self.escape(node.text) 679 case .statisticsCookie: return Self.escape(node.text)
680 // `org-html-export-snippet`: only HTML snippets.
681 case .exportSnippet:
682 guard node.tokens.first?.text == "@@html:" else { return "" }
683 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
684 case .citation, .inlineBabelCall: return Self.escape(node.text)
683 default: return inline(node) 685 default: return inline(node)
684 } 686 }
685 } 687 }
Sources/OrgCore/Export/MarkdownExport.swift +13 −6
@@ -96,6 +96,9 @@ final class MarkdownRenderer {
96 return block(node, indent: indent) 96 return block(node, indent: indent)
97 case .horizontalRule: 97 case .horizontalRule:
98 return indent + "---" 98 return indent + "---"
99 case .tableEl:
100 let lines = node.text.components(separatedBy: "\n").filter { !$0.isEmpty && !$0.lowercased().contains("#+tblfm") }
101 return fence(lines, language: "", indent: indent)
99 case .fixedWidth: 102 case .fixedWidth:
100 let lines = node.text.split(separator: "\n").map { line -> String in 103 let lines = node.text.split(separator: "\n").map { line -> String in
101 let trimmed = line.drop { $0 == " " || $0 == "\t" } 104 let trimmed = line.drop { $0 == " " || $0 == "\t" }
@@ -126,18 +129,17 @@ final class MarkdownRenderer {
126 } 129 }
127 let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count) 130 let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count)
128 var parts: [String] = [] 131 var parts: [String] = []
129 for (index, child) in item.children.enumerated() { 132 let term = item.firstChild(.itemTag).map { "**" + inline($0).trimmingCharacters(in: .whitespaces) + "**: " }
133 for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
130 if index == 0, child.kind == .paragraph { 134 if index == 0, child.kind == .paragraph {
131 var text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines) 135 let text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
132 .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent) 136 .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent)
133 if let separator = text.range(of: " :: ") { 137 parts.append((term ?? "") + text)
134 text = "**" + text[..<separator.lowerBound] + "**: " + text[separator.upperBound...]
135 }
136 parts.append(text)
137 } else { 138 } else {
138 parts.append(element(child, indent: childIndent)) 139 parts.append(element(child, indent: childIndent))
139 } 140 }
140 } 141 }
142 if let term, parts.first.map({ !$0.hasPrefix(term) }) ?? true { parts.insert(term.trimmingCharacters(in: .whitespaces), at: 0) }
141 let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined() 143 let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined()
142 out.append(indent + marker + " " + body) 144 out.append(indent + marker + " " + body)
143 } 145 }
@@ -250,6 +252,11 @@ final class MarkdownRenderer {
250 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" 252 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
251 case .lineBreak: return " \n" 253 case .lineBreak: return " \n"
252 case .target, .macro: return "" 254 case .target, .macro: return ""
255 // ox-md uses ox-html's translator: only HTML snippets.
256 case .exportSnippet:
257 guard node.tokens.first?.text == "@@html:" else { return "" }
258 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
259 case .citation, .inlineBabelCall: return node.text
253 default: return inline(node) 260 default: return inline(node)
254 } 261 }
255 } 262 }
Sources/OrgCore/Keymap/VimExtras.swift +5 −3
@@ -286,7 +286,7 @@ extension Vim {
286 case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask: 286 case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask:
287 return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n) 287 return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n)
288 case .item: 288 case .item:
289 let first = n.children.first { ![.bullet, .checkbox].contains($0.kind) }?.range.lowerBound ?? r.upperBound 289 let first = n.children.first { ![.bullet, .checkbox, .itemTag].contains($0.kind) }?.range.lowerBound ?? r.upperBound
290 return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n) 290 return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n)
291 case .block: 291 case .block:
292 let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased() 292 let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased()
@@ -324,7 +324,8 @@ extension Vim {
324 func atPoint() -> OrgElement? { 324 func atPoint() -> OrgElement? {
325 let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, 325 let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription,
326 .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, 326 .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock,
327 .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty] 327 .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty,
328 .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag]
328 var elements = chain.filter { !objects.contains($0.kind) } 329 var elements = chain.filter { !objects.contains($0.kind) }
329 if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) { 330 if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) {
330 elements = Array(elements[...i]) 331 elements = Array(elements[...i])
@@ -365,7 +366,8 @@ extension Vim {
365 return (inner ? innerRange(e) : e.begin..<e.end, !inner) 366 return (inner ? innerRange(e) : e.begin..<e.end, !inner)
366 default: 367 default:
367 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .timestamp, .footnoteReference, 368 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .timestamp, .footnoteReference,
368 .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript] 369 .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript,
370 .citation, .citationReference, .exportSnippet, .inlineBabelCall]
369 guard let found = chain.last(where: { objects.contains($0.kind) }) else { 371 guard let found = chain.last(where: { objects.contains($0.kind) }) else {
370 guard let e = atPoint() else { return nil } 372 guard let e = atPoint() else { return nil }
371 return (inner ? innerRange(e) : e.begin..<e.end, false) 373 return (inner ? innerRange(e) : e.begin..<e.end, false)
Sources/OrgCore/Parser/Incremental.swift +8
@@ -100,6 +100,14 @@ private struct ReparseContext {
100 // An element that starts mid-line (an item's paragraph) follows tokens that would absorb 100 // An element that starts mid-line (an item's paragraph) follows tokens that would absorb
101 // whitespace typed at its start. 101 // whitespace typed at its start.
102 guard start == leaf.range.lowerBound || edit.range.lowerBound > leaf.range.lowerBound else { return nil } 102 guard start == leaf.range.lowerBound || edit.range.lowerBound > leaf.range.lowerBound else { return nil }
103 // An item's first paragraph: its line can turn into a counter, checkbox or tag.
104 if start != leaf.range.lowerBound {
105 let oldLine = slice(oldText, leaf.range.lowerBound, lineEnd(oldText, leaf.range.lowerBound))
106 let newLine = slice(newText, leaf.range.lowerBound, lineEnd(newText, leaf.range.lowerBound))
107 for line in [oldLine, newLine] where line.contains("::") || line.hasPrefix("[@") || line.hasPrefix("[") {
108 return nil
109 }
110 }
103 let oldEnd = leaf.range.upperBound 111 let oldEnd = leaf.range.upperBound
104 let newEnd = oldEnd + delta 112 let newEnd = oldEnd + delta
105 let newLength = newText.utf16.count 113 let newLength = newText.utf16.count
Sources/OrgCore/Parser/Inline.swift +161 −1
@@ -44,6 +44,11 @@ enum InlineMatch {
44 case object(SyntaxKind, Range<Int>) 44 case object(SyntaxKind, Range<Int>)
45 /// An object whose contents hold objects, between markers. 45 /// An object whose contents hold objects, between markers.
46 case container(SyntaxKind, contents: Range<Int>, whole: Range<Int>) 46 case container(SyntaxKind, contents: Range<Int>, whole: Range<Int>)
47 /// `[cite/style:prefix; refs…; suffix]`: the common prefix and suffix, and each reference
48 /// with where its key is.
49 case citation(whole: Range<Int>, opener: Int, prefix: Range<Int>?, references: [(range: Range<Int>, key: Range<Int>)], suffix: Range<Int>?)
50 /// `@@backend:value@@`.
51 case exportSnippet(whole: Range<Int>, value: Range<Int>)
47 52
48 var end: Int { 53 var end: Int {
49 switch self { 54 switch self {
@@ -51,6 +56,8 @@ enum InlineMatch {
51 case .link(_, _, let whole): whole.upperBound 56 case .link(_, _, let whole): whole.upperBound
52 case .object(_, let range): range.upperBound 57 case .object(_, let range): range.upperBound
53 case .container(_, _, let whole): whole.upperBound 58 case .container(_, _, let whole): whole.upperBound
59 case .citation(let whole, _, _, _, _): whole.upperBound
60 case .exportSnippet(let whole, _): whole.upperBound
54 } 61 }
55 } 62 }
56} 63}
@@ -106,7 +113,7 @@ struct InlineScanner {
106 /// The first characters any recognizer accepts. 113 /// The first characters any recognizer accepts.
107 static func mayStartObject(_ c: Unicode.Scalar) -> Bool { 114 static func mayStartObject(_ c: Unicode.Scalar) -> Bool {
108 switch c { 115 switch c {
109 case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$": true 116 case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$", "@", "c": true
110 default: false 117 default: false
111 } 118 }
112 } 119 }
@@ -163,6 +170,43 @@ struct InlineScanner {
163 b.start(kind) 170 b.start(kind)
164 emitText(range, into: &b) 171 emitText(range, into: &b)
165 b.finish() 172 b.finish()
173 case .citation(let whole, let opener, let prefix, let references, let suffix):
174 b.start(.citation)
175 var at = whole.lowerBound
176 func marker(to end: Int) {
177 if at < end { b.token(.marker, string(at..<end)) }
178 at = max(at, end)
179 }
180 marker(to: opener)
181 if let prefix {
182 scan(prefix, into: &b, inLink: true)
183 at = prefix.upperBound
184 }
185 for reference in references {
186 marker(to: reference.range.lowerBound)
187 b.start(.citationReference)
188 scan(reference.range.lowerBound..<reference.key.lowerBound, into: &b, inLink: true)
189 b.token(.citationKey, string(reference.key))
190 let separator = reference.range.upperBound > reference.key.upperBound && chars[reference.range.upperBound - 1] == ";"
191 let suffixEnd = separator ? reference.range.upperBound - 1 : reference.range.upperBound
192 scan(reference.key.upperBound..<suffixEnd, into: &b, inLink: true)
193 if separator { b.token(.marker, string(suffixEnd..<reference.range.upperBound)) }
194 b.finish()
195 at = reference.range.upperBound
196 }
197 if let suffix {
198 marker(to: suffix.lowerBound)
199 scan(suffix, into: &b, inLink: true)
200 at = suffix.upperBound
201 }
202 marker(to: whole.upperBound)
203 b.finish()
204 case .exportSnippet(let whole, let value):
205 b.start(.exportSnippet)
206 b.token(.marker, string(whole.lowerBound..<value.lowerBound))
207 emitText(value, into: &b)
208 b.token(.marker, string(value.upperBound..<whole.upperBound))
209 b.finish()
166 } 210 }
167 } 211 }
168 212
@@ -194,6 +238,7 @@ struct InlineScanner {
194 switch chars[i] { 238 switch chars[i] {
195 case "[": 239 case "[":
196 if !inLink, let link = bracketLink(i, limit) { return link } 240 if !inLink, let link = bracketLink(i, limit) { return link }
241 if !inLink, let cite = citation(i, limit) { return cite }
197 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) } 242 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
198 if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) } 243 if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
199 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) } 244 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
@@ -217,6 +262,10 @@ struct InlineScanner {
217 } 262 }
218 case "s": 263 case "s":
219 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) } 264 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) }
265 case "c":
266 if !afterWord, let end = inlineBabelCall(i, limit) { return .object(.inlineBabelCall, i..<end) }
267 case "@":
268 if let snippet = exportSnippet(i, limit) { return snippet }
220 case "h", "m", "f": 269 case "h", "m", "f":
221 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) } 270 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
222 default: 271 default:
@@ -318,6 +367,117 @@ struct InlineScanner {
318 return j + 1 367 return j + 1
319 } 368 }
320 369
370 /// `org-element-citation-parser`: `[cite` and an optional `/style`, `:`, then up to the
371 /// matching `]` at least one `@key`. A common prefix ends at the `;` before the first key,
372 /// a common suffix starts at the last `;` with no key after it.
373 func citation(_ i: Int, _ limit: Int) -> InlineMatch? {
374 guard hasPrefix("[cite", at: i, limit) else { return nil }
375 var k = i + 5
376 if k < limit, chars[k] == "/" {
377 let style = k + 1
378 k = style
379 while k < limit, chars[k] == "/" || chars[k] == "_" || chars[k] == "-" || isWordScalar(chars[k]) && chars[k].isASCII { k += 1 }
380 guard k > style else { return nil }
381 }
382 guard k < limit, chars[k] == ":" else { return nil }
383 k += 1
384 while k < limit, chars[k] == " " || chars[k] == "\t" || chars[k] == "\n" { k += 1 }
385 let start = k
386 // `scan-lists` with only square brackets paired.
387 var depth = 0
388 var closing: Int?
389 var j = i
390 while j < limit {
391 if chars[j] == "[" { depth += 1 }
392 if chars[j] == "]" {
393 depth -= 1
394 if depth == 0 { closing = j + 1; break }
395 }
396 j += 1
397 }
398 guard let closing, let first = citationKey(from: start, to: closing) else { return nil }
399 var prefix: Range<Int>?
400 var contentsBegin = start
401 if let semi = (start..<first.lowerBound).last(where: { chars[$0] == ";" }) {
402 if start < semi { prefix = start..<semi }
403 contentsBegin = semi + 1
404 }
405 var end = closing - 1
406 while end > first.upperBound, [" ", "\t", "\n", "\r"].contains(chars[end - 1]) { end -= 1 }
407 var suffix: Range<Int>?
408 var contentsEnd = end
409 if let semi = (first.upperBound..<end).last(where: { chars[$0] == ";" }), citationKey(from: semi, to: end) == nil {
410 if semi + 1 < end { suffix = (semi + 1)..<end }
411 contentsEnd = semi + 1
412 }
413 var references: [(range: Range<Int>, key: Range<Int>)] = []
414 var at = contentsBegin
415 while at < contentsEnd, let key = citationKey(from: at, to: contentsEnd) {
416 let separator = (key.upperBound..<contentsEnd).first { chars[$0] == ";" }
417 let referenceEnd = separator.map { $0 + 1 } ?? contentsEnd
418 references.append((at..<referenceEnd, key))
419 at = referenceEnd
420 }
421 return .citation(whole: i..<closing, opener: start, prefix: prefix, references: references, suffix: suffix)
422 }
423
424 /// `org-element-citation-key-re`: `@` and word or symbol characters.
425 func citationKey(from: Int, to limit: Int) -> Range<Int>? {
426 var k = from
427 while k < limit {
428 if chars[k] == "@" {
429 var e = k + 1
430 while e < limit, isWordScalar(chars[e]) || "-.:?!`'/*@+|(){}<>&_^$#%~".unicodeScalars.contains(chars[e]) { e += 1 }
431 if e > k + 1 { return k..<e }
432 }
433 k += 1
434 }
435 return nil
436 }
437
438 /// `org-element-export-snippet-parser`: `@@backend:value@@`.
439 func exportSnippet(_ i: Int, _ limit: Int) -> InlineMatch? {
440 guard hasPrefix("@@", at: i, limit) else { return nil }
441 var k = i + 2
442 while k < limit, chars[k] == "-" || (chars[k].isASCII && isWordScalar(chars[k])) { k += 1 }
443 guard k > i + 2, k < limit, chars[k] == ":" else { return nil }
444 let value = k + 1
445 var e = value
446 while e + 1 < limit {
447 if chars[e] == "@", chars[e + 1] == "@" { return .exportSnippet(whole: i..<(e + 2), value: value..<e) }
448 e += 1
449 }
450 return nil
451 }
452
453 /// `org-element-inline-babel-call-parser`: `call_NAME`, an optional `[inside header]`,
454 /// `(arguments)` and an optional `[end header]`, each balanced in its own brackets.
455 func inlineBabelCall(_ i: Int, _ limit: Int) -> Int? {
456 guard hasPrefix("call_", at: i, limit) else { return nil }
457 var k = i + 5
458 while k < limit, !" \t\n[(".unicodeScalars.contains(chars[k]) { k += 1 }
459 guard k > i + 5, k < limit, chars[k] == "(" || chars[k] == "[" else { return nil }
460 func paired(_ open: Unicode.Scalar, _ close: Unicode.Scalar) -> Int? {
461 guard k < limit, chars[k] == open else { return nil }
462 var depth = 0
463 var j = k
464 while j < limit {
465 if chars[j] == open { depth += 1 }
466 if chars[j] == close {
467 depth -= 1
468 if depth == 0 { return j + 1 }
469 }
470 j += 1
471 }
472 return nil
473 }
474 if let end = paired("[", "]") { k = end }
475 guard let arguments = paired("(", ")") else { return nil }
476 k = arguments
477 if let end = paired("[", "]") { k = end }
478 return k
479 }
480
321 /// `<<target>>`. 481 /// `<<target>>`.
322 func target(_ i: Int, _ limit: Int) -> Int? { 482 func target(_ i: Int, _ limit: Int) -> Int? {
323 guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil } 483 guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil }
Sources/OrgCore/Parser/Lines.swift +6
@@ -52,6 +52,10 @@ enum LineClass: Equatable {
52 case fixedWidth 52 case fixedWidth
53 case horizontalRule 53 case horizontalRule
54 case tableRow 54 case tableRow
55 /// `+--+--+`, which may start a table.el table.
56 case tableElRule
57 /// `%%(…)` at the start of a line.
58 case diarySexp
55 case footnoteDefinition 59 case footnoteDefinition
56 case clock 60 case clock
57 case planning 61 case planning
@@ -136,6 +140,8 @@ private func lineClass(_ rest: Substring, columnZero: Bool, alphabetical: Bool)
136 } 140 }
137 141
138 if rest.first == "|" { return .tableRow } 142 if rest.first == "|" { return .tableRow }
143 if rest.first == "+", trimmed.wholeMatch(of: /\+(?:-+\+)+/) != nil { return .tableElRule }
144 if columnZero, rest.hasPrefix("%%(") { return .diarySexp }
139 if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule } 145 if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule }
140 146
141 if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"), 147 if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"),
Sources/OrgCore/Parser/Parser.swift +47
@@ -206,6 +206,22 @@ struct Parser {
206 single(.clock) 206 single(.clock)
207 case .tableRow: 207 case .tableRow:
208 table(limit: limit, floor: floor) 208 table(limit: limit, floor: floor)
209 case .tableElRule:
210 if let end = tableElEnd(limit: limit, floor: floor) {
211 builder.start(.tableEl)
212 while i < end {
213 line(i)
214 i += 1
215 }
216 while i < limit, info[i].cls == .keyword(key: "TBLFM"), within(floor, i) {
217 single(.tableFormula)
218 }
219 builder.finish()
220 } else {
221 paragraph(limit: limit, floor: floor)
222 }
223 case .diarySexp:
224 single(.diarySexp)
209 case .footnoteDefinition: 225 case .footnoteDefinition:
210 footnoteDefinition(limit: limit) 226 footnoteDefinition(limit: limit)
211 case .listItem: 227 case .listItem:
@@ -282,6 +298,22 @@ struct Parser {
282 builder.finish() 298 builder.finish()
283 } 299 }
284 300
301 /// Where the table.el table starting at line `i` ends, as `org-element--current-element`
302 /// decides: a full rule first and last, at least two lines, every line starting with `+`
303 /// or `|`. Nil for none.
304 func tableElEnd(limit: Int, floor: Int?) -> Int? {
305 guard i + 1 < limit else { return nil }
306 func isTableLine(_ k: Int) -> Bool {
307 guard within(floor, k) else { return false }
308 let first = lines[k].content.drop { $0 == " " || $0 == "\t" }.first
309 return first == "+" || first == "|"
310 }
311 var k = i + 1
312 while k < limit, isTableLine(k) { k += 1 }
313 if k == i + 1 { return nil }
314 return info[k - 1].cls == .tableElRule ? k : nil
315 }
316
285 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells; 317 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells;
286 /// every pair of pipes gets a cell, even an empty one, so columns line up. 318 /// every pair of pipes gets a cell, even an empty one, so columns line up.
287 mutating func tableRow() { 319 mutating func tableRow() {
@@ -350,10 +382,25 @@ struct Parser {
350 let bullet = rest.prefix { $0 != " " && $0 != "\t" } 382 let bullet = rest.prefix { $0 != " " && $0 != "\t" }
351 builder.token(.bullet, bullet) 383 builder.token(.bullet, bullet)
352 rest = whitespace(rest.dropFirst(bullet.count)) 384 rest = whitespace(rest.dropFirst(bullet.count))
385 if let counter = rest.prefixMatch(of: /\[@(?:start:)?(?:[0-9]+|[A-Za-z])\]/) {
386 builder.token(.counter, rest[counter.range])
387 rest = whitespace(rest[counter.range.upperBound...])
388 }
353 if let box = checkbox(rest) { 389 if let box = checkbox(rest) {
354 builder.token(.checkbox, box) 390 builder.token(.checkbox, box)
355 rest = whitespace(rest.dropFirst(box.count)) 391 rest = whitespace(rest.dropFirst(box.count))
356 } 392 }
393 // A description item's tag: up to the last ` ::` on the line, for `-`, `+` and `*`.
394 if "-+*".contains(bullet), let m = rest.prefixMatch(of: /(.*)([ \t]+::)(?:[ \t]+|$)/.anchorsMatchLineEndings()) {
395 builder.start(.itemTag)
396 inline(rest[m.1.startIndex..<m.1.endIndex])
397 builder.finish()
398 let separator = rest[m.2.startIndex..<m.2.endIndex]
399 let blank = separator.prefix { $0 == " " || $0 == "\t" }
400 builder.token(.whitespace, blank)
401 builder.token(.marker, separator.dropFirst(blank.count))
402 rest = whitespace(rest[m.2.endIndex...])
403 }
357 // The rest of the bullet line and its continuation lines are the item's first paragraph. 404 // The rest of the bullet line and its continuation lines are the item's first paragraph.
358 var end = i + 1 405 var end = i + 1
359 while end < limit, within(base, end), continuesParagraph(end) { end += 1 } 406 while end < limit, within(base, end), continuesParagraph(end) { end += 1 }
Sources/OrgCore/Syntax/SyntaxKind.swift +6
@@ -3,6 +3,8 @@ public enum SyntaxKind: String, Sendable {
3 case text, newline, whitespace 3 case text, newline, whitespace
4 case stars, todoKeyword, priority, tags 4 case stars, todoKeyword, priority, tags
5 case marker, linkPath, bullet, checkbox 5 case marker, linkPath, bullet, checkbox
6 /// `[@3]` on an item; a citation reference's `@key`.
7 case counter, citationKey
6 8
7 // Elements 9 // Elements
8 case document, zerothSection, section, heading, title 10 case document, zerothSection, section, heading, title
@@ -10,10 +12,14 @@ public enum SyntaxKind: String, Sendable {
10 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula 12 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
11 case block, dynamicBlock, keyword, affiliatedKeyword 13 case block, dynamicBlock, keyword, affiliatedKeyword
12 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask 14 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask
15 case diarySexp, tableEl
16 /// A description item's term, before ` :: `.
17 case itemTag
13 18
14 // Objects 19 // Objects
15 case bold, italic, underline, strikeThrough, verbatim, code 20 case bold, italic, underline, strikeThrough, verbatim, code
16 case link, linkDescription, timestamp, footnoteReference, statisticsCookie 21 case link, linkDescription, timestamp, footnoteReference, statisticsCookie
17 case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript 22 case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript
18 case `subscript`, entity, radioTarget 23 case `subscript`, entity, radioTarget
24 case citation, citationReference, exportSnippet, inlineBabelCall
19} 25}
Sources/OrgPresentation/Presentation.swift +5 −2
@@ -155,12 +155,15 @@ public enum Presentation {
155 case .target, .radioTarget: .target 155 case .target, .radioTarget: .target
156 case .macro: .macro 156 case .macro: .macro
157 case .latexFragment, .latexEnvironment: .latex 157 case .latexFragment, .latexEnvironment: .latex
158 case .inlineSourceBlock: .inlineSource 158 case .inlineSourceBlock, .inlineBabelCall, .exportSnippet: .inlineSource
159 case .citation: .link
160 case .itemTag: .bold
161 case .diarySexp: .timestamp
159 case .comment: .comment 162 case .comment: .comment
160 case .keyword, .affiliatedKeyword: .keyword 163 case .keyword, .affiliatedKeyword: .keyword
161 case .planning, .propertyDrawer, .drawer, .clock: .metadata 164 case .planning, .propertyDrawer, .drawer, .clock: .metadata
162 case .block, .dynamicBlock, .fixedWidth: .block 165 case .block, .dynamicBlock, .fixedWidth: .block
163 case .table: .table 166 case .table, .tableEl: .table
164 case .horizontalRule: .rule 167 case .horizontalRule: .rule
165 default: nil 168 default: nil
166 } 169 }
Sources/OrgPresentation/SpellCheck.swift +3 −2
@@ -12,8 +12,9 @@ public enum SpellCheck {
12 } 12 }
13 let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, 13 let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget,
14 .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword, 14 .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword,
15 .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule] 15 .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule,
16 let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox] 16 .citation, .exportSnippet, .inlineBabelCall, .diarySexp]
17 let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox, .counter]
17 var ranges: [Range<Int>] = [] 18 var ranges: [Range<Int>] = []
18 for node in tree.root.descendants() { 19 for node in tree.root.descendants() {
19 if whole.contains(node.kind) { 20 if whole.contains(node.kind) {
Tests/OrgCoreTests/ParserGapsTests.swift added +122
@@ -0,0 +1,122 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Citations, export snippets, inline Babel calls, diary sexps, table.el tables, and item
6/// counters and tags, where org-element finds them.
7struct ParserGapsTests {
8 static let text = #"""
9 As [cite:@doe2020] and [cite/t/b:see @a p. 2; @b;and @c-d:e] say, also [cite:pre; @x; post].
10 Not a cite: [cite:no key], [cite/:@x], [cite:@k [nested] here], [cite:@open
11 and on @@html:<b>bold</b>@@ or @@latex:\emph{x}@@, not @@x@@ or @@:y@@ or @@html:open.
12 Calls: call_square(4) call_f[:results raw](x=1, y=(2))[:exports both] xcall_no(1) call_f[x] call_(1).
13 %%(diary-float t 4 2) Thanksgiving
14 %%(indented) is text
15
16 +----+-----+
17 | a | b |
18 +----+-----+
19 | c | d |
20 +----+-----+
21
22 +--+--+
23 Single rule is a paragraph.
24
25 +--+
26 |x |
27 not closed by a rule
28
29 | org | table |
30 +---+---+
31 | other |
32 +-+
33 +-+
34
35 - term :: definition with *bold*
36 - a :: b :: c
37 - [X] boxed :: yes
38 - [@3] counted
39 - [@start:7] [X] both
40 - no tag::here
41 - tag ::
42 1. one :: two
43 2. [@5] five
44 - plain
45 - *bold* term :: x
46 """# + "\n"
47
48 static let kinds: [(lisp: String, ours: SyntaxKind)] = [
49 ("citation", .citation), ("citation-reference", .citationReference), ("export-snippet", .exportSnippet),
50 ("inline-babel-call", .inlineBabelCall), ("diary-sexp", .diarySexp), ("paragraph", .paragraph), ("item", .item),
51 ]
52
53 @Test func objectsAndElementsMatchOrgElement() throws {
54 let tree = OrgParser.parse(Self.text)
55 var forms = Self.kinds.map { kind in
56 "(mapconcat (lambda (o) (format \"%d\" (1- (org-element-begin o)))) (org-element-map (org-element-parse-buffer) '\(kind.lisp) #'identity) \",\")"
57 }
58 forms.append("(mapconcat (lambda (o) (format \"%d:%s\" (1- (org-element-begin o)) (org-element-property :type o))) (org-element-map (org-element-parse-buffer) 'table #'identity) \",\")")
59 forms.append("""
60 (mapconcat (lambda (o) (format "%s|%s|%s" (let ((tag (org-element-interpret-data (org-element-property :tag o)))) (if (string-empty-p tag) "-" tag))
61 (or (org-element-property :counter o) "-") (or (org-element-property :checkbox o) "-")))
62 (org-element-map (org-element-parse-buffer) 'item #'identity) ",")
63 """)
64 let emacs = try EmacsOracle.evaluate(Self.text, "(with-current-buffer (find-file-noselect \"oracle.org\") (org-mode) (list \(forms.joined(separator: " "))))")
65 let nodes = tree.root.descendants()
66 for (i, kind) in Self.kinds.enumerated() {
67 let ours = nodes.filter { $0.kind == kind.ours }.map { String($0.range.lowerBound) }
68 #expect(ours == emacs[i].split(separator: ",").map(String.init), "\(kind.lisp)")
69 }
70 let tables = nodes.filter { $0.kind == .table || $0.kind == .tableEl }.map { "\($0.range.lowerBound):\($0.kind == .table ? "org" : "table.el")" }
71 #expect(tables == emacs[Self.kinds.count].split(separator: ",").map(String.init), "tables")
72 let items = nodes.filter { $0.kind == .item }.map { item -> String in
73 let tag = item.firstChild(.itemTag)?.text ?? "-"
74 let counter = item.tokens.first { $0.kind == .counter }.map { $0.text.replacingOccurrences(of: "[@start:", with: "").replacingOccurrences(of: "[@", with: "").replacingOccurrences(of: "]", with: "") } ?? "-"
75 let box = item.tokens.first { $0.kind == .checkbox }.map { ["[X]": "on", "[x]": "on", "[ ]": "off", "[-]": "trans"][$0.text]! } ?? "-"
76 return "\(tag)|\(counter)|\(box)"
77 }
78 #expect(items == emacs[Self.kinds.count + 1].split(separator: ",").map(String.init), "items")
79 }
80
81 @Test func citationParts() throws {
82 let tree = OrgParser.parse("[cite/t:see @a p. 2; @b; post]\n")
83 let cite = try #require(tree.root.descendants().first { $0.kind == .citation })
84 #expect(cite.text == "[cite/t:see @a p. 2; @b; post]")
85 let references = cite.children.filter { $0.kind == .citationReference }
86 #expect(references.map(\.text) == ["see @a p. 2;", " @b;"])
87 #expect(references.compactMap { $0.tokens.first { $0.kind == .citationKey }?.text } == ["@a", "@b"])
88 }
89}
90
91/// The new syntax through ox-html, compared as the conformance files are.
92struct ParserGapsExportTests {
93 static let text = """
94 Raw @@html:<b>bold</b>@@ and @@latex:\\emph{x}@@ here.
95
96 %%(diary-float t 4 2) Thanksgiving
97
98 - term :: definition here
99 - untagged
100 - [X] boxed :: yes
101
102
103 1. one
104 2. two
105
106
107 - [@3] unordered counter
108 """ + "\n"
109
110 @Test func htmlMatchesOxHTML() throws {
111 let emacs = try EmacsOracle.evaluate(Self.text, """
112 (list (progn (require 'ox-html)
113 (with-current-buffer (find-file-noselect "oracle.org")
114 (org-export-as 'html nil nil t '(:with-toc nil :section-numbers nil)))))
115 """)
116 let ours = Skeleton.reduce(HTMLExport.body(Self.text))
117 #expect(ours == Skeleton.reduce(emacs[0]), "ours:\n\(ours)\nemacs:\n\(Skeleton.reduce(emacs[0]))")
118 // Org 9.8.7's ox-html fails on an ordered item's counter (`format` given a number);
119 // the value is what it means to write.
120 #expect(HTMLExport.body("1. one\n2. [@5] five\n").contains("<li value=\"5\">five</li>"))
121 }
122}
Tests/OrgCoreTests/RoundTripTests.swift +2
@@ -24,6 +24,8 @@ let fragments = [
24 "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]", 24 "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]",
25 "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.", 25 "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.",
26 "<https://e.com>", "| *a* | b |", "||", "- [X] done", "+ [ ] todo", "-", 26 "<https://e.com>", "| *a* | b |", "||", "- [X] done", "+ [ ] todo", "-",
27 "[cite:@a; @b]", "[cite/t:see @k p. 2;]", "@@html:<b>@@", "call_f(x=1)[:results raw]", "%%(diary-float t 4 2)",
28 "+---+---+", "+-", "- term :: def", "1. [@3] three", "- [@2] [X] x :: y", " :: ", "@", "call_",
27] 29]
28 30
29func randomDocument(_ rng: inout SeededGenerator) -> String { 31func randomDocument(_ rng: inout SeededGenerator) -> String {