Sort list items !130
2 files changed, +112 −4
Layout: unified · split
Sources/OrgCore/Commands/SubtreeCommands.swift +66 −4
| @@ -239,11 +239,9 @@ public struct SortEntries: OrgCommand { | |||
| 239 | ] | 239 | ] |
| 240 | 240 | ||
| 241 | public func run(in context: EditContext) -> CommandStep { | 241 | public func run(in context: EditContext) -> CommandStep { |
| 242 | // `org-sort` sorts table lines and list items there; those aren't here yet. | 242 | // `org-sort`: table lines in a table, the list on an item line. |
| 243 | if KeyContext.holds("table", in: context) { return SortTableLines().run(in: context) } | 243 | if KeyContext.holds("table", in: context) { return SortTableLines().run(in: context) } |
| 244 | if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil { | 244 | if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem { return sortList(context) } |
| 245 | return .failed("Sorting list items isn't supported yet") | ||
| 246 | } | ||
| 247 | let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) | 245 | let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| 248 | guard let bounds = Self.bounds(probe, context.selection.first ?? context.caret..<context.caret) else { return .failed("Nothing to sort") } | 246 | guard let bounds = Self.bounds(probe, context.selection.first ?? context.caret..<context.caret) else { return .failed("Nothing to sort") } |
| 249 | guard let answer = context.answers["sort"], let key = answer.first else { | 247 | guard let answer = context.answers["sort"], let key = answer.first else { |
| @@ -267,6 +265,70 @@ public struct SortEntries: OrgCommand { | |||
| 267 | } | 265 | } |
| 268 | } | 266 | } |
| 269 | 267 | ||
| 268 | /// `org-sort-list`: [a]lpha, [n]umeric, [t]ime, [x] checked; the capital letter reverses. | ||
| 269 | func sortList(_ context: EditContext) -> CommandStep { | ||
| 270 | guard let answer = context.answers["sort"], let key = answer.first else { | ||
| 271 | let keys: [(Character, String)] = [("a", "alpha"), ("n", "numeric"), ("t", "time"), ("x", "checked")] | ||
| 272 | var items: [FastSelection.Item] = keys.map { .option(key: $0.0, name: $0.1) } | ||
| 273 | items.append(.newline) | ||
| 274 | items += keys.map { .option(key: Character($0.0.uppercased()), name: $0.1 + " (reversed)") } | ||
| 275 | return .prompt(Prompt(key: "sort", message: "Sort plain list: [a]lpha [n]umeric [t]ime [x]checked A/N/T/X means reversed:", | ||
| 276 | fast: FastSelection(items: items, multiple: false))) | ||
| 277 | } | ||
| 278 | let kind = Character(key.lowercased()) | ||
| 279 | guard "antx".contains(kind) else { return .failed("Invalid sorting type `\(key)'") } | ||
| 280 | return commitBuffer(context) { buffer in Self.sortList(buffer, key: kind, reverse: key.isUppercase) } | ||
| 281 | } | ||
| 282 | |||
| 283 | static func sortList(_ buffer: EmacsBuffer, key: Character, reverse: Bool) { | ||
| 284 | let line = buffer.lineStart(buffer.point) | ||
| 285 | var structure = buffer.saveExcursion { buffer.point = line; return buffer.listStruct() } | ||
| 286 | let prevs = structure.prevs | ||
| 287 | let siblings = structure.allItems(line, prevs) | ||
| 288 | let records = siblings.map { $0..<buffer.itemEndBeforeBlank($0, structure) } | ||
| 289 | let now = buffer.now.timeIntervalSince1970 | ||
| 290 | let keys = records.map { record -> SortKey in | ||
| 291 | let first = buffer.substring(record.lowerBound, buffer.lineEnd(record.lowerBound)) | ||
| 292 | guard let m = first.firstMatch(of: /^[ \t]*[-+*0-9.)]+([ \t]+\[[- X]\])?[ \t]+/) else { return .text("") } | ||
| 293 | let rest = String(first[m.range.upperBound...]) | ||
| 294 | switch key { | ||
| 295 | case "n": return .number((try? Elisp.number(Elisp.stringToNumber(visibleTitle(rest))))?.double ?? 0) | ||
| 296 | case "a": return .text(visibleTitle(rest).lowercased()) | ||
| 297 | case "x": return .text(m.1.map(String.init) ?? "") | ||
| 298 | default: | ||
| 299 | if let timer = rest.firstMatch(of: /^([0-9]+):([0-9]+):([0-9]+)[ \t]+::/) { | ||
| 300 | return .number(Double(Int(timer.1)! * 3600 + Int(timer.2)! * 60 + Int(timer.3)!)) | ||
| 301 | } | ||
| 302 | for pattern in ["<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>", "[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]"] { | ||
| 303 | if let r = first.range(of: pattern, options: .regularExpression) { | ||
| 304 | return .number(TagsMatcher.seconds(String(first[r]), now: buffer.now, calendar: buffer.calendar)) | ||
| 305 | } | ||
| 306 | } | ||
| 307 | return .number(now) | ||
| 308 | } | ||
| 309 | } | ||
| 310 | // `sort-subr`: stable, reversed before and after for the capital letters. | ||
| 311 | var order = Array(records.indices) | ||
| 312 | if reverse { order.reverse() } | ||
| 313 | order = order.enumerated().sorted { a, b in | ||
| 314 | let less = keys[a.element] < keys[b.element] | ||
| 315 | let greater = keys[b.element] < keys[a.element] | ||
| 316 | return less != greater ? less : a.offset < b.offset | ||
| 317 | }.map(\.element) | ||
| 318 | if reverse { order.reverse() } | ||
| 319 | let texts = records.map { buffer.substring($0.lowerBound, $0.upperBound) } | ||
| 320 | for (i, record) in records.enumerated().reversed() { | ||
| 321 | buffer.deleteRegion(record.lowerBound, record.upperBound) | ||
| 322 | buffer.point = record.lowerBound | ||
| 323 | buffer.insert(texts[order[i]]) | ||
| 324 | } | ||
| 325 | // `org-list-repair`. | ||
| 326 | buffer.point = records[0].lowerBound | ||
| 327 | structure = buffer.listStruct() | ||
| 328 | buffer.writeStruct(&structure, structure.parents) | ||
| 329 | buffer.point = records[0].lowerBound | ||
| 330 | } | ||
| 331 | |||
| 270 | /// `org-buffer-property-keys`, roughly: the property names in the text. | 332 | /// `org-buffer-property-keys`, roughly: the property names in the text. |
| 271 | static func propertyKeys(_ text: String) -> [String] { | 333 | static func propertyKeys(_ text: String) -> [String] { |
| 272 | var names: [String] = [] | 334 | var names: [String] = [] |
Tests/OrgCoreTests/SortListTests.swift added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// C-c ^ on an item: `org-sort-list`. | ||
| 6 | struct SortListTests { | ||
| 7 | static let lists = [ | ||
| 8 | "- pear\n- Apple\n- banana\n - sub b\n - sub a\n- cherry\n", | ||
| 9 | "* H\n - 10 ten\n - 9 nine\n - [[https://x.org][2 two]]\n - 1.5 one and a half\n", | ||
| 10 | "1. zeta\n2. alpha\n3. mu\n\nAfter.\n", | ||
| 11 | "- [ ] open\n- [X] done\n- [-] partial\n- no box\n", | ||
| 12 | "- meet <2026-10-09 Fri>\n- call [2026-10-05 Mon]\n- none\n- plan <2026-10-07 Wed 10:00>\n", | ||
| 13 | "- b\n\n- a\n\n\n- c\n", | ||
| 14 | "Intro\n- one\n- two\n continued\n- three\nOutro\n", | ||
| 15 | ] | ||
| 16 | |||
| 17 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 18 | func matchesEmacs() throws { | ||
| 19 | var cases: [EmacsOracle.Case] = [] | ||
| 20 | var ours: [(text: String, caret: Int, failure: String?)] = [] | ||
| 21 | for text in Self.lists { | ||
| 22 | let ns = text as NSString | ||
| 23 | var carets: [Int] = [] | ||
| 24 | var location = 0 | ||
| 25 | while location < ns.length { | ||
| 26 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | ||
| 27 | let content = ns.substring(with: line) | ||
| 28 | if !content.hasPrefix("*"), content.range(of: "^[ \\t]*([-+*]|[0-9]+\\.) ", options: .regularExpression) != nil { | ||
| 29 | carets.append(line.location + content.prefix { $0 == " " || $0 == "\t" }.utf16.count + 1) | ||
| 30 | } | ||
| 31 | location = NSMaxRange(line) | ||
| 32 | } | ||
| 33 | for caret in carets.prefix(2) { | ||
| 34 | for key in ["a", "A", "n", "N", "t", "T", "x", "X"] { | ||
| 35 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: "(org-sort-list nil ?\(key))")) | ||
| 36 | ours.append(runCommand(SortEntries(), text, caret: caret, answers: ["sort": key])) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
| 40 | let emacs = try EmacsOracle.run(cases) | ||
| 41 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 42 | #expect(mine.text == theirs.text && mine.failure == (theirs.error.isEmpty ? nil : theirs.error), | ||
| 43 | "\(cases[i].form) at \(cases[i].point) in\n\(cases[i].text)\nours:\n\(mine.text) \(mine.failure ?? "")\nemacs:\n\(theirs.text) \(theirs.error)") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||