Sort list items !130

merged merged by cmc on 2026-10-07 05:40 UTC · krz/orgstar:sort-list into main

2 files changed, +112 −4

Layout: unified · split

Sources/OrgCore/Commands/SubtreeCommands.swift +66 −4
@@ -239,11 +239,9 @@ public struct SortEntries: OrgCommand {
239239 ]
240240
241241 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.
243243 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 {
245 return .failed("Sorting list items isn't supported yet")
246 }
244 if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem { return sortList(context) }
247245 let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
248246 guard let bounds = Self.bounds(probe, context.selection.first ?? context.caret..<context.caret) else { return .failed("Nothing to sort") }
249247 guard let answer = context.answers["sort"], let key = answer.first else {
@@ -267,6 +265,70 @@ public struct SortEntries: OrgCommand {
267265 }
268266 }
269267
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
270332 /// `org-buffer-property-keys`, roughly: the property names in the text.
271333 static func propertyKeys(_ text: String) -> [String] {
272334 var names: [String] = []
Tests/OrgCoreTests/SortListTests.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// C-c ^ on an item: `org-sort-list`.
6struct 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}