krz/orgstar

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

Commit 8b9aa86b71

8b9aa86b71ab23487b623d4ef90cc75d8bf435e4

parent: 6c7e17cffd

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 05:36 UTC

Parse inline tasks, file property drawers, alphabetical bullets; table constants

- Lines of fifteen stars or more are inline tasks (with an END line, their
  contents), not headings; they export as ox-html's inlinetask.
- A :PROPERTIES: drawer first in the file (after comments and blank lines)
  is a property drawer, and its properties are the file's.
- a. and A) bullets with org-list-allow-alphabetical.
- Table formulas use #+CONSTANTS and $PROP_name values.

Layout: unified · split

Sources/OrgCore/Compute/TableFormulas.swift +27
@@ -313,6 +313,7 @@ extension EmacsBuffer {
313313 }
314314 }
315315 if formula.hasPrefix("'(") { throw Calc.Unsupported("Lisp formula") }
316 formula = substituteConstants(formula)
316317 if formula.range(of: "\\$[a-zA-Z_]", options: .regularExpression) != nil || formula.contains("remote(")
317318 || formula.range(of: "[<\\[][0-9]{4}-", options: .regularExpression) != nil || formula.contains("&") {
318319 throw Calc.Unsupported("names, constants, remote references or dates")
@@ -362,6 +363,32 @@ extension EmacsBuffer {
362363 tableSetField(result)
363364 }
364365
366 /// `org-table-formula-substitute-names` for constants: `$name` from `#+CONSTANTS`, or
367 /// `$PROP_name` from the entry's property, in parentheses; unknown names become
368 /// `#UNDEFINED_NAME`.
369 func substituteConstants(_ formula: String) -> String {
370 guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula }
371 var constants: [String: String] = [:]
372 for m in string.matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) {
373 for pair in m.1.split(whereSeparator: \.isWhitespace) {
374 let parts = pair.split(separator: "=", maxSplits: 1)
375 if parts.count == 2, constants[String(parts[0])] == nil { constants[String(parts[0])] = String(parts[1]) }
376 }
377 }
378 let regex = try! NSRegularExpression(pattern: "\\$([a-zA-Z][_a-zA-Z0-9]*)")
379 var out = formula
380 while let m = regex.firstMatch(in: out, range: NSRange(location: 0, length: (out as NSString).length)) {
381 let ns = out as NSString
382 let name = ns.substring(with: m.range(at: 1))
383 var value = constants[name]
384 if value == nil, name.hasPrefix("PROP_") {
385 value = inheritedProperty(String(name.dropFirst(5)))?.value
386 }
387 out = ns.replacingCharacters(in: m.range, with: "(" + (value ?? "#UNDEFINED_NAME") + ")")
388 }
389 return out
390 }
391
365392 /// `string-to-number`.
366393 static func stringToNumber(_ s: String) -> Double {
367394 guard let r = s.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 }
Sources/OrgCore/Export/HTMLExport.swift +14
@@ -235,6 +235,20 @@ final class HTMLRenderer {
235235 return block(node)
236236 case .horizontalRule:
237237 return "<hr>\n"
238 case .inlineTask:
239 // As ox-html: the title in bold, then the contents.
240 var out = "<div class=\"inlinetask\">\n"
241 for child in node.children {
242 if child.kind == .heading {
243 guard child.range.lowerBound == node.range.lowerBound else { continue }
244 let title = child.firstChild(.title).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? ""
245 let todo = options.todo ? child.tokens.first { $0.kind == .todoKeyword }.map { "<span class=\"todo \(Self.escape($0.text))\">\(Self.escape($0.text))</span> " } ?? "" : ""
246 out += "<b>\(todo)\(title)</b><br>\n"
247 } else {
248 out += element(child)
249 }
250 }
251 return out + "</div>\n"
238252 case .latexEnvironment:
239253 // For MathJax, as ox-html leaves it with `tex:t`.
240254 guard options.tex else { return "" }
Sources/OrgCore/Parser/Incremental.swift +4 −2
@@ -40,7 +40,7 @@ extension OrgParser {
4040 _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default
4141 ) -> (tree: OrgTree, strategy: ReparseStrategy) {
4242 let newText = edit.apply(to: oldText)
43 let context = ReparseContext(oldText: oldText, newText: newText, edit: edit)
43 let context = ReparseContext(oldText: oldText, newText: newText, edit: edit, alphabetical: defaults.listAllowAlphabetical)
4444 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
4545 if let tree = context.reparseElement(old) { return (tree, .element) }
4646 if let tree = context.reparseRegion(old) { return (tree, .region) }
@@ -53,6 +53,8 @@ private struct ReparseContext {
5353 let oldText: String
5454 let newText: String
5555 let edit: TextEdit
56 /// `org-list-allow-alphabetical`, for classifying lines.
57 let alphabetical: Bool
5658 /// Change in UTF-16 length.
5759 var delta: Int { edit.replacement.utf16.count - edit.range.count }
5860
@@ -334,7 +336,7 @@ private struct ReparseContext {
334336 // MARK: - Text helpers
335337
336338 func classes(_ text: Substring) -> [ClassifiedLine] {
337 splitRawLines(String(text)).map { classifyLine($0.content) }
339 splitRawLines(String(text)).map { classifyLine($0.content, alphabetical: alphabetical) }
338340 }
339341
340342 func slice(_ text: String, _ from: Int, _ to: Int) -> Substring {
Sources/OrgCore/Parser/Lines.swift +17 −8
@@ -35,6 +35,9 @@ func splitRawLines(_ text: String) -> [RawLine] {
3535enum LineClass: Equatable {
3636 case blank
3737 case heading(level: Int)
38 /// Fifteen stars or more (`org-inlinetask-min-level`): an inline task, not a heading.
39 case inlineTask(level: Int)
40 case inlineTaskEnd
3841 case blockBegin(name: String)
3942 case blockEnd(name: String)
4043 case dynamicBegin
@@ -62,7 +65,7 @@ struct ClassifiedLine {
6265 let indent: Int
6366}
6467
65func classifyLine(_ line: Substring) -> ClassifiedLine {
68func classifyLine(_ line: Substring, alphabetical: Bool = false) -> ClassifiedLine {
6669 var column = 0
6770 var rest = line
6871 while let c = rest.first, c == " " || c == "\t" {
@@ -70,10 +73,13 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
7073 rest = rest.dropFirst()
7174 }
7275 if rest.isEmpty { return ClassifiedLine(cls: .blank, indent: column) }
73 return ClassifiedLine(cls: lineClass(rest, columnZero: column == 0), indent: column)
76 return ClassifiedLine(cls: lineClass(rest, columnZero: column == 0, alphabetical: alphabetical), indent: column)
7477}
7578
76private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
79/// `org-inlinetask-min-level`.
80let inlineTaskLevel = 15
81
82private func lineClass(_ rest: Substring, columnZero: Bool, alphabetical: Bool) -> LineClass {
7783 let trimmed = rest.trimmingTrailingWhitespace
7884
7985 // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
@@ -81,7 +87,8 @@ private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
8187 if columnZero, rest.first == "*" {
8288 let stars = rest.prefix { $0 == "*" }
8389 if rest.dropFirst(stars.count).first == " " {
84 return .heading(level: stars.count)
90 guard stars.count >= inlineTaskLevel else { return .heading(level: stars.count) }
91 return trimmed.dropFirst(stars.count).trimmingCharacters(in: .whitespaces) == "END" ? .inlineTaskEnd : .inlineTask(level: stars.count)
8592 }
8693 }
8794
@@ -138,17 +145,19 @@ private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
138145
139146 if rest.hasPrefix("CLOCK:") { return .clock }
140147 if rest.hasPrefix("SCHEDULED:") || rest.hasPrefix("DEADLINE:") || rest.hasPrefix("CLOSED:") { return .planning }
141 if isListBullet(rest, indented: !columnZero) { return .listItem }
148 if isListBullet(rest, indented: !columnZero, alphabetical: alphabetical) { return .listItem }
142149 return .plain
143150}
144151
145/// `-`, `+`, `*` (indented only), `1.` or `1)`, followed by whitespace or end of line.
146/// Alphabetical bullets are off, as in org's default.
147private func isListBullet(_ rest: Substring, indented: Bool) -> Bool {
152/// `-`, `+`, `*` (indented only), `1.` or `1)`, and with `org-list-allow-alphabetical` `a.` or
153/// `A)`, followed by whitespace or end of line.
154private func isListBullet(_ rest: Substring, indented: Bool, alphabetical: Bool) -> Bool {
148155 guard let first = rest.first else { return false }
149156 let afterBullet: Substring
150157 if first == "-" || first == "+" || (first == "*" && indented) {
151158 afterBullet = rest.dropFirst()
159 } else if alphabetical, first.isASCII, first.isLetter, let separator = rest.dropFirst().first, separator == "." || separator == ")" {
160 afterBullet = rest.dropFirst(2)
152161 } else if first.isASCII, first.isNumber {
153162 let digits = rest.prefix { $0.isASCII && $0.isNumber }
154163 let tail = rest.dropFirst(digits.count)
Sources/OrgCore/Parser/Parser.swift +37 −2
@@ -20,7 +20,7 @@ struct Parser {
2020
2121 init(text: String, defaults: OrgSettings) {
2222 let lines = splitRawLines(text)
23 let info = lines.map { classifyLine($0.content) }
23 let info = lines.map { classifyLine($0.content, alphabetical: defaults.listAllowAlphabetical) }
2424 let ends = Parser.matchEnds(info, lines)
2525 let settings = SettingsScanner.scan(lines: lines, info: info, blockEnds: ends, defaults: defaults)
2626 self.init(lines: lines, info: info, blockEnds: ends, settings: settings)
@@ -29,7 +29,7 @@ struct Parser {
2929 /// Parses part of a document with the settings already read from the whole file.
3030 init(text: String, settings: OrgSettings) {
3131 let lines = splitRawLines(text)
32 let info = lines.map { classifyLine($0.content) }
32 let info = lines.map { classifyLine($0.content, alphabetical: settings.listAllowAlphabetical) }
3333 self.init(lines: lines, info: info, blockEnds: Parser.matchEnds(info, lines), settings: settings)
3434 }
3535
@@ -87,6 +87,14 @@ struct Parser {
8787 builder.start(.document)
8888 if !lines.isEmpty, !isHeading(0) {
8989 builder.start(.zerothSection)
90 // A property drawer first in the file, after comments and blank lines, holds the
91 // file's properties.
92 while i < lines.count, info[i].cls == .blank || info[i].cls == .comment {
93 if info[i].cls == .blank { line(i); i += 1 } else { consecutive(.comment, limit: lines.count, floor: nil) { $0 == .comment } }
94 }
95 if i < lines.count, case .drawerBegin(let name) = info[i].cls, name.uppercased() == "PROPERTIES", let end = blockEnds[i] {
96 propertyDrawer(end: end)
97 }
9098 parseContent(limit: lines.count)
9199 builder.finish()
92100 }
@@ -183,6 +191,8 @@ struct Parser {
183191 } else {
184192 paragraph(limit: limit, floor: floor)
185193 }
194 case .inlineTask:
195 inlineTask(limit: limit)
186196 case .keyword(let key):
187197 let affiliated = Self.affiliatedKeys.contains(key) || key.hasPrefix("ATTR_")
188198 single(affiliated ? .affiliatedKeyword : .keyword)
@@ -205,6 +215,31 @@ struct Parser {
205215 }
206216 }
207217
218 /// An inline task: its heading line, and with an END line before the next heading, the
219 /// elements up to it and that line.
220 mutating func inlineTask(limit: Int) {
221 builder.start(.inlineTask)
222 headingLine(lines[i])
223 i += 1
224 var end: Int?
225 var k = i
226 while k < limit, !isHeading(k) {
227 if info[k].cls == .inlineTaskEnd { end = k; break }
228 if case .inlineTask = info[k].cls { break }
229 k += 1
230 }
231 if let end {
232 if i < end, info[i].cls == .planning { single(.planning) }
233 if i < end, case .drawerBegin(let name) = info[i].cls, name.uppercased() == "PROPERTIES", let drawerEnd = blockEnds[i], drawerEnd < end {
234 propertyDrawer(end: drawerEnd)
235 }
236 parseContent(limit: end)
237 headingLine(lines[i])
238 i += 1
239 }
240 builder.finish()
241 }
242
208243 /// Kinds whose single line holds inline objects (timestamps on planning and clock lines).
209244 static let inlineLineKinds: Set<SyntaxKind> = [.planning, .clock]
210245
Sources/OrgCore/Parser/Settings.swift +6 −2
@@ -45,6 +45,8 @@ public struct OrgSettings: Sendable, Equatable {
4545 public var priorities: Priorities
4646 /// `<<<radio target>>>` texts in the file, longest first: their words elsewhere are links.
4747 public var radioTargets: [String] = []
48 /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets.
49 public var listAllowAlphabetical = false
4850
4951 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
5052 self.todoSequences = todoSequences
@@ -113,8 +115,10 @@ enum SettingsScanner {
113115 radio.append(String(m.1))
114116 }
115117 }
116 return OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities,
117 radioTargets: radio.sorted { $0.count > $1.count })
118 var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities,
119 radioTargets: radio.sorted { $0.count > $1.count })
120 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
121 return settings
118122 }
119123
120124 static func keywordValue(_ line: Substring) -> Substring {
Sources/OrgCore/Semantic/DocumentModel.swift +3 −3
@@ -121,9 +121,9 @@ public struct DocumentModel: Sendable {
121121 case .planning:
122122 if let heading { planning(child, heading) }
123123 case .propertyDrawer:
124 if let heading {
125 headings[heading].properties += child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
126 }
124 let properties = child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
125 // The file's own drawer comes after its #+PROPERTY lines, as org reads them.
126 if let heading { headings[heading].properties += properties } else { fileProperties += properties }
127127 case .clock:
128128 if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) }
129129 case .timestamp:
Sources/OrgCore/Syntax/SyntaxKind.swift +1 −1
@@ -9,7 +9,7 @@ public enum SyntaxKind: String, Sendable {
99 case planning, propertyDrawer, nodeProperty, drawer, clock
1010 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
1111 case block, dynamicBlock, keyword, affiliatedKeyword
12 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment
12 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask
1313
1414 // Objects
1515 case bold, italic, underline, strikeThrough, verbatim, code
Sources/Orgstar/Preferences.swift +3 −1
@@ -12,7 +12,9 @@ enum Preferences {
1212 // config.toml applies before anything reads settings.
1313 ConfigSync.shared.start()
1414 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords
15 return OrgSettings.default.withTodoKeywords(line) ?? .default
15 var settings = OrgSettings.default.withTodoKeywords(line) ?? .default
16 settings.listAllowAlphabetical = UserDefaults.standard.object(forKey: "alphabeticalLists") as? Bool ?? true
17 return settings
1618 }
1719
1820 static func options(
Tests/OrgCoreTests/ElementParseTests.swift added +51
@@ -0,0 +1,51 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Where org-element finds inline tasks, property drawers and list items.
6struct ElementParseTests {
7 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
8
9 static let text = """
10 # A comment
11 :PROPERTIES:
12 :Owner: me
13 :END:
14 Intro.
15 * Heading
16 Body.
17 *************** TODO Inline task :tag:
18 :PROPERTIES:
19 :Effort: 1:00
20 :END:
21 Task body.
22 *************** END
23 After the task.
24 *************** Single-line task
25 More text.
26 ** Child
27 a. alpha item
28 b) beta item
29 - plain
30 1. one
31 """ + "\n"
32
33 static let kinds: [(lisp: String, ours: SyntaxKind)] = [("inlinetask", .inlineTask), ("property-drawer", .propertyDrawer), ("item", .item), ("headline", .section)]
34
35 @Test(.enabled(if: oracle))
36 func elementsMatchOrgElement() throws {
37 var settings = OrgSettings.default
38 settings.listAllowAlphabetical = true
39 let tree = OrgParser.parse(Self.text, defaults: settings)
40 let forms = Self.kinds.map { kind in
41 "(mapconcat (lambda (o) (format \"%d\" (1- (org-element-begin o)))) (org-element-map (org-element-parse-buffer) '\(kind.lisp) #'identity) \",\")"
42 }
43 let emacs = try EmacsOracle.evaluate(Self.text, "(with-current-buffer (find-file-noselect \"oracle.org\") (org-mode) (list \(forms.joined(separator: " "))))",
44 prelude: "(require 'org-inlinetask) (setq org-list-allow-alphabetical t)")
45 for (i, kind) in Self.kinds.enumerated() {
46 let ours = tree.root.descendants().filter { $0.kind == kind.ours }.map { String($0.range.lowerBound) }
47 #expect(ours == emacs[i].split(separator: ",").map(String.init), "\(kind.lisp)")
48 }
49 #expect(DocumentModel(tree: tree).fileProperties.contains { $0.key == "Owner" && $0.value == "me" })
50 }
51}
Tests/OrgCoreTests/TableFormulaTests.swift +2
@@ -24,6 +24,8 @@ struct TableFormulaTests {
2424 "| a | b |\n|---+---|\n| 1 | |\n|---+---|\n| 2 | |\n| 3 | |\n#+TBLFM: $2=@I$1+@II$1+@-I$1\n",
2525 "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n#+TBLFM: $3=($1+$2)*2/3-1::$1=$1\n",
2626 "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1=vmax(@I..@II)-vmin(@I..@II)+vcount(@I..@II)+vmedian(@I..@II)\n",
27 "#+CONSTANTS: c=10 k=2.5\n| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: $2=$1*$c+$k\n",
28 "* Rates\n:PROPERTIES:\n:Rate: 3\n:END:\n| h | pay |\n|---+-----|\n| 2 | |\n#+TBLFM: $2=$1*$PROP_Rate\n",
2729 ]
2830
2931 static func carets(_ text: String) -> [Int] {