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

merged merged by cmc on 2026-10-06 05:37 UTC · krz/orgstar:parse-elements into main

11 files changed, +165 −19

Layout: unified · split

Sources/OrgCore/Compute/TableFormulas.swift +27
@@ -313,6 +313,7 @@ extension EmacsBuffer {
313 } 313 }
314 } 314 }
315 if formula.hasPrefix("'(") { throw Calc.Unsupported("Lisp formula") } 315 if formula.hasPrefix("'(") { throw Calc.Unsupported("Lisp formula") }
316 formula = substituteConstants(formula)
316 if formula.range(of: "\\$[a-zA-Z_]", options: .regularExpression) != nil || formula.contains("remote(") 317 if formula.range(of: "\\$[a-zA-Z_]", options: .regularExpression) != nil || formula.contains("remote(")
317 || formula.range(of: "[<\\[][0-9]{4}-", options: .regularExpression) != nil || formula.contains("&") { 318 || formula.range(of: "[<\\[][0-9]{4}-", options: .regularExpression) != nil || formula.contains("&") {
318 throw Calc.Unsupported("names, constants, remote references or dates") 319 throw Calc.Unsupported("names, constants, remote references or dates")
@@ -362,6 +363,32 @@ extension EmacsBuffer {
362 tableSetField(result) 363 tableSetField(result)
363 } 364 }
364 365
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
365 /// `string-to-number`. 392 /// `string-to-number`.
366 static func stringToNumber(_ s: String) -> Double { 393 static func stringToNumber(_ s: String) -> Double {
367 guard let r = s.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 } 394 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 {
235 return block(node) 235 return block(node)
236 case .horizontalRule: 236 case .horizontalRule:
237 return "<hr>\n" 237 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"
238 case .latexEnvironment: 252 case .latexEnvironment:
239 // For MathJax, as ox-html leaves it with `tex:t`. 253 // For MathJax, as ox-html leaves it with `tex:t`.
240 guard options.tex else { return "" } 254 guard options.tex else { return "" }
Sources/OrgCore/Parser/Incremental.swift +4 −2
@@ -40,7 +40,7 @@ extension OrgParser {
40 _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default 40 _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default
41 ) -> (tree: OrgTree, strategy: ReparseStrategy) { 41 ) -> (tree: OrgTree, strategy: ReparseStrategy) {
42 let newText = edit.apply(to: oldText) 42 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)
44 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) } 44 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
45 if let tree = context.reparseElement(old) { return (tree, .element) } 45 if let tree = context.reparseElement(old) { return (tree, .element) }
46 if let tree = context.reparseRegion(old) { return (tree, .region) } 46 if let tree = context.reparseRegion(old) { return (tree, .region) }
@@ -53,6 +53,8 @@ private struct ReparseContext {
53 let oldText: String 53 let oldText: String
54 let newText: String 54 let newText: String
55 let edit: TextEdit 55 let edit: TextEdit
56 /// `org-list-allow-alphabetical`, for classifying lines.
57 let alphabetical: Bool
56 /// Change in UTF-16 length. 58 /// Change in UTF-16 length.
57 var delta: Int { edit.replacement.utf16.count - edit.range.count } 59 var delta: Int { edit.replacement.utf16.count - edit.range.count }
58 60
@@ -334,7 +336,7 @@ private struct ReparseContext {
334 // MARK: - Text helpers 336 // MARK: - Text helpers
335 337
336 func classes(_ text: Substring) -> [ClassifiedLine] { 338 func classes(_ text: Substring) -> [ClassifiedLine] {
337 splitRawLines(String(text)).map { classifyLine($0.content) } 339 splitRawLines(String(text)).map { classifyLine($0.content, alphabetical: alphabetical) }
338 } 340 }
339 341
340 func slice(_ text: String, _ from: Int, _ to: Int) -> Substring { 342 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] {
35enum LineClass: Equatable { 35enum LineClass: Equatable {
36 case blank 36 case blank
37 case heading(level: Int) 37 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
38 case blockBegin(name: String) 41 case blockBegin(name: String)
39 case blockEnd(name: String) 42 case blockEnd(name: String)
40 case dynamicBegin 43 case dynamicBegin
@@ -62,7 +65,7 @@ struct ClassifiedLine {
62 let indent: Int 65 let indent: Int
63} 66}
64 67
65func classifyLine(_ line: Substring) -> ClassifiedLine { 68func classifyLine(_ line: Substring, alphabetical: Bool = false) -> ClassifiedLine {
66 var column = 0 69 var column = 0
67 var rest = line 70 var rest = line
68 while let c = rest.first, c == " " || c == "\t" { 71 while let c = rest.first, c == " " || c == "\t" {
@@ -70,10 +73,13 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
70 rest = rest.dropFirst() 73 rest = rest.dropFirst()
71 } 74 }
72 if rest.isEmpty { return ClassifiedLine(cls: .blank, indent: column) } 75 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)
74} 77}
75 78
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 {
77 let trimmed = rest.trimmingTrailingWhitespace 83 let trimmed = rest.trimmingTrailingWhitespace
78 84
79 // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab, 85 // 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 {
81 if columnZero, rest.first == "*" { 87 if columnZero, rest.first == "*" {
82 let stars = rest.prefix { $0 == "*" } 88 let stars = rest.prefix { $0 == "*" }
83 if rest.dropFirst(stars.count).first == " " { 89 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)
85 } 92 }
86 } 93 }
87 94
@@ -138,17 +145,19 @@ private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
138 145
139 if rest.hasPrefix("CLOCK:") { return .clock } 146 if rest.hasPrefix("CLOCK:") { return .clock }
140 if rest.hasPrefix("SCHEDULED:") || rest.hasPrefix("DEADLINE:") || rest.hasPrefix("CLOSED:") { return .planning } 147 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 }
142 return .plain 149 return .plain
143} 150}
144 151
145/// `-`, `+`, `*` (indented only), `1.` or `1)`, followed by whitespace or end of line. 152/// `-`, `+`, `*` (indented only), `1.` or `1)`, and with `org-list-allow-alphabetical` `a.` or
146/// Alphabetical bullets are off, as in org's default. 153/// `A)`, followed by whitespace or end of line.
147private func isListBullet(_ rest: Substring, indented: Bool) -> Bool { 154private func isListBullet(_ rest: Substring, indented: Bool, alphabetical: Bool) -> Bool {
148 guard let first = rest.first else { return false } 155 guard let first = rest.first else { return false }
149 let afterBullet: Substring 156 let afterBullet: Substring
150 if first == "-" || first == "+" || (first == "*" && indented) { 157 if first == "-" || first == "+" || (first == "*" && indented) {
151 afterBullet = rest.dropFirst() 158 afterBullet = rest.dropFirst()
159 } else if alphabetical, first.isASCII, first.isLetter, let separator = rest.dropFirst().first, separator == "." || separator == ")" {
160 afterBullet = rest.dropFirst(2)
152 } else if first.isASCII, first.isNumber { 161 } else if first.isASCII, first.isNumber {
153 let digits = rest.prefix { $0.isASCII && $0.isNumber } 162 let digits = rest.prefix { $0.isASCII && $0.isNumber }
154 let tail = rest.dropFirst(digits.count) 163 let tail = rest.dropFirst(digits.count)
Sources/OrgCore/Parser/Parser.swift +37 −2
@@ -20,7 +20,7 @@ struct Parser {
20 20
21 init(text: String, defaults: OrgSettings) { 21 init(text: String, defaults: OrgSettings) {
22 let lines = splitRawLines(text) 22 let lines = splitRawLines(text)
23 let info = lines.map { classifyLine($0.content) } 23 let info = lines.map { classifyLine($0.content, alphabetical: defaults.listAllowAlphabetical) }
24 let ends = Parser.matchEnds(info, lines) 24 let ends = Parser.matchEnds(info, lines)
25 let settings = SettingsScanner.scan(lines: lines, info: info, blockEnds: ends, defaults: defaults) 25 let settings = SettingsScanner.scan(lines: lines, info: info, blockEnds: ends, defaults: defaults)
26 self.init(lines: lines, info: info, blockEnds: ends, settings: settings) 26 self.init(lines: lines, info: info, blockEnds: ends, settings: settings)
@@ -29,7 +29,7 @@ struct Parser {
29 /// Parses part of a document with the settings already read from the whole file. 29 /// Parses part of a document with the settings already read from the whole file.
30 init(text: String, settings: OrgSettings) { 30 init(text: String, settings: OrgSettings) {
31 let lines = splitRawLines(text) 31 let lines = splitRawLines(text)
32 let info = lines.map { classifyLine($0.content) } 32 let info = lines.map { classifyLine($0.content, alphabetical: settings.listAllowAlphabetical) }
33 self.init(lines: lines, info: info, blockEnds: Parser.matchEnds(info, lines), settings: settings) 33 self.init(lines: lines, info: info, blockEnds: Parser.matchEnds(info, lines), settings: settings)
34 } 34 }
35 35
@@ -87,6 +87,14 @@ struct Parser {
87 builder.start(.document) 87 builder.start(.document)
88 if !lines.isEmpty, !isHeading(0) { 88 if !lines.isEmpty, !isHeading(0) {
89 builder.start(.zerothSection) 89 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 }
90 parseContent(limit: lines.count) 98 parseContent(limit: lines.count)
91 builder.finish() 99 builder.finish()
92 } 100 }
@@ -183,6 +191,8 @@ struct Parser {
183 } else { 191 } else {
184 paragraph(limit: limit, floor: floor) 192 paragraph(limit: limit, floor: floor)
185 } 193 }
194 case .inlineTask:
195 inlineTask(limit: limit)
186 case .keyword(let key): 196 case .keyword(let key):
187 let affiliated = Self.affiliatedKeys.contains(key) || key.hasPrefix("ATTR_") 197 let affiliated = Self.affiliatedKeys.contains(key) || key.hasPrefix("ATTR_")
188 single(affiliated ? .affiliatedKeyword : .keyword) 198 single(affiliated ? .affiliatedKeyword : .keyword)
@@ -205,6 +215,31 @@ struct Parser {
205 } 215 }
206 } 216 }
207 217
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
208 /// Kinds whose single line holds inline objects (timestamps on planning and clock lines). 243 /// Kinds whose single line holds inline objects (timestamps on planning and clock lines).
209 static let inlineLineKinds: Set<SyntaxKind> = [.planning, .clock] 244 static let inlineLineKinds: Set<SyntaxKind> = [.planning, .clock]
210 245
Sources/OrgCore/Parser/Settings.swift +6 −2
@@ -45,6 +45,8 @@ public struct OrgSettings: Sendable, Equatable {
45 public var priorities: Priorities 45 public var priorities: Priorities
46 /// `<<<radio target>>>` texts in the file, longest first: their words elsewhere are links. 46 /// `<<<radio target>>>` texts in the file, longest first: their words elsewhere are links.
47 public var radioTargets: [String] = [] 47 public var radioTargets: [String] = []
48 /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets.
49 public var listAllowAlphabetical = false
48 50
49 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { 51 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
50 self.todoSequences = todoSequences 52 self.todoSequences = todoSequences
@@ -113,8 +115,10 @@ enum SettingsScanner {
113 radio.append(String(m.1)) 115 radio.append(String(m.1))
114 } 116 }
115 } 117 }
116 return OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities, 118 var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities,
117 radioTargets: radio.sorted { $0.count > $1.count }) 119 radioTargets: radio.sorted { $0.count > $1.count })
120 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
121 return settings
118 } 122 }
119 123
120 static func keywordValue(_ line: Substring) -> Substring { 124 static func keywordValue(_ line: Substring) -> Substring {
Sources/OrgCore/Semantic/DocumentModel.swift +3 −3
@@ -121,9 +121,9 @@ public struct DocumentModel: Sendable {
121 case .planning: 121 case .planning:
122 if let heading { planning(child, heading) } 122 if let heading { planning(child, heading) }
123 case .propertyDrawer: 123 case .propertyDrawer:
124 if let heading { 124 let properties = child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
125 headings[heading].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 } 126 if let heading { headings[heading].properties += properties } else { fileProperties += properties }
127 case .clock: 127 case .clock:
128 if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) } 128 if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) }
129 case .timestamp: 129 case .timestamp:
Sources/OrgCore/Syntax/SyntaxKind.swift +1 −1
@@ -9,7 +9,7 @@ public enum SyntaxKind: String, Sendable {
9 case planning, propertyDrawer, nodeProperty, drawer, clock 9 case planning, propertyDrawer, nodeProperty, drawer, clock
10 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula 10 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
11 case block, dynamicBlock, keyword, affiliatedKeyword 11 case block, dynamicBlock, keyword, affiliatedKeyword
12 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment 12 case comment, fixedWidth, horizontalRule, footnoteDefinition, latexEnvironment, inlineTask
13 13
14 // Objects 14 // Objects
15 case bold, italic, underline, strikeThrough, verbatim, code 15 case bold, italic, underline, strikeThrough, verbatim, code
Sources/Orgstar/Preferences.swift +3 −1
@@ -12,7 +12,9 @@ enum Preferences {
12 // config.toml applies before anything reads settings. 12 // config.toml applies before anything reads settings.
13 ConfigSync.shared.start() 13 ConfigSync.shared.start()
14 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords 14 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
16 } 18 }
17 19
18 static func options( 20 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 {
24 "| a | b |\n|---+---|\n| 1 | |\n|---+---|\n| 2 | |\n| 3 | |\n#+TBLFM: $2=@I$1+@II$1+@-I$1\n", 24 "| a | b |\n|---+---|\n| 1 | |\n|---+---|\n| 2 | |\n| 3 | |\n#+TBLFM: $2=@I$1+@II$1+@-I$1\n",
25 "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n#+TBLFM: $3=($1+$2)*2/3-1::$1=$1\n", 25 "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n#+TBLFM: $3=($1+$2)*2/3-1::$1=$1\n",
26 "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1=vmax(@I..@II)-vmin(@I..@II)+vcount(@I..@II)+vmedian(@I..@II)\n", 26 "| 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",
27 ] 29 ]
28 30
29 static func carets(_ text: String) -> [Int] { 31 static func carets(_ text: String) -> [Int] {