Commit 5a1ddcb0e6
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +7 −1
| @@ -14,10 +14,12 @@ public struct EditContext: Sendable { | ||
| 14 | 14 | public let calendar: Calendar |
| 15 | 15 | /// Replies to earlier prompts, by prompt key. |
| 16 | 16 | public let answers: [String: String] |
| 17 | /// Ranges the view hides (folded text); motion commands skip headings inside them. | |
| 18 | public let hidden: [Range<Int>] | |
| 17 | 19 | |
| 18 | 20 | public init( |
| 19 | 21 | revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], |
| 20 | now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:] | |
| 22 | now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [] | |
| 21 | 23 | ) { |
| 22 | 24 | self.revision = revision |
| 23 | 25 | self.text = text |
| @@ -26,6 +28,7 @@ public struct EditContext: Sendable { | ||
| 26 | 28 | self.now = now |
| 27 | 29 | self.calendar = calendar |
| 28 | 30 | self.answers = answers |
| 31 | self.hidden = hidden | |
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | public var caret: Int { selection.first?.lowerBound ?? 0 } |
| @@ -87,6 +90,9 @@ public protocol OrgCommand: Sendable { | ||
| 87 | 90 | public enum Commands { |
| 88 | 91 | public static let all: [any OrgCommand] = [ |
| 89 | 92 | TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(), |
| 93 | InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(), | |
| 94 | MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), | |
| 95 | NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), | |
| 90 | 96 | ] |
| 91 | 97 | |
| 92 | 98 | public static func command(_ id: String) -> (any OrgCommand)? { |
Sources/OrgCore/Commands/EmacsBuffer.swift added +392
| @@ -0,0 +1,392 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A whole-text buffer with Emacs's point and marker rules, for porting org commands that | |
| 4 | /// edit several lines. Offsets are UTF-16, as everywhere in `OrgCore`. | |
| 5 | /// | |
| 6 | /// Inserting at point moves point past the text; a marker at the insertion point stays before | |
| 7 | /// it unless the insertion is `insertBeforeMarkers`. Deleting a range moves positions inside | |
| 8 | /// it to its start. `saveExcursion` restores point from a marker, as Emacs does. | |
| 9 | final class EmacsBuffer { | |
| 10 | final class Marker { | |
| 11 | var position: Int | |
| 12 | init(_ position: Int) { self.position = position } | |
| 13 | } | |
| 14 | ||
| 15 | struct UserError: Error { | |
| 16 | let message: String | |
| 17 | } | |
| 18 | ||
| 19 | private(set) var text: NSMutableString | |
| 20 | var point: Int | |
| 21 | private var markers: [Marker] = [] | |
| 22 | let settings: OrgSettings | |
| 23 | ||
| 24 | init(_ text: String, point: Int, settings: OrgSettings = .default) { | |
| 25 | self.text = NSMutableString(string: text) | |
| 26 | self.point = point | |
| 27 | self.settings = settings | |
| 28 | } | |
| 29 | ||
| 30 | var string: String { text as String } | |
| 31 | var length: Int { text.length } | |
| 32 | ||
| 33 | func character(at offset: Int) -> unichar? { | |
| 34 | offset >= 0 && offset < length ? text.character(at: offset) : nil | |
| 35 | } | |
| 36 | ||
| 37 | static let newline = unichar(10) | |
| 38 | ||
| 39 | // MARK: - Markers | |
| 40 | ||
| 41 | func marker(_ position: Int) -> Marker { | |
| 42 | let marker = Marker(position) | |
| 43 | markers.append(marker) | |
| 44 | return marker | |
| 45 | } | |
| 46 | ||
| 47 | func release(_ marker: Marker) { | |
| 48 | markers.removeAll { $0 === marker } | |
| 49 | } | |
| 50 | ||
| 51 | /// Runs `body` and puts point back where it was, following edits as a marker does. | |
| 52 | @discardableResult | |
| 53 | func saveExcursion<T>(_ body: () throws -> T) rethrows -> T { | |
| 54 | let saved = marker(point) | |
| 55 | defer { | |
| 56 | point = saved.position | |
| 57 | release(saved) | |
| 58 | } | |
| 59 | return try body() | |
| 60 | } | |
| 61 | ||
| 62 | // MARK: - Editing | |
| 63 | ||
| 64 | /// `insert` at point. | |
| 65 | func insert(_ string: String) { | |
| 66 | let count = (string as NSString).length | |
| 67 | guard count > 0 else { return } | |
| 68 | text.insert(string, at: point) | |
| 69 | for marker in markers where marker.position > point { marker.position += count } | |
| 70 | point += count | |
| 71 | } | |
| 72 | ||
| 73 | /// `insert-before-markers` at point: markers at point move past the text too. | |
| 74 | func insertBeforeMarkers(_ string: String) { | |
| 75 | let count = (string as NSString).length | |
| 76 | guard count > 0 else { return } | |
| 77 | text.insert(string, at: point) | |
| 78 | for marker in markers where marker.position >= point { marker.position += count } | |
| 79 | point += count | |
| 80 | } | |
| 81 | ||
| 82 | func deleteRegion(_ start: Int, _ end: Int) { | |
| 83 | let (lower, upper) = (min(start, end), max(start, end)) | |
| 84 | guard upper > lower else { return } | |
| 85 | text.deleteCharacters(in: NSRange(lower..<upper)) | |
| 86 | func map(_ position: Int) -> Int { | |
| 87 | position <= lower ? position : position >= upper ? position - (upper - lower) : lower | |
| 88 | } | |
| 89 | point = map(point) | |
| 90 | for marker in markers { marker.position = map(marker.position) } | |
| 91 | } | |
| 92 | ||
| 93 | func substring(_ start: Int, _ end: Int) -> String { | |
| 94 | text.substring(with: NSRange(min(start, end)..<max(start, end))) | |
| 95 | } | |
| 96 | ||
| 97 | /// `replace-match` over `range`: positions strictly inside go to its start, positions at | |
| 98 | /// or after its end shift with the text. | |
| 99 | func replace(_ range: Range<Int>, with replacement: String) { | |
| 100 | let delta = (replacement as NSString).length - range.count | |
| 101 | text.replaceCharacters(in: NSRange(range), with: replacement) | |
| 102 | func map(_ position: Int) -> Int { | |
| 103 | if position >= range.upperBound, position > range.lowerBound { return position + delta } | |
| 104 | return position > range.lowerBound ? range.lowerBound : position | |
| 105 | } | |
| 106 | point = map(point) | |
| 107 | for marker in markers { marker.position = map(marker.position) } | |
| 108 | } | |
| 109 | ||
| 110 | // MARK: - Motion | |
| 111 | ||
| 112 | var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline } | |
| 113 | var eolp: Bool { point == length || character(at: point) == Self.newline } | |
| 114 | var bobp: Bool { point == 0 } | |
| 115 | var eobp: Bool { point == length } | |
| 116 | ||
| 117 | func lineStart(_ offset: Int) -> Int { | |
| 118 | var at = min(offset, length) | |
| 119 | while at > 0, character(at: at - 1) != Self.newline { at -= 1 } | |
| 120 | return at | |
| 121 | } | |
| 122 | ||
| 123 | func lineEnd(_ offset: Int) -> Int { | |
| 124 | var at = max(offset, 0) | |
| 125 | while at < length, character(at: at) != Self.newline { at += 1 } | |
| 126 | return at | |
| 127 | } | |
| 128 | ||
| 129 | func beginningOfLine() { point = lineStart(point) } | |
| 130 | func endOfLine() { point = lineEnd(point) } | |
| 131 | ||
| 132 | /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends. | |
| 133 | @discardableResult | |
| 134 | func forwardLine(_ n: Int) -> Int { | |
| 135 | point = lineStart(point) | |
| 136 | var remaining = n | |
| 137 | while remaining > 0 { | |
| 138 | let end = lineEnd(point) | |
| 139 | if end == length { | |
| 140 | point = length | |
| 141 | return remaining | |
| 142 | } | |
| 143 | point = end + 1 | |
| 144 | remaining -= 1 | |
| 145 | } | |
| 146 | while remaining < 0 { | |
| 147 | if point == 0 { return remaining } | |
| 148 | point = lineStart(point - 1) | |
| 149 | remaining += 1 | |
| 150 | } | |
| 151 | return 0 | |
| 152 | } | |
| 153 | ||
| 154 | func skipForward(_ set: Set<unichar>) { | |
| 155 | while let c = character(at: point), set.contains(c) { point += 1 } | |
| 156 | } | |
| 157 | ||
| 158 | func skipBackward(_ set: Set<unichar>) { | |
| 159 | while let c = character(at: point - 1), set.contains(c) { point -= 1 } | |
| 160 | } | |
| 161 | ||
| 162 | static let blanks: Set<unichar> = [32, 9] | |
| 163 | static let whitespace: Set<unichar> = [32, 9, 10, 13] | |
| 164 | ||
| 165 | var currentColumn: Int { column(of: substring(lineStart(point), point)) } | |
| 166 | ||
| 167 | /// `move-to-column`: as far as `target` on this line, or the line's end. | |
| 168 | func moveToColumn(_ target: Int) { | |
| 169 | let start = lineStart(point) | |
| 170 | let line = LineBuffer(substring(start, lineEnd(point)), caret: nil) | |
| 171 | point = start + line.offset(ofColumn: target) | |
| 172 | } | |
| 173 | ||
| 174 | // MARK: - Outline | |
| 175 | ||
| 176 | /// Stars of a heading line starting at `lineStart`, or nil. | |
| 177 | func headingLevel(atLine lineStart: Int) -> Int? { | |
| 178 | var at = lineStart | |
| 179 | while character(at: at) == 42 { at += 1 } | |
| 180 | return at > lineStart && character(at: at) == 32 ? at - lineStart : nil | |
| 181 | } | |
| 182 | ||
| 183 | var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil } | |
| 184 | ||
| 185 | /// Start of the first heading line after `offset`'s line, or nil. | |
| 186 | func nextHeadingStart(after offset: Int) -> Int? { | |
| 187 | var line = lineEnd(offset) | |
| 188 | while line < length { | |
| 189 | line += 1 | |
| 190 | if headingLevel(atLine: line) != nil { return line } | |
| 191 | line = lineEnd(line) | |
| 192 | } | |
| 193 | return nil | |
| 194 | } | |
| 195 | ||
| 196 | /// Start of the last heading line before `offset`'s line, or nil. | |
| 197 | func previousHeadingStart(before offset: Int) -> Int? { | |
| 198 | var line = lineStart(offset) | |
| 199 | while line > 0 { | |
| 200 | line = lineStart(line - 1) | |
| 201 | if headingLevel(atLine: line) != nil { return line } | |
| 202 | } | |
| 203 | return nil | |
| 204 | } | |
| 205 | ||
| 206 | /// `org-outline-level`: the level of the heading at or before point's line, or 0. | |
| 207 | var outlineLevel: Int { | |
| 208 | let start = lineStart(point) | |
| 209 | if let level = headingLevel(atLine: start) { return level } | |
| 210 | return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0 | |
| 211 | } | |
| 212 | ||
| 213 | var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil } | |
| 214 | ||
| 215 | var beforeFirstHeading: Bool { outlineLevel == 0 } | |
| 216 | ||
| 217 | /// `org-back-to-heading`. | |
| 218 | @discardableResult | |
| 219 | func backToHeading() throws -> Int { | |
| 220 | let start = lineStart(point) | |
| 221 | if headingLevel(atLine: start) != nil { | |
| 222 | point = start | |
| 223 | } else if let previous = previousHeadingStart(before: start) { | |
| 224 | point = previous | |
| 225 | } else { | |
| 226 | throw UserError(message: "Before first headline at position \(point + 1) in buffer") | |
| 227 | } | |
| 228 | return point | |
| 229 | } | |
| 230 | ||
| 231 | /// `outline-next-heading`: the next heading line, or the buffer end. True when found. | |
| 232 | @discardableResult | |
| 233 | func outlineNextHeading() -> Bool { | |
| 234 | if bolp, !eobp { point += 1 } | |
| 235 | // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would. | |
| 236 | if bolp, headingLevel(atLine: point) != nil { return true } | |
| 237 | guard let next = nextHeadingStart(after: point) else { | |
| 238 | point = length | |
| 239 | return false | |
| 240 | } | |
| 241 | point = next | |
| 242 | return true | |
| 243 | } | |
| 244 | ||
| 245 | /// `outline-previous-heading`: the previous heading line, or the buffer start. | |
| 246 | func outlinePreviousHeading() { | |
| 247 | // `re-search-backward` matches must end by point: a heading on this line counts once | |
| 248 | // point is past its stars and space. | |
| 249 | let start = lineStart(point) | |
| 250 | if let level = headingLevel(atLine: start), point >= start + level + 1 { | |
| 251 | point = start | |
| 252 | return | |
| 253 | } | |
| 254 | point = previousHeadingStart(before: point) ?? 0 | |
| 255 | } | |
| 256 | ||
| 257 | /// `org-end-of-subtree`. | |
| 258 | @discardableResult | |
| 259 | func endOfSubtree(toHeading: Bool = false) -> Int { | |
| 260 | if beforeFirstHeading { | |
| 261 | point = length | |
| 262 | } else { | |
| 263 | let start = try! backToHeading() | |
| 264 | let level = headingLevel(atLine: start)! | |
| 265 | var next = nextHeadingStart(after: start) | |
| 266 | while let candidate = next, headingLevel(atLine: candidate)! > level { | |
| 267 | next = nextHeadingStart(after: candidate) | |
| 268 | } | |
| 269 | point = next ?? length | |
| 270 | } | |
| 271 | if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 { | |
| 272 | point -= 1 | |
| 273 | skipBackward([10, 13, 9, 32]) | |
| 274 | } | |
| 275 | return point | |
| 276 | } | |
| 277 | ||
| 278 | /// `org-get-next-sibling`. | |
| 279 | func nextSibling() -> Int? { | |
| 280 | let level = outlineLevel | |
| 281 | outlineNextHeading() | |
| 282 | while !eobp, outlineLevel > level { outlineNextHeading() } | |
| 283 | return eobp || outlineLevel < level ? nil : point | |
| 284 | } | |
| 285 | ||
| 286 | /// `org-get-previous-sibling`. | |
| 287 | func previousSibling() -> Int? { | |
| 288 | let start = point | |
| 289 | let level = outlineLevel | |
| 290 | outlinePreviousHeading() | |
| 291 | guard point != start, atHeading else { return nil } | |
| 292 | while outlineLevel > level, !bobp { outlinePreviousHeading() } | |
| 293 | return outlineLevel < level ? nil : point | |
| 294 | } | |
| 295 | ||
| 296 | /// `org--line-empty-p -1`. | |
| 297 | var previousLineEmpty: Bool { | |
| 298 | guard !bobp else { return false } | |
| 299 | return saveExcursion { | |
| 300 | forwardLine(-1) | |
| 301 | skipForward(Self.blanks) | |
| 302 | return eolp | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | // MARK: - Regular expressions | |
| 307 | ||
| 308 | /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines), | |
| 309 | /// or nil. Unmatched groups are nil. | |
| 310 | func lookingAt(_ pattern: String) -> [Range<Int>?]? { | |
| 311 | let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) | |
| 312 | guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point..<length)) else { return nil } | |
| 313 | return (0..<match.numberOfRanges).map { i in | |
| 314 | let range = match.range(at: i) | |
| 315 | return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range) | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags. | |
| 320 | var complexHeadingPattern: String { | |
| 321 | "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(\\[#(?:[A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]))?(?: +(.*?))??(?:[ \t]+(:[[:alnum:]_@#%:]+:))?[ \t]*$" | |
| 322 | } | |
| 323 | ||
| 324 | /// `org-todo-line-regexp`: 1 stars, 2 keyword, 3 title. | |
| 325 | var todoLinePattern: String { | |
| 326 | "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(.*?))?[ \t]*$" | |
| 327 | } | |
| 328 | ||
| 329 | private var keywordAlternation: String { | |
| 330 | keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") | |
| 331 | } | |
| 332 | ||
| 333 | /// `org-todo-keywords-1`. | |
| 334 | var keywordsInOrder: [String] { | |
| 335 | settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) } | |
| 336 | } | |
| 337 | ||
| 338 | // MARK: - Tags | |
| 339 | ||
| 340 | /// `org-align-tags` on the line at `offset`, moving point and markers on the line as the | |
| 341 | /// tag alignment rules say. | |
| 342 | func alignTags(onLineAt offset: Int) { | |
| 343 | let start = lineStart(offset) | |
| 344 | let end = lineEnd(offset) | |
| 345 | let line = substring(start, end) | |
| 346 | var plain = LineBuffer(line, caret: nil) | |
| 347 | OrgCore.alignTags(&plain) | |
| 348 | guard plain.string != line else { return } | |
| 349 | func map(_ position: Int) -> Int { | |
| 350 | guard position >= start, position <= end else { | |
| 351 | return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position | |
| 352 | } | |
| 353 | var buffer = LineBuffer(line, caret: position - start) | |
| 354 | OrgCore.alignTags(&buffer) | |
| 355 | return start + buffer.caret! | |
| 356 | } | |
| 357 | let newPoint = map(point) | |
| 358 | let newMarkers = markers.map { map($0.position) } | |
| 359 | text.replaceCharacters(in: NSRange(start..<end), with: plain.string) | |
| 360 | point = newPoint | |
| 361 | for (marker, position) in zip(markers, newMarkers) { marker.position = position } | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | /// Runs a buffer-editing body against a context and turns the result into one minimal edit | |
| 366 | /// and the new caret. A user error becomes `.failed`. | |
| 367 | func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | |
| 368 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) | |
| 369 | do { | |
| 370 | try body(buffer) | |
| 371 | } catch let error as EmacsBuffer.UserError { | |
| 372 | return .failed(error.message) | |
| 373 | } catch { | |
| 374 | return .failed("\(error)") | |
| 375 | } | |
| 376 | let old = context.text as NSString | |
| 377 | let new = buffer.text | |
| 378 | var prefix = 0 | |
| 379 | let shorter = min(old.length, new.length) | |
| 380 | while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 } | |
| 381 | var suffix = 0 | |
| 382 | while suffix < shorter - prefix, old.character(at: old.length - 1 - suffix) == new.character(at: new.length - 1 - suffix) { suffix += 1 } | |
| 383 | // Keep surrogate pairs whole. | |
| 384 | while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 } | |
| 385 | while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 } | |
| 386 | let caret = buffer.point | |
| 387 | guard old.length != new.length || prefix != old.length else { | |
| 388 | return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) | |
| 389 | } | |
| 390 | let edit = TextEdit(range: prefix..<(old.length - suffix), replacement: new.substring(with: NSRange(prefix..<(new.length - suffix)))) | |
| 391 | return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret])) | |
| 392 | } | |
Sources/OrgCore/Commands/StructureCommands.swift added +438
| @@ -0,0 +1,438 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Structure commands, ported from Org 9.8.7 onto `EmacsBuffer` and matched to Emacs by the | |
| 4 | // oracle tests. Defaults as in org: `org-blank-before-new-entry` heading `auto`, | |
| 5 | // `org-M-RET-may-split-line` t, `org-auto-align-tags` t. | |
| 6 | ||
| 7 | extension EmacsBuffer { | |
| 8 | /// `org--blank-before-heading-p` for headings. | |
| 9 | var blankBeforeHeading: Bool { | |
| 10 | saveExcursion { | |
| 11 | if beforeFirstHeading, !outlineNextHeading() { return false } | |
| 12 | _ = try? backToHeading() | |
| 13 | if !bobp { return previousLineEmpty } | |
| 14 | if outlineNextHeading() { return previousLineEmpty } | |
| 15 | skipBackward(Self.blanks) | |
| 16 | return bolp ? previousLineEmpty : false | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | /// `org-N-empty-lines-before-current`. | |
| 21 | func emptyLinesBeforeCurrent(_ n: Int) { | |
| 22 | let column = currentColumn | |
| 23 | forwardLine(0) | |
| 24 | if !bobp { | |
| 25 | let start = saveExcursion { | |
| 26 | skipBackward(Self.whitespace) | |
| 27 | return lineEnd(point) | |
| 28 | } | |
| 29 | deleteRegion(start, point - 1) | |
| 30 | } | |
| 31 | insert(String(repeating: "\n", count: n)) | |
| 32 | moveToColumn(column) | |
| 33 | } | |
| 34 | ||
| 35 | /// The blank line `org-insert-heading` keeps before the next heading. | |
| 36 | func addBlankAfter(_ blank: Bool) { | |
| 37 | saveExcursion { | |
| 38 | endOfLine() | |
| 39 | guard !eobp else { return } | |
| 40 | point += 1 | |
| 41 | if blank, atHeading { insert("\n") } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// `org-insert-heading` with no prefix, or with `respectContent` as C-RET. | |
| 46 | func insertHeading(respectContent: Bool, hidden: [Range<Int>]) throws { | |
| 47 | let blank = blankBeforeHeading | |
| 48 | let level = currentLevel | |
| 49 | let stars = String(repeating: "*", count: level ?? 1) | |
| 50 | let invisible = hidden.contains { $0.contains(max(point - 1, 0)) } | |
| 51 | if respectContent || invisible { | |
| 52 | if level == nil { | |
| 53 | outlineNextHeading() | |
| 54 | } else { | |
| 55 | try backToHeading() | |
| 56 | endOfSubtree(toHeading: true) | |
| 57 | } | |
| 58 | if !bolp { insert("\n") } | |
| 59 | if blank, saveExcursion({ point -= 1; return beforeFirstHeading }) { | |
| 60 | insert("\n") | |
| 61 | point -= 1 | |
| 62 | } | |
| 63 | if level == nil, !eobp, !bobp { | |
| 64 | if atHeading { insert("\n") } | |
| 65 | point -= 1 | |
| 66 | } | |
| 67 | if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } | |
| 68 | insert(stars + " \n") | |
| 69 | point -= 1 | |
| 70 | addBlankAfter(blank) | |
| 71 | } else if atHeading { | |
| 72 | if bolp { | |
| 73 | if blank { saveExcursion { insert("\n") } } | |
| 74 | saveExcursion { insert(stars + " \n") } | |
| 75 | if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } | |
| 76 | endOfLine() | |
| 77 | } else if let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }), | |
| 78 | let title = match[4], title.lowerBound <= point, point <= title.upperBound { | |
| 79 | let split = substring(point, title.upperBound) | |
| 80 | deleteRegion(point, title.upperBound) | |
| 81 | if let blanks = lookingAt("[ \\t]*$") { | |
| 82 | deleteRegion(blanks[0]!.lowerBound, blanks[0]!.upperBound) | |
| 83 | } else { | |
| 84 | alignTags(onLineAt: point) | |
| 85 | } | |
| 86 | endOfLine() | |
| 87 | if blank { insert("\n") } | |
| 88 | insert("\n" + stars + " ") | |
| 89 | addBlankAfter(blank) | |
| 90 | if split.contains(where: { !$0.isWhitespace }) { insert(split) } | |
| 91 | } else { | |
| 92 | endOfLine() | |
| 93 | if blank { insert("\n") } | |
| 94 | insert("\n" + stars + " ") | |
| 95 | addBlankAfter(blank) | |
| 96 | } | |
| 97 | } else if bolp { | |
| 98 | insert(stars + " ") | |
| 99 | if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } | |
| 100 | addBlankAfter(blank) | |
| 101 | } else { | |
| 102 | insert("\n" + stars + " ") | |
| 103 | if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } | |
| 104 | addBlankAfter(blank) | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | /// `org-forward-heading-same-level` over all headings, visible or not. | |
| 109 | func forwardHeadingSameLevel(_ count: Int) throws { | |
| 110 | let backward = count < 0 | |
| 111 | if beforeFirstHeading { | |
| 112 | if backward { point = 0 } else { outlineNextHeading() } | |
| 113 | return | |
| 114 | } | |
| 115 | try backToHeading() | |
| 116 | if !backward { endOfLine() } | |
| 117 | let level = currentLevel ?? 0 | |
| 118 | var remaining = abs(count) | |
| 119 | var result = point | |
| 120 | while remaining > 0, let found = backward ? searchHeadingBackward() : searchHeadingForward() { | |
| 121 | let l = headingLevel(atLine: found)! | |
| 122 | if l < level { | |
| 123 | remaining = 0 | |
| 124 | } else if l == level { | |
| 125 | remaining -= 1 | |
| 126 | result = point | |
| 127 | } | |
| 128 | } | |
| 129 | point = result | |
| 130 | forwardLine(0) | |
| 131 | } | |
| 132 | ||
| 133 | /// `re-search-forward "^\\*+ " nil 'move`: point to the match end, or the buffer end. | |
| 134 | /// Returns the heading's line start. | |
| 135 | func searchHeadingForward() -> Int? { | |
| 136 | var line = bolp ? point : lineEnd(point) + 1 | |
| 137 | while line <= length { | |
| 138 | if let level = headingLevel(atLine: line) { | |
| 139 | point = line + level + 1 | |
| 140 | return line | |
| 141 | } | |
| 142 | line = lineEnd(line) + 1 | |
| 143 | } | |
| 144 | point = length | |
| 145 | return nil | |
| 146 | } | |
| 147 | ||
| 148 | /// `re-search-backward "^\\*+ " nil 'move`: point to the match start, or the buffer start. | |
| 149 | /// The match must end by point. | |
| 150 | func searchHeadingBackward() -> Int? { | |
| 151 | var line = lineStart(point) | |
| 152 | if let level = headingLevel(atLine: line), line + level + 1 <= point { | |
| 153 | point = line | |
| 154 | return line | |
| 155 | } | |
| 156 | while line > 0 { | |
| 157 | line = lineStart(line - 1) | |
| 158 | if headingLevel(atLine: line) != nil { | |
| 159 | point = line | |
| 160 | return line | |
| 161 | } | |
| 162 | } | |
| 163 | point = 0 | |
| 164 | return nil | |
| 165 | } | |
| 166 | ||
| 167 | /// `org-promote` or `org-demote` on the heading at point. | |
| 168 | func changeLevel(by delta: Int) throws { | |
| 169 | try saveExcursion { | |
| 170 | try backToHeading() | |
| 171 | let level = headingLevel(atLine: point)! | |
| 172 | guard level + delta >= 1 else { throw UserError(message: "Cannot promote to level 0. UNDO to recover if necessary") } | |
| 173 | replace(point..<(point + level + 1), with: String(repeating: "*", count: level + delta) + " ") | |
| 174 | alignTags(onLineAt: point) | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | /// `org-fix-position-after-promote`. | |
| 179 | func fixPositionAfterPromote() { | |
| 180 | let position = point | |
| 181 | let match = saveExcursion { () -> [Range<Int>?]? in | |
| 182 | forwardLine(0) | |
| 183 | return lookingAt(todoLinePattern) | |
| 184 | } | |
| 185 | guard let match, match[1]?.upperBound == position || match[2]?.upperBound == position else { return } | |
| 186 | if eobp || eolp { | |
| 187 | insert(" ") | |
| 188 | } else if character(at: point) == 32 { | |
| 189 | point += 1 | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | /// `org-map-tree` over the subtree at point. | |
| 194 | func mapTree(_ body: () throws -> Void) throws { | |
| 195 | try backToHeading() | |
| 196 | let level = outlineLevel | |
| 197 | try saveExcursion { | |
| 198 | try body() | |
| 199 | while true { | |
| 200 | outlineNextHeading() | |
| 201 | guard outlineLevel > level, !eobp else { break } | |
| 202 | try body() | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | /// `org-back-over-empty-lines` with blank lines before headings on (`auto`). | |
| 208 | func backOverEmptyLines() { | |
| 209 | let position = point | |
| 210 | skipBackward(Self.whitespace) | |
| 211 | forwardLine(1) | |
| 212 | point = min(point, position) | |
| 213 | } | |
| 214 | ||
| 215 | /// `org-move-subtree-down`; negative counts move up. | |
| 216 | func moveSubtree(_ count: Int) throws { | |
| 217 | let column = currentColumn | |
| 218 | try backToHeading() | |
| 219 | let begin = point | |
| 220 | endOfSubtree(toHeading: true) | |
| 221 | if !eobp { point -= 1 } | |
| 222 | outlineNextHeading() | |
| 223 | let end = point | |
| 224 | point = begin | |
| 225 | for _ in 0..<abs(count) { | |
| 226 | let found = count > 0 ? nextSibling() : previousSibling() | |
| 227 | guard found != nil, atHeading, bolp else { | |
| 228 | point = begin | |
| 229 | throw UserError(message: "Cannot move past superior level or buffer limit") | |
| 230 | } | |
| 231 | } | |
| 232 | if count > 0 { | |
| 233 | endOfSubtree(toHeading: true) | |
| 234 | saveExcursion { | |
| 235 | backOverEmptyLines() | |
| 236 | if !bolp { insert("\n") } | |
| 237 | } | |
| 238 | } | |
| 239 | let insertion = marker(point) | |
| 240 | defer { release(insertion) } | |
| 241 | let subtree = substring(begin, end) | |
| 242 | deleteRegion(begin, end) | |
| 243 | if !bolp, character(at: point) == Self.newline { point += 1 } | |
| 244 | let at = point | |
| 245 | insertBeforeMarkers(subtree) | |
| 246 | insertion.position = at | |
| 247 | if !bolp { insert("\n") } | |
| 248 | point = insertion.position | |
| 249 | skipForward(Self.whitespace) | |
| 250 | moveToColumn(column) | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | private func headingCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | |
| 255 | commitBuffer(context, body) | |
| 256 | } | |
| 257 | ||
| 258 | // MARK: - Inserting | |
| 259 | ||
| 260 | /// M-RET on a heading or text: `org-insert-heading`. | |
| 261 | public struct InsertHeading: OrgCommand { | |
| 262 | public init() {} | |
| 263 | public var id: String { "org.heading.insert" } | |
| 264 | public var title: String { "Insert Heading" } | |
| 265 | public func applies(in context: EditContext) -> Bool { true } | |
| 266 | public func run(in context: EditContext) -> CommandStep { | |
| 267 | headingCommand(context) { try $0.insertHeading(respectContent: false, hidden: context.hidden) } | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | /// C-RET: `org-insert-heading-respect-content`. | |
| 272 | public struct InsertHeadingAfterSubtree: OrgCommand { | |
| 273 | public init() {} | |
| 274 | public var id: String { "org.heading.insert-after-subtree" } | |
| 275 | public var title: String { "Insert Heading After Subtree" } | |
| 276 | public func applies(in context: EditContext) -> Bool { true } | |
| 277 | public func run(in context: EditContext) -> CommandStep { | |
| 278 | headingCommand(context) { try $0.insertHeading(respectContent: true, hidden: context.hidden) } | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | /// M-S-RET on a heading or text: `org-insert-todo-heading`. | |
| 283 | public struct InsertTodoHeading: OrgCommand { | |
| 284 | public init() {} | |
| 285 | public var id: String { "org.heading.insert-todo" } | |
| 286 | public var title: String { "Insert TODO Heading" } | |
| 287 | public func applies(in context: EditContext) -> Bool { true } | |
| 288 | public func run(in context: EditContext) -> CommandStep { | |
| 289 | headingCommand(context) { buffer in | |
| 290 | try buffer.insertHeading(respectContent: false, hidden: context.hidden) | |
| 291 | let previous = buffer.saveExcursion { () -> String? in | |
| 292 | try? buffer.forwardHeadingSameLevel(-1) | |
| 293 | guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil } | |
| 294 | return buffer.substring(keyword.lowerBound, keyword.upperBound) | |
| 295 | } | |
| 296 | let keyword = previous.flatMap { context.tree.settings.isDone($0) ? nil : $0 } ?? buffer.keywordsInOrder.first ?? "TODO" | |
| 297 | buffer.forwardLine(0) | |
| 298 | if let stars = buffer.lookingAt("\\*+ ") { | |
| 299 | buffer.point = stars[0]!.upperBound | |
| 300 | buffer.insert(keyword + " ") | |
| 301 | } | |
| 302 | } | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | // MARK: - Moving and levels | |
| 307 | ||
| 308 | /// M-up on a heading: `org-move-subtree-up`. | |
| 309 | public struct MoveSubtreeUp: OrgCommand { | |
| 310 | public init() {} | |
| 311 | public var id: String { "org.subtree.up" } | |
| 312 | public var title: String { "Move Subtree Up" } | |
| 313 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | |
| 314 | public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(-1) } } | |
| 315 | } | |
| 316 | ||
| 317 | /// M-down on a heading: `org-move-subtree-down`. | |
| 318 | public struct MoveSubtreeDown: OrgCommand { | |
| 319 | public init() {} | |
| 320 | public var id: String { "org.subtree.down" } | |
| 321 | public var title: String { "Move Subtree Down" } | |
| 322 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | |
| 323 | public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(1) } } | |
| 324 | } | |
| 325 | ||
| 326 | /// M-S-left on a heading: `org-promote-subtree`. | |
| 327 | public struct PromoteSubtree: OrgCommand { | |
| 328 | public init() {} | |
| 329 | public var id: String { "org.subtree.promote" } | |
| 330 | public var title: String { "Promote Subtree" } | |
| 331 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | |
| 332 | public func run(in context: EditContext) -> CommandStep { | |
| 333 | headingCommand(context) { buffer in | |
| 334 | try buffer.saveExcursion { | |
| 335 | try buffer.backToHeading() | |
| 336 | try buffer.mapTree { try buffer.changeLevel(by: -1) } | |
| 337 | } | |
| 338 | buffer.fixPositionAfterPromote() | |
| 339 | } | |
| 340 | } | |
| 341 | } | |
| 342 | ||
| 343 | /// M-S-right on a heading: `org-demote-subtree`. | |
| 344 | public struct DemoteSubtree: OrgCommand { | |
| 345 | public init() {} | |
| 346 | public var id: String { "org.subtree.demote" } | |
| 347 | public var title: String { "Demote Subtree" } | |
| 348 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | |
| 349 | public func run(in context: EditContext) -> CommandStep { | |
| 350 | headingCommand(context) { buffer in | |
| 351 | try buffer.saveExcursion { | |
| 352 | try buffer.backToHeading() | |
| 353 | try buffer.mapTree { try buffer.changeLevel(by: 1) } | |
| 354 | } | |
| 355 | buffer.fixPositionAfterPromote() | |
| 356 | } | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | // MARK: - Motion | |
| 361 | ||
| 362 | /// C-c C-n: `org-next-visible-heading`. | |
| 363 | public struct NextHeading: OrgCommand { | |
| 364 | public init() {} | |
| 365 | public var id: String { "org.heading.next" } | |
| 366 | public var title: String { "Next Heading" } | |
| 367 | public func applies(in context: EditContext) -> Bool { true } | |
| 368 | public func run(in context: EditContext) -> CommandStep { | |
| 369 | headingCommand(context) { buffer in | |
| 370 | buffer.endOfLine() | |
| 371 | var found = buffer.nextHeadingStart(after: buffer.point) | |
| 372 | while let start = found, context.hidden.contains(where: { $0.contains(start) }) { | |
| 373 | found = buffer.nextHeadingStart(after: start) | |
| 374 | } | |
| 375 | buffer.point = found ?? buffer.length | |
| 376 | } | |
| 377 | } | |
| 378 | } | |
| 379 | ||
| 380 | /// C-c C-p: `org-previous-visible-heading`. | |
| 381 | public struct PreviousHeading: OrgCommand { | |
| 382 | public init() {} | |
| 383 | public var id: String { "org.heading.previous" } | |
| 384 | public var title: String { "Previous Heading" } | |
| 385 | public func applies(in context: EditContext) -> Bool { true } | |
| 386 | public func run(in context: EditContext) -> CommandStep { | |
| 387 | headingCommand(context) { buffer in | |
| 388 | buffer.forwardLine(0) | |
| 389 | var found = buffer.previousHeadingStart(before: buffer.point) | |
| 390 | while let start = found, context.hidden.contains(where: { $0.contains(start) }) { | |
| 391 | found = buffer.previousHeadingStart(before: start) | |
| 392 | } | |
| 393 | buffer.point = found ?? 0 | |
| 394 | } | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | /// C-c C-f: `org-forward-heading-same-level`. | |
| 399 | public struct ForwardSameLevel: OrgCommand { | |
| 400 | public init() {} | |
| 401 | public var id: String { "org.heading.forward-same-level" } | |
| 402 | public var title: String { "Next Heading at Same Level" } | |
| 403 | public func applies(in context: EditContext) -> Bool { true } | |
| 404 | public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(1) } } | |
| 405 | } | |
| 406 | ||
| 407 | /// C-c C-b: `org-backward-heading-same-level`. | |
| 408 | public struct BackwardSameLevel: OrgCommand { | |
| 409 | public init() {} | |
| 410 | public var id: String { "org.heading.backward-same-level" } | |
| 411 | public var title: String { "Previous Heading at Same Level" } | |
| 412 | public func applies(in context: EditContext) -> Bool { true } | |
| 413 | public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(-1) } } | |
| 414 | } | |
| 415 | ||
| 416 | /// C-c C-u: `outline-up-heading`. | |
| 417 | public struct UpHeading: OrgCommand { | |
| 418 | public init() {} | |
| 419 | public var id: String { "org.heading.up" } | |
| 420 | public var title: String { "Up to Parent Heading" } | |
| 421 | public func applies(in context: EditContext) -> Bool { true } | |
| 422 | public func run(in context: EditContext) -> CommandStep { | |
| 423 | headingCommand(context) { buffer in | |
| 424 | do { | |
| 425 | try buffer.backToHeading() | |
| 426 | } catch { | |
| 427 | throw EmacsBuffer.UserError(message: "before first heading") | |
| 428 | } | |
| 429 | let start = buffer.outlineLevel | |
| 430 | guard start > 1 else { throw EmacsBuffer.UserError(message: "Already at top level of the outline") } | |
| 431 | var level = start | |
| 432 | while level >= start, !buffer.bobp { | |
| 433 | buffer.outlinePreviousHeading() | |
| 434 | level = buffer.outlineLevel | |
| 435 | } | |
| 436 | } | |
| 437 | } | |
| 438 | } | |
Tests/OrgCoreTests/EmacsOracle.swift +2
| @@ -44,6 +44,8 @@ enum EmacsOracle { | ||
| 44 | 44 | (with-temp-buffer |
| 45 | 45 | (insert (alist-get 'text c)) |
| 46 | 46 | (org-mode) |
| 47 | ;; `#+STARTUP' may fold the buffer; cases run with everything visible. | |
| 48 | (org-fold-show-all) | |
| 47 | 49 | (goto-char (alist-get 'point c)) |
| 48 | 50 | ;; As an interactive call, not a repeat: some commands cycle differently on repeats. |
| 49 | 51 | (setq this-command 'orgstar-oracle last-command nil) |
Tests/OrgCoreTests/HeadingCommandTests.swift +32 −30
| @@ -61,7 +61,7 @@ struct HeadingOracleTests { | ||
| 61 | 61 | |
| 62 | 62 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 63 | 63 | func headingCommandsMatchEmacs() throws { |
| 64 | try compare(Self.variants.map { ($0, EmacsOracle.positions($0)) }) | |
| 64 | try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands) | |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading |
| @@ -86,40 +86,42 @@ struct HeadingOracleTests { | ||
| 86 | 86 | samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd])) |
| 87 | 87 | } |
| 88 | 88 | } |
| 89 | try compare(samples) | |
| 89 | try compareWithEmacs(samples, Self.commands) | |
| 90 | 90 | } |
| 91 | } | |
| 91 | 92 | |
| 92 | func compare(_ inputs: [(text: String, carets: [Int])]) throws { | |
| 93 | try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip") | |
| 94 | var cases: [EmacsOracle.Case] = [] | |
| 95 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | |
| 96 | for (text, carets) in inputs { | |
| 97 | for offset in carets { | |
| 98 | let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil | |
| 99 | for (command, form, headingLineOnly) in Self.commands where onHeadingLine || !headingLineOnly { | |
| 100 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form)) | |
| 101 | let result = runCommand(command, text, caret: offset) | |
| 102 | ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | |
| 103 | } | |
| 93 | /// Runs every command at every caret of every input here and in Emacs, and records each | |
| 94 | /// difference in text, caret or failure. | |
| 95 | func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)]) throws { | |
| 96 | try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip") | |
| 97 | var cases: [EmacsOracle.Case] = [] | |
| 98 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | |
| 99 | for (text, carets) in inputs { | |
| 100 | for offset in carets { | |
| 101 | let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil | |
| 102 | for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly { | |
| 103 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form)) | |
| 104 | let result = runCommand(command, text, caret: offset) | |
| 105 | ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | |
| 104 | 106 | } |
| 105 | 107 | } |
| 106 | let theirs = try EmacsOracle.run(cases) | |
| 107 | var mismatches = 0 | |
| 108 | for (mine, emacs) in zip(ours, theirs) { | |
| 109 | let emacsFailed = !emacs.error.isEmpty | |
| 110 | let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text) | |
| 111 | let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret)) | |
| 112 | if !same { | |
| 113 | mismatches += 1 | |
| 114 | if mismatches <= 15 { | |
| 115 | Issue.record(""" | |
| 116 | \(mine.label) | |
| 117 | ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)") | |
| 118 | emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)") | |
| 119 | """) | |
| 120 | } | |
| 108 | } | |
| 109 | let theirs = try EmacsOracle.run(cases) | |
| 110 | var mismatches = 0 | |
| 111 | for (mine, emacs) in zip(ours, theirs) { | |
| 112 | let emacsFailed = !emacs.error.isEmpty | |
| 113 | let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text) | |
| 114 | let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret)) | |
| 115 | if !same { | |
| 116 | mismatches += 1 | |
| 117 | if mismatches <= 15 { | |
| 118 | Issue.record(""" | |
| 119 | \(mine.label) | |
| 120 | ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)") | |
| 121 | emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)") | |
| 122 | """) | |
| 121 | 123 | } |
| 122 | 124 | } |
| 123 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs") | |
| 124 | 125 | } |
| 126 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs") | |
| 125 | 127 | } |
Tests/OrgCoreTests/StructureCommandTests.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct StructureCommandTests { | |
| 6 | @Test func insertHeading() { | |
| 7 | #expect(runCommand(InsertHeading(), "* a\nbody\n", caret: 3).text == "* a\n* \nbody\n") | |
| 8 | #expect(runCommand(InsertHeading(), "* ab\n", caret: 3).text == "* a\n* b\n") | |
| 9 | #expect(runCommand(InsertHeadingAfterSubtree(), "* a\nbody\n** b\n* c\n", caret: 1).text == "* a\nbody\n** b\n* \n* c\n") | |
| 10 | #expect(runCommand(InsertTodoHeading(), "* TODO a\n", caret: 8).text == "* TODO a\n* TODO \n") | |
| 11 | } | |
| 12 | ||
| 13 | @Test func moveAndLevels() { | |
| 14 | #expect(runCommand(MoveSubtreeDown(), "* a\nx\n* b\ny\n", caret: 0).text == "* b\ny\n* a\nx\n") | |
| 15 | #expect(runCommand(MoveSubtreeUp(), "* a\n* b\n", caret: 0).failure != nil) | |
| 16 | #expect(runCommand(DemoteSubtree(), "* a\n** b\n* c\n", caret: 0).text == "** a\n*** b\n* c\n") | |
| 17 | #expect(runCommand(PromoteSubtree(), "* a\n", caret: 0).failure != nil) | |
| 18 | } | |
| 19 | ||
| 20 | @Test func motion() { | |
| 21 | let text = "intro\n* a\n** b\n** c\n* d\n" | |
| 22 | #expect(runCommand(NextHeading(), text, caret: 0).caret == 6) | |
| 23 | #expect(runCommand(ForwardSameLevel(), text, caret: 10).caret == 15) | |
| 24 | #expect(runCommand(BackwardSameLevel(), text, caret: 16).caret == 10) | |
| 25 | #expect(runCommand(UpHeading(), text, caret: 16).caret == 6) | |
| 26 | #expect(runCommand(UpHeading(), text, caret: 6).failure != nil) | |
| 27 | } | |
| 28 | ||
| 29 | @Test func nextHeadingSkipsHiddenOnes() { | |
| 30 | let text = "* a\n** b\n* c\n" | |
| 31 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0], hidden: [3..<8]) | |
| 32 | guard case .commit(let result) = NextHeading().run(in: context) else { | |
| 33 | Issue.record("expected a commit") | |
| 34 | return | |
| 35 | } | |
| 36 | #expect(result.selection == [9..<9]) | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | struct StructureOracleTests { | |
| 41 | static let variants = [ | |
| 42 | "* a\nbody\n* b\nmore\n", | |
| 43 | "* a\n\n* b\n\n* c\n", | |
| 44 | "intro\n* a\n** b\ntext\n** c\n* d\n", | |
| 45 | "* TODO a :x:\n** DONE b\n* c :y:\n", | |
| 46 | "* a\n** b\n*** c\n** d\n", | |
| 47 | "* a\n* b", | |
| 48 | "text\nmore\n", | |
| 49 | "\n* a\n\nbody\n\n* b\n", | |
| 50 | "* TODO [#A] title here :t:\nbody\n", | |
| 51 | "* a 😀 b\n** c\n", | |
| 52 | ] | |
| 53 | ||
| 54 | static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [ | |
| 55 | (InsertHeading(), "(org-insert-heading)", false), | |
| 56 | (InsertHeadingAfterSubtree(), "(org-insert-heading-respect-content)", false), | |
| 57 | (InsertTodoHeading(), "(org-insert-todo-heading nil)", false), | |
| 58 | (MoveSubtreeUp(), "(org-move-subtree-up)", false), | |
| 59 | (MoveSubtreeDown(), "(org-move-subtree-down)", false), | |
| 60 | (PromoteSubtree(), "(org-promote-subtree)", false), | |
| 61 | (DemoteSubtree(), "(org-demote-subtree)", false), | |
| 62 | (NextHeading(), "(org-next-visible-heading 1)", false), | |
| 63 | (PreviousHeading(), "(org-previous-visible-heading 1)", false), | |
| 64 | (ForwardSameLevel(), "(org-forward-heading-same-level 1)", false), | |
| 65 | (BackwardSameLevel(), "(org-backward-heading-same-level 1)", false), | |
| 66 | (UpHeading(), "(outline-up-heading 1)", false), | |
| 67 | ] | |
| 68 | ||
| 69 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 70 | func structureCommandsMatchEmacs() throws { | |
| 71 | try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands) | |
| 72 | } | |
| 73 | ||
| 74 | /// Real files: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every structure command in files up to | |
| 75 | /// 20 KB, with the caret at the start and end of up to eight heading lines per file and on | |
| 76 | /// a body line, for up to 60 files. | |
| 77 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | |
| 78 | func structureCommandsMatchEmacsOnACorpus() throws { | |
| 79 | let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!) | |
| 80 | let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)! | |
| 81 | .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path } | |
| 82 | var samples: [(String, [Int])] = [] | |
| 83 | for file in files where samples.count < 60 { | |
| 84 | guard let text = try? String(contentsOf: file, encoding: .utf8), text.utf16.count <= 20_000, !text.contains("\r") else { continue } | |
| 85 | let ns = text as NSString | |
| 86 | var carets: [Int] = [] | |
| 87 | var location = 0 | |
| 88 | while location < ns.length, carets.count < 17 { | |
| 89 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | |
| 90 | let content = ns.substring(with: line) | |
| 91 | if content.hasPrefix("* ") || content.hasPrefix("** ") || content.hasPrefix("*** ") { | |
| 92 | carets.append(line.location) | |
| 93 | carets.append(NSMaxRange(line) - (content.hasSuffix("\n") ? 1 : 0)) | |
| 94 | } else if carets.count == 2 { | |
| 95 | carets.append(line.location) | |
| 96 | } | |
| 97 | location = NSMaxRange(line) | |
| 98 | } | |
| 99 | if !carets.isEmpty { samples.append((text, carets)) } | |
| 100 | } | |
| 101 | try compareWithEmacs(samples, Self.commands) | |
| 102 | } | |
| 103 | } | |