Commit ec4eda648b
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +15 −3
| @@ -15,10 +15,17 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 15 | public var logRepeat: Bool | 15 | public var logRepeat: Bool |
| 16 | /// `fill-column` for M-q. | 16 | /// `fill-column` for M-q. |
| 17 | public var fillColumn: Int | 17 | public var fillColumn: Int |
| 18 | /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. | ||
| 19 | public var hideEmphasisMarkers: Bool | ||
| 20 | /// `org-pretty-entities`: table alignment measures `\alpha` as `α`. | ||
| 21 | public var prettyEntities: Bool | ||
| 22 | /// `org-use-sub-superscripts` `{}`: only `_{…}` and `^{…}` are sub- and superscripts. | ||
| 23 | public var subSuperscriptsNeedBraces: Bool | ||
| 18 | 24 | ||
| 19 | public init( | 25 | public init( |
| 20 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, | 26 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, |
| 21 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70 | 27 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, |
| 28 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false | ||
| 22 | ) { | 29 | ) { |
| 23 | self.tagsColumn = tagsColumn | 30 | self.tagsColumn = tagsColumn |
| 24 | self.insertHeadingRespectContent = insertHeadingRespectContent | 31 | self.insertHeadingRespectContent = insertHeadingRespectContent |
| @@ -26,6 +33,9 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 26 | self.listAllowAlphabetical = listAllowAlphabetical | 33 | self.listAllowAlphabetical = listAllowAlphabetical |
| 27 | self.logRepeat = logRepeat | 34 | self.logRepeat = logRepeat |
| 28 | self.fillColumn = fillColumn | 35 | self.fillColumn = fillColumn |
| 36 | self.hideEmphasisMarkers = hideEmphasisMarkers | ||
| 37 | self.prettyEntities = prettyEntities | ||
| 38 | self.subSuperscriptsNeedBraces = subSuperscriptsNeedBraces | ||
| 29 | } | 39 | } |
| 30 | 40 | ||
| 31 | /// Org's own defaults. | 41 | /// Org's own defaults. |
| @@ -151,11 +161,13 @@ public enum Commands { | |||
| 151 | InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), | 161 | InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), |
| 152 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), | 162 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), |
| 153 | InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), | 163 | InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), |
| 154 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), | 164 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), |
| 155 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 165 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 156 | ] | 166 | ] |
| 157 | 167 | ||
| 168 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } | ||
| 169 | |||
| 158 | public static func command(_ id: String) -> (any OrgCommand)? { | 170 | public static func command(_ id: String) -> (any OrgCommand)? { |
| 159 | all.first { $0.id == id } | 171 | everything.first { $0.id == id } |
| 160 | } | 172 | } |
| 161 | } | 173 | } |
Sources/OrgCore/Commands/EmacsBuffer.swift +30 −5
| @@ -25,6 +25,7 @@ final class EmacsBuffer { | |||
| 25 | var point: Int | 25 | var point: Int |
| 26 | private var markers: [Marker] = [] | 26 | private var markers: [Marker] = [] |
| 27 | let settings: OrgSettings | 27 | let settings: OrgSettings |
| 28 | private var visibleCache: [String: VisibleLine] = [:] | ||
| 28 | /// Groups of the last successful match, as Emacs's match data. | 29 | /// Groups of the last successful match, as Emacs's match data. |
| 29 | var lastMatch: [Range<Int>?] = [] | 30 | var lastMatch: [Range<Int>?] = [] |
| 30 | /// The clock and calendar commands see. | 31 | /// The clock and calendar commands see. |
| @@ -206,14 +207,38 @@ final class EmacsBuffer { | |||
| 206 | static let blanks: Set<unichar> = [32, 9] | 207 | static let blanks: Set<unichar> = [32, 9] |
| 207 | static let whitespace: Set<unichar> = [32, 9, 10, 13] | 208 | static let whitespace: Set<unichar> = [32, 9, 10, 13] |
| 208 | 209 | ||
| 209 | var currentColumn: Int { column(of: substring(lineStart(point), point)) } | 210 | /// Display column of point, counting only what Emacs shows. |
| 211 | var currentColumn: Int { | ||
| 212 | let start = lineStart(point) | ||
| 213 | return visibleLine(start).column(at: point - start) | ||
| 214 | } | ||
| 215 | |||
| 216 | /// The display widths of the line starting at `start`, seen with three lines on either | ||
| 217 | /// side so that links spanning lines are whole. Cached by that text. | ||
| 218 | func visibleLine(_ start: Int) -> VisibleLine { | ||
| 219 | let (from, to) = saveExcursion { () -> (Int, Int) in | ||
| 220 | point = start | ||
| 221 | forwardLine(-3) | ||
| 222 | let from = point | ||
| 223 | point = start | ||
| 224 | forwardLine(3) | ||
| 225 | return (from, lineEnd(point)) | ||
| 226 | } | ||
| 227 | let context = substring(from, to) | ||
| 228 | let range = (start - from)..<(lineEnd(start) - from) | ||
| 229 | let key = context + "\u{0}\(range.lowerBound)" | ||
| 230 | if let cached = visibleCache[key] { return cached } | ||
| 231 | let line = VisibleLine(context, line: range, options: options, settings: settings) | ||
| 232 | if visibleCache.count > 256 { visibleCache.removeAll() } | ||
| 233 | visibleCache[key] = line | ||
| 234 | return line | ||
| 235 | } | ||
| 210 | 236 | ||
| 211 | /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a | 237 | /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a |
| 212 | /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it. | 238 | /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it. |
| 213 | func moveToColumn(_ target: Int, force: Bool = false) { | 239 | func moveToColumn(_ target: Int, force: Bool = false) { |
| 214 | let start = lineStart(point) | 240 | let start = lineStart(point) |
| 215 | let line = LineBuffer(substring(start, lineEnd(point)), caret: nil) | 241 | point = start + visibleLine(start).offset(ofColumn: target) |
| 216 | point = start + line.offset(ofColumn: target) | ||
| 217 | guard force, currentColumn > target, character(at: point - 1) == 9 else { return } | 242 | guard force, currentColumn > target, character(at: point - 1) == 9 else { return } |
| 218 | let tabEnd = currentColumn | 243 | let tabEnd = currentColumn |
| 219 | point -= 1 | 244 | point -= 1 |
| @@ -415,14 +440,14 @@ final class EmacsBuffer { | |||
| 415 | let end = lineEnd(offset) | 440 | let end = lineEnd(offset) |
| 416 | let line = substring(start, end) | 441 | let line = substring(start, end) |
| 417 | var plain = LineBuffer(line, caret: nil) | 442 | var plain = LineBuffer(line, caret: nil) |
| 418 | OrgCore.alignTags(&plain, tagsColumn: options.tagsColumn) | 443 | OrgCore.alignTags(&plain, options: options) |
| 419 | guard plain.string != line else { return } | 444 | guard plain.string != line else { return } |
| 420 | func map(_ position: Int) -> Int { | 445 | func map(_ position: Int) -> Int { |
| 421 | guard position >= start, position <= end else { | 446 | guard position >= start, position <= end else { |
| 422 | return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position | 447 | return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position |
| 423 | } | 448 | } |
| 424 | var buffer = LineBuffer(line, caret: position - start) | 449 | var buffer = LineBuffer(line, caret: position - start) |
| 425 | OrgCore.alignTags(&buffer, tagsColumn: options.tagsColumn) | 450 | OrgCore.alignTags(&buffer, options: options) |
| 426 | return start + buffer.caret! | 451 | return start + buffer.caret! |
| 427 | } | 452 | } |
| 428 | let newPoint = map(point) | 453 | let newPoint = map(point) |
Sources/OrgCore/Commands/Fill.swift +5 −1
| @@ -34,7 +34,11 @@ extension EmacsBuffer { | |||
| 34 | /// The innermost element (not object) holding `offset`, as `org-element-at-point`. Greater | 34 | /// The innermost element (not object) holding `offset`, as `org-element-at-point`. Greater |
| 35 | /// blocks (quote, center, special) are parsed into their elements. | 35 | /// blocks (quote, center, special) are parsed into their elements. |
| 36 | func element(at offset: Int) -> Element? { | 36 | func element(at offset: Int) -> Element? { |
| 37 | element(at: offset, in: string, base: 0) | 37 | // Elements never cross a heading, so the section around `offset` is enough to parse. |
| 38 | let lineBegin = lineStart(offset) | ||
| 39 | let from = headingLevel(atLine: lineBegin) != nil ? lineBegin : (previousHeadingStart(before: lineBegin) ?? 0) | ||
| 40 | let to = nextHeadingStart(after: lineBegin) ?? length | ||
| 41 | return element(at: offset, in: substring(from, to), base: from) | ||
| 38 | } | 42 | } |
| 39 | 43 | ||
| 40 | private func element(at offset: Int, in text: String, base: Int) -> Element? { | 44 | private func element(at offset: Int, in text: String, base: Int) -> Element? { |
Sources/OrgCore/Commands/HeadingCommands.swift +13 −29
| @@ -111,18 +111,6 @@ struct LineBuffer { | |||
| 111 | let delta = edit(position..<position, string) | 111 | let delta = edit(position..<position, string) |
| 112 | caret = position + delta | 112 | caret = position + delta |
| 113 | } | 113 | } |
| 114 | |||
| 115 | /// Offset of display column `target` in the line, as `move-to-column`. | ||
| 116 | func offset(ofColumn target: Int) -> Int { | ||
| 117 | var column = 0 | ||
| 118 | var offset = 0 | ||
| 119 | for character in string { | ||
| 120 | if column >= target { break } | ||
| 121 | column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) | ||
| 122 | offset += String(character).utf16.count | ||
| 123 | } | ||
| 124 | return offset | ||
| 125 | } | ||
| 126 | } | 114 | } |
| 127 | 115 | ||
| 128 | extension HeadingLine { | 116 | extension HeadingLine { |
| @@ -156,25 +144,29 @@ extension HeadingLine { | |||
| 156 | /// Commands align under `save-excursion`, where the caret is a marker: in the blanks before | 144 | /// Commands align under `save-excursion`, where the caret is a marker: in the blanks before |
| 157 | /// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the | 145 | /// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the |
| 158 | /// caret's column instead, as org does for point. | 146 | /// caret's column instead, as org does for point. |
| 159 | func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) { | 147 | func alignTags(_ buffer: inout LineBuffer, options: EditingOptions = .org, preservingColumn: Bool = false) { |
| 160 | let line = buffer.string | 148 | let line = buffer.string |
| 161 | guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return } | 149 | guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return } |
| 162 | let matched = String(line[match]) | 150 | let matched = String(line[match]) |
| 163 | let tags = matched.trimmingCharacters(in: .whitespaces) | 151 | let tags = matched.trimmingCharacters(in: .whitespaces) |
| 164 | let blankStart = (String(line[..<match.lowerBound]) as NSString).length | 152 | let blankStart = (String(line[..<match.lowerBound]) as NSString).length |
| 165 | let tagsStart = blankStart + (matched as NSString).range(of: tags).location | 153 | let tagsStart = blankStart + (matched as NSString).range(of: tags).location |
| 166 | let prefixColumn = column(of: String(line[..<match.lowerBound])) | 154 | // Columns as Emacs displays the line: link markup and, with the options, emphasis markers |
| 167 | let currentColumn = column(of: (line as NSString).substring(to: tagsStart)) | 155 | // don't count. |
| 156 | let visible = VisibleLine(line, options: options) | ||
| 157 | let prefixColumn = visible.column(at: blankStart) | ||
| 158 | let currentColumn = visible.column(at: tagsStart) | ||
| 159 | let tagsColumn = options.tagsColumn | ||
| 168 | let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags) | 160 | let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags) |
| 169 | let newColumn = max(target, prefixColumn + 1) | 161 | let newColumn = max(target, prefixColumn + 1) |
| 170 | guard newColumn != currentColumn else { return } | 162 | guard newColumn != currentColumn else { return } |
| 171 | let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false | 163 | let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false |
| 172 | let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil | 164 | let caretColumn: Int? = inBlanks && preservingColumn ? visible.column(at: buffer.caret!) : nil |
| 173 | let old = buffer.caret | 165 | let old = buffer.caret |
| 174 | buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn)) | 166 | buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn)) |
| 175 | let delta = (newColumn - prefixColumn) - (tagsStart - blankStart) | 167 | let delta = (newColumn - prefixColumn) - (tagsStart - blankStart) |
| 176 | if let caretColumn { | 168 | if let caretColumn { |
| 177 | buffer.caret = buffer.offset(ofColumn: caretColumn) | 169 | buffer.caret = VisibleLine(buffer.string, options: options).offset(ofColumn: caretColumn) |
| 178 | } else if inBlanks { | 170 | } else if inBlanks { |
| 179 | buffer.caret = blankStart | 171 | buffer.caret = blankStart |
| 180 | } else if let old, old > tagsStart { | 172 | } else if let old, old > tagsStart { |
| @@ -182,14 +174,6 @@ func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColu | |||
| 182 | } | 174 | } |
| 183 | } | 175 | } |
| 184 | 176 | ||
| 185 | /// Display column at the end of `text`, starting from column 0. | ||
| 186 | func column(of text: String) -> Int { | ||
| 187 | var column = 0 | ||
| 188 | for character in text { | ||
| 189 | column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) | ||
| 190 | } | ||
| 191 | return column | ||
| 192 | } | ||
| 193 | 177 | ||
| 194 | // MARK: - TODO | 178 | // MARK: - TODO |
| 195 | 179 | ||
| @@ -256,7 +240,7 @@ public struct TodoCycle: OrgCommand { | |||
| 256 | } | 240 | } |
| 257 | } | 241 | } |
| 258 | buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ") | 242 | buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ") |
| 259 | alignTags(&buffer, tagsColumn: context.options.tagsColumn) | 243 | alignTags(&buffer, options: context.options) |
| 260 | } | 244 | } |
| 261 | } | 245 | } |
| 262 | } | 246 | } |
| @@ -304,7 +288,7 @@ struct PriorityChange { | |||
| 304 | buffer.insert(cookie + " ", at: line.titleStart - line.start) | 288 | buffer.insert(cookie + " ", at: line.titleStart - line.start) |
| 305 | } | 289 | } |
| 306 | } | 290 | } |
| 307 | alignTags(&buffer, tagsColumn: context.options.tagsColumn) | 291 | alignTags(&buffer, options: context.options) |
| 308 | } | 292 | } |
| 309 | } | 293 | } |
| 310 | 294 | ||
| @@ -350,7 +334,7 @@ public struct PromoteHeading: OrgCommand { | |||
| 350 | guard line.level > 1 else { return .failed("Cannot promote to level 0") } | 334 | guard line.level > 1 else { return .failed("Cannot promote to level 0") } |
| 351 | return line.commit(context) { buffer in | 335 | return line.commit(context) { buffer in |
| 352 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ") | 336 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ") |
| 353 | alignTags(&buffer, tagsColumn: context.options.tagsColumn) | 337 | alignTags(&buffer, options: context.options) |
| 354 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) | 338 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) |
| 355 | } | 339 | } |
| 356 | } | 340 | } |
| @@ -367,7 +351,7 @@ public struct DemoteHeading: OrgCommand { | |||
| 367 | let line = HeadingLine(heading) | 351 | let line = HeadingLine(heading) |
| 368 | return line.commit(context) { buffer in | 352 | return line.commit(context) { buffer in |
| 369 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ") | 353 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ") |
| 370 | alignTags(&buffer, tagsColumn: context.options.tagsColumn) | 354 | alignTags(&buffer, options: context.options) |
| 371 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) | 355 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) |
| 372 | } | 356 | } |
| 373 | } | 357 | } |
Sources/OrgCore/Commands/Table.swift added +631
| @@ -0,0 +1,631 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Org tables (org-table.el, Org 9.8.7): alignment and the editing commands, with | ||
| 4 | // `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t, no shrunk columns, | ||
| 5 | // `org-table-fix-formulas-confirm` nil (formulas are fixed silently). | ||
| 6 | |||
| 7 | extension EmacsBuffer { | ||
| 8 | static let numberPattern = "^([<>]?[-+^.0-9]*[0-9][-+^.0-9eEdDx()%:]*|[<>]?[-+]?0[xX][0-9a-fA-F.]+|[<>]?[-+]?[0-9]+#[0-9a-zA-Z.]+|nan|[-+u]?inf)$" | ||
| 9 | |||
| 10 | /// `org-at-table-p`: an org table line outside blocks. | ||
| 11 | var atTable: Bool { | ||
| 12 | guard lookingAtLine("^[ \\t]*\\|") else { return false } | ||
| 13 | return element(at: lineStart(point))?.kind == .table || element(at: lineStart(point))?.kind == .tableRow | ||
| 14 | } | ||
| 15 | |||
| 16 | var atTableHline: Bool { lookingAtLine("^[ \\t]*\\|-") } | ||
| 17 | |||
| 18 | func lookingAtLine(_ pattern: String) -> Bool { | ||
| 19 | saveExcursion { | ||
| 20 | beginningOfLine() | ||
| 21 | return looking(pattern, caseInsensitive: false, setsMatch: false) != nil | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | /// `org-table-begin`. | ||
| 26 | func tableBegin() -> Int { | ||
| 27 | saveExcursion { | ||
| 28 | searchBackward("^[ \\t]*[^| \\t]", caseInsensitive: false).map { _ in lineEnd(point) + 1 } ?? 0 | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | /// `org-table-end`. | ||
| 33 | func tableEnd() -> Int { | ||
| 34 | saveExcursion { | ||
| 35 | if let match = searchForward("^[ \\t]*[^| \\t]", caseInsensitive: false) { return match[0]!.lowerBound } | ||
| 36 | point = length | ||
| 37 | skipBackward(Self.blanks) | ||
| 38 | return bolp ? point : lineEnd(point) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | /// `org-table-to-lisp`: rows of trimmed fields, nil for rules. | ||
| 43 | func tableToLisp() -> [[String]?] { | ||
| 44 | saveExcursion { | ||
| 45 | point = tableBegin() | ||
| 46 | var table: [[String]?] = [] | ||
| 47 | while true { | ||
| 48 | skipForward(Self.blanks) | ||
| 49 | guard character(at: point) == 124 else { break } | ||
| 50 | point += 1 | ||
| 51 | if character(at: point) == 45 { | ||
| 52 | table.append(nil) | ||
| 53 | } else { | ||
| 54 | var row: [String] = [] | ||
| 55 | while true { | ||
| 56 | skipForward(Self.blanks) | ||
| 57 | if eolp { break } | ||
| 58 | let start = point | ||
| 59 | while let c = character(at: point), c != 124, c != 10 { point += 1 } | ||
| 60 | let next = eolp ? point : point + 1 | ||
| 61 | skipBackward(Self.blanks, bound: start) | ||
| 62 | row.append(substring(start, point)) | ||
| 63 | point = next | ||
| 64 | } | ||
| 65 | table.append(row) | ||
| 66 | } | ||
| 67 | if forwardLine(1) != 0 { break } | ||
| 68 | } | ||
| 69 | return table | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | /// `org-string-width` of a cell as Emacs shows it. | ||
| 74 | func cellWidth(_ cell: String) -> Int { | ||
| 75 | VisibleLine(cell, options: options, settings: settings).width | ||
| 76 | } | ||
| 77 | |||
| 78 | /// `org-table--align-field`. | ||
| 79 | func alignField(_ field: String, width: Int, alignment: Character, fieldWidth: Int) -> String { | ||
| 80 | let spaces = width - fieldWidth | ||
| 81 | let prefix: Int | ||
| 82 | switch alignment { | ||
| 83 | case "r": prefix = spaces | ||
| 84 | case "c": prefix = spaces / 2 | ||
| 85 | default: prefix = 0 | ||
| 86 | } | ||
| 87 | return " " + String(repeating: " ", count: prefix) + field + String(repeating: " ", count: spaces - prefix) + " " | ||
| 88 | } | ||
| 89 | |||
| 90 | /// `org-table-align`, keeping point in the same field. | ||
| 91 | func tableAlign() { | ||
| 92 | let begin = tableBegin() | ||
| 93 | let end = marker(tableEnd()) | ||
| 94 | defer { release(end) } | ||
| 95 | let line = marker(lineStart(point)) | ||
| 96 | defer { release(line) } | ||
| 97 | let column = tableCurrentColumn() | ||
| 98 | point = begin | ||
| 99 | let table = tableToLisp() | ||
| 100 | let rows = table.compactMap { $0 } | ||
| 101 | var widths: [Int] = [] | ||
| 102 | var alignments: [Character] = [] | ||
| 103 | var widthCache: [String: Int] = [:] | ||
| 104 | func width(_ cell: String) -> Int { | ||
| 105 | if let cached = widthCache[cell] { return cached } | ||
| 106 | let value = cellWidth(cell) | ||
| 107 | widthCache[cell] = value | ||
| 108 | return value | ||
| 109 | } | ||
| 110 | let numberRegex = Self.regex(Self.numberPattern, caseInsensitive: false) | ||
| 111 | var columns = 1 | ||
| 112 | if rows.isEmpty { | ||
| 113 | let lineEndOffset = lineEnd(point) | ||
| 114 | columns += substring(point, lineEndOffset).filter { $0 == "+" }.count | ||
| 115 | widths = Array(repeating: 1, count: columns) | ||
| 116 | alignments = Array(repeating: "l", count: columns) | ||
| 117 | } else { | ||
| 118 | columns = rows.map(\.count).max() ?? 1 | ||
| 119 | for i in 0..<columns { | ||
| 120 | var maxWidth = 1 | ||
| 121 | var fixed: Character? | ||
| 122 | var numbers = 0, nonEmpty = 0 | ||
| 123 | for row in rows { | ||
| 124 | let cell = i < row.count ? row[i] : "" | ||
| 125 | maxWidth = max(maxWidth, width(cell)) | ||
| 126 | if fixed != nil || cell.isEmpty { continue } | ||
| 127 | if let cookie = cell.firstMatch(of: /^<([lrc])[0-9]*>$/) { | ||
| 128 | fixed = cookie.1.first | ||
| 129 | } else { | ||
| 130 | nonEmpty += 1 | ||
| 131 | if numberRegex.firstMatch(in: cell, range: NSRange(location: 0, length: (cell as NSString).length)) != nil { numbers += 1 } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | widths.append(maxWidth) | ||
| 135 | alignments.append(fixed ?? (Double(numbers) >= 0.5 * Double(nonEmpty) ? "r" : "l")) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | let rule = widths.map { String(repeating: "-", count: $0 + 2) }.joined(separator: "+") | ||
| 139 | let indent = looking("[ \\t]*\\|", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? "|" | ||
| 140 | for row in table { | ||
| 141 | let previous = substring(point, lineEnd(point)) | ||
| 142 | let new: String | ||
| 143 | if let row { | ||
| 144 | let fields = row + Array(repeating: "", count: max(0, columns - row.count)) | ||
| 145 | new = indent + zip(fields, zip(widths, alignments)).map { field, spec in | ||
| 146 | alignField(field, width: spec.0, alignment: spec.1, fieldWidth: width(field)) | ||
| 147 | }.joined(separator: "|") + "|" | ||
| 148 | } else { | ||
| 149 | new = indent + rule + "|" | ||
| 150 | } | ||
| 151 | if new == previous { | ||
| 152 | forwardLine(1) | ||
| 153 | } else { | ||
| 154 | insert(new + "\n") | ||
| 155 | deleteRegion(point, min(length, lineEnd(point) + 1)) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | point = line.position | ||
| 159 | tableGotoColumn(column) | ||
| 160 | } | ||
| 161 | |||
| 162 | /// `org-table-current-column`. | ||
| 163 | func tableCurrentColumn() -> Int { | ||
| 164 | saveExcursion { | ||
| 165 | let pos = point | ||
| 166 | beginningOfLine() | ||
| 167 | let text = substring(point, pos) | ||
| 168 | guard let first = text.firstIndex(of: "|") else { return 0 } | ||
| 169 | let separators: Set<Character> = atTableHline ? ["+", "|"] : ["|"] | ||
| 170 | return 1 + text[text.index(after: first)...].filter { separators.contains($0) }.count | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | /// `org-table-goto-column`. | ||
| 175 | func tableGotoColumn(_ n: Int, onDelim: Bool = false, force: Bool = false) { | ||
| 176 | beginningOfLine() | ||
| 177 | guard n > 0 else { return } | ||
| 178 | var remaining = n | ||
| 179 | while remaining > 0 { | ||
| 180 | remaining -= 1 | ||
| 181 | if let match = searchForward("\\|", bound: lineEnd(point), caseInsensitive: false) { | ||
| 182 | _ = match | ||
| 183 | } else if force { | ||
| 184 | endOfLine() | ||
| 185 | skipBackwardNot([124]) | ||
| 186 | insert(" | ") | ||
| 187 | } else { | ||
| 188 | break | ||
| 189 | } | ||
| 190 | } | ||
| 191 | if force, looking(".*\\|", caseInsensitive: false, setsMatch: false) == nil { | ||
| 192 | saveExcursion { | ||
| 193 | endOfLine() | ||
| 194 | insert(" | ") | ||
| 195 | } | ||
| 196 | } | ||
| 197 | if onDelim { | ||
| 198 | point -= 1 | ||
| 199 | } else if character(at: point) == 32 { | ||
| 200 | point += 1 | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | /// `skip-chars-backward "^|"`. | ||
| 205 | func skipBackwardNot(_ set: Set<unichar>) { | ||
| 206 | while point > lineStart(point), let c = character(at: point - 1), !set.contains(c) { point -= 1 } | ||
| 207 | } | ||
| 208 | |||
| 209 | /// `org-table-current-dline`. | ||
| 210 | func tableCurrentDataLine() -> Int { | ||
| 211 | saveExcursion { | ||
| 212 | let pos = lineStart(point) | ||
| 213 | point = tableBegin() | ||
| 214 | var count = 0 | ||
| 215 | while point <= pos { | ||
| 216 | if looking("^[ \\t]*\\|[^-]", caseInsensitive: false, setsMatch: false) != nil { count += 1 } | ||
| 217 | if forwardLine(1) != 0 { break } | ||
| 218 | } | ||
| 219 | return count | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | /// `org-table-clean-line`. | ||
| 224 | func cleanTableLine(_ line: String) -> String { | ||
| 225 | if line.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil { | ||
| 226 | return String(line.map { $0 == "|" || $0 == "+" ? "|" : " " }) | ||
| 227 | } | ||
| 228 | var result = line | ||
| 229 | let regex = try! NSRegularExpression(pattern: "\\|([ \\t]*?[^ \\t\\r\\n|][^\\r\\n|]*)\\|") | ||
| 230 | while let match = regex.firstMatch(in: result, range: NSRange(location: 0, length: (result as NSString).length)) { | ||
| 231 | let cell = (result as NSString).substring(with: match.range(at: 1)) | ||
| 232 | result = (result as NSString).replacingCharacters(in: match.range, with: "|" + String(repeating: " ", count: cellWidth(cell)) + "|") | ||
| 233 | } | ||
| 234 | return result | ||
| 235 | } | ||
| 236 | |||
| 237 | /// `org-table-insert-row`; `below` as with a prefix argument. | ||
| 238 | func tableInsertRow(below: Bool) throws { | ||
| 239 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 240 | if eobp { saveExcursion { insert("\n") } } | ||
| 241 | if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() } | ||
| 242 | let line = substring(lineStart(point), lineEnd(point)) | ||
| 243 | var new = cleanTableLine(line) | ||
| 244 | if let match = line.range(of: "^[ \\t]*\\| *[#*$] *\\|", options: .regularExpression), let target = new.range(of: "^[ \\t]*\\| *[#*$] *\\|", options: .regularExpression) ?? new.range(of: "^[ \\t]*\\|[^|]*\\|", options: .regularExpression) { | ||
| 245 | new.replaceSubrange(target, with: line[match]) | ||
| 246 | } | ||
| 247 | forwardLine(below ? 1 : 0) | ||
| 248 | if !bolp { insert("\n") } | ||
| 249 | insertBeforeMarkers(new + "\n") | ||
| 250 | forwardLine(-1) | ||
| 251 | searchForward("\\| ?", bound: lineEnd(point), caseInsensitive: false) | ||
| 252 | // `org-table-may-need-update' is set by any edit to a table, so org realigns here. | ||
| 253 | tableAlign() | ||
| 254 | fixFormulas("@", replace: [:], limit: tableCurrentDataLine() - 1, delta: 1) | ||
| 255 | } | ||
| 256 | |||
| 257 | /// `org-table-insert-hline`; `above` as with a prefix argument. | ||
| 258 | func tableInsertHline(above: Bool) throws { | ||
| 259 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 260 | if eobp { saveExcursion { insert("\n") } } | ||
| 261 | if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() } | ||
| 262 | var line = cleanTableLine(substring(lineStart(point), lineEnd(point))) | ||
| 263 | let column = currentColumn | ||
| 264 | let regex = try! NSRegularExpression(pattern: "\\|( +)\\|") | ||
| 265 | while let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) { | ||
| 266 | line = (line as NSString).replacingCharacters(in: match.range, with: "+" + String(repeating: "-", count: match.range(at: 1).length) + "|") | ||
| 267 | } | ||
| 268 | if let plus = line.firstIndex(of: "+") { line.replaceSubrange(plus...plus, with: "|") } | ||
| 269 | forwardLine(above ? 0 : 1) | ||
| 270 | insert(line + "\n") | ||
| 271 | forwardLine(above ? 0 : -2) | ||
| 272 | moveToColumn(column) | ||
| 273 | } | ||
| 274 | |||
| 275 | /// What `org-table-maybe-eval-formula` does to point: `org-table-get-field` leaves it one | ||
| 276 | /// character into the field. | ||
| 277 | func moveIntoField() { | ||
| 278 | skipBackwardNot([124, 10]) | ||
| 279 | if bolp || looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { return } | ||
| 280 | let start = point | ||
| 281 | point = min(lineEnd(point), start + 1) | ||
| 282 | } | ||
| 283 | |||
| 284 | /// `org-table-next-field`. | ||
| 285 | func tableNextField() throws { | ||
| 286 | moveIntoField() | ||
| 287 | tableAlign() | ||
| 288 | let end = tableEnd() | ||
| 289 | if atTableHline { endOfLine() } | ||
| 290 | func toNextBar() -> Bool { searchForward("\\|", bound: end, caseInsensitive: false) != nil } | ||
| 291 | var ok = toNextBar() | ||
| 292 | if ok, looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { ok = toNextBar() } | ||
| 293 | guard ok else { | ||
| 294 | try tableInsertRow(below: true) | ||
| 295 | return | ||
| 296 | } | ||
| 297 | if looking("-", caseInsensitive: false, setsMatch: false) != nil, let match = searchForward("^[ \\t]*\\|([^-])", bound: end, caseInsensitive: false) { | ||
| 298 | point = match[1]!.lowerBound | ||
| 299 | } | ||
| 300 | if looking("-", caseInsensitive: false, setsMatch: false) != nil { | ||
| 301 | forwardLine(-1) | ||
| 302 | try tableInsertRow(below: true) | ||
| 303 | } else if character(at: point) == 32 { | ||
| 304 | point += 1 | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | /// `org-table-previous-field`. | ||
| 309 | func tablePreviousField() throws { | ||
| 310 | tableAlign() | ||
| 311 | if atTableHline { endOfLine() } | ||
| 312 | let start = tableBegin() | ||
| 313 | let origin = point | ||
| 314 | func back() -> Bool { | ||
| 315 | guard let match = searchBackward("\\|", bound: start, caseInsensitive: false) else { return false } | ||
| 316 | point = match[0]!.lowerBound | ||
| 317 | return true | ||
| 318 | } | ||
| 319 | var ok = back() && back() | ||
| 320 | while ok, looking("\\|(?:-|[ \\t]*$)", caseInsensitive: false, setsMatch: false) != nil { ok = back() } | ||
| 321 | guard ok else { | ||
| 322 | point = origin | ||
| 323 | throw UserError(message: "Cannot move to previous table field") | ||
| 324 | } | ||
| 325 | if let bar = looking("\\| ?", caseInsensitive: false, setsMatch: false) { point = bar[0]!.upperBound } | ||
| 326 | } | ||
| 327 | |||
| 328 | /// `org-table-next-row`. | ||
| 329 | func tableNextRow() throws { | ||
| 330 | moveIntoField() | ||
| 331 | tableAlign() | ||
| 332 | let column = tableCurrentColumn() | ||
| 333 | forwardLine(1) | ||
| 334 | if !bolp { insert("\n") } | ||
| 335 | if !atTable || atTableHline { | ||
| 336 | forwardLine(-1) | ||
| 337 | try tableInsertRow(below: true) | ||
| 338 | } | ||
| 339 | tableGotoColumn(column) | ||
| 340 | skipBackwardNot([124, 10, 13]) | ||
| 341 | if character(at: point) == 32 { point += 1 } | ||
| 342 | } | ||
| 343 | |||
| 344 | /// `org-table-move-row`. | ||
| 345 | func tableMoveRow(up: Bool) throws { | ||
| 346 | let column = currentColumn | ||
| 347 | let pos = point | ||
| 348 | let hline1 = atTableHline | ||
| 349 | let dline1 = tableCurrentDataLine() | ||
| 350 | let dline2 = dline1 + (up ? -1 : 1) | ||
| 351 | if up, lineStart(point) == 0 { throw UserError(message: "Cannot move row further") } | ||
| 352 | forwardLine(up ? -1 : 1) | ||
| 353 | if (!up && eobp) || !atTable { | ||
| 354 | point = pos | ||
| 355 | throw UserError(message: "Cannot move row further") | ||
| 356 | } | ||
| 357 | let hline2 = atTableHline | ||
| 358 | point = pos | ||
| 359 | let rowStart = lineStart(point) | ||
| 360 | let rowEnd = min(length, lineEnd(point) + 1) | ||
| 361 | let row = substring(rowStart, rowEnd) | ||
| 362 | deleteRegion(rowStart, rowEnd) | ||
| 363 | forwardLine(up ? -1 : 1) | ||
| 364 | if !bolp { insert("\n") } | ||
| 365 | insert(row) | ||
| 366 | if !bolp { insert("\n") } | ||
| 367 | forwardLine(-1) | ||
| 368 | moveToColumn(column) | ||
| 369 | if !hline1, !hline2 { | ||
| 370 | fixFormulas("@", replace: [String(dline1): String(dline2), String(dline2): String(dline1)]) | ||
| 371 | } | ||
| 372 | } | ||
| 373 | |||
| 374 | /// `org-table-move-column`. | ||
| 375 | func tableMoveColumn(left: Bool) throws { | ||
| 376 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 377 | try findDataLine() | ||
| 378 | try checkInsideDataField() | ||
| 379 | let column = tableCurrentColumn() | ||
| 380 | let first = left ? column - 1 : column | ||
| 381 | let target = left ? column - 1 : column + 1 | ||
| 382 | let begin = tableBegin() | ||
| 383 | let end = marker(tableEnd()) | ||
| 384 | defer { release(end) } | ||
| 385 | if left, column == 1 { throw UserError(message: "Cannot move column further left") } | ||
| 386 | if !left, looking("[^|\\n]*\\|[^|\\n]*$", caseInsensitive: false, setsMatch: false) != nil { throw UserError(message: "Cannot move column further right") } | ||
| 387 | let line = marker(lineStart(point)) | ||
| 388 | point = begin | ||
| 389 | while point < end.position { | ||
| 390 | if !atTableHline { | ||
| 391 | tableGotoColumn(first, onDelim: true) | ||
| 392 | if let match = looking("\\|([^|\\n]+)\\|([^|\\n]+)\\|", caseInsensitive: false, setsMatch: false) { | ||
| 393 | let a = substring(match[1]!.lowerBound, match[1]!.upperBound) | ||
| 394 | let b = substring(match[2]!.lowerBound, match[2]!.upperBound) | ||
| 395 | replace(match[1]!.lowerBound..<match[2]!.upperBound, with: b + "|" + a) | ||
| 396 | } | ||
| 397 | } | ||
| 398 | if forwardLine(1) != 0 { break } | ||
| 399 | } | ||
| 400 | point = line.position | ||
| 401 | release(line) | ||
| 402 | tableGotoColumn(column) | ||
| 403 | tableGotoColumn(target) | ||
| 404 | tableAlign() | ||
| 405 | fixFormulas("$", replace: [String(column): String(target), String(target): String(column)]) | ||
| 406 | } | ||
| 407 | |||
| 408 | /// `org-table-insert-column`. | ||
| 409 | func tableInsertColumn() throws { | ||
| 410 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 411 | if eobp { saveExcursion { insert("\n") } } | ||
| 412 | if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() } | ||
| 413 | try findDataLine() | ||
| 414 | let column = max(1, tableCurrentColumn()) | ||
| 415 | let begin = tableBegin() | ||
| 416 | let end = marker(tableEnd()) | ||
| 417 | defer { release(end) } | ||
| 418 | saveExcursion { | ||
| 419 | point = begin | ||
| 420 | while point < end.position { | ||
| 421 | if !atTableHline { | ||
| 422 | tableGotoColumn(column, onDelim: true) | ||
| 423 | insert("|") | ||
| 424 | } | ||
| 425 | if forwardLine(1) != 0 { break } | ||
| 426 | } | ||
| 427 | } | ||
| 428 | tableGotoColumn(column) | ||
| 429 | tableAlign() | ||
| 430 | fixFormulas("$", replace: [:], limit: column - 1, delta: 1) | ||
| 431 | } | ||
| 432 | |||
| 433 | /// `org-table-delete-column`. | ||
| 434 | func tableDeleteColumn() throws { | ||
| 435 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 436 | try findDataLine() | ||
| 437 | if saveExcursion({ () -> Bool in skipForward(Self.blanks); return eolp }) { | ||
| 438 | if let match = searchBackward("\\|", caseInsensitive: false) { point = match[0]!.lowerBound } | ||
| 439 | } | ||
| 440 | try checkInsideDataField() | ||
| 441 | let column = tableCurrentColumn() | ||
| 442 | let begin = tableBegin() | ||
| 443 | let end = marker(tableEnd()) | ||
| 444 | defer { release(end) } | ||
| 445 | let line = marker(lineStart(point)) | ||
| 446 | point = begin | ||
| 447 | while point < end.position { | ||
| 448 | if !atTableHline { | ||
| 449 | tableGotoColumn(column, onDelim: true) | ||
| 450 | if let match = looking("\\|[^|\\n]+\\|", caseInsensitive: false, setsMatch: false) { replace(match[0]!, with: "|") } | ||
| 451 | } | ||
| 452 | if forwardLine(1) != 0 { break } | ||
| 453 | } | ||
| 454 | point = line.position | ||
| 455 | release(line) | ||
| 456 | tableGotoColumn(column) | ||
| 457 | tableAlign() | ||
| 458 | fixFormulas("$", replace: [String(column): "INVALID"], limit: column, delta: -1, remove: column) | ||
| 459 | } | ||
| 460 | |||
| 461 | /// `org-table-kill-row`. | ||
| 462 | func tableKillRow() throws { | ||
| 463 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 464 | let column = currentColumn | ||
| 465 | let dline = atTableHline ? nil : tableCurrentDataLine() | ||
| 466 | deleteRegion(lineStart(point), min(lineEnd(point) + 1, length)) | ||
| 467 | if !atTable { forwardLine(-1) } | ||
| 468 | moveToColumn(column) | ||
| 469 | if let dline { fixFormulas("@", replace: [String(dline): "INVALID"], limit: dline, delta: -1, remove: dline) } | ||
| 470 | } | ||
| 471 | |||
| 472 | /// `org-table-find-dataline`. | ||
| 473 | func findDataLine() throws { | ||
| 474 | guard atTableHline else { return } | ||
| 475 | let column = currentColumn | ||
| 476 | let end = tableEnd() | ||
| 477 | forwardLine(1) | ||
| 478 | while point < end, atTableHline { forwardLine(1) } | ||
| 479 | if point >= end { throw UserError(message: "Cannot find data row for column operation") } | ||
| 480 | moveToColumn(column) | ||
| 481 | } | ||
| 482 | |||
| 483 | /// `org-table-check-inside-data-field`. | ||
| 484 | func checkInsideDataField() throws { | ||
| 485 | let atLineStart = saveExcursion { () -> Bool in skipBackward(Self.blanks); return bolp } | ||
| 486 | if atLineStart || atTableHline || looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { | ||
| 487 | throw UserError(message: "Not in table data field") | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | /// `org-table-fix-formulas`. | ||
| 492 | func fixFormulas(_ key: String, replace replacements: [String: String], limit: Int? = nil, delta: Int = 0, remove: Int? = nil) { | ||
| 493 | saveExcursion { | ||
| 494 | point = tableEnd() | ||
| 495 | while looking("[ \\t]*#\\+tblfm:", setsMatch: false) != nil { | ||
| 496 | if let remove { | ||
| 497 | let pattern = key == "$" ? "(@[0-9]+)?\\$\(remove)=.*?(::|$)" : "@\(remove)\\$[0-9]+=.*?(::|$)" | ||
| 498 | saveExcursion { | ||
| 499 | while let match = searchForward(pattern, bound: lineEnd(point), caseInsensitive: false) { | ||
| 500 | replace(match[0]!, with: "") | ||
| 501 | point = match[0]!.lowerBound | ||
| 502 | } | ||
| 503 | } | ||
| 504 | } | ||
| 505 | let lineStartOffset = point | ||
| 506 | let pattern = NSRegularExpression.escapedPattern(for: key) + "([0-9]+)" | ||
| 507 | while let match = searchForward(pattern, bound: lineEnd(point), caseInsensitive: false) { | ||
| 508 | let number = substring(match[1]!.lowerBound, match[1]!.upperBound) | ||
| 509 | if let new = replacements[number] { | ||
| 510 | replace(match[0]!, with: key + new) | ||
| 511 | } else if let limit, let n = Int(number), n > limit { | ||
| 512 | replace(match[0]!, with: key + String(n + delta)) | ||
| 513 | } | ||
| 514 | } | ||
| 515 | _ = lineStartOffset | ||
| 516 | if forwardLine(1) != 0 { break } | ||
| 517 | } | ||
| 518 | } | ||
| 519 | } | ||
| 520 | |||
| 521 | /// `org-table-create` with a size like `3x2`. | ||
| 522 | func tableCreate(_ size: String) throws { | ||
| 523 | let parts = size.split(separator: "x").map { $0.trimmingCharacters(in: .whitespaces) } | ||
| 524 | guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]) else { throw UserError(message: "Size must be like 5x2") } | ||
| 525 | let pos = point | ||
| 526 | let indent = String(repeating: " ", count: currentColumn) | ||
| 527 | let line = indent + "|" + String(repeating: " |", count: columns) + "\n" | ||
| 528 | if substring(lineStart(point), point).allSatisfy({ $0 == " " || $0 == "\t" }) { | ||
| 529 | beginningOfLine() | ||
| 530 | } else { | ||
| 531 | insert("\n") | ||
| 532 | } | ||
| 533 | for _ in 0..<rows { insert(line) } | ||
| 534 | point = pos | ||
| 535 | if rows > 1 { | ||
| 536 | endOfLine() | ||
| 537 | insert("\n|-") | ||
| 538 | point = pos | ||
| 539 | } | ||
| 540 | tableAlign() | ||
| 541 | } | ||
| 542 | } | ||
| 543 | |||
| 544 | |||
| 545 | private func tableCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | ||
| 546 | commitBuffer(context, body) | ||
| 547 | } | ||
| 548 | |||
| 549 | /// A table command that needs the caret in an org table. | ||
| 550 | public struct TableCommand: OrgCommand { | ||
| 551 | public enum Action: String, Sendable, CaseIterable { | ||
| 552 | case align, nextField, previousField, nextRow, moveRowUp, moveRowDown, moveColumnLeft, moveColumnRight | ||
| 553 | case insertColumn, deleteColumn, killRow, insertRowAbove, insertHline | ||
| 554 | } | ||
| 555 | |||
| 556 | public let action: Action | ||
| 557 | public init(_ action: Action) { self.action = action } | ||
| 558 | |||
| 559 | public var id: String { | ||
| 560 | switch action { | ||
| 561 | case .align: "org.table.align" | ||
| 562 | case .nextField: "org.table.next-field" | ||
| 563 | case .previousField: "org.table.previous-field" | ||
| 564 | case .nextRow: "org.table.next-row" | ||
| 565 | case .moveRowUp: "org.table.row-up" | ||
| 566 | case .moveRowDown: "org.table.row-down" | ||
| 567 | case .moveColumnLeft: "org.table.column-left" | ||
| 568 | case .moveColumnRight: "org.table.column-right" | ||
| 569 | case .insertColumn: "org.table.insert-column" | ||
| 570 | case .deleteColumn: "org.table.delete-column" | ||
| 571 | case .killRow: "org.table.kill-row" | ||
| 572 | case .insertRowAbove: "org.table.insert-row" | ||
| 573 | case .insertHline: "org.table.insert-hline" | ||
| 574 | } | ||
| 575 | } | ||
| 576 | |||
| 577 | public var title: String { | ||
| 578 | switch action { | ||
| 579 | case .align: "Align Table" | ||
| 580 | case .nextField: "Next Table Field" | ||
| 581 | case .previousField: "Previous Table Field" | ||
| 582 | case .nextRow: "Next Table Row" | ||
| 583 | case .moveRowUp: "Move Table Row Up" | ||
| 584 | case .moveRowDown: "Move Table Row Down" | ||
| 585 | case .moveColumnLeft: "Move Table Column Left" | ||
| 586 | case .moveColumnRight: "Move Table Column Right" | ||
| 587 | case .insertColumn: "Insert Table Column" | ||
| 588 | case .deleteColumn: "Delete Table Column" | ||
| 589 | case .killRow: "Delete Table Row" | ||
| 590 | case .insertRowAbove: "Insert Table Row" | ||
| 591 | case .insertHline: "Insert Table Rule" | ||
| 592 | } | ||
| 593 | } | ||
| 594 | |||
| 595 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) } | ||
| 596 | |||
| 597 | public func run(in context: EditContext) -> CommandStep { | ||
| 598 | tableCommand(context) { buffer in | ||
| 599 | guard buffer.atTable else { throw EmacsBuffer.UserError(message: "Not at a table") } | ||
| 600 | switch action { | ||
| 601 | case .align: buffer.tableAlign() | ||
| 602 | case .nextField: try buffer.tableNextField() | ||
| 603 | case .previousField: try buffer.tablePreviousField() | ||
| 604 | case .nextRow: try buffer.tableNextRow() | ||
| 605 | case .moveRowUp: try buffer.tableMoveRow(up: true) | ||
| 606 | case .moveRowDown: try buffer.tableMoveRow(up: false) | ||
| 607 | case .moveColumnLeft: try buffer.tableMoveColumn(left: true) | ||
| 608 | case .moveColumnRight: try buffer.tableMoveColumn(left: false) | ||
| 609 | case .insertColumn: try buffer.tableInsertColumn() | ||
| 610 | case .deleteColumn: try buffer.tableDeleteColumn() | ||
| 611 | case .killRow: try buffer.tableKillRow() | ||
| 612 | case .insertRowAbove: try buffer.tableInsertRow(below: false) | ||
| 613 | case .insertHline: try buffer.tableInsertHline(above: false) | ||
| 614 | } | ||
| 615 | } | ||
| 616 | } | ||
| 617 | } | ||
| 618 | |||
| 619 | /// C-c |: `org-table-create` with a size prompt. | ||
| 620 | public struct CreateTable: OrgCommand { | ||
| 621 | public init() {} | ||
| 622 | public var id: String { "org.table.create" } | ||
| 623 | public var title: String { "Create Table" } | ||
| 624 | public func applies(in context: EditContext) -> Bool { !KeyContext.holds("table", in: context) } | ||
| 625 | public func run(in context: EditContext) -> CommandStep { | ||
| 626 | guard let size = context.answers["size"] else { | ||
| 627 | return .prompt(Prompt(key: "size", message: "Table size Columns x Rows [e.g. 5x2]:", initial: "")) | ||
| 628 | } | ||
| 629 | return tableCommand(context) { try $0.tableCreate(size.isEmpty ? "5x2" : size) } | ||
| 630 | } | ||
| 631 | } | ||
Sources/OrgCore/Commands/VisibleText.swift added +112
| @@ -0,0 +1,112 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// How wide each character of a line is as Emacs displays Org text: bracket links show only | ||
| 4 | /// their description (or path), and with the options, emphasis markers are hidden and entities | ||
| 5 | /// and sub/superscripts show as their characters. Emacs's `current-column` counts only what is | ||
| 6 | /// visible, so tag alignment, filling and table widths all depend on this. | ||
| 7 | struct VisibleLine { | ||
| 8 | /// Display width of the character starting at each UTF-16 offset; -1 for a tab. | ||
| 9 | private var widths: [Int] | ||
| 10 | /// Whether a character starts at each offset (false for the second half of a pair). | ||
| 11 | private var starts: [Bool] | ||
| 12 | private var hidden: [Bool] | ||
| 13 | |||
| 14 | /// The line at `range` (UTF-16) inside `context`, which holds the lines around it so that | ||
| 15 | /// links spanning lines are seen whole. | ||
| 16 | init(_ context: String, line range: Range<Int>, options: EditingOptions, settings: OrgSettings = .default) { | ||
| 17 | let whole = VisibleLine(context, options: options, settings: settings) | ||
| 18 | widths = Array(whole.widths[range]) | ||
| 19 | starts = Array(whole.starts[range]) | ||
| 20 | hidden = Array(whole.hidden[range]) | ||
| 21 | } | ||
| 22 | |||
| 23 | init(_ line: String, options: EditingOptions, settings: OrgSettings = .default) { | ||
| 24 | let count = line.utf16.count | ||
| 25 | widths = [Int](repeating: 0, count: count) | ||
| 26 | starts = [Bool](repeating: false, count: count) | ||
| 27 | hidden = [Bool](repeating: false, count: count) | ||
| 28 | var index = 0 | ||
| 29 | for character in line { | ||
| 30 | widths[index] = character == "\t" ? -1 : displayWidth(of: character) | ||
| 31 | starts[index] = true | ||
| 32 | index += String(character).utf16.count | ||
| 33 | } | ||
| 34 | let needsLinks = line.contains("[[") | ||
| 35 | let needsEmphasis = options.hideEmphasisMarkers && line.contains(where: { "*/_+=~".contains($0) }) | ||
| 36 | if needsLinks || needsEmphasis { | ||
| 37 | let tree = OrgParser.parse(line + "\n", defaults: settings) | ||
| 38 | for node in tree.root.descendants() { | ||
| 39 | switch node.kind { | ||
| 40 | case .link where node.text.hasPrefix("[["): | ||
| 41 | if let description = node.firstChild(.linkDescription) { | ||
| 42 | hide(node.range.lowerBound..<description.range.lowerBound) | ||
| 43 | hide(description.range.upperBound..<node.range.upperBound) | ||
| 44 | } else { | ||
| 45 | hide(node.range.lowerBound..<(node.range.lowerBound + 2)) | ||
| 46 | hide((node.range.upperBound - 2)..<node.range.upperBound) | ||
| 47 | } | ||
| 48 | case .bold, .italic, .underline, .strikeThrough, .verbatim, .code: | ||
| 49 | guard options.hideEmphasisMarkers else { break } | ||
| 50 | hide(node.range.lowerBound..<(node.range.lowerBound + 1)) | ||
| 51 | hide((node.range.upperBound - 1)..<node.range.upperBound) | ||
| 52 | default: | ||
| 53 | break | ||
| 54 | } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | if options.prettyEntities, line.contains(where: { $0 == "\\" || $0 == "_" || $0 == "^" }) { | ||
| 58 | let ns = line as NSString | ||
| 59 | let all = NSRange(location: 0, length: ns.length) | ||
| 60 | // `org-fontify-entities`: a single-character entity is composed into one glyph. | ||
| 61 | // Positions inside it stay at its starting column; its width counts at its end. | ||
| 62 | let entity = try! NSRegularExpression(pattern: "\\\\(there4|sup[123]|frac[13][24]|[a-zA-Z]+)($|\\{\\}|[^\\p{L}\\n])") | ||
| 63 | for match in entity.matches(in: line, range: all) { | ||
| 64 | guard let display = OrgEntities.display[ns.substring(with: match.range(at: 1))], display.count == 1 else { continue } | ||
| 65 | let end = ns.substring(with: match.range(at: 2)) == "{}" ? NSMaxRange(match.range(at: 2)) : NSMaxRange(match.range(at: 1)) | ||
| 66 | for i in match.range.location..<end where i < widths.count { widths[i] = 0 } | ||
| 67 | widths[end - 1] = displayWidth(display) | ||
| 68 | } | ||
| 69 | let pattern = options.subSuperscriptsNeedBraces | ||
| 70 | ? "\\S([_^])(\\{)[^{}\\n]*(\\})" | ||
| 71 | : "\\S([_^])(?:(\\{)[^{}\\n]*(\\})|[*]|[-+]?[^-+*!@#$%^_ \\t\\r\\n,:\"?<>~;./{}=()]+)" | ||
| 72 | for match in try! NSRegularExpression(pattern: pattern).matches(in: line, range: all) { | ||
| 73 | for group in 1...3 where match.range(at: group).location != NSNotFound { | ||
| 74 | hide(match.range(at: group).location..<NSMaxRange(match.range(at: group))) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | private mutating func hide(_ range: Range<Int>) { | ||
| 81 | for i in range where i >= 0 && i < hidden.count { hidden[i] = true } | ||
| 82 | } | ||
| 83 | |||
| 84 | private func advance(_ column: Int, _ i: Int) -> Int { | ||
| 85 | widths[i] < 0 ? (column / 8 + 1) * 8 : column + widths[i] | ||
| 86 | } | ||
| 87 | |||
| 88 | /// Display column at UTF-16 `offset`. | ||
| 89 | func column(at offset: Int) -> Int { | ||
| 90 | var column = 0 | ||
| 91 | for i in 0..<min(offset, widths.count) where starts[i] && !hidden[i] { | ||
| 92 | column = advance(column, i) | ||
| 93 | } | ||
| 94 | return column | ||
| 95 | } | ||
| 96 | |||
| 97 | var width: Int { column(at: widths.count) } | ||
| 98 | |||
| 99 | /// `move-to-column`: hidden text is skipped before the goal test, so point ends past | ||
| 100 | /// markup that sits at the goal column. | ||
| 101 | func offset(ofColumn target: Int) -> Int { | ||
| 102 | var column = 0 | ||
| 103 | var i = 0 | ||
| 104 | while true { | ||
| 105 | while i < widths.count, hidden[i] || !starts[i] { i += 1 } | ||
| 106 | if column >= target || i >= widths.count { break } | ||
| 107 | column = advance(column, i) | ||
| 108 | i += 1 | ||
| 109 | } | ||
| 110 | return i | ||
| 111 | } | ||
| 112 | } | ||
Sources/OrgCore/Keymap/Presets.swift +94
| @@ -32,6 +32,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 32 | [[bind]] | 32 | [[bind]] |
| 33 | keys = "S-TAB" | 33 | keys = "S-TAB" |
| 34 | command = "org.cycle-global" | 34 | command = "org.cycle-global" |
| 35 | [[bind]] | ||
| 36 | keys = "TAB" | ||
| 37 | command = "org.table.next-field" | ||
| 38 | when = "table" | ||
| 39 | [[bind]] | ||
| 40 | keys = "S-TAB" | ||
| 41 | command = "org.table.previous-field" | ||
| 42 | when = "table" | ||
| 43 | [[bind]] | ||
| 44 | keys = "RET" | ||
| 45 | command = "org.table.next-row" | ||
| 46 | when = "table" | ||
| 35 | 47 | ||
| 36 | """ | 48 | """ |
| 37 | 49 | ||
| @@ -270,6 +282,49 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 270 | keys = "M-q" | 282 | keys = "M-q" |
| 271 | command = "org.fill-paragraph" | 283 | command = "org.fill-paragraph" |
| 272 | [[bind]] | 284 | [[bind]] |
| 285 | keys = "C-c C-c" | ||
| 286 | command = "org.table.align" | ||
| 287 | when = "table" | ||
| 288 | [[bind]] | ||
| 289 | keys = "M-<up>" | ||
| 290 | command = "org.table.row-up" | ||
| 291 | when = "table" | ||
| 292 | [[bind]] | ||
| 293 | keys = "M-<down>" | ||
| 294 | command = "org.table.row-down" | ||
| 295 | when = "table" | ||
| 296 | [[bind]] | ||
| 297 | keys = "M-<left>" | ||
| 298 | command = "org.table.column-left" | ||
| 299 | when = "table" | ||
| 300 | [[bind]] | ||
| 301 | keys = "M-<right>" | ||
| 302 | command = "org.table.column-right" | ||
| 303 | when = "table" | ||
| 304 | [[bind]] | ||
| 305 | keys = "M-S-<right>" | ||
| 306 | command = "org.table.insert-column" | ||
| 307 | when = "table" | ||
| 308 | [[bind]] | ||
| 309 | keys = "M-S-<left>" | ||
| 310 | command = "org.table.delete-column" | ||
| 311 | when = "table" | ||
| 312 | [[bind]] | ||
| 313 | keys = "M-S-<up>" | ||
| 314 | command = "org.table.kill-row" | ||
| 315 | when = "table" | ||
| 316 | [[bind]] | ||
| 317 | keys = "M-S-<down>" | ||
| 318 | command = "org.table.insert-row" | ||
| 319 | when = "table" | ||
| 320 | [[bind]] | ||
| 321 | keys = "C-c -" | ||
| 322 | command = "org.table.insert-hline" | ||
| 323 | when = "table" | ||
| 324 | [[bind]] | ||
| 325 | keys = "C-c |" | ||
| 326 | command = "org.table.create" | ||
| 327 | [[bind]] | ||
| 273 | keys = "S-<up>" | 328 | keys = "S-<up>" |
| 274 | command = "org.timestamp.up" | 329 | command = "org.timestamp.up" |
| 275 | when = "timestamp" | 330 | when = "timestamp" |
| @@ -394,6 +449,45 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 394 | command = "org.fill-paragraph" | 449 | command = "org.fill-paragraph" |
| 395 | [[bind]] | 450 | [[bind]] |
| 396 | keys = "C-s-<up>" | 451 | keys = "C-s-<up>" |
| 452 | command = "org.table.row-up" | ||
| 453 | when = "table" | ||
| 454 | [[bind]] | ||
| 455 | keys = "C-s-<down>" | ||
| 456 | command = "org.table.row-down" | ||
| 457 | when = "table" | ||
| 458 | [[bind]] | ||
| 459 | keys = "C-s-<left>" | ||
| 460 | command = "org.table.column-left" | ||
| 461 | when = "table" | ||
| 462 | [[bind]] | ||
| 463 | keys = "C-s-<right>" | ||
| 464 | command = "org.table.column-right" | ||
| 465 | when = "table" | ||
| 466 | [[bind]] | ||
| 467 | keys = "C-M-s-<right>" | ||
| 468 | command = "org.table.insert-column" | ||
| 469 | when = "table" | ||
| 470 | [[bind]] | ||
| 471 | keys = "C-M-s-<left>" | ||
| 472 | command = "org.table.delete-column" | ||
| 473 | when = "table" | ||
| 474 | [[bind]] | ||
| 475 | keys = "C-M-s-<up>" | ||
| 476 | command = "org.table.kill-row" | ||
| 477 | when = "table" | ||
| 478 | [[bind]] | ||
| 479 | keys = "C-M-s-<down>" | ||
| 480 | command = "org.table.insert-row" | ||
| 481 | when = "table" | ||
| 482 | [[bind]] | ||
| 483 | keys = "C-s--" | ||
| 484 | command = "org.table.insert-hline" | ||
| 485 | when = "table" | ||
| 486 | [[bind]] | ||
| 487 | keys = "C-s-\\\\" | ||
| 488 | command = "org.table.create" | ||
| 489 | [[bind]] | ||
| 490 | keys = "C-s-<up>" | ||
| 397 | command = "org.timestamp.up" | 491 | command = "org.timestamp.up" |
| 398 | when = "timestamp" | 492 | when = "timestamp" |
| 399 | [[bind]] | 493 | [[bind]] |
Sources/OrgCore/Semantic/OrgEntities.swift added +419
| @@ -0,0 +1,419 @@ | |||
| 1 | // Generated from `org-entities` (Org 9.8.7): entity name to the text org shows for it with | ||
| 2 | // `org-pretty-entities`. The first definition of a name wins, as `assoc` finds it. | ||
| 3 | |||
| 4 | enum OrgEntities { | ||
| 5 | static let display: [String: String] = [ | ||
| 6 | "Agrave": "À", | ||
| 7 | "agrave": "à", | ||
| 8 | "Aacute": "Á", | ||
| 9 | "aacute": "á", | ||
| 10 | "Acirc": "Â", | ||
| 11 | "acirc": "â", | ||
| 12 | "Amacr": "Ã", | ||
| 13 | "amacr": "ã", | ||
| 14 | "Atilde": "Ã", | ||
| 15 | "atilde": "ã", | ||
| 16 | "Auml": "Ä", | ||
| 17 | "auml": "ä", | ||
| 18 | "Aring": "Å", | ||
| 19 | "AA": "Å", | ||
| 20 | "aring": "å", | ||
| 21 | "AElig": "Æ", | ||
| 22 | "aelig": "æ", | ||
| 23 | "Ccedil": "Ç", | ||
| 24 | "ccedil": "ç", | ||
| 25 | "Egrave": "È", | ||
| 26 | "egrave": "è", | ||
| 27 | "Eacute": "É", | ||
| 28 | "eacute": "é", | ||
| 29 | "Ecirc": "Ê", | ||
| 30 | "ecirc": "ê", | ||
| 31 | "Euml": "Ë", | ||
| 32 | "euml": "ë", | ||
| 33 | "Igrave": "Ì", | ||
| 34 | "igrave": "ì", | ||
| 35 | "Iacute": "Í", | ||
| 36 | "iacute": "í", | ||
| 37 | "Idot": "İ", | ||
| 38 | "inodot": "ı", | ||
| 39 | "Icirc": "Î", | ||
| 40 | "icirc": "î", | ||
| 41 | "Iuml": "Ï", | ||
| 42 | "iuml": "ï", | ||
| 43 | "Ntilde": "Ñ", | ||
| 44 | "ntilde": "ñ", | ||
| 45 | "Ograve": "Ò", | ||
| 46 | "ograve": "ò", | ||
| 47 | "Oacute": "Ó", | ||
| 48 | "oacute": "ó", | ||
| 49 | "Ocirc": "Ô", | ||
| 50 | "ocirc": "ô", | ||
| 51 | "Otilde": "Õ", | ||
| 52 | "otilde": "õ", | ||
| 53 | "Ouml": "Ö", | ||
| 54 | "ouml": "ö", | ||
| 55 | "Oslash": "Ø", | ||
| 56 | "oslash": "ø", | ||
| 57 | "OElig": "Œ", | ||
| 58 | "oelig": "œ", | ||
| 59 | "Scaron": "Š", | ||
| 60 | "scaron": "š", | ||
| 61 | "szlig": "ß", | ||
| 62 | "Ugrave": "Ù", | ||
| 63 | "ugrave": "ù", | ||
| 64 | "Uacute": "Ú", | ||
| 65 | "uacute": "ú", | ||
| 66 | "Ucirc": "Û", | ||
| 67 | "ucirc": "û", | ||
| 68 | "Uuml": "Ü", | ||
| 69 | "uuml": "ü", | ||
| 70 | "Yacute": "Ý", | ||
| 71 | "yacute": "ý", | ||
| 72 | "Yuml": "Ÿ", | ||
| 73 | "yuml": "ÿ", | ||
| 74 | "fnof": "ƒ", | ||
| 75 | "real": "ℜ", | ||
| 76 | "image": "ℑ", | ||
| 77 | "weierp": "℘", | ||
| 78 | "ell": "ℓ", | ||
| 79 | "imath": "ı", | ||
| 80 | "jmath": "ȷ", | ||
| 81 | "Alpha": "Α", | ||
| 82 | "alpha": "α", | ||
| 83 | "Beta": "Β", | ||
| 84 | "beta": "β", | ||
| 85 | "Gamma": "Γ", | ||
| 86 | "gamma": "γ", | ||
| 87 | "Delta": "Δ", | ||
| 88 | "delta": "δ", | ||
| 89 | "Epsilon": "Ε", | ||
| 90 | "epsilon": "ε", | ||
| 91 | "varepsilon": "ε", | ||
| 92 | "Zeta": "Ζ", | ||
| 93 | "zeta": "ζ", | ||
| 94 | "Eta": "Η", | ||
| 95 | "eta": "η", | ||
| 96 | "Theta": "Θ", | ||
| 97 | "theta": "θ", | ||
| 98 | "thetasym": "ϑ", | ||
| 99 | "vartheta": "ϑ", | ||
| 100 | "Iota": "Ι", | ||
| 101 | "iota": "ι", | ||
| 102 | "Kappa": "Κ", | ||
| 103 | "kappa": "κ", | ||
| 104 | "Lambda": "Λ", | ||
| 105 | "lambda": "λ", | ||
| 106 | "Mu": "Μ", | ||
| 107 | "mu": "μ", | ||
| 108 | "nu": "ν", | ||
| 109 | "Nu": "Ν", | ||
| 110 | "Xi": "Ξ", | ||
| 111 | "xi": "ξ", | ||
| 112 | "Omicron": "Ο", | ||
| 113 | "omicron": "ο", | ||
| 114 | "Pi": "Π", | ||
| 115 | "pi": "π", | ||
| 116 | "Rho": "Ρ", | ||
| 117 | "rho": "ρ", | ||
| 118 | "Sigma": "Σ", | ||
| 119 | "sigma": "σ", | ||
| 120 | "sigmaf": "ς", | ||
| 121 | "varsigma": "ς", | ||
| 122 | "Tau": "Τ", | ||
| 123 | "Upsilon": "Υ", | ||
| 124 | "upsih": "ϒ", | ||
| 125 | "upsilon": "υ", | ||
| 126 | "Phi": "Φ", | ||
| 127 | "phi": "ɸ", | ||
| 128 | "varphi": "φ", | ||
| 129 | "Chi": "Χ", | ||
| 130 | "chi": "χ", | ||
| 131 | "acutex": "𝑥́", | ||
| 132 | "Psi": "Ψ", | ||
| 133 | "psi": "ψ", | ||
| 134 | "tau": "τ", | ||
| 135 | "Omega": "Ω", | ||
| 136 | "omega": "ω", | ||
| 137 | "piv": "ϖ", | ||
| 138 | "varpi": "ϖ", | ||
| 139 | "partial": "∂", | ||
| 140 | "alefsym": "ℵ", | ||
| 141 | "aleph": "ℵ", | ||
| 142 | "gimel": "ℷ", | ||
| 143 | "beth": "ב", | ||
| 144 | "dalet": "ד", | ||
| 145 | "ETH": "Ð", | ||
| 146 | "eth": "ð", | ||
| 147 | "THORN": "Þ", | ||
| 148 | "thorn": "þ", | ||
| 149 | "dots": "…", | ||
| 150 | "cdots": "⋯", | ||
| 151 | "hellip": "…", | ||
| 152 | "middot": "·", | ||
| 153 | "iexcl": "¡", | ||
| 154 | "iquest": "¿", | ||
| 155 | "shy": "", | ||
| 156 | "ndash": "–", | ||
| 157 | "mdash": "—", | ||
| 158 | "quot": "\"", | ||
| 159 | "acute": "´", | ||
| 160 | "ldquo": "“", | ||
| 161 | "rdquo": "”", | ||
| 162 | "bdquo": "„", | ||
| 163 | "lsquo": "‘", | ||
| 164 | "rsquo": "’", | ||
| 165 | "sbquo": "‚", | ||
| 166 | "laquo": "«", | ||
| 167 | "raquo": "»", | ||
| 168 | "lsaquo": "‹", | ||
| 169 | "rsaquo": "›", | ||
| 170 | "circ": "∘", | ||
| 171 | "vert": "|", | ||
| 172 | "vbar": "|", | ||
| 173 | "brvbar": "¦", | ||
| 174 | "S": "§", | ||
| 175 | "sect": "§", | ||
| 176 | "P": "¶", | ||
| 177 | "para": "¶", | ||
| 178 | "amp": "&", | ||
| 179 | "lt": "<", | ||
| 180 | "gt": ">", | ||
| 181 | "tilde": "~", | ||
| 182 | "slash": "/", | ||
| 183 | "plus": "+", | ||
| 184 | "under": "_", | ||
| 185 | "equal": "=", | ||
| 186 | "asciicirc": "^", | ||
| 187 | "dagger": "†", | ||
| 188 | "dag": "†", | ||
| 189 | "Dagger": "‡", | ||
| 190 | "ddag": "‡", | ||
| 191 | "nbsp": " ", | ||
| 192 | "ensp": " ", | ||
| 193 | "emsp": " ", | ||
| 194 | "thinsp": " ", | ||
| 195 | "curren": "¤", | ||
| 196 | "cent": "¢", | ||
| 197 | "pound": "£", | ||
| 198 | "yen": "¥", | ||
| 199 | "euro": "€", | ||
| 200 | "EUR": "€", | ||
| 201 | "dollar": "$", | ||
| 202 | "USD": "$", | ||
| 203 | "copy": "©", | ||
| 204 | "reg": "®", | ||
| 205 | "trade": "™", | ||
| 206 | "minus": "−", | ||
| 207 | "pm": "±", | ||
| 208 | "plusmn": "±", | ||
| 209 | "times": "×", | ||
| 210 | "frasl": "⁄", | ||
| 211 | "colon": ":", | ||
| 212 | "div": "÷", | ||
| 213 | "frac12": "½", | ||
| 214 | "frac14": "¼", | ||
| 215 | "frac34": "¾", | ||
| 216 | "permil": "‰", | ||
| 217 | "sup1": "¹", | ||
| 218 | "sup2": "²", | ||
| 219 | "sup3": "³", | ||
| 220 | "radic": "√", | ||
| 221 | "sum": "∑", | ||
| 222 | "prod": "∏", | ||
| 223 | "micro": "µ", | ||
| 224 | "macr": "¯", | ||
| 225 | "deg": "°", | ||
| 226 | "prime": "′", | ||
| 227 | "Prime": "″", | ||
| 228 | "infin": "∞", | ||
| 229 | "infty": "∞", | ||
| 230 | "prop": "∝", | ||
| 231 | "propto": "∝", | ||
| 232 | "not": "¬", | ||
| 233 | "neg": "¬", | ||
| 234 | "land": "∧", | ||
| 235 | "wedge": "∧", | ||
| 236 | "lor": "∨", | ||
| 237 | "vee": "∨", | ||
| 238 | "cap": "∩", | ||
| 239 | "cup": "∪", | ||
| 240 | "smile": "⌣", | ||
| 241 | "frown": "⌢", | ||
| 242 | "int": "∫", | ||
| 243 | "therefore": "∴", | ||
| 244 | "there4": "∴", | ||
| 245 | "because": "∵", | ||
| 246 | "sim": "∼", | ||
| 247 | "cong": "≅", | ||
| 248 | "simeq": "≅", | ||
| 249 | "asymp": "≈", | ||
| 250 | "approx": "≈", | ||
| 251 | "ne": "≠", | ||
| 252 | "neq": "≠", | ||
| 253 | "equiv": "≡", | ||
| 254 | "triangleq": "≜", | ||
| 255 | "le": "≤", | ||
| 256 | "leq": "≤", | ||
| 257 | "ge": "≥", | ||
| 258 | "geq": "≥", | ||
| 259 | "lessgtr": "≶", | ||
| 260 | "lesseqgtr": "⋚", | ||
| 261 | "ll": "≪", | ||
| 262 | "Ll": "⋘", | ||
| 263 | "lll": "⋘", | ||
| 264 | "gg": "≫", | ||
| 265 | "Gg": "⋙", | ||
| 266 | "ggg": "⋙", | ||
| 267 | "prec": "≺", | ||
| 268 | "preceq": "≼", | ||
| 269 | "preccurlyeq": "≼", | ||
| 270 | "succ": "≻", | ||
| 271 | "succeq": "≽", | ||
| 272 | "succcurlyeq": "≽", | ||
| 273 | "sub": "⊂", | ||
| 274 | "subset": "⊂", | ||
| 275 | "sup": "⊃", | ||
| 276 | "supset": "⊃", | ||
| 277 | "nsub": "⊄", | ||
| 278 | "sube": "⊆", | ||
| 279 | "nsup": "⊅", | ||
| 280 | "supe": "⊇", | ||
| 281 | "setminus": "⧵", | ||
| 282 | "forall": "∀", | ||
| 283 | "exist": "∃", | ||
| 284 | "exists": "∃", | ||
| 285 | "nexist": "∄", | ||
| 286 | "nexists": "∄", | ||
| 287 | "empty": "∅", | ||
| 288 | "emptyset": "∅", | ||
| 289 | "isin": "∈", | ||
| 290 | "in": "∈", | ||
| 291 | "notin": "∉", | ||
| 292 | "ni": "∋", | ||
| 293 | "nabla": "∇", | ||
| 294 | "ang": "∠", | ||
| 295 | "angle": "∠", | ||
| 296 | "perp": "⊥", | ||
| 297 | "parallel": "∥", | ||
| 298 | "sdot": "⋅", | ||
| 299 | "cdot": "⋅", | ||
| 300 | "lceil": "⌈", | ||
| 301 | "rceil": "⌉", | ||
| 302 | "lfloor": "⌊", | ||
| 303 | "rfloor": "⌋", | ||
| 304 | "lang": "⟨", | ||
| 305 | "rang": "⟩", | ||
| 306 | "langle": "⟨", | ||
| 307 | "rangle": "⟩", | ||
| 308 | "hbar": "ℏ", | ||
| 309 | "mho": "℧", | ||
| 310 | "larr": "←", | ||
| 311 | "leftarrow": "←", | ||
| 312 | "gets": "←", | ||
| 313 | "lArr": "⇐", | ||
| 314 | "Leftarrow": "⇐", | ||
| 315 | "uarr": "↑", | ||
| 316 | "uparrow": "↑", | ||
| 317 | "uArr": "⇑", | ||
| 318 | "Uparrow": "⇑", | ||
| 319 | "rarr": "→", | ||
| 320 | "to": "→", | ||
| 321 | "rightarrow": "→", | ||
| 322 | "rArr": "⇒", | ||
| 323 | "Rightarrow": "⇒", | ||
| 324 | "darr": "↓", | ||
| 325 | "downarrow": "↓", | ||
| 326 | "dArr": "⇓", | ||
| 327 | "Downarrow": "⇓", | ||
| 328 | "harr": "↔", | ||
| 329 | "leftrightarrow": "↔", | ||
| 330 | "hArr": "⇔", | ||
| 331 | "Leftrightarrow": "⇔", | ||
| 332 | "crarr": "↵", | ||
| 333 | "hookleftarrow": "↵", | ||
| 334 | "arccos": "arccos", | ||
| 335 | "arcsin": "arcsin", | ||
| 336 | "arctan": "arctan", | ||
| 337 | "arg": "arg", | ||
| 338 | "cos": "cos", | ||
| 339 | "cosh": "cosh", | ||
| 340 | "cot": "cot", | ||
| 341 | "coth": "coth", | ||
| 342 | "csc": "csc", | ||
| 343 | "det": "det", | ||
| 344 | "dim": "dim", | ||
| 345 | "exp": "exp", | ||
| 346 | "gcd": "gcd", | ||
| 347 | "hom": "hom", | ||
| 348 | "inf": "inf", | ||
| 349 | "ker": "ker", | ||
| 350 | "lg": "lg", | ||
| 351 | "lim": "lim", | ||
| 352 | "liminf": "liminf", | ||
| 353 | "limsup": "limsup", | ||
| 354 | "ln": "ln", | ||
| 355 | "log": "log", | ||
| 356 | "max": "max", | ||
| 357 | "min": "min", | ||
| 358 | "Pr": "Pr", | ||
| 359 | "sec": "sec", | ||
| 360 | "sin": "sin", | ||
| 361 | "sinh": "sinh", | ||
| 362 | "tan": "tan", | ||
| 363 | "tanh": "tanh", | ||
| 364 | "bull": "•", | ||
| 365 | "bullet": "•", | ||
| 366 | "star": "⋆", | ||
| 367 | "lowast": "∗", | ||
| 368 | "ast": "*", | ||
| 369 | "odot": "ʘ", | ||
| 370 | "oplus": "⊕", | ||
| 371 | "otimes": "⊗", | ||
| 372 | "check": "✓", | ||
| 373 | "checkmark": "✓", | ||
| 374 | "ordf": "ª", | ||
| 375 | "ordm": "º", | ||
| 376 | "cedil": "¸", | ||
| 377 | "oline": "‾", | ||
| 378 | "uml": "¨", | ||
| 379 | "zwnj": "", | ||
| 380 | "zwj": "", | ||
| 381 | "lrm": "", | ||
| 382 | "rlm": "", | ||
| 383 | "smiley": "☺", | ||
| 384 | "blacksmile": "☻", | ||
| 385 | "sad": "☹", | ||
| 386 | "frowny": "☹", | ||
| 387 | "clubs": "♣", | ||
| 388 | "clubsuit": "♣", | ||
| 389 | "spades": "♠", | ||
| 390 | "spadesuit": "♠", | ||
| 391 | "hearts": "♥", | ||
| 392 | "heartsuit": "♥", | ||
| 393 | "diams": "◆", | ||
| 394 | "diamondsuit": "◆", | ||
| 395 | "diamond": "◆", | ||
| 396 | "Diamond": "◆", | ||
| 397 | "loz": "⧫", | ||
| 398 | "_ ": " ", | ||
| 399 | "_ ": " ", | ||
| 400 | "_ ": " ", | ||
| 401 | "_ ": " ", | ||
| 402 | "_ ": " ", | ||
| 403 | "_ ": " ", | ||
| 404 | "_ ": " ", | ||
| 405 | "_ ": " ", | ||
| 406 | "_ ": " ", | ||
| 407 | "_ ": " ", | ||
| 408 | "_ ": " ", | ||
| 409 | "_ ": " ", | ||
| 410 | "_ ": " ", | ||
| 411 | "_ ": " ", | ||
| 412 | "_ ": " ", | ||
| 413 | "_ ": " ", | ||
| 414 | "_ ": " ", | ||
| 415 | "_ ": " ", | ||
| 416 | "_ ": " ", | ||
| 417 | "_ ": " ", | ||
| 418 | ] | ||
| 419 | } | ||
Tests/OrgCoreTests/EmacsOracle.swift +4 −2
| @@ -49,6 +49,8 @@ enum EmacsOracle { | |||
| 49 | (org-mode) | 49 | (org-mode) |
| 50 | ;; `#+STARTUP' may fold the buffer; cases run with everything visible. | 50 | ;; `#+STARTUP' may fold the buffer; cases run with everything visible. |
| 51 | (org-fold-show-all) | 51 | (org-fold-show-all) |
| 52 | ;; Fontified, as in a displayed buffer: widths follow what is visible. | ||
| 53 | (font-lock-ensure) | ||
| 52 | (goto-char (alist-get 'point c)) | 54 | (goto-char (alist-get 'point c)) |
| 53 | ;; As an interactive call, not a repeat: some commands cycle differently on repeats. | 55 | ;; As an interactive call, not a repeat: some commands cycle differently on repeats. |
| 54 | (setq this-command 'orgstar-oracle last-command nil) | 56 | (setq this-command 'orgstar-oracle last-command nil) |
| @@ -124,11 +126,11 @@ enum EmacsOracle { | |||
| 124 | /// The options as Emacs Lisp bindings around `form`. | 126 | /// The options as Emacs Lisp bindings around `form`. |
| 125 | func withOptions(_ options: EditingOptions, _ form: String) -> String { | 127 | func withOptions(_ options: EditingOptions, _ form: String) -> String { |
| 126 | func flag(_ value: Bool) -> String { value ? "t" : "nil" } | 128 | func flag(_ value: Bool) -> String { value ? "t" : "nil" } |
| 127 | return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical))) (fill-column \(options.fillColumn)) (sentence-end-double-space nil)) \(form))" | 129 | return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical))) (fill-column \(options.fillColumn)) (sentence-end-double-space nil) (org-hide-emphasis-markers \(flag(options.hideEmphasisMarkers))) (org-pretty-entities \(flag(options.prettyEntities))) (org-use-sub-superscripts \(options.subSuperscriptsNeedBraces ? "'{}" : "t"))) (font-lock-flush) (font-lock-ensure) \(form))" |
| 128 | } | 130 | } |
| 129 | 131 | ||
| 130 | /// The options in the user's Doom Emacs. | 132 | /// The options in the user's Doom Emacs. |
| 131 | let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true, fillColumn: 80) | 133 | let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true, fillColumn: 80, hideEmphasisMarkers: true, prettyEntities: true, subSuperscriptsNeedBraces: true) |
| 132 | 134 | ||
| 133 | /// Runs a command the way an editor would: build a context, run, apply the edits. | 135 | /// Runs a command the way an editor would: build a context, run, apply the edits. |
| 134 | func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) { | 136 | func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) { |
Tests/OrgCoreTests/TableTests.swift added +101
| @@ -0,0 +1,101 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct TableTests { | ||
| 6 | @Test func aligns() { | ||
| 7 | let result = runCommand(TableCommand(.align), "|a|bb|\n|ccc|1|\n", caret: 1).text | ||
| 8 | #expect(result == "| a | bb |\n| ccc | 1 |\n") | ||
| 9 | } | ||
| 10 | |||
| 11 | static let variants = [ | ||
| 12 | "|a|bb|\n|ccc|1|\n", | ||
| 13 | "| Name | Qty |\n|-\n| apple | 3 |\n| pear | 12 |\n", | ||
| 14 | " | x | y |\n |---+---|\n | 1 | 2 |\n", | ||
| 15 | "| [[https://example.com][link]] | *bold* | =code= |\n| a | b | c |\n", | ||
| 16 | "| \\\\alpha | a_{bc} | x^2 |\n| 1 | 2 | 3 |\n", | ||
| 17 | "| <r> | <c5> |\n| a | b |\n", | ||
| 18 | "| 1 | 2 |\n| 3 | 4 |\n#+TBLFM: $2=$1*2::@2$1=7\n", | ||
| 19 | "text\n| a |\n| b |\nmore\n", | ||
| 20 | "| ü | 漢字 |\n| x | y |\n", | ||
| 21 | "| a | b\n| c |\n", | ||
| 22 | "|-+-|\n", | ||
| 23 | ] | ||
| 24 | |||
| 25 | static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [ | ||
| 26 | (TableCommand(.align), "(org-table-align)", false), | ||
| 27 | (TableCommand(.nextField), "(org-table-next-field)", false), | ||
| 28 | (TableCommand(.previousField), "(org-table-previous-field)", false), | ||
| 29 | (TableCommand(.nextRow), "(org-table-next-row)", false), | ||
| 30 | (TableCommand(.moveRowUp), "(org-table-move-row-up)", false), | ||
| 31 | (TableCommand(.moveRowDown), "(org-table-move-row-down)", false), | ||
| 32 | (TableCommand(.moveColumnLeft), "(org-table-move-column-left)", false), | ||
| 33 | (TableCommand(.moveColumnRight), "(org-table-move-column-right)", false), | ||
| 34 | (TableCommand(.insertColumn), "(org-table-insert-column)", false), | ||
| 35 | (TableCommand(.deleteColumn), "(org-table-delete-column)", false), | ||
| 36 | (TableCommand(.killRow), "(org-table-kill-row)", false), | ||
| 37 | (TableCommand(.insertRowAbove), "(org-table-insert-row)", false), | ||
| 38 | (TableCommand(.insertHline), "(org-table-insert-hline)", false), | ||
| 39 | ] | ||
| 40 | |||
| 41 | static func tableCarets(_ text: String) -> [Int] { | ||
| 42 | EmacsOracle.positions(text).filter { offset in | ||
| 43 | let ns = text as NSString | ||
| 44 | let line = ns.lineRange(for: NSRange(location: min(offset, max(0, ns.length - 1)), length: 0)) | ||
| 45 | return offset < ns.length && ns.substring(with: line).trimmingCharacters(in: .whitespaces).hasPrefix("|") | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 50 | func tableCommandsMatchEmacs() throws { | ||
| 51 | let inputs = Self.variants.map { ($0, Self.tableCarets($0)) } | ||
| 52 | try compareWithEmacs(inputs, Self.commands) | ||
| 53 | try compareWithEmacs(inputs, Self.commands, options: doomOptions) | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 57 | func createTableMatchesEmacs() throws { | ||
| 58 | for (text, caret) in [("", 0), ("x\n", 1), (" \n", 2)] { | ||
| 59 | for size in ["3x2", "1x1", "2x3"] { | ||
| 60 | let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: caret + 1, form: "(org-table-create \"\(size)\")")])[0] | ||
| 61 | let ours = runCommand(CreateTable(), text, caret: caret, answers: ["size": size]) | ||
| 62 | #expect(ours.text == emacs.text && ours.caret == EmacsOracle.offset(emacs.point, in: emacs.text), "\(size) in \(text.debugDescription): ours \(ours.text.debugDescription)@\(ours.caret) emacs \(emacs.text.debugDescription)@\(EmacsOracle.offset(emacs.point, in: emacs.text))") | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | /// Real tables: `ORGSTAR_ORACLE_CORPUS=<folder>` aligns and TABs in the first cell of up to | ||
| 68 | /// eight table rows per file, in up to 60 files. | ||
| 69 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | ||
| 70 | func tablesMatchEmacsOnACorpus() throws { | ||
| 71 | let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!) | ||
| 72 | let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)! | ||
| 73 | .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path } | ||
| 74 | var samples: [(String, [Int])] = [] | ||
| 75 | for file in files where samples.count < 60 { | ||
| 76 | guard let text = try? String(contentsOf: file, encoding: .utf8), text.utf16.count <= 30_000, !text.contains("\r") else { continue } | ||
| 77 | let ns = text as NSString | ||
| 78 | var carets: [Int] = [] | ||
| 79 | var location = 0 | ||
| 80 | var inBlock = false | ||
| 81 | while location < ns.length, carets.count < 8 { | ||
| 82 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | ||
| 83 | let content = ns.substring(with: line) | ||
| 84 | let trimmed = content.trimmingCharacters(in: .whitespaces).lowercased() | ||
| 85 | if trimmed.hasPrefix("#+begin_") { inBlock = true } | ||
| 86 | if trimmed.hasPrefix("#+end_") { inBlock = false } | ||
| 87 | if !inBlock, trimmed.hasPrefix("|"), let bar = content.firstIndex(of: "|") { | ||
| 88 | carets.append(line.location + content.distance(from: content.startIndex, to: bar) + 2) | ||
| 89 | } | ||
| 90 | location = NSMaxRange(line) | ||
| 91 | } | ||
| 92 | if !carets.isEmpty { samples.append((text, carets)) } | ||
| 93 | } | ||
| 94 | let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [ | ||
| 95 | (TableCommand(.align), "(org-table-align)", false), | ||
| 96 | (TableCommand(.nextField), "(org-table-next-field)", false), | ||
| 97 | ] | ||
| 98 | try compareWithEmacs(samples, commands) | ||
| 99 | try compareWithEmacs(samples, commands, options: doomOptions) | ||
| 100 | } | ||
| 101 | } | ||