krz/orgstar

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

Sources/OrgCore/Commands/ElectricPair.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgCore/Commands/ElectricPair.swift history · blame · raw

436 lines · 18230 bytes

  1import Foundation
  2
  3// `electric-pair-mode` (Emacs 31.1 elec-pair.el) in an Org buffer, with its defaults:
  4// `electric-pair-preserve-balance`, skipping whitespace before a closer, deleting adjacent
  5// pairs and opening a line between them. Delimiters are what `org-mode-syntax-table` makes
  6// them: () [] {} <> and "; inside a string, `prog-mode-syntax-table`'s.
  7
  8/// A syntax table's view of a character, as far as pairing goes.
  9private enum SyntaxClass: Equatable {
 10    case open(unichar), close(unichar), string, escape, other
 11}
 12
 13private enum SyntaxTable {
 14    case org, text
 15
 16    func classify(_ c: unichar) -> SyntaxClass {
 17        switch c {
 18        case 40: return .open(41)
 19        case 91: return .open(93)
 20        case 123: return .open(125)
 21        case 41: return .close(40)
 22        case 93: return .close(91)
 23        case 125: return .close(123)
 24        case 34: return .string
 25        case 60: return self == .org ? .open(62) : .other
 26        case 62: return self == .org ? .close(60) : .other
 27        // Org makes the backslash a symbol constituent; the standard table escapes with it.
 28        case 92: return self == .org ? .other : .escape
 29        default: return .other
 30        }
 31    }
 32
 33    /// `matching-paren`.
 34    func matching(_ c: unichar) -> unichar? {
 35        switch classify(c) {
 36        case .open(let m), .close(let m): return m
 37        default: return nil
 38        }
 39    }
 40}
 41
 42/// The text being typed into, with point, as the hook functions see the buffer.
 43private final class PairBuffer {
 44    let s: NSMutableString
 45    var point: Int
 46
 47    init(_ text: String, point: Int) {
 48        s = NSMutableString(string: text)
 49        self.point = point
 50    }
 51
 52    var length: Int { s.length }
 53    func char(_ i: Int) -> unichar? { i >= 0 && i < s.length ? s.character(at: i) : nil }
 54    var charAfter: unichar? { char(point) }
 55    var charBefore: unichar? { char(point - 1) }
 56
 57    func insert(_ text: String, at i: Int) {
 58        s.insert(text, at: i)
 59        if point >= i { point += (text as NSString).length }
 60    }
 61
 62    func delete(_ range: Range<Int>) {
 63        s.deleteCharacters(in: NSRange(location: range.lowerBound, length: range.count))
 64        if point > range.upperBound { point -= range.count } else if point > range.lowerBound { point = range.lowerBound }
 65    }
 66
 67    // MARK: Parsing, as `parse-partial-sexp` and `scan-sexps`
 68
 69    struct State {
 70        var depth = 0
 71        /// Positions of the open parens enclosing the end, innermost last (nth 9).
 72        var opens: [Int] = []
 73        /// The string's delimiter while inside one (nth 3).
 74        var stringChar: unichar?
 75        /// Where that string starts (nth 8).
 76        var stringStart: Int?
 77    }
 78
 79    func parse(from: Int, to: Int, table: SyntaxTable) -> State {
 80        var state = State()
 81        var i = from
 82        while i < to, let c = char(i) {
 83            let cls = table.classify(c)
 84            if let delimiter = state.stringChar {
 85                if cls == .escape { i += 2; continue }
 86                if c == delimiter { state.stringChar = nil; state.stringStart = nil }
 87            } else {
 88                switch cls {
 89                case .open: state.depth += 1; state.opens.append(i)
 90                case .close: state.depth -= 1; if !state.opens.isEmpty { state.opens.removeLast() }
 91                case .string: state.stringChar = c; state.stringStart = i
 92                case .escape: i += 2; continue
 93                case .other: break
 94                }
 95            }
 96            i += 1
 97        }
 98        return state
 99    }
100
101    /// `syntax-ppss`: the buffer's table, from the start.
102    func ppss(_ pos: Int) -> State { parse(from: 0, to: pos, table: .org) }
103
104    /// `electric-pair--syntax-ppss` with WHERE `(string comment)`: inside a string, its text
105    /// parsed with the text table.
106    func pairPpss(_ pos: Int) -> State {
107        let quick = ppss(pos)
108        if quick.stringChar != nil, let start = quick.stringStart {
109            return parse(from: start + 1, to: pos, table: .text)
110        }
111        return quick
112    }
113
114    enum ScanResult {
115        case done
116        /// "Containing expression ends prematurely", and where it stopped.
117        case premature(Int)
118        case unbalanced
119    }
120
121    /// `scan-sexps` from `from` over as many sexps as there are, forward or back.
122    func scan(from: Int, forward: Bool, table: SyntaxTable) -> ScanResult {
123        var depth = 0
124        if forward {
125            var i = from
126            while let c = char(i) {
127                switch table.classify(c) {
128                case .open: depth += 1
129                case .close:
130                    depth -= 1
131                    if depth < 0 { return .premature(i + 1) }
132                case .string:
133                    var j = i + 1
134                    while let d = char(j), d != c { j += table.classify(d) == .escape ? 2 : 1 }
135                    if j >= length { return .unbalanced }
136                    i = j
137                case .escape: i += 1
138                case .other: break
139                }
140                i += 1
141            }
142        } else {
143            var i = from - 1
144            while i >= 0, let c = char(i) {
145                if quoted(i, table) { i -= 1; continue }
146                switch table.classify(c) {
147                case .close: depth += 1
148                case .open:
149                    depth -= 1
150                    if depth < 0 { return .premature(i) }
151                case .string:
152                    var j = i - 1
153                    while j >= 0, !(char(j) == c && !quoted(j, table)) { j -= 1 }
154                    if j < 0 { return .unbalanced }
155                    i = j
156                default: break
157                }
158                i -= 1
159            }
160        }
161        return depth > 0 ? .unbalanced : .done
162    }
163
164    /// `char-quoted`: an odd number of escapes before `i`.
165    private func quoted(_ i: Int, _ table: SyntaxTable) -> Bool {
166        var n = 0
167        var j = i - 1
168        while j >= 0, let c = char(j), table.classify(c) == .escape { n += 1; j -= 1 }
169        return n % 2 == 1
170    }
171
172    /// `scan-sexps` forward by one from the open paren at `from`: past its closer, or nil on an
173    /// error.
174    func scanOne(from: Int, table: SyntaxTable) -> Int? {
175        var depth = 0
176        var i = from
177        while let c = char(i) {
178            switch table.classify(c) {
179            case .open: depth += 1
180            case .close:
181                depth -= 1
182                if depth < 0 { return nil }
183                if depth == 0 { return i + 1 }
184            case .string:
185                var j = i + 1
186                while let d = char(j), d != c { j += table.classify(d) == .escape ? 2 : 1 }
187                if j >= length { return nil }
188                i = j
189                if depth == 0 { return i + 1 }
190            case .escape: i += 1
191            case .other: break
192            }
193            i += 1
194        }
195        return nil
196    }
197
198    // MARK: elec-pair.el
199
200    struct Info {
201        enum Syntax { case open, close, string }
202        let syntax: Syntax
203        let pair: unichar
204        let unconditional: Bool
205        /// Where the string point is in starts.
206        let stringStart: Int?
207    }
208
209    /// `electric-quote-chars` pairs, from `electric-pair-pairs` (and the text pairs, the same).
210    static let fallback: [(unichar, unichar)] = [(34, 34), (0x2018, 0x2019), (0x201C, 0x201D)]
211
212    /// `electric-pair-syntax-info`.
213    func info(_ c: unichar) -> Info? {
214        let pre = point == 0 || ppss(point - 1).stringStart != nil
215        let post = ppss(point).stringStart
216        let stringStart = pre ? post : nil
217        let table: SyntaxTable = stringStart == nil ? .org : .text
218        switch table.classify(c) {
219        case .open(let m): return Info(syntax: .open, pair: m, unconditional: false, stringStart: stringStart)
220        case .close(let m): return Info(syntax: .close, pair: m, unconditional: false, stringStart: stringStart)
221        case .string: return Info(syntax: .string, pair: c, unconditional: false, stringStart: stringStart)
222        default: break
223        }
224        if let direct = Self.fallback.first(where: { $0.0 == c }) {
225            return direct.0 == direct.1
226                ? Info(syntax: .string, pair: c, unconditional: true, stringStart: stringStart)
227                : Info(syntax: .open, pair: direct.1, unconditional: true, stringStart: stringStart)
228        }
229        if let reverse = Self.fallback.first(where: { $0.1 == c }) {
230            return Info(syntax: .close, pair: reverse.0, unconditional: true, stringStart: stringStart)
231        }
232        return nil
233    }
234
235    struct Side {
236        var matched: Bool
237        var pair: unichar?
238    }
239
240    /// `electric-pair--balance-info`: the innermost and outermost lists around point.
241    func balance(forward: Bool, stringStart: Int?) -> (innermost: Side, outermost: Side) {
242        let table: SyntaxTable = stringStart == nil ? .org : .text
243        var innermost: Side?
244        var outermost: Side?
245        let saved = point
246        defer { point = saved }
247        while outermost == nil {
248            switch scan(from: point, forward: forward, table: table) {
249            case .done, .unbalanced:
250                outermost = Side(matched: true)
251                if innermost == nil { innermost = Side(matched: true) }
252            case .premature(let pos):
253                if stringStart != nil, ppss(point).stringStart == nil {
254                    outermost = Side(matched: true)
255                    if innermost == nil { innermost = Side(matched: true) }
256                    break
257                }
258                point = pos
259                let matched: Bool
260                if forward {
261                    let state = pairPpss(point - 1)
262                    if let start = state.opens.last, let opener = char(start) {
263                        matched = charBefore == (table.matching(opener) ?? opener)
264                    } else {
265                        matched = false
266                    }
267                } else if let end = scanOne(from: point, table: table), let closer = char(end - 1) {
268                    matched = charAfter == table.matching(closer)
269                } else {
270                    matched = false
271                }
272                let actual = forward ? charBefore : charAfter
273                if innermost == nil { innermost = Side(matched: matched, pair: actual) }
274                if !matched { outermost = Side(matched: matched, pair: actual) }
275            }
276        }
277        return (innermost!, outermost!)
278    }
279
280    /// `electric-pair-inhibit-if-helps-balance`, with the typed character before point.
281    func inhibit(_ c: unichar) -> Bool {
282        guard let info = info(c) else { return false }
283        let at = point - 1
284        delete(at..<point)
285        defer { insert(String(utf16CodeUnits: [c], count: 1), at: at) }
286        switch info.syntax {
287        case .open:
288            let outermost = balance(forward: true, stringStart: info.stringStart).outermost
289            return outermost.matched ? false : outermost.pair == info.pair
290        case .string:
291            return ppss(length).stringChar == c
292        case .close:
293            return false
294        }
295    }
296
297    /// `electric-pair-skip-if-helps-balance`, with the typed character before point.
298    func skip(_ c: unichar) -> Bool {
299        guard let info = info(c) else { return false }
300        let at = point - 1
301        delete(at..<point)
302        defer { insert(String(utf16CodeUnits: [c], count: 1), at: at) }
303        switch info.syntax {
304        case .close:
305            let (innermost, outermost) = balance(forward: false, stringStart: info.stringStart)
306            if outermost.matched { return innermost.matched }
307            return innermost.matched && outermost.pair != info.pair
308        case .string:
309            return ppss(point).stringChar == c
310        case .open:
311            return false
312        }
313    }
314
315    /// `electric-pair--skip-whitespace`, unless that leaves or enters a string.
316    func skipWhitespace() {
317        let saved = point
318        let before = ppss(point).stringStart
319        while let c = charAfter, c == 9 || c == 32 || c == 10 { point += 1 }
320        if ppss(point).stringStart != before { point = saved }
321    }
322}
323
324public enum ElectricPair {
325    /// Typing `c` into `text` with `selection` (point at its upper end, mark at its lower):
326    /// the text and point that `self-insert-command` and `electric-pair-post-self-insert-function`
327    /// leave.
328    public static func type(_ c: unichar, text: String, selection: Range<Int>) -> (text: String, point: Int) {
329        let b = PairBuffer(text, point: selection.upperBound)
330        let typed = String(utf16CodeUnits: [c], count: 1)
331        b.insert(typed, at: b.point)
332        let pos = b.point
333        guard let info = b.info(c) else { return (b.s as String, b.point) }
334        if !selection.isEmpty {
335            // `use-region-p`: the region is wrapped.
336            let mark = selection.lowerBound
337            let pair = String(utf16CodeUnits: [info.pair], count: 1)
338            if info.syntax == .string || info.syntax == .close {
339                b.insert(pair, at: mark)
340            } else {
341                b.delete(pos - 1..<pos)
342                b.insert(pair, at: b.point)
343                b.point = mark
344                b.insert(typed, at: b.point)
345            }
346            return (b.s as String, b.point)
347        }
348        if info.syntax == .close || info.syntax == .string {
349            let skipsSelf = info.unconditional || b.skip(c)
350            if skipsSelf {
351                let skipsSpace = !(info.unconditional && info.syntax == .string)
352                let saved = b.point
353                if skipsSpace { b.skipWhitespace() }
354                let lands = b.charAfter == c
355                b.point = saved
356                if lands {
357                    if skipsSpace { b.skipWhitespace() }
358                    b.delete(pos - 1..<pos)
359                    b.point += 1
360                    return (b.s as String, b.point)
361                }
362            }
363        }
364        if info.syntax == .open || info.syntax == .string, info.unconditional || !b.inhibit(c) {
365            b.insert(String(utf16CodeUnits: [info.pair], count: 1), at: b.point)
366            b.point = pos
367        }
368        return (b.s as String, b.point)
369    }
370
371    /// DEL at `caret` between an opening delimiter and its pair: both go
372    /// (`electric-pair-delete-pair`). Nil leaves DEL as it is.
373    public static func deletePair(text: String, caret: Int) -> Range<Int>? {
374        let b = PairBuffer(text, point: caret)
375        guard let prev = b.charBefore, let next = b.charAfter, let info = b.info(prev),
376              info.syntax != .close, info.pair == next else { return nil }
377        return caret - 1..<caret + 1
378    }
379
380    /// RET at `caret` between an open paren and its closer: a second newline after point
381    /// (`electric-pair-open-newline-between-pairs`).
382    public static func opensLine(text: String, caret: Int) -> Bool {
383        let b = PairBuffer(text, point: caret)
384        guard caret > 0, caret < b.length, let prev = b.charBefore, let next = b.charAfter else { return false }
385        return SyntaxTable.org.matching(next) == prev
386    }
387}
388
389/// A key typed with `electric-pair-mode` on: a pairing character, DEL or RET. It doesn't
390/// apply where the key types as it would without the mode.
391public struct ElectricPairKey: OrgCommand {
392    public let key: String
393    public init(key: String) { self.key = key }
394    public var id: String { "internal.electric-pair" }
395    public var title: String { "Electric Pair" }
396
397    /// The characters `electric-pair-syntax-info` knows in an Org buffer.
398    public static let characters: Set<String> = ["(", ")", "[", "]", "{", "}", "<", ">", "\"", "\u{2018}", "\u{2019}", "\u{201C}", "\u{201D}"]
399
400    public func applies(in context: EditContext) -> Bool { result(context) != nil }
401
402    public func run(in context: EditContext) -> CommandStep {
403        guard let result = result(context) else { return .failed("") }
404        return .commit(result)
405    }
406
407    private func result(_ context: EditContext) -> EditResult? {
408        guard context.options.electricPair else { return nil }
409        let selection = context.selection.first ?? context.caret..<context.caret
410        let ns = context.text as NSString
411        switch key {
412        case "DEL":
413            guard selection.isEmpty, let range = ElectricPair.deletePair(text: context.text, caret: selection.lowerBound) else { return nil }
414            return EditResult(baseRevision: context.revision, edits: [TextEdit(range: range, replacement: "")], selection: [range.lowerBound..<range.lowerBound])
415        case "RET":
416            guard selection.isEmpty, ElectricPair.opensLine(text: context.text, caret: selection.lowerBound) else { return nil }
417            let at = selection.lowerBound
418            return EditResult(baseRevision: context.revision, edits: [TextEdit(range: at..<at, replacement: "\n\n")], selection: [at + 1..<at + 1])
419        default:
420            guard Self.characters.contains(key), let unit = key.utf16.first else { return nil }
421            let typed = ElectricPair.type(unit, text: context.text, selection: selection)
422            let plain = ns.replacingCharacters(in: NSRange(location: selection.lowerBound, length: selection.count), with: key)
423            if typed.text == plain, typed.point == selection.lowerBound + 1 { return nil }
424            // One replacement: what lies between the common start and end.
425            let new = typed.text as NSString
426            var start = 0
427            while start < ns.length, start < new.length, ns.character(at: start) == new.character(at: start) { start += 1 }
428            var end = 0
429            while end < ns.length - start, end < new.length - start,
430                  ns.character(at: ns.length - 1 - end) == new.character(at: new.length - 1 - end) { end += 1 }
431            let replacement = new.substring(with: NSRange(location: start, length: new.length - start - end))
432            return EditResult(baseRevision: context.revision, edits: [TextEdit(range: start..<ns.length - end, replacement: replacement)],
433                              selection: [typed.point..<typed.point])
434        }
435    }
436}