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 {
127127 return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true
128128 }
129129 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]
131132 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer]
132133 let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) }
133134 switch element?.kind {
Sources/OrgCore/Commands/Narrowing.swift +2 −1
@@ -32,7 +32,8 @@ public enum Narrowing {
3232 probe = buffer.lineStart(buffer.point)
3333 }
3434 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]
3637 var chain: [SyntaxNode] = []
3738 var node = tree.root
3839 while let child = node.child(containing: probe) {
Sources/OrgCore/Export/HTMLExport.swift +22 −20
@@ -260,6 +260,9 @@ final class HTMLRenderer {
260260 return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
261261 }
262262 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"
263266 case .dynamicBlock:
264267 let lines = node.text.components(separatedBy: "\n")
265268 return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
@@ -434,7 +437,8 @@ final class HTMLRenderer {
434437 private func list(_ items: [SyntaxNode]) -> String {
435438 guard let first = items.first else { return "" }
436439 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
438442 if description {
439443 var out = "<dl>\n"
440444 for item in items {
@@ -449,32 +453,25 @@ final class HTMLRenderer {
449453 return out + "</\(tag)>\n"
450454 }
451455
452 private func firstLine(_ item: SyntaxNode) -> String {
453 item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? ""
454 }
455
456 /// A description item's term, `(no term)` without one, and the rest of the item.
456457 private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
457 guard let paragraph = item.firstChild(.paragraph) else { return ("", "") }
458 let text = paragraph.text
459 guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) }
460 let term = inlineString(String(text[..<separator.lowerBound]))
461 var rest = inlineParagraphText(String(text[separator.upperBound...]))
462 for child in item.children where child.kind != .paragraph || child.range != paragraph.range {
463 rest += element(child)
458 var term = item.firstChild(.itemTag).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? "(no term)"
459 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
460 term = "<code>\(box == "[ ]" ? "[&nbsp;]" : box.uppercased())</code> " + term
461 }
462 var rest = ""
463 for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
464 rest += index == 0 && child.kind == .paragraph ? inline(child).trimmingCharacters(in: .whitespacesAndNewlines) : element(child)
464465 }
465466 return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
466467 }
467468
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
476469 private func listItem(_ item: SyntaxNode) -> String {
470 // ox-html gives an ordered item's counter as its value.
477471 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 }
478475 var prefix = ""
479476 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
480477 switch box {
@@ -680,6 +677,11 @@ final class HTMLRenderer {
680677 hasMath = true
681678 return Self.escape(node.text)
682679 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)
683685 default: return inline(node)
684686 }
685687 }
Sources/OrgCore/Export/MarkdownExport.swift +13 −6
@@ -96,6 +96,9 @@ final class MarkdownRenderer {
9696 return block(node, indent: indent)
9797 case .horizontalRule:
9898 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)
99102 case .fixedWidth:
100103 let lines = node.text.split(separator: "\n").map { line -> String in
101104 let trimmed = line.drop { $0 == " " || $0 == "\t" }
@@ -126,18 +129,17 @@ final class MarkdownRenderer {
126129 }
127130 let childIndent = indent + String(repeating: " ", count: (ordered ? "\(number). " : "- ").count)
128131 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() {
130134 if index == 0, child.kind == .paragraph {
131 var text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
135 let text = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
132136 .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + childIndent)
133 if let separator = text.range(of: " :: ") {
134 text = "**" + text[..<separator.lowerBound] + "**: " + text[separator.upperBound...]
135 }
136 parts.append(text)
137 parts.append((term ?? "") + text)
137138 } else {
138139 parts.append(element(child, indent: childIndent))
139140 }
140141 }
142 if let term, parts.first.map({ !$0.hasPrefix(term) }) ?? true { parts.insert(term.trimmingCharacters(in: .whitespaces), at: 0) }
141143 let body = parts.isEmpty ? "" : parts[0].trimmingCharacters(in: .whitespaces) + parts.dropFirst().map { "\n" + $0 }.joined()
142144 out.append(indent + marker + " " + body)
143145 }
@@ -250,6 +252,11 @@ final class MarkdownRenderer {
250252 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
251253 case .lineBreak: return " \n"
252254 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
253260 default: return inline(node)
254261 }
255262 }
Sources/OrgCore/Keymap/VimExtras.swift +5 −3
@@ -286,7 +286,7 @@ extension Vim {
286286 case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask:
287287 return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n)
288288 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
290290 return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n)
291291 case .block:
292292 let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased()
@@ -324,7 +324,8 @@ extension Vim {
324324 func atPoint() -> OrgElement? {
325325 let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription,
326326 .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]
328329 var elements = chain.filter { !objects.contains($0.kind) }
329330 if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) {
330331 elements = Array(elements[...i])
@@ -365,7 +366,8 @@ extension Vim {
365366 return (inner ? innerRange(e) : e.begin..<e.end, !inner)
366367 default:
367368 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]
369371 guard let found = chain.last(where: { objects.contains($0.kind) }) else {
370372 guard let e = atPoint() else { return nil }
371373 return (inner ? innerRange(e) : e.begin..<e.end, false)
Sources/OrgCore/Parser/Incremental.swift +8
@@ -100,6 +100,14 @@ private struct ReparseContext {
100100 // An element that starts mid-line (an item's paragraph) follows tokens that would absorb
101101 // whitespace typed at its start.
102102 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 }
103111 let oldEnd = leaf.range.upperBound
104112 let newEnd = oldEnd + delta
105113 let newLength = newText.utf16.count
Sources/OrgCore/Parser/Inline.swift +161 −1
@@ -44,6 +44,11 @@ enum InlineMatch {
4444 case object(SyntaxKind, Range<Int>)
4545 /// An object whose contents hold objects, between markers.
4646 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>)
4752
4853 var end: Int {
4954 switch self {
@@ -51,6 +56,8 @@ enum InlineMatch {
5156 case .link(_, _, let whole): whole.upperBound
5257 case .object(_, let range): range.upperBound
5358 case .container(_, _, let whole): whole.upperBound
59 case .citation(let whole, _, _, _, _): whole.upperBound
60 case .exportSnippet(let whole, _): whole.upperBound
5461 }
5562 }
5663}
@@ -106,7 +113,7 @@ struct InlineScanner {
106113 /// The first characters any recognizer accepts.
107114 static func mayStartObject(_ c: Unicode.Scalar) -> Bool {
108115 switch c {
109 case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$": true
116 case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~", "$", "@", "c": true
110117 default: false
111118 }
112119 }
@@ -163,6 +170,43 @@ struct InlineScanner {
163170 b.start(kind)
164171 emitText(range, into: &b)
165172 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()
166210 }
167211 }
168212
@@ -194,6 +238,7 @@ struct InlineScanner {
194238 switch chars[i] {
195239 case "[":
196240 if !inLink, let link = bracketLink(i, limit) { return link }
241 if !inLink, let cite = citation(i, limit) { return cite }
197242 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
198243 if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
199244 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
@@ -217,6 +262,10 @@ struct InlineScanner {
217262 }
218263 case "s":
219264 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 }
220269 case "h", "m", "f":
221270 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
222271 default:
@@ -318,6 +367,117 @@ struct InlineScanner {
318367 return j + 1
319368 }
320369
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
321481 /// `<<target>>`.
322482 func target(_ i: Int, _ limit: Int) -> Int? {
323483 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 {
5252 case fixedWidth
5353 case horizontalRule
5454 case tableRow
55 /// `+--+--+`, which may start a table.el table.
56 case tableElRule
57 /// `%%(…)` at the start of a line.
58 case diarySexp
5559 case footnoteDefinition
5660 case clock
5761 case planning
@@ -136,6 +140,8 @@ private func lineClass(_ rest: Substring, columnZero: Bool, alphabetical: Bool)
136140 }
137141
138142 if rest.first == "|" { return .tableRow }
143 if rest.first == "+", trimmed.wholeMatch(of: /\+(?:-+\+)+/) != nil { return .tableElRule }
144 if columnZero, rest.hasPrefix("%%(") { return .diarySexp }
139145 if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule }
140146
141147 if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"),
Sources/OrgCore/Parser/Parser.swift +47
@@ -206,6 +206,22 @@ struct Parser {
206206 single(.clock)
207207 case .tableRow:
208208 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)
209225 case .footnoteDefinition:
210226 footnoteDefinition(limit: limit)
211227 case .listItem:
@@ -282,6 +298,22 @@ struct Parser {
282298 builder.finish()
283299 }
284300
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
285317 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells;
286318 /// every pair of pipes gets a cell, even an empty one, so columns line up.
287319 mutating func tableRow() {
@@ -350,10 +382,25 @@ struct Parser {
350382 let bullet = rest.prefix { $0 != " " && $0 != "\t" }
351383 builder.token(.bullet, bullet)
352384 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 }
353389 if let box = checkbox(rest) {
354390 builder.token(.checkbox, box)
355391 rest = whitespace(rest.dropFirst(box.count))
356392 }
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 }
357404 // The rest of the bullet line and its continuation lines are the item's first paragraph.
358405 var end = i + 1
359406 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 {
33 case text, newline, whitespace
44 case stars, todoKeyword, priority, tags
55 case marker, linkPath, bullet, checkbox
6 /// `[@3]` on an item; a citation reference's `@key`.
7 case counter, citationKey
68
79 // Elements
810 case document, zerothSection, section, heading, title
@@ -10,10 +12,14 @@ public enum SyntaxKind: String, Sendable {
1012 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
1113 case block, dynamicBlock, keyword, affiliatedKeyword
1214 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask
15 case diarySexp, tableEl
16 /// A description item's term, before ` :: `.
17 case itemTag
1318
1419 // Objects
1520 case bold, italic, underline, strikeThrough, verbatim, code
1621 case link, linkDescription, timestamp, footnoteReference, statisticsCookie
1722 case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript
1823 case `subscript`, entity, radioTarget
24 case citation, citationReference, exportSnippet, inlineBabelCall
1925}
Sources/OrgPresentation/Presentation.swift +5 −2
@@ -155,12 +155,15 @@ public enum Presentation {
155155 case .target, .radioTarget: .target
156156 case .macro: .macro
157157 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
159162 case .comment: .comment
160163 case .keyword, .affiliatedKeyword: .keyword
161164 case .planning, .propertyDrawer, .drawer, .clock: .metadata
162165 case .block, .dynamicBlock, .fixedWidth: .block
163 case .table: .table
166 case .table, .tableEl: .table
164167 case .horizontalRule: .rule
165168 default: nil
166169 }
Sources/OrgPresentation/SpellCheck.swift +3 −2
@@ -12,8 +12,9 @@ public enum SpellCheck {
1212 }
1313 let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget,
1414 .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword,
15 .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule]
16 let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox]
15 .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule,
16 .citation, .exportSnippet, .inlineBabelCall, .diarySexp]
17 let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox, .counter]
1718 var ranges: [Range<Int>] = []
1819 for node in tree.root.descendants() {
1920 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 = [
2424 "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]",
2525 "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.",
2626 "<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_",
2729]
2830
2931func randomDocument(_ rng: inout SeededGenerator) -> String {