krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Commands/OrgList.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgCore/Commands/OrgList.swift history · blame · raw

1009 lines · 44859 bytes

   1import Foundation
   2
   3// Org's plain list machinery (org-list.el, Org 9.8.7) on `EmacsBuffer`: the list structure,
   4// its repairs (bullets, indentation, checkboxes) and how changes are written back. Defaults as
   5// in org: `org-list-allow-alphabetical` nil, `org-plain-list-ordered-item-terminator` t,
   6// `org-list-indent-offset` 0, `org-list-demote-modify-bullet` nil,
   7// `org-list-two-spaces-after-bullet-regexp` nil, `org-blank-before-new-entry` items `auto`,
   8// `org-checkbox-hierarchical-statistics` t, `indent-tabs-mode` nil.
   9
  10/// One item of `org-list-struct`.
  11struct ListItem: Equatable {
  12    var pos: Int
  13    var ind: Int
  14    /// With its trailing whitespace.
  15    var bullet: String
  16    var counter: String?
  17    var checkbox: String?
  18    var tag: String?
  19    var end: Int
  20}
  21
  22/// Items in buffer order.
  23typealias ListStruct = [ListItem]
  24
  25extension ListStruct {
  26    func item(_ pos: Int) -> ListItem? { first { $0.pos == pos } }
  27    func index(of pos: Int) -> Int? { firstIndex { $0.pos == pos } }
  28    func ind(_ pos: Int) -> Int { item(pos)!.ind }
  29    func bullet(_ pos: Int) -> String { item(pos)!.bullet }
  30    func checkbox(_ pos: Int) -> String? { item(pos)?.checkbox }
  31    func end(_ pos: Int) -> Int { item(pos)!.end }
  32    mutating func set(_ pos: Int, _ change: (inout ListItem) -> Void) {
  33        if let i = index(of: pos) { change(&self[i]) }
  34    }
  35
  36    var top: Int { self[0].pos }
  37    var bottom: Int { map(\.end).max()! }
  38
  39    /// `org-list-prevs-alist`: item to previous item in its sub-list.
  40    var prevs: [Int: Int] {
  41        var byEnd: [Int: Int] = [:]
  42        for item in self where byEnd[item.end] == nil { byEnd[item.end] = item.pos }
  43        var result: [Int: Int] = [:]
  44        for item in self { if let previous = byEnd[item.pos] { result[item.pos] = previous } }
  45        return result
  46    }
  47
  48    /// `org-list-parents-alist`, in buffer order.
  49    var parents: [(item: Int, parent: Int?)] {
  50        var indToOri: [(ind: Int, origin: Int?)] = [(self[0].ind, nil)]
  51        var previousPositions = [top]
  52        var result: [(Int, Int?)] = [(top, nil)]
  53        for item in dropFirst() {
  54            let previousInd = indToOri[0].ind
  55            previousPositions.insert(item.pos, at: 0)
  56            if previousInd > item.ind {
  57                if let i = indToOri.firstIndex(where: { $0.ind == item.ind }) {
  58                    indToOri.removeFirst(i)
  59                } else if let i = indToOri.firstIndex(where: { $0.ind < item.ind }) {
  60                    indToOri.removeFirst(i)
  61                } else {
  62                    indToOri = [(item.ind, nil)]
  63                }
  64                result.append((item.pos, indToOri[0].origin))
  65            } else if previousInd < item.ind {
  66                let origin = previousPositions[1]
  67                indToOri.insert((item.ind, origin), at: 0)
  68                result.append((item.pos, origin))
  69            } else {
  70                result.append((item.pos, indToOri[0].origin))
  71            }
  72        }
  73        return result
  74    }
  75
  76    func nextItem(_ pos: Int, _ prevs: [Int: Int]) -> Int? { first { prevs[$0.pos] == pos }?.pos }
  77    func previousItem(_ pos: Int, _ prevs: [Int: Int]) -> Int? { prevs[pos] }
  78
  79    func hasChild(_ pos: Int) -> Int? {
  80        guard let i = index(of: pos), i + 1 < count, self[i + 1].ind > self[i].ind else { return nil }
  81        return self[i + 1].pos
  82    }
  83
  84    func subtree(_ pos: Int) -> [Int] {
  85        guard let i = index(of: pos) else { return [] }
  86        let end = self[i].end
  87        return self[(i + 1)...].prefix { $0.pos < end }.map(\.pos)
  88    }
  89
  90    func children(_ pos: Int, _ parents: [(item: Int, parent: Int?)]) -> [Int] {
  91        parents.filter { $0.parent == pos }.map(\.item)
  92    }
  93
  94    func parent(_ pos: Int, _ parents: [(item: Int, parent: Int?)]) -> Int? {
  95        parents.first { $0.item == pos }?.parent ?? nil
  96    }
  97
  98    func listBegin(_ pos: Int, _ prevs: [Int: Int]) -> Int {
  99        var first = pos
 100        while let previous = prevs[first] { first = previous }
 101        return first
 102    }
 103
 104    func lastItem(_ pos: Int, _ prevs: [Int: Int]) -> Int {
 105        var last = pos
 106        while let next = nextItem(last, prevs) { last = next }
 107        return last
 108    }
 109
 110    func allItems(_ pos: Int, _ prevs: [Int: Int]) -> [Int] {
 111        var before: [Int] = []
 112        var item = pos
 113        while let previous = prevs[item] {
 114            before.insert(previous, at: 0)
 115            item = previous
 116        }
 117        var after: [Int] = []
 118        item = pos
 119        while let next = nextItem(item, prevs) {
 120            after.append(next)
 121            item = next
 122        }
 123        return before + [pos] + after
 124    }
 125
 126    enum ListType { case ordered, descriptive, unordered }
 127
 128    func listType(_ pos: Int, _ prevs: [Int: Int]) -> ListType {
 129        let first = item(listBegin(pos, prevs))!
 130        if first.bullet.contains(where: { $0.isLetter || $0.isNumber }) { return .ordered }
 131        return first.tag != nil ? .descriptive : .unordered
 132    }
 133}
 134
 135/// `org-list-struct-assoc-end`: gives each item the first ending at its indentation or less.
 136private func assocEnd(_ structure: inout ListStruct, _ endList: [(ind: Int, pos: Int)]) {
 137    var endings = endList[...]
 138    for i in structure.indices {
 139        let item = structure[i]
 140        while let first = endings.first, first.pos <= item.pos { endings = endings.dropFirst() }
 141        structure[i].end = endings.first { $0.ind <= item.ind }?.pos ?? structure[i].end
 142    }
 143}
 144
 145/// `org-list-bullet-string`: one space after the bullet's text.
 146func bulletString(_ bullet: String) -> String {
 147    guard let range = bullet.range(of: "\\S+([ \\t]*)", options: .regularExpression) else { return bullet }
 148    let text = bullet[range]
 149    let core = text.prefix { !$0.isWhitespace }
 150    return bullet[..<range.lowerBound] + core + " " + bullet[range.upperBound...]
 151}
 152
 153extension EmacsBuffer {
 154    // MARK: - Lines and searching
 155
 156    /// `org-current-text-indentation` of the line at point.
 157    var currentTextIndentation: Int {
 158        var at = lineStart(point)
 159        var column = 0
 160        while let c = character(at: at), c == 32 || c == 9 {
 161            column = c == 9 ? (column / 8 + 1) * 8 : column + 1
 162            at += 1
 163        }
 164        return column
 165    }
 166
 167    private func regex(_ pattern: String, caseInsensitive: Bool) -> NSRegularExpression {
 168        EmacsBuffer.regex(pattern, caseInsensitive: caseInsensitive)
 169    }
 170
 171    /// `looking-at`; with `setsMatch` false, `looking-at-p` or inside `save-match-data`.
 172    func looking(_ pattern: String, caseInsensitive: Bool = true, setsMatch: Bool = true) -> [Range<Int>?]? {
 173        let match = regex(pattern, caseInsensitive: caseInsensitive).firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length))
 174        let groups = match.map(ranges)
 175        if let groups, setsMatch { lastMatch = groups }
 176        return groups
 177    }
 178
 179    private func ranges(_ match: NSTextCheckingResult) -> [Range<Int>?] {
 180        (0..<match.numberOfRanges).map { i in
 181            let range = match.range(at: i)
 182            return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
 183        }
 184    }
 185
 186    /// `re-search-forward` with a bound and no error: point to the match end; nil leaves point.
 187    @discardableResult
 188    func searchForward(_ pattern: String, bound: Int? = nil, caseInsensitive: Bool = true, move: Bool = false) -> [Range<Int>?]? {
 189        let limit = min(bound ?? length, length)
 190        guard point <= limit else { return nil }
 191        guard let match = regex(pattern, caseInsensitive: caseInsensitive).firstMatch(in: string, options: Self.searchOptions, range: NSRange(point..<limit)) else {
 192            if move { point = limit }
 193            return nil
 194        }
 195        let groups = ranges(match)
 196        point = groups[0]!.upperBound
 197        lastMatch = groups
 198        return groups
 199    }
 200
 201    /// `re-search-backward` with a bound and no error: the match closest before point that
 202    /// ends by point; point to its start.
 203    @discardableResult
 204    func searchBackward(_ pattern: String, bound: Int? = nil, caseInsensitive: Bool = true) -> [Range<Int>?]? {
 205        let lower = max(bound ?? 0, 0)
 206        guard lower <= point else { return nil }
 207        let expression = regex(pattern, caseInsensitive: caseInsensitive)
 208        var start = point
 209        while start >= lower {
 210            if let match = expression.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(start..<point)) {
 211                let groups = ranges(match)
 212                point = start
 213                lastMatch = groups
 214                return groups
 215            }
 216            start -= 1
 217        }
 218        return nil
 219    }
 220
 221    /// `indent-to` with spaces.
 222    func indentTo(_ column: Int) {
 223        let current = currentColumn
 224        if column > current { insert(String(repeating: " ", count: column - current)) }
 225    }
 226
 227    /// `indent-line-to` with spaces and a tab width of 8.
 228    func indentLineTo(_ column: Int) {
 229        beginningOfLine()
 230        skipForward(Self.blanks)
 231        let current = currentColumn
 232        if current < column {
 233            if column - (current / 8) * 8 >= 8 {
 234                let end = point
 235                skipBackward([32])
 236                deleteRegion(point, end)
 237            }
 238            indentTo(column)
 239        } else if current > column {
 240            moveToColumn(column, force: true)
 241            let start = point
 242            beginningOfLine()
 243            skipForward(Self.blanks)
 244            deleteRegion(start, point)
 245        }
 246    }
 247
 248    // MARK: - Context
 249
 250    /// `org-item-re`.
 251    var itemPattern: String {
 252        let alpha = options.listAllowAlphabetical ? "|[A-Za-z]" : ""
 253        return "([ \\t]*([-+]|(([0-9]+\(alpha))[.)]))|[ \\t]+\\*)([ \\t]+|$)"
 254    }
 255
 256    var itemBeginningPattern: String { "^" + itemPattern }
 257    static let fullItemPattern = "^[ \\t]*((?:[-+*]|(?:[0-9]+|[A-Za-z])[.)])(?:[ \\t]+|$))(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(?:(\\[[ X-]\\])(?:[ \\t]+|$))?(?:(.*)[ \\t]+::(?:[ \\t]+|$))?"
 258    static let listEndPattern = "^[ \\t]*\\n[ \\t]*\\n"
 259    static let drawerPattern = "^[ \\t]*:([-_\\w]+):[ \\t]*$"
 260    static let forbiddenBlocks = ["example", "verse", "src", "export"]
 261
 262    enum ListContextKind: Equatable { case none, drawer, block(String), invalid }
 263
 264    /// `org-list-context`.
 265    func listContext() -> (up: Int, down: Int, kind: ListContextKind) {
 266        let savedMatch = lastMatch
 267        defer { lastMatch = savedMatch }
 268        return saveExcursion {
 269            beginningOfLine()
 270            let pos = point
 271            var up = saveExcursion { (try? backToHeading()) ?? 0 }
 272            var down = saveExcursion { outlineNextHeading() ? point : length }
 273            var kind = ListContextKind.none
 274            let drawerEnd = "^[ \\t]*:END:"
 275            if looking(Self.drawerPattern) == nil, looking(drawerEnd) == nil {
 276                let found = saveExcursion { () -> (Int, Int)? in
 277                    guard searchBackward(Self.drawerPattern, bound: up) != nil else { return nil }
 278                    let begin = lineEnd(point) + 1
 279                    let end = searchForward(drawerEnd, bound: down).map { $0[0]!.lowerBound - 1 } ?? down
 280                    return end >= pos ? (begin, end) : nil
 281                }
 282                if let (begin, end) = found {
 283                    (up, down, kind) = (begin, end, .drawer)
 284                }
 285            }
 286            let blockPattern = "^[ \\t]*#\\+(begin|end)_"
 287            if looking(blockPattern) == nil {
 288                let found = saveExcursion { () -> (Int, Int, String)? in
 289                    guard searchBackward(blockPattern, bound: up) != nil else { return nil }
 290                    let begin = lineEnd(point) + 1
 291                    guard let open = looking("^[ \\t]*#\\+begin_(\\S+)") else { return nil }
 292                    let type = substring(open[1]!.lowerBound, open[1]!.upperBound).lowercased()
 293                    point = begin
 294                    let close = searchForward(blockPattern, bound: down)
 295                    let end = close.map { _ in lineStart(point) - 1 } ?? down
 296                    guard end >= pos else { return nil }
 297                    let word = close.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) } ?? type
 298                    return word.lowercased() == "end" ? (begin, end, type) : nil
 299                }
 300                if let (begin, end, type) = found {
 301                    (up, down, kind) = (begin, end, Self.forbiddenBlocks.contains(type) ? .invalid : .block(type))
 302                }
 303            }
 304            return (up, down, kind)
 305        }
 306    }
 307
 308    /// `org-in-item-p`: the start of the item holding point, or nil.
 309    func inItem() -> Int? {
 310        saveExcursion {
 311            beginningOfLine()
 312            let context = listContext()
 313            var reference = looking("^[ \\t]*$") != nil ? 10000 : currentTextIndentation
 314            if context.kind == .invalid { return nil }
 315            if looking(itemPattern) != nil { return point }
 316            // Inside `org-list-end-re`: start above it.
 317            let windowStart = saveExcursion { forwardLine(-2); return point }
 318            let windowEnd = saveExcursion { forwardLine(2); return lineEnd(point) }
 319            let expression = regex(Self.listEndPattern, caseInsensitive: true)
 320            for match in expression.matches(in: string, options: Self.searchOptions, range: NSRange(windowStart..<windowEnd)) {
 321                let range = match.range.location..<NSMaxRange(match.range)
 322                if point >= range.lowerBound, point < range.upperBound {
 323                    point = range.lowerBound
 324                    forwardLine(-1)
 325                    break
 326                }
 327            }
 328            while true {
 329                let ind = currentTextIndentation
 330                if looking(itemPattern) != nil, ind < reference { return point }
 331                if point <= context.up { return nil }
 332                if looking(Self.listEndPattern) != nil { return nil }
 333                if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
 334                    continue
 335                }
 336                if looking("^[ \\t]*:END:") != nil, searchBackward(Self.drawerPattern, bound: context.up) != nil {
 337                    beginningOfLine()
 338                    continue
 339                }
 340                if looking("^[ \\t]*$") != nil {
 341                    forwardLine(-1)
 342                    continue
 343                }
 344                if ind == 0 { return nil }
 345                if ind < reference { reference = ind }
 346                forwardLine(-1)
 347            }
 348        }
 349    }
 350
 351    /// `org-at-item-p`: an item line where the element parser sees one, so not in blocks
 352    /// whose contents aren't elements.
 353    var atItem: Bool {
 354        saveExcursion {
 355            beginningOfLine()
 356            switch listContext().kind {
 357            case .invalid, .block("comment"), .block("verse"): return false
 358            default: return looking(itemPattern) != nil
 359            }
 360        }
 361    }
 362
 363    // MARK: - Structure
 364
 365    private func assocAtPoint(_ ind: Int) -> ListItem {
 366        let match = looking(Self.fullItemPattern)!
 367        func group(_ i: Int) -> String? { match[i].map { substring($0.lowerBound, $0.upperBound) } }
 368        let bullet = group(1) ?? ""
 369        let tag = bullet.range(of: "[-+*]", options: .regularExpression) != nil ? group(4) : nil
 370        return ListItem(pos: point, ind: ind, bullet: bullet, counter: group(2), checkbox: group(3), tag: tag, end: 0)
 371    }
 372
 373    /// `org-list-struct` for the list holding point, which is at an item.
 374    func listStruct() -> ListStruct {
 375        saveExcursion {
 376            beginningOfLine()
 377            let context = listContext()
 378            var textMinInd = 10000
 379            var beginCell = (pos: point, ind: currentTextIndentation)
 380            var items: [ListItem] = []
 381            var ends: [(ind: Int, pos: Int)] = []
 382            func endBeforeBlank() -> Int {
 383                skipBackward(Self.whitespace)
 384                return min(lineEnd(point) + 1, context.down)
 385            }
 386            func trimToBegin() {
 387                if let i = items.firstIndex(where: { $0.pos == beginCell.pos }) { items = Array(items[i...]) } else { items = [] }
 388            }
 389            saveExcursion {
 390                while true {
 391                    let ind = currentTextIndentation
 392                    if point <= context.up {
 393                        if looking(itemPattern) == nil {
 394                            trimToBegin()
 395                        } else {
 396                            beginCell = (point, ind)
 397                            items.insert(assocAtPoint(ind), at: 0)
 398                        }
 399                        return
 400                    }
 401                    if looking(Self.listEndPattern) != nil {
 402                        trimToBegin()
 403                        return
 404                    }
 405                    if looking(itemPattern) != nil {
 406                        items.insert(assocAtPoint(ind), at: 0)
 407                        ends.insert((ind, point), at: 0)
 408                        if ind < textMinInd { beginCell = (point, ind) }
 409                        forwardLine(-1)
 410                    } else if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
 411                    } else if looking("^[ \\t]*:END:") != nil, searchBackward(Self.drawerPattern, bound: context.up) != nil {
 412                        beginningOfLine()
 413                    } else if looking("^[ \\t]*$") != nil {
 414                        forwardLine(-1)
 415                    } else if ind == 0 {
 416                        trimToBegin()
 417                        return
 418                    } else {
 419                        if ind < textMinInd { textMinInd = ind }
 420                        ends.insert((ind, point), at: 0)
 421                        forwardLine(-1)
 422                    }
 423                }
 424            }
 425            var items2: [ListItem] = []
 426            var ends2: [(ind: Int, pos: Int)] = []
 427            while true {
 428                let ind = currentTextIndentation
 429                if point >= context.down {
 430                    ends2.append((0, endBeforeBlank()))
 431                    break
 432                }
 433                if looking(Self.listEndPattern) != nil {
 434                    ends2.append((0, point))
 435                    break
 436                }
 437                if looking(itemPattern) != nil {
 438                    items2.append(assocAtPoint(ind))
 439                    ends2.append((ind, point))
 440                    forwardLine(1)
 441                } else if looking("^[ \\t]*$") != nil {
 442                    forwardLine(1)
 443                } else if ind <= beginCell.ind {
 444                    ends2.append((0, endBeforeBlank()))
 445                    break
 446                } else {
 447                    if let last = items2.last, ind <= last.ind { ends2.append((ind, point)) }
 448                    if looking("^[ \\t]*#\\+begin_") != nil, searchForward("^[ \\t]*#\\+end_", bound: context.down) != nil {
 449                    } else if looking(Self.drawerPattern) != nil, searchForward("^[ \\t]*:END:", bound: context.down) != nil {
 450                    }
 451                    forwardLine(1)
 452                }
 453            }
 454            var structure = items + Array(items2.dropFirst())
 455            assocEnd(&structure, ends + Array(ends2.dropFirst()))
 456            return structure
 457        }
 458    }
 459
 460    func itemEndBeforeBlank(_ pos: Int, _ structure: ListStruct) -> Int {
 461        saveExcursion {
 462            point = structure.end(pos)
 463            skipBackward(Self.whitespace)
 464            return lineEnd(point)
 465        }
 466    }
 467
 468    // MARK: - Editing structures
 469
 470    /// `org-list-swap-items`.
 471    func swapItems(_ a: Int, _ b: Int, _ structure: ListStruct) -> ListStruct {
 472        saveExcursion {
 473            let endANoBlank = itemEndBeforeBlank(a, structure)
 474            let endBNoBlank = itemEndBeforeBlank(b, structure)
 475            let endA = structure.end(a), endB = structure.end(b)
 476            let sizeA = endANoBlank - a, sizeB = endBNoBlank - b
 477            let bodyA = substring(a, endANoBlank), bodyB = substring(b, endBNoBlank)
 478            let between = substring(endANoBlank, b)
 479            let subA = [a] + structure.subtree(a), subB = [b] + structure.subtree(b)
 480            point = a
 481            deleteRegion(a, endBNoBlank)
 482            insert(bodyB + between + bodyA)
 483            var result = structure
 484            for i in result.indices {
 485                let pos = result[i].pos, end = result[i].end
 486                if pos < a {
 487                } else if subA.contains(pos) {
 488                    result[i].pos = pos + (endBNoBlank - endANoBlank)
 489                    result[i].end = end + (endBNoBlank - endANoBlank)
 490                    if end == endA { result[i].end = endB }
 491                } else if subB.contains(pos) {
 492                    result[i].pos = pos + a - b
 493                    result[i].end = end + (a - b)
 494                    if end == endB { result[i].end = a + sizeB + (endA - endANoBlank) }
 495                } else if pos < b {
 496                    result[i].pos = pos + (sizeB - sizeA)
 497                    result[i].end = end + (sizeB - sizeA)
 498                }
 499            }
 500            return result.sorted { $0.pos < $1.pos }
 501        }
 502    }
 503
 504    /// `org-list-separating-blank-lines-number`, with point at the item.
 505    func separatingBlankLines(_ pos: Int, _ structure: ListStruct, _ prevs: [Int: Int]) -> Int {
 506        saveExcursion {
 507            let item = point
 508            func countBlanks() -> Int {
 509                saveExcursion {
 510                    point = lineStart(point)
 511                    let start = point
 512                    skipBackward(Self.whitespace)
 513                    forwardLine(1)
 514                    return countLines(point, start)
 515                }
 516            }
 517            if let next = structure.nextItem(item, prevs) {
 518                point = next
 519                return countBlanks()
 520            }
 521            if structure.previousItem(item, prevs) != nil { return countBlanks() }
 522            if pos > itemEndBeforeBlank(item, structure) {
 523                let blanks = saveExcursion { point = pos; return countBlanks() }
 524                if blanks > 0 { return blanks }
 525            }
 526            point = structure.top
 527            if searchForward("^[ \\t]*$", bound: itemEndBeforeBlank(item, structure)) != nil { return 1 }
 528            return 0
 529        }
 530    }
 531
 532    /// `count-lines` between two positions.
 533    func countLines(_ start: Int, _ end: Int) -> Int {
 534        let (lower, upper) = (min(start, end), max(start, end))
 535        guard upper > lower else { return 0 }
 536        let newlines = substring(lower, upper).utf16.filter { $0 == 10 }.count
 537        return character(at: upper - 1) == Self.newline ? newlines : newlines + 1
 538    }
 539
 540    /// `org-list-insert-item`. Leaves point at the new item.
 541    func insertItem(at position: Int, _ structure: ListStruct, _ prevs: [Int: Int], checkbox: Bool, afterBullet: String?, splitLine: Bool = true) -> ListStruct {
 542        var pos = position
 543        var found: Int?
 544        var stopped = false
 545        for entry in structure {
 546            if entry.pos > pos {
 547                stopped = true
 548                break
 549            }
 550            if entry.end < pos { continue }
 551            found = entry.pos
 552        }
 553        guard let item = stopped ? found : (found ?? structure.last?.pos) else { return structure }
 554        let itemEnd = structure.end(item)
 555        let itemEndNoBlank = itemEndBeforeBlank(item, structure)
 556        point = item
 557        let match = looking(Self.fullItemPattern)!
 558        let bodyStart: Int
 559        if match[4] == nil {
 560            bodyStart = match[0]!.upperBound
 561        } else if substring(match[1]!.lowerBound, match[1]!.upperBound).contains(where: { $0 == "." || $0 == ")" }) {
 562            bodyStart = match[4]!.lowerBound
 563        } else {
 564            bodyStart = saveExcursion { point = match[4]!.upperBound; skipForward(Self.blanks); return point }
 565        }
 566        let before = pos <= bodyStart
 567        let blanks = separatingBlankLines(pos, structure, prevs)
 568        let ind = structure.ind(item)
 569        let bullet = bulletString(structure.bullet(item))
 570        let box = checkbox ? "[ ]" : nil
 571        var textCut: String?
 572        if !before, splitLine {
 573            point = pos
 574            if itemEnd < pos { deleteRegion(itemEnd - 1, lineEnd(point)) }
 575            skipBackward(Self.whitespace)
 576            pos = saveExcursion { skipForward(Self.blanks); return point }
 577            textCut = substring(point, itemEndNoBlank)
 578            deleteRegion(point, itemEndNoBlank)
 579        }
 580        var body = bullet + (box.map { $0 + " " } ?? "") + (afterBullet ?? "")
 581        if let textCut {
 582            // `string-match' sets the match data to the string's blanks.
 583            let blanks = textCut.prefix { $0 == " " || $0 == "\t" }.utf16.count
 584            if blanks > 0 { lastMatch = [0..<blanks] }
 585            body += textCut.replacingOccurrences(of: "^[ \\t]+", with: "", options: .regularExpression)
 586        }
 587        let separator = String(repeating: "\n", count: blanks + 1)
 588        let itemSize = ind + (body as NSString).length + (separator as NSString).length
 589        let sizeOffset = itemSize - ((textCut ?? "") as NSString).length
 590        point = item
 591        indentTo(ind)
 592        insert(body + separator)
 593        var result = structure
 594        for i in result.indices {
 595            let p = result[i].pos, end = result[i].end
 596            if p < item {
 597                if end > item { result[i].end = end + sizeOffset }
 598            } else if p == item, !before, splitLine {
 599                result[i].pos = p + itemSize
 600                result[i].end = end + sizeOffset
 601            } else if splitLine, !before, p >= pos, p <= itemEndNoBlank {
 602                let offset = pos - item - ind - (bullet as NSString).length - ((afterBullet ?? "") as NSString).length
 603                result[i].pos = p - offset
 604                result[i].end = end - offset
 605            } else {
 606                result[i].pos = p + sizeOffset
 607                result[i].end = end + sizeOffset
 608            }
 609        }
 610        result.append(ListItem(pos: item, ind: ind, bullet: bullet, counter: nil, checkbox: box, tag: nil, end: item + itemSize))
 611        result.sort { $0.pos < $1.pos }
 612        if before {
 613            point = item
 614        } else {
 615            result = swapItems(item, item + itemSize, result)
 616            point = result.nextItem(item, result.prevs)!
 617        }
 618        return result
 619    }
 620
 621    /// `org-list-delete-item`.
 622    func deleteItem(_ item: Int, _ structure: ListStruct) -> ListStruct {
 623        let end = structure.end(item)
 624        let begin = structure.bottom == end ? saveExcursion { () -> Int in
 625            point = item
 626            skipBackward(Self.whitespace)
 627            return min(lineEnd(point) + 1, length)
 628        } : item
 629        deleteRegion(begin, end)
 630        let size = end - begin
 631        return structure.compactMap { entry in
 632            var entry = entry
 633            if entry.pos < item {
 634                if entry.end == item { entry.end = begin } else if entry.end > item { entry.end -= size }
 635                return entry
 636            }
 637            if entry.pos < end { return nil }
 638            entry.pos -= size
 639            entry.end -= size
 640            return entry
 641        }
 642    }
 643
 644    enum SendDestination { case begin, end }
 645
 646    /// `org-list-send-item` to the start or end of the item's sub-list.
 647    func sendItem(_ item: Int, to destination: SendDestination, _ structure: ListStruct) -> ListStruct {
 648        let prevs = structure.prevs
 649        let itemEnd = structure.end(item)
 650        let bulletEnd = saveExcursion { () -> Int in
 651            point = item
 652            return looking("[ \\t]*" + NSRegularExpression.escapedPattern(for: structure.bullet(item)))![0]!.upperBound
 653        }
 654        let body = substring(bulletEnd, itemEnd).trimmingCharacters(in: .whitespacesAndNewlines)
 655        let insertion: Int
 656        switch destination {
 657        case .begin:
 658            insertion = structure.listBegin(item, prevs)
 659        case .end:
 660            insertion = saveExcursion { () -> Int in
 661                point = structure.lastItem(item, prevs)
 662                return lineEnd(point)
 663            }
 664        }
 665        guard item != insertion else { return structure }
 666        let itemMarker = marker(item)
 667        defer { release(itemMarker) }
 668        var result = insertItem(at: insertion, structure, prevs, checkbox: false, afterBullet: body, splitLine: false)
 669        let moved = [itemMarker.position] + result.subtree(itemMarker.position)
 670        let newEnd = result.end(point)
 671        let oldEnd = result.end(itemMarker.position)
 672        let newItem = point
 673        let shift = point - itemMarker.position
 674        result.removeAll { $0.pos == newItem }
 675        let copies = moved.map { pos -> ListItem in
 676            var cell = result.item(pos)!
 677            cell.end = cell.end == oldEnd ? newEnd : cell.end + shift
 678            cell.pos = pos + shift
 679            return cell
 680        }
 681        result = (result + copies).sorted { $0.pos < $1.pos }
 682        return deleteItem(itemMarker.position, result)
 683    }
 684
 685    // MARK: - Indentation changes
 686
 687    /// `org-list-struct-outdent`.
 688    func structOutdent(_ start: Int, _ end: Int, _ structure: ListStruct, _ parents: [(item: Int, parent: Int?)]) throws -> [(item: Int, parent: Int?)] {
 689        var acc: [Int: Int] = [:]
 690        return try parents.map { cell in
 691            let (item, parent) = cell
 692            if item < start { return cell }
 693            if item >= end {
 694                if let parent, let convert = acc[parent] { return (item, convert) }
 695                return cell
 696            }
 697            guard let parent else { throw UserError(message: "Cannot outdent top-level items") }
 698            if parent >= start {
 699                acc[parent] = item
 700                return cell
 701            }
 702            acc[parent] = item
 703            return (item, structure.parent(parent, parents))
 704        }
 705    }
 706
 707    /// `org-list-struct-indent`.
 708    func structIndent(_ start: Int, _ end: Int, _ structure: ListStruct, _ parents: [(item: Int, parent: Int?)], _ prevs: [Int: Int]) throws -> [(item: Int, parent: Int?)] {
 709        var acc: [(item: Int, parent: Int?)] = []
 710        func remember(_ cell: (item: Int, parent: Int?)) -> (item: Int, parent: Int?) {
 711            acc.insert(cell, at: 0)
 712            return cell
 713        }
 714        return try parents.map { cell in
 715            let (item, parent) = cell
 716            if item < start { return cell }
 717            if item >= end {
 718                if let convert = acc.first(where: { $0.item == parent }) { return (item, convert.parent) }
 719                return cell
 720            }
 721            let previous = structure.previousItem(item, prevs)
 722            guard let previous else {
 723                if parent == nil || parent! < start { throw UserError(message: "Cannot indent the first item of a list") }
 724                return remember(cell)
 725            }
 726            if previous < start { return remember((item, previous)) }
 727            return remember((item, acc.first { $0.item == previous }?.parent ?? nil))
 728        }
 729    }
 730
 731    // MARK: - Repairs
 732
 733    /// Records a successful `string-match' on a string in the match data.
 734    private func stringMatched(_ string: String, _ range: Range<String.Index>) {
 735        let start = string.utf16.distance(from: string.startIndex, to: range.lowerBound)
 736        lastMatch = [start..<(start + string[range].utf16.count)]
 737    }
 738
 739    /// `org-list-inc-bullet-maybe`.
 740    func incrementBullet(_ bullet: String) -> String {
 741        if let range = bullet.range(of: "[0-9]+", options: .regularExpression) {
 742            stringMatched(bullet, range)
 743            return bullet.replacingCharacters(in: range, with: String(Int(bullet[range])! + 1))
 744        }
 745        if let range = bullet.range(of: "[A-Za-z]", options: .regularExpression) {
 746            stringMatched(bullet, range)
 747            let scalar = bullet[range].unicodeScalars.first!.value + 1
 748            return bullet.replacingCharacters(in: range, with: String(Character(Unicode.Scalar(scalar)!)))
 749        }
 750        return bullet
 751    }
 752
 753    /// `org-list-struct-fix-bul` (alphabetical bullets off).
 754    func fixBullets(_ structure: inout ListStruct, _ prevs: [Int: Int]) {
 755        func find(_ pattern: String, _ string: String) -> Range<String.Index>? {
 756            guard let range = string.range(of: pattern, options: .regularExpression) else { return nil }
 757            stringMatched(string, range)
 758            return range
 759        }
 760        for pos in structure.map(\.pos) {
 761            let previous = prevs[pos]
 762            let counter = structure.item(pos)!.counter
 763            let bullet = structure.bullet(pos)
 764            let previousBullet = previous.map { structure.bullet($0) }
 765            let alphap = previous == nil && useAlphabeticalBullets(pos, structure, prevs)
 766            let new: String
 767            if previous != nil, let counter, let previousBullet, find("[a-zA-Z]", counter) != nil, find("[a-zA-Z]", previousBullet) != nil {
 768                let lower = find("[a-z]", previousBullet)
 769                let range = lower ?? find("[A-Z]", previousBullet)!
 770                new = previousBullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
 771            } else if previous != nil, let counter, let previousBullet, let counterRange = find("[0-9]+", counter),
 772                      let range = find("[0-9]+", previousBullet) {
 773                new = previousBullet.replacingCharacters(in: range, with: counter[counterRange])
 774            } else if let previousBullet {
 775                new = incrementBullet(previousBullet)
 776            } else if let counter, useAlphabeticalBullets(pos, structure, prevs), find("[A-Za-z]", counter) != nil, find("[A-Za-z]", bullet) != nil {
 777                let lower = find("[a-z]", bullet)
 778                let range = lower ?? find("[A-Z]", bullet)!
 779                new = bullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
 780            } else if let counter, let counterRange = find("[0-9]+", counter), let range = find("[0-9]+", bullet) {
 781                new = bullet.replacingCharacters(in: range, with: counter[counterRange])
 782            } else if alphap, let range = find("[A-Z]", bullet) {
 783                new = bullet.replacingCharacters(in: range, with: "A")
 784            } else if alphap, let range = find("[a-z]", bullet) {
 785                new = bullet.replacingCharacters(in: range, with: "a")
 786            } else if let range = find("([0-9]+|[A-Za-z])", bullet) {
 787                new = bullet.replacingCharacters(in: range, with: "1")
 788            } else {
 789                new = bullet
 790            }
 791            structure.set(pos) { $0.bullet = bulletString(new) }
 792        }
 793    }
 794
 795    /// `org-list-use-alpha-bul-p`: the list starting at `first` fits in the alphabet.
 796    func useAlphabeticalBullets(_ first: Int, _ structure: ListStruct, _ prevs: [Int: Int]) -> Bool {
 797        guard options.listAllowAlphabetical else { return false }
 798        var item: Int? = first
 799        var ascii = 64
 800        while let current = item {
 801            if let counter = structure.item(current)?.counter, let letter = counter.first, letter.isASCII, letter.isLetter {
 802                ascii = Int(letter.uppercased().unicodeScalars.first!.value)
 803            } else {
 804                ascii += 1
 805            }
 806            if ascii > 90 { return false }
 807            item = structure.nextItem(current, prevs)
 808        }
 809        return true
 810    }
 811
 812    /// `org-list-struct-fix-ind`.
 813    func fixIndentation(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], bulletSize: Int? = nil) {
 814        let topInd = structure.ind(structure.top)
 815        for pos in structure.dropFirst().map(\.pos) {
 816            if let parent = structure.parent(pos, parents) {
 817                let ind = (bulletSize ?? (structure.bullet(parent) as NSString).length) + structure.ind(parent)
 818                structure.set(pos) { $0.ind = ind }
 819            } else {
 820                structure.set(pos) { $0.ind = topInd }
 821            }
 822        }
 823    }
 824
 825    /// `org-list-struct-fix-box` without ORDERED.
 826    func fixCheckboxes(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)]) {
 827        var parentList: [Int] = []
 828        for pos in structure.map(\.pos) {
 829            guard let parent = structure.parent(pos, parents), structure.checkbox(parent) != nil, !parentList.contains(parent) else { continue }
 830            parentList.insert(parent, at: 0)
 831        }
 832        // `sort` in Emacs is stable; ties keep their order.
 833        let ordered = parentList.enumerated().sorted { lhs, rhs in
 834            let (a, b) = (structure.ind(lhs.element), structure.ind(rhs.element))
 835            return a != b ? a > b : lhs.offset < rhs.offset
 836        }.map(\.element)
 837        for item in ordered {
 838            let boxes = structure.children(item, parents).map { structure.checkbox($0) }
 839            let new: String?
 840            if boxes.contains("[ ]"), boxes.contains("[X]") {
 841                new = "[-]"
 842            } else if boxes.contains("[-]") {
 843                new = "[-]"
 844            } else if boxes.contains("[X]") {
 845                new = "[X]"
 846            } else if boxes.contains("[ ]") {
 847                new = "[ ]"
 848            } else {
 849                new = structure.checkbox(item)
 850            }
 851            structure.set(item) { $0.checkbox = new }
 852        }
 853    }
 854
 855    /// `org-list-struct-fix-item-end`.
 856    func fixItemEnds(_ structure: inout ListStruct) {
 857        var endList: [(ind: Int, pos: Int)] = []
 858        var accEnd: [(end: Int, pos: Int)] = []
 859        for item in structure {
 860            if structure.item(item.end) == nil {
 861                let up = accEnd.first { item.end < $0.end }?.pos
 862                endList.insert((up.map { structure.ind($0) + 2 } ?? 0, item.end), at: 0)
 863            }
 864            endList.insert((item.ind, item.pos), at: 0)
 865            accEnd.insert((item.end, item.pos), at: 0)
 866        }
 867        let sorted = endList.enumerated().sorted { $0.element.pos != $1.element.pos ? $0.element.pos < $1.element.pos : $0.offset < $1.offset }.map(\.element)
 868        assocEnd(&structure, sorted)
 869    }
 870
 871    /// The previous match data, with groups it lacks as unmatched.
 872    private var staleMatch: [Range<Int>?] {
 873        lastMatch + Array(repeating: nil, count: max(0, 6 - lastMatch.count))
 874    }
 875
 876    /// `org-list-struct-apply-struct`.
 877    func applyStruct(_ structure: ListStruct, _ old: ListStruct) {
 878        let origin = marker(point)
 879        defer {
 880            point = origin.position
 881            release(origin)
 882        }
 883        func shiftBody(end: Int, begin: Int, delta: Int, ind: Int?) {
 884            point = end
 885            skipBackward(Self.whitespace)
 886            beginningOfLine()
 887            while point > begin || (point == begin && looking(itemPattern) == nil) {
 888                if looking("^[ \\t]*\\S", setsMatch: false) != nil {
 889                    indentLineTo(max(currentTextIndentation + delta, ind.map { $0 + 1 } ?? -1))
 890                }
 891                if point == 0 { break }
 892                forwardLine(-1)
 893            }
 894        }
 895        func modify(_ item: Int) {
 896            point = item
 897            let newInd = structure.ind(item)
 898            let oldInd = currentTextIndentation
 899            let newBullet = bulletString(structure.bullet(item))
 900            let oldBullet = old.bullet(item)
 901            let newBox = structure.checkbox(item)
 902            // A failed `looking-at' leaves the previous match data, which org then uses; this
 903            // happens when a split item takes a sub-list with it.
 904            var match = looking(Self.fullItemPattern) ?? staleMatch
 905            if oldBullet != newBullet {
 906                var keepSpace = ""
 907                saveExcursion {
 908                    let bulletRange = match[1]!
 909                    if bulletRange.lowerBound <= origin.position, origin.position <= bulletRange.upperBound {
 910                        let spaces = saveExcursion { () -> String? in
 911                            point = origin.position
 912                            return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
 913                        }
 914                        keepSpace = spaces ?? ""
 915                    }
 916                    replace(bulletRange, with: "")
 917                    point = bulletRange.lowerBound
 918                    insertBeforeMarkers(newBullet)
 919                    insert(keepSpace)
 920                }
 921            }
 922            point = item
 923            match = looking(Self.fullItemPattern) ?? staleMatch
 924            let oldBox = match[3].map { substring($0.lowerBound, $0.upperBound) }
 925            if oldBox == newBox {
 926            } else if let boxRange = match[3], let newBox {
 927                replace(boxRange, with: newBox)
 928            } else if oldBox != nil {
 929                let box = looking(".*?([ \\t]*\\[[ X-]\\])")!
 930                replace(box[1]!, with: "")
 931            } else if let newBox {
 932                let counterEnd = match[2]?.upperBound
 933                point = counterEnd.map { $0 + 1 } ?? match[1]!.upperBound
 934                insert(newBox + (counterEnd == nil ? " " : ""))
 935            }
 936            if newInd != oldInd {
 937                point = lineStart(point)
 938                let start = point
 939                skipForward(Self.blanks)
 940                deleteRegion(start, point)
 941                indentTo(newInd)
 942            }
 943        }
 944        var endList: [(end: Int, up: Int?)] = []
 945        var accEnd: [(end: Int, pos: Int)] = []
 946        var shifts: [Int: (delta: Int, ind: Int)] = [:]
 947        for entry in old {
 948            let pos = entry.pos
 949            let indNew = structure.ind(pos), indOld = entry.ind
 950            let delta = (indNew + (structure.bullet(pos) as NSString).length) - (indOld + (entry.bullet as NSString).length)
 951            shifts[pos] = (delta, indNew)
 952            if old.item(entry.end) == nil {
 953                endList.insert((entry.end, accEnd.first { entry.end < $0.end }?.pos), at: 0)
 954            }
 955            accEnd.insert((entry.end, pos), at: 0)
 956        }
 957        var uniqueEnds: [Int] = []
 958        for end in endList.map(\.end) where !uniqueEnds.contains(end) { uniqueEnds.append(end) }
 959        var allEnds = (old.map(\.pos) + uniqueEnds).sorted()
 960        accEnd.reverse()
 961        var slices: [(down: Int, up: Int, delta: Int, ind: Int?)] = []
 962        while allEnds.count > 1 {
 963            let up = allEnds.removeFirst()
 964            let down = allEnds[0]
 965            let isItem = structure.item(up) != nil
 966            let delta: Int
 967            if isItem {
 968                delta = shifts[up]!.delta
 969            } else {
 970                let child = accEnd.first { $0.end == up }!.pos
 971                let ind = structure.ind(child)
 972                var minInd = Int.max
 973                saveExcursion {
 974                    point = up
 975                    while point < down {
 976                        if looking("[ \\t]*$", setsMatch: false) == nil {
 977                            minInd = min(currentTextIndentation, minInd)
 978                            if let open = looking("#\\+BEGIN(:|_\\S+)") {
 979                                let name = NSRegularExpression.escapedPattern(for: substring(open[1]!.lowerBound, open[1]!.upperBound))
 980                                searchForward("^[ \\t]*#\\+END\(name)[ \\t]*$", bound: down)
 981                            } else if looking(Self.drawerPattern) != nil {
 982                                searchForward("^[ \\t]*:END:[ \\t]*$", bound: down)
 983                            }
 984                        }
 985                        if forwardLine(1) != 0 { break }
 986                    }
 987                }
 988                delta = ind - minInd
 989            }
 990            slices.insert((down, up, delta, isItem ? shifts[up]!.ind : nil), at: 0)
 991        }
 992        for slice in slices {
 993            if slice.delta != 0 { shiftBody(end: slice.down, begin: slice.up, delta: slice.delta, ind: slice.ind) }
 994            if let cell = structure.item(slice.up), cell != old.item(slice.up) { modify(slice.up) }
 995        }
 996    }
 997
 998    /// `org-list-write-struct`.
 999    func writeStruct(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], old: ListStruct? = nil) {
1000        let old = old ?? structure
1001        fixIndentation(&structure, parents, bulletSize: 2)
1002        fixItemEnds(&structure)
1003        let prevs = structure.prevs
1004        fixBullets(&structure, prevs)
1005        fixIndentation(&structure, parents)
1006        fixCheckboxes(&structure, parents)
1007        applyStruct(structure, old)
1008    }
1009}