Parse inline tasks, file property drawers, alphabetical bullets; table constants !97
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] { | |||
| 35 | enum LineClass: Equatable { | 35 | enum 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 | ||
| 65 | func classifyLine(_ line: Substring) -> ClassifiedLine { | 68 | func 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 | ||
| 76 | private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass { | 79 | /// `org-inlinetask-min-level`. |
| 80 | let inlineTaskLevel = 15 | ||
| 81 | |||
| 82 | private 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. |
| 147 | private func isListBullet(_ rest: Substring, indented: Bool) -> Bool { | 154 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Where org-element finds inline tasks, property drawers and list items. | ||
| 6 | struct 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] { |