Commit b8d091bdcb
Verified · cmc
Layout: unified · split
Sources/OrgCore/Semantic/TextStats.swift added +112
| @@ -0,0 +1,112 @@ | |||
| 1 | /// Position and counts for the modeline, as Emacs computes them in an org buffer. | ||
| 2 | public enum TextStats { | ||
| 3 | /// The line (from 1) and `current-column` (from 0, tabs to multiples of 8, wide | ||
| 4 | /// characters two columns, control characters as `^X` or `\200`) at a UTF-16 offset. | ||
| 5 | public static func position(in text: String, at offset: Int) -> (line: Int, column: Int) { | ||
| 6 | let utf16 = text.utf16 | ||
| 7 | let end = utf16.index(utf16.startIndex, offsetBy: min(max(0, offset), utf16.count)) | ||
| 8 | var line = 1 | ||
| 9 | var lineStart = utf16.startIndex | ||
| 10 | var index = utf16.startIndex | ||
| 11 | while index < end { | ||
| 12 | if utf16[index] == 10 { | ||
| 13 | line += 1 | ||
| 14 | lineStart = utf16.index(after: index) | ||
| 15 | } | ||
| 16 | index = utf16.index(after: index) | ||
| 17 | } | ||
| 18 | var column = 0 | ||
| 19 | for character in text[lineStart..<end] { | ||
| 20 | let v = character.unicodeScalars.first!.value | ||
| 21 | if character == "\t" { | ||
| 22 | column = (column / 8 + 1) * 8 | ||
| 23 | } else if v < 0x20 || v == 0x7F { | ||
| 24 | column += 2 // ^X | ||
| 25 | } else if (0x80...0x9F).contains(v) { | ||
| 26 | column += 4 // \200 | ||
| 27 | } else { | ||
| 28 | column += displayWidth(of: character) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | return (line, column) | ||
| 32 | } | ||
| 33 | |||
| 34 | /// `count-words`: runs of characters with word syntax in org-mode (`WordSyntax.swift`), | ||
| 35 | /// split where `forward-word` sees a boundary between scripts. | ||
| 36 | public static func words(in text: some StringProtocol) -> Int { | ||
| 37 | var count = 0 | ||
| 38 | var previous: Unicode.Scalar? | ||
| 39 | for scalar in text.unicodeScalars { | ||
| 40 | if isWord(scalar) { | ||
| 41 | if let previous, !boundary(previous, scalar) {} else { count += 1 } | ||
| 42 | previous = scalar | ||
| 43 | } else { | ||
| 44 | previous = nil | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return count | ||
| 48 | } | ||
| 49 | |||
| 50 | /// `word_boundary_p` with the default `word-combining-categories` and | ||
| 51 | /// `word-separating-categories`. | ||
| 52 | static func boundary(_ a: Unicode.Scalar, _ b: Unicode.Scalar) -> Bool { | ||
| 53 | if script(a) == script(b) { return isHiragana(a) && isKatakana(b) } | ||
| 54 | if isCombining(a) || isCombining(b) { return false } | ||
| 55 | return !(isHan(a) && (isHiragana(b) || isKatakana(b))) | ||
| 56 | } | ||
| 57 | |||
| 58 | static func script(_ scalar: Unicode.Scalar) -> UInt16 { | ||
| 59 | let v = scalar.value | ||
| 60 | var low = 0 | ||
| 61 | var high = scripts.count | ||
| 62 | while low < high { | ||
| 63 | let mid = (low + high) / 2 | ||
| 64 | if scripts[mid].start <= v { low = mid + 1 } else { high = mid } | ||
| 65 | } | ||
| 66 | return scripts[low - 1].script | ||
| 67 | } | ||
| 68 | |||
| 69 | static func isCombining(_ scalar: Unicode.Scalar) -> Bool { | ||
| 70 | switch scalar.properties.generalCategory { | ||
| 71 | case .nonspacingMark, .spacingMark, .enclosingMark: true | ||
| 72 | default: false | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | static func isHan(_ scalar: Unicode.Scalar) -> Bool { | ||
| 77 | let v = scalar.value | ||
| 78 | return (0x3400...0x4DBF).contains(v) || (0x4E00...0x9FFF).contains(v) || (0xF900...0xFAFF).contains(v) || (0x20000...0x3FFFF).contains(v) | ||
| 79 | } | ||
| 80 | |||
| 81 | static func isHiragana(_ scalar: Unicode.Scalar) -> Bool { (0x3040...0x309F).contains(scalar.value) } | ||
| 82 | |||
| 83 | static func isKatakana(_ scalar: Unicode.Scalar) -> Bool { | ||
| 84 | let v = scalar.value | ||
| 85 | return (0x30A0...0x30FF).contains(v) || (0x31F0...0x31FF).contains(v) || (0xFF65...0xFF9F).contains(v) | ||
| 86 | } | ||
| 87 | |||
| 88 | static func isWord(_ scalar: Unicode.Scalar) -> Bool { | ||
| 89 | let v = scalar.value | ||
| 90 | var low = 0 | ||
| 91 | var high = nonWord.count | ||
| 92 | while low < high { | ||
| 93 | let mid = (low + high) / 2 | ||
| 94 | if nonWord[mid].upperBound < v { low = mid + 1 } else { high = mid } | ||
| 95 | } | ||
| 96 | return low == nonWord.count || !nonWord[low].contains(v) | ||
| 97 | } | ||
| 98 | |||
| 99 | /// `count-words-region`: lines (a partial last line counts), words and characters. | ||
| 100 | public static func region(_ text: some StringProtocol) -> (lines: Int, words: Int, characters: Int) { | ||
| 101 | var lines = 0 | ||
| 102 | var characters = 0 | ||
| 103 | var last: Unicode.Scalar? | ||
| 104 | for scalar in text.unicodeScalars { | ||
| 105 | characters += 1 | ||
| 106 | if scalar == "\n" { lines += 1 } | ||
| 107 | last = scalar | ||
| 108 | } | ||
| 109 | if let last, last != "\n" { lines += 1 } | ||
| 110 | return (lines, words(in: text), characters) | ||
| 111 | } | ||
| 112 | } | ||
Sources/OrgCore/Semantic/WordSyntax.swift added +625
| @@ -0,0 +1,625 @@ | |||
| 1 | // Generated by scripts/update-word-syntax.sh from GNU Emacs 31.1 and Org 9.8.7. | ||
| 2 | |||
| 3 | extension TextStats { | ||
| 4 | /// Code points without word syntax in org-mode, sorted. | ||
| 5 | static let nonWord: [ClosedRange<UInt32>] = [ | ||
| 6 | 0x0...0x23, | ||
| 7 | 0x26...0x26, | ||
| 8 | 0x28...0x2F, | ||
| 9 | 0x3A...0x40, | ||
| 10 | 0x5B...0x60, | ||
| 11 | 0x7B...0x7F, | ||
| 12 | 0xA0...0xA4, | ||
| 13 | 0xA6...0xB1, | ||
| 14 | 0xB4...0xB4, | ||
| 15 | 0xB6...0xB6, | ||
| 16 | 0xB8...0xB8, | ||
| 17 | 0xBA...0xBF, | ||
| 18 | 0xD7...0xD7, | ||
| 19 | 0xF7...0xF7, | ||
| 20 | 0x2C7...0x2C7, | ||
| 21 | 0x2C9...0x2C9, | ||
| 22 | 0x2D0...0x2D0, | ||
| 23 | 0x2D8...0x2DB, | ||
| 24 | 0x2DD...0x2DD, | ||
| 25 | 0x384...0x385, | ||
| 26 | 0x5BE...0x5BE, | ||
| 27 | 0x5C0...0x5C0, | ||
| 28 | 0x5C3...0x5C3, | ||
| 29 | 0x5C6...0x5C6, | ||
| 30 | 0xE2F...0xE2F, | ||
| 31 | 0xE3F...0xE3F, | ||
| 32 | 0xE46...0xE46, | ||
| 33 | 0xE4F...0xE4F, | ||
| 34 | 0xE5A...0xE5B, | ||
| 35 | 0xEAF...0xEAF, | ||
| 36 | 0xEC6...0xEC6, | ||
| 37 | 0xF00...0xF0B, | ||
| 38 | 0xF0D...0xF18, | ||
| 39 | 0xF1A...0xF1F, | ||
| 40 | 0xF34...0xF34, | ||
| 41 | 0xF36...0xF36, | ||
| 42 | 0xF38...0xF3F, | ||
| 43 | 0xF7F...0xF7F, | ||
| 44 | 0xF85...0xF85, | ||
| 45 | 0xFBE...0xFCF, | ||
| 46 | 0x1361...0x1368, | ||
| 47 | 0x1800...0x180A, | ||
| 48 | 0x2000...0x2026, | ||
| 49 | 0x202F...0x205F, | ||
| 50 | 0x207D...0x207E, | ||
| 51 | 0x208D...0x208E, | ||
| 52 | 0x20AC...0x20AC, | ||
| 53 | 0x2103...0x2103, | ||
| 54 | 0x2109...0x2109, | ||
| 55 | 0x2116...0x2116, | ||
| 56 | 0x2121...0x2122, | ||
| 57 | 0x2153...0x2154, | ||
| 58 | 0x215B...0x215E, | ||
| 59 | 0x2190...0x244F, | ||
| 60 | 0x2460...0x246E, | ||
| 61 | 0x2474...0x24B5, | ||
| 62 | 0x2500...0x254B, | ||
| 63 | 0x2592...0x2592, | ||
| 64 | 0x25A0...0x25A1, | ||
| 65 | 0x25A3...0x25A9, | ||
| 66 | 0x25B2...0x25B3, | ||
| 67 | 0x25B6...0x25B7, | ||
| 68 | 0x25BC...0x25BD, | ||
| 69 | 0x25C0...0x25C1, | ||
| 70 | 0x25C6...0x25C8, | ||
| 71 | 0x25CB...0x25CB, | ||
| 72 | 0x25CE...0x25D1, | ||
| 73 | 0x25EF...0x25EF, | ||
| 74 | 0x2605...0x2606, | ||
| 75 | 0x260E...0x260F, | ||
| 76 | 0x261C...0x261C, | ||
| 77 | 0x261E...0x261E, | ||
| 78 | 0x2640...0x2640, | ||
| 79 | 0x2642...0x2642, | ||
| 80 | 0x2660...0x2661, | ||
| 81 | 0x2663...0x2665, | ||
| 82 | 0x2667...0x266A, | ||
| 83 | 0x266C...0x266D, | ||
| 84 | 0x266F...0x266F, | ||
| 85 | 0x2768...0x276D, | ||
| 86 | 0x2770...0x2775, | ||
| 87 | 0x27E6...0x27EB, | ||
| 88 | 0x2983...0x2998, | ||
| 89 | 0x29FC...0x29FD, | ||
| 90 | 0x2A00...0x2BFF, | ||
| 91 | 0x2E00...0x2E7F, | ||
| 92 | 0x3000...0x3003, | ||
| 93 | 0x3008...0x301C, | ||
| 94 | 0x30FB...0x30FB, | ||
| 95 | 0x3200...0x321C, | ||
| 96 | 0x3220...0x3229, | ||
| 97 | 0x3260...0x327B, | ||
| 98 | 0x327E...0x327F, | ||
| 99 | 0x3380...0x3384, | ||
| 100 | 0x3388...0x33CA, | ||
| 101 | 0x33CF...0x33D0, | ||
| 102 | 0x33D3...0x33D3, | ||
| 103 | 0x33D6...0x33D6, | ||
| 104 | 0x33D8...0x33D8, | ||
| 105 | 0x33DB...0x33DD, | ||
| 106 | 0xAADB...0xAADF, | ||
| 107 | 0xFD3E...0xFD3F, | ||
| 108 | 0xFE35...0xFE44, | ||
| 109 | 0xFE59...0xFE5E, | ||
| 110 | 0xFF01...0xFF0F, | ||
| 111 | 0xFF1B...0xFF20, | ||
| 112 | 0xFF3B...0xFF40, | ||
| 113 | 0xFF5B...0xFF65, | ||
| 114 | 0xFFE0...0xFFE3, | ||
| 115 | 0xFFE5...0xFFE5, | ||
| 116 | 0x11660...0x1166C, | ||
| 117 | 0x1FB00...0x1FBFF, | ||
| 118 | ] | ||
| 119 | |||
| 120 | /// `char-script-table`: where each script's range starts, and its script. | ||
| 121 | static let scripts: [(start: UInt32, script: UInt16)] = [ | ||
| 122 | (0x0, 0), | ||
| 123 | (0x80, 1), | ||
| 124 | (0xA0, 0), | ||
| 125 | (0x250, 2), | ||
| 126 | (0x2B0, 0), | ||
| 127 | (0x2EA, 3), | ||
| 128 | (0x2EC, 0), | ||
| 129 | (0x370, 4), | ||
| 130 | (0x3E2, 5), | ||
| 131 | (0x3F0, 4), | ||
| 132 | (0x400, 6), | ||
| 133 | (0x530, 7), | ||
| 134 | (0x590, 8), | ||
| 135 | (0x600, 9), | ||
| 136 | (0x700, 10), | ||
| 137 | (0x750, 9), | ||
| 138 | (0x780, 11), | ||
| 139 | (0x7C0, 12), | ||
| 140 | (0x800, 13), | ||
| 141 | (0x840, 14), | ||
| 142 | (0x860, 10), | ||
| 143 | (0x870, 9), | ||
| 144 | (0x900, 15), | ||
| 145 | (0x980, 16), | ||
| 146 | (0xA00, 17), | ||
| 147 | (0xA80, 18), | ||
| 148 | (0xB00, 19), | ||
| 149 | (0xB80, 20), | ||
| 150 | (0xC00, 21), | ||
| 151 | (0xC80, 22), | ||
| 152 | (0xD00, 23), | ||
| 153 | (0xD80, 24), | ||
| 154 | (0xE00, 25), | ||
| 155 | (0xE80, 26), | ||
| 156 | (0xF00, 27), | ||
| 157 | (0x1000, 28), | ||
| 158 | (0x10A0, 29), | ||
| 159 | (0x1100, 30), | ||
| 160 | (0x1200, 31), | ||
| 161 | (0x13A0, 32), | ||
| 162 | (0x1400, 33), | ||
| 163 | (0x1680, 34), | ||
| 164 | (0x16A0, 35), | ||
| 165 | (0x1700, 36), | ||
| 166 | (0x1720, 37), | ||
| 167 | (0x1740, 38), | ||
| 168 | (0x1760, 39), | ||
| 169 | (0x1780, 40), | ||
| 170 | (0x1800, 41), | ||
| 171 | (0x18B0, 33), | ||
| 172 | (0x1900, 42), | ||
| 173 | (0x1950, 43), | ||
| 174 | (0x1980, 44), | ||
| 175 | (0x19E0, 40), | ||
| 176 | (0x1A00, 45), | ||
| 177 | (0x1A20, 46), | ||
| 178 | (0x1AB0, 0), | ||
| 179 | (0x1B00, 47), | ||
| 180 | (0x1B80, 48), | ||
| 181 | (0x1BC0, 49), | ||
| 182 | (0x1C00, 50), | ||
| 183 | (0x1C50, 51), | ||
| 184 | (0x1C80, 6), | ||
| 185 | (0x1C90, 29), | ||
| 186 | (0x1CC0, 48), | ||
| 187 | (0x1CD0, 52), | ||
| 188 | (0x1D00, 2), | ||
| 189 | (0x1DC0, 0), | ||
| 190 | (0x1F00, 4), | ||
| 191 | (0x2000, 53), | ||
| 192 | (0x231A, 54), | ||
| 193 | (0x231C, 53), | ||
| 194 | (0x23E9, 54), | ||
| 195 | (0x23ED, 53), | ||
| 196 | (0x23F0, 54), | ||
| 197 | (0x23F1, 53), | ||
| 198 | (0x23F3, 54), | ||
| 199 | (0x23F4, 53), | ||
| 200 | (0x25FD, 54), | ||
| 201 | (0x25FF, 53), | ||
| 202 | (0x2614, 54), | ||
| 203 | (0x2616, 53), | ||
| 204 | (0x2648, 54), | ||
| 205 | (0x2654, 53), | ||
| 206 | (0x267F, 54), | ||
| 207 | (0x2680, 53), | ||
| 208 | (0x2693, 54), | ||
| 209 | (0x2694, 53), | ||
| 210 | (0x26A1, 54), | ||
| 211 | (0x26A2, 53), | ||
| 212 | (0x26AA, 54), | ||
| 213 | (0x26AC, 53), | ||
| 214 | (0x26BD, 54), | ||
| 215 | (0x26BF, 53), | ||
| 216 | (0x26C4, 54), | ||
| 217 | (0x26C6, 53), | ||
| 218 | (0x26CE, 54), | ||
| 219 | (0x26CF, 53), | ||
| 220 | (0x26D4, 54), | ||
| 221 | (0x26D5, 53), | ||
| 222 | (0x26EA, 54), | ||
| 223 | (0x26EB, 53), | ||
| 224 | (0x26F2, 54), | ||
| 225 | (0x26F4, 53), | ||
| 226 | (0x26F5, 54), | ||
| 227 | (0x26F6, 53), | ||
| 228 | (0x26FA, 54), | ||
| 229 | (0x26FB, 53), | ||
| 230 | (0x26FD, 54), | ||
| 231 | (0x26FE, 53), | ||
| 232 | (0x2705, 54), | ||
| 233 | (0x2706, 53), | ||
| 234 | (0x270A, 54), | ||
| 235 | (0x270C, 53), | ||
| 236 | (0x2728, 54), | ||
| 237 | (0x2729, 53), | ||
| 238 | (0x274C, 54), | ||
| 239 | (0x274D, 53), | ||
| 240 | (0x274E, 54), | ||
| 241 | (0x274F, 53), | ||
| 242 | (0x2753, 54), | ||
| 243 | (0x2756, 53), | ||
| 244 | (0x2757, 54), | ||
| 245 | (0x2758, 53), | ||
| 246 | (0x2795, 54), | ||
| 247 | (0x2798, 53), | ||
| 248 | (0x27B0, 54), | ||
| 249 | (0x27B1, 53), | ||
| 250 | (0x27BF, 54), | ||
| 251 | (0x27C0, 53), | ||
| 252 | (0x2800, 55), | ||
| 253 | (0x2900, 53), | ||
| 254 | (0x2B1B, 54), | ||
| 255 | (0x2B1D, 53), | ||
| 256 | (0x2B50, 54), | ||
| 257 | (0x2B51, 53), | ||
| 258 | (0x2B55, 54), | ||
| 259 | (0x2B56, 53), | ||
| 260 | (0x2C00, 56), | ||
| 261 | (0x2C60, 0), | ||
| 262 | (0x2C80, 5), | ||
| 263 | (0x2D00, 29), | ||
| 264 | (0x2D30, 57), | ||
| 265 | (0x2D80, 31), | ||
| 266 | (0x2DE0, 6), | ||
| 267 | (0x2E00, 53), | ||
| 268 | (0x2E80, 58), | ||
| 269 | (0x2FE0, 1), | ||
| 270 | (0x2FF0, 59), | ||
| 271 | (0x3000, 60), | ||
| 272 | (0x3040, 61), | ||
| 273 | (0x3100, 3), | ||
| 274 | (0x3130, 30), | ||
| 275 | (0x3190, 62), | ||
| 276 | (0x31A0, 3), | ||
| 277 | (0x31C0, 60), | ||
| 278 | (0x31F0, 61), | ||
| 279 | (0x3200, 58), | ||
| 280 | (0x3300, 61), | ||
| 281 | (0x3358, 58), | ||
| 282 | (0x4DC0, 60), | ||
| 283 | (0x4E00, 58), | ||
| 284 | (0xA000, 63), | ||
| 285 | (0xA4D0, 64), | ||
| 286 | (0xA500, 65), | ||
| 287 | (0xA640, 6), | ||
| 288 | (0xA6A0, 66), | ||
| 289 | (0xA700, 0), | ||
| 290 | (0xA800, 67), | ||
| 291 | (0xA830, 68), | ||
| 292 | (0xA840, 69), | ||
| 293 | (0xA880, 70), | ||
| 294 | (0xA8E0, 15), | ||
| 295 | (0xA900, 71), | ||
| 296 | (0xA930, 72), | ||
| 297 | (0xA960, 30), | ||
| 298 | (0xA980, 73), | ||
| 299 | (0xA9E0, 28), | ||
| 300 | (0xAA00, 74), | ||
| 301 | (0xAA60, 28), | ||
| 302 | (0xAA80, 75), | ||
| 303 | (0xAAE0, 76), | ||
| 304 | (0xAB00, 31), | ||
| 305 | (0xAB30, 0), | ||
| 306 | (0xAB65, 4), | ||
| 307 | (0xAB66, 0), | ||
| 308 | (0xAB70, 32), | ||
| 309 | (0xABC0, 76), | ||
| 310 | (0xAC00, 30), | ||
| 311 | (0xD800, 1), | ||
| 312 | (0xF900, 58), | ||
| 313 | (0xFB00, 0), | ||
| 314 | (0xFB07, 1), | ||
| 315 | (0xFB13, 7), | ||
| 316 | (0xFB18, 1), | ||
| 317 | (0xFB1D, 8), | ||
| 318 | (0xFB50, 9), | ||
| 319 | (0xFE00, 1), | ||
| 320 | (0xFE0F, 54), | ||
| 321 | (0xFE10, 77), | ||
| 322 | (0xFE20, 0), | ||
| 323 | (0xFE30, 58), | ||
| 324 | (0xFE50, 53), | ||
| 325 | (0xFE70, 9), | ||
| 326 | (0xFF00, 60), | ||
| 327 | (0xFF61, 61), | ||
| 328 | (0xFFA0, 30), | ||
| 329 | (0xFFE0, 60), | ||
| 330 | (0xFFF0, 1), | ||
| 331 | (0x10000, 78), | ||
| 332 | (0x10100, 79), | ||
| 333 | (0x10140, 80), | ||
| 334 | (0x10190, 81), | ||
| 335 | (0x101D0, 82), | ||
| 336 | (0x10200, 1), | ||
| 337 | (0x10280, 83), | ||
| 338 | (0x102A0, 84), | ||
| 339 | (0x102E0, 5), | ||
| 340 | (0x10300, 85), | ||
| 341 | (0x10330, 86), | ||
| 342 | (0x10350, 87), | ||
| 343 | (0x10380, 88), | ||
| 344 | (0x103A0, 89), | ||
| 345 | (0x103E0, 1), | ||
| 346 | (0x10400, 90), | ||
| 347 | (0x10450, 91), | ||
| 348 | (0x10480, 92), | ||
| 349 | (0x104B0, 93), | ||
| 350 | (0x10500, 94), | ||
| 351 | (0x10530, 95), | ||
| 352 | (0x10570, 96), | ||
| 353 | (0x105C0, 97), | ||
| 354 | (0x10600, 98), | ||
| 355 | (0x10780, 0), | ||
| 356 | (0x107C0, 1), | ||
| 357 | (0x10800, 99), | ||
| 358 | (0x10840, 100), | ||
| 359 | (0x10860, 101), | ||
| 360 | (0x10880, 102), | ||
| 361 | (0x108B0, 1), | ||
| 362 | (0x108E0, 103), | ||
| 363 | (0x10900, 104), | ||
| 364 | (0x10920, 105), | ||
| 365 | (0x10940, 106), | ||
| 366 | (0x10960, 1), | ||
| 367 | (0x10980, 107), | ||
| 368 | (0x10A00, 108), | ||
| 369 | (0x10A60, 109), | ||
| 370 | (0x10A80, 110), | ||
| 371 | (0x10AA0, 1), | ||
| 372 | (0x10AC0, 111), | ||
| 373 | (0x10B00, 112), | ||
| 374 | (0x10B40, 113), | ||
| 375 | (0x10B60, 114), | ||
| 376 | (0x10B80, 115), | ||
| 377 | (0x10BB0, 1), | ||
| 378 | (0x10C00, 116), | ||
| 379 | (0x10C50, 1), | ||
| 380 | (0x10C80, 117), | ||
| 381 | (0x10D00, 118), | ||
| 382 | (0x10D40, 119), | ||
| 383 | (0x10D90, 1), | ||
| 384 | (0x10E60, 9), | ||
| 385 | (0x10E80, 120), | ||
| 386 | (0x10EC0, 9), | ||
| 387 | (0x10F00, 121), | ||
| 388 | (0x10F30, 122), | ||
| 389 | (0x10F70, 123), | ||
| 390 | (0x10FB0, 124), | ||
| 391 | (0x10FE0, 125), | ||
| 392 | (0x11000, 126), | ||
| 393 | (0x11080, 127), | ||
| 394 | (0x110D0, 128), | ||
| 395 | (0x11100, 129), | ||
| 396 | (0x11150, 130), | ||
| 397 | (0x11180, 131), | ||
| 398 | (0x111E0, 24), | ||
| 399 | (0x11200, 132), | ||
| 400 | (0x11250, 1), | ||
| 401 | (0x11280, 133), | ||
| 402 | (0x112B0, 134), | ||
| 403 | (0x11300, 135), | ||
| 404 | (0x11380, 136), | ||
| 405 | (0x11400, 137), | ||
| 406 | (0x11480, 138), | ||
| 407 | (0x114E0, 1), | ||
| 408 | (0x11580, 139), | ||
| 409 | (0x11600, 140), | ||
| 410 | (0x11660, 41), | ||
| 411 | (0x11680, 141), | ||
| 412 | (0x116D0, 28), | ||
| 413 | (0x11700, 142), | ||
| 414 | (0x11750, 1), | ||
| 415 | (0x11800, 143), | ||
| 416 | (0x11850, 1), | ||
| 417 | (0x118A0, 144), | ||
| 418 | (0x11900, 145), | ||
| 419 | (0x11960, 1), | ||
| 420 | (0x119A0, 146), | ||
| 421 | (0x11A00, 147), | ||
| 422 | (0x11A50, 148), | ||
| 423 | (0x11AB0, 33), | ||
| 424 | (0x11AC0, 149), | ||
| 425 | (0x11B00, 15), | ||
| 426 | (0x11B60, 131), | ||
| 427 | (0x11B80, 1), | ||
| 428 | (0x11BC0, 150), | ||
| 429 | (0x11C00, 151), | ||
| 430 | (0x11C70, 152), | ||
| 431 | (0x11CC0, 1), | ||
| 432 | (0x11D00, 153), | ||
| 433 | (0x11D60, 154), | ||
| 434 | (0x11DB0, 155), | ||
| 435 | (0x11DF0, 1), | ||
| 436 | (0x11EE0, 156), | ||
| 437 | (0x11F00, 157), | ||
| 438 | (0x11F60, 1), | ||
| 439 | (0x11FB0, 64), | ||
| 440 | (0x11FC0, 20), | ||
| 441 | (0x12000, 158), | ||
| 442 | (0x12550, 1), | ||
| 443 | (0x12F90, 159), | ||
| 444 | (0x13000, 160), | ||
| 445 | (0x14400, 161), | ||
| 446 | (0x14680, 1), | ||
| 447 | (0x16100, 162), | ||
| 448 | (0x16140, 1), | ||
| 449 | (0x16800, 66), | ||
| 450 | (0x16A40, 163), | ||
| 451 | (0x16A70, 164), | ||
| 452 | (0x16AD0, 165), | ||
| 453 | (0x16B00, 166), | ||
| 454 | (0x16B90, 1), | ||
| 455 | (0x16D40, 167), | ||
| 456 | (0x16D80, 1), | ||
| 457 | (0x16E40, 168), | ||
| 458 | (0x16EA0, 169), | ||
| 459 | (0x16EE0, 1), | ||
| 460 | (0x16F00, 170), | ||
| 461 | (0x16FA0, 1), | ||
| 462 | (0x16FE0, 171), | ||
| 463 | (0x16FE1, 172), | ||
| 464 | (0x16FE2, 53), | ||
| 465 | (0x17000, 171), | ||
| 466 | (0x18B00, 173), | ||
| 467 | (0x18D00, 171), | ||
| 468 | (0x18E00, 1), | ||
| 469 | (0x1AFF0, 61), | ||
| 470 | (0x1B170, 172), | ||
| 471 | (0x1B300, 1), | ||
| 472 | (0x1BC00, 174), | ||
| 473 | (0x1BCB0, 1), | ||
| 474 | (0x1CC00, 53), | ||
| 475 | (0x1CF00, 175), | ||
| 476 | (0x1CFD0, 1), | ||
| 477 | (0x1D000, 176), | ||
| 478 | (0x1D100, 177), | ||
| 479 | (0x1D200, 178), | ||
| 480 | (0x1D250, 1), | ||
| 481 | (0x1D2C0, 179), | ||
| 482 | (0x1D2E0, 180), | ||
| 483 | (0x1D300, 181), | ||
| 484 | (0x1D360, 182), | ||
| 485 | (0x1D380, 1), | ||
| 486 | (0x1D400, 183), | ||
| 487 | (0x1D800, 184), | ||
| 488 | (0x1DAB0, 1), | ||
| 489 | (0x1DF00, 0), | ||
| 490 | (0x1E000, 56), | ||
| 491 | (0x1E030, 6), | ||
| 492 | (0x1E090, 1), | ||
| 493 | (0x1E100, 185), | ||
| 494 | (0x1E150, 1), | ||
| 495 | (0x1E290, 186), | ||
| 496 | (0x1E2C0, 187), | ||
| 497 | (0x1E300, 1), | ||
| 498 | (0x1E4D0, 188), | ||
| 499 | (0x1E500, 1), | ||
| 500 | (0x1E5D0, 189), | ||
| 501 | (0x1E600, 1), | ||
| 502 | (0x1E6C0, 190), | ||
| 503 | (0x1E700, 1), | ||
| 504 | (0x1E7E0, 31), | ||
| 505 | (0x1E800, 191), | ||
| 506 | (0x1E8E0, 1), | ||
| 507 | (0x1E900, 192), | ||
| 508 | (0x1E960, 1), | ||
| 509 | (0x1EC70, 193), | ||
| 510 | (0x1ECC0, 1), | ||
| 511 | (0x1ED00, 194), | ||
| 512 | (0x1ED50, 1), | ||
| 513 | (0x1EE00, 9), | ||
| 514 | (0x1EF00, 1), | ||
| 515 | (0x1F000, 195), | ||
| 516 | (0x1F004, 54), | ||
| 517 | (0x1F005, 195), | ||
| 518 | (0x1F030, 196), | ||
| 519 | (0x1F0A0, 197), | ||
| 520 | (0x1F0CF, 54), | ||
| 521 | (0x1F0D0, 197), | ||
| 522 | (0x1F100, 53), | ||
| 523 | (0x1F18E, 54), | ||
| 524 | (0x1F18F, 53), | ||
| 525 | (0x1F191, 54), | ||
| 526 | (0x1F19B, 53), | ||
| 527 | (0x1F1E6, 54), | ||
| 528 | (0x1F200, 58), | ||
| 529 | (0x1F201, 54), | ||
| 530 | (0x1F202, 58), | ||
| 531 | (0x1F21A, 54), | ||
| 532 | (0x1F21B, 58), | ||
| 533 | (0x1F22F, 54), | ||
| 534 | (0x1F230, 58), | ||
| 535 | (0x1F232, 54), | ||
| 536 | (0x1F237, 58), | ||
| 537 | (0x1F238, 54), | ||
| 538 | (0x1F23B, 58), | ||
| 539 | (0x1F250, 54), | ||
| 540 | (0x1F252, 58), | ||
| 541 | (0x1F300, 54), | ||
| 542 | (0x1F321, 53), | ||
| 543 | (0x1F32D, 54), | ||
| 544 | (0x1F336, 53), | ||
| 545 | (0x1F337, 54), | ||
| 546 | (0x1F37D, 53), | ||
| 547 | (0x1F37E, 54), | ||
| 548 | (0x1F394, 53), | ||
| 549 | (0x1F3A0, 54), | ||
| 550 | (0x1F3CB, 53), | ||
| 551 | (0x1F3CF, 54), | ||
| 552 | (0x1F3D4, 53), | ||
| 553 | (0x1F3E0, 54), | ||
| 554 | (0x1F3F1, 53), | ||
| 555 | (0x1F3F4, 54), | ||
| 556 | (0x1F3F5, 53), | ||
| 557 | (0x1F3F8, 54), | ||
| 558 | (0x1F43F, 53), | ||
| 559 | (0x1F440, 54), | ||
| 560 | (0x1F441, 53), | ||
| 561 | (0x1F442, 54), | ||
| 562 | (0x1F4FD, 53), | ||
| 563 | (0x1F4FF, 54), | ||
| 564 | (0x1F53E, 53), | ||
| 565 | (0x1F54B, 54), | ||
| 566 | (0x1F54F, 53), | ||
| 567 | (0x1F550, 54), | ||
| 568 | (0x1F568, 53), | ||
| 569 | (0x1F57A, 54), | ||
| 570 | (0x1F57B, 53), | ||
| 571 | (0x1F595, 54), | ||
| 572 | (0x1F597, 53), | ||
| 573 | (0x1F5A4, 54), | ||
| 574 | (0x1F5A5, 53), | ||
| 575 | (0x1F5FB, 54), | ||
| 576 | (0x1F650, 53), | ||
| 577 | (0x1F680, 54), | ||
| 578 | (0x1F6C6, 53), | ||
| 579 | (0x1F6CC, 54), | ||
| 580 | (0x1F6CD, 53), | ||
| 581 | (0x1F6D0, 54), | ||
| 582 | (0x1F6D3, 53), | ||
| 583 | (0x1F6D5, 54), | ||
| 584 | (0x1F6D9, 53), | ||
| 585 | (0x1F6DC, 54), | ||
| 586 | (0x1F6E0, 53), | ||
| 587 | (0x1F6EB, 54), | ||
| 588 | (0x1F6ED, 53), | ||
| 589 | (0x1F6F4, 54), | ||
| 590 | (0x1F6FD, 53), | ||
| 591 | (0x1F7E0, 54), | ||
| 592 | (0x1F7EC, 53), | ||
| 593 | (0x1F7F0, 54), | ||
| 594 | (0x1F7F1, 53), | ||
| 595 | (0x1F90C, 54), | ||
| 596 | (0x1F93B, 53), | ||
| 597 | (0x1F93C, 54), | ||
| 598 | (0x1F946, 53), | ||
| 599 | (0x1F947, 54), | ||
| 600 | (0x1FA00, 198), | ||
| 601 | (0x1FA70, 54), | ||
| 602 | (0x1FA7D, 53), | ||
| 603 | (0x1FA80, 54), | ||
| 604 | (0x1FA8B, 53), | ||
| 605 | (0x1FA8E, 54), | ||
| 606 | (0x1FAC7, 53), | ||
| 607 | (0x1FAC8, 54), | ||
| 608 | (0x1FAC9, 53), | ||
| 609 | (0x1FACD, 54), | ||
| 610 | (0x1FADD, 53), | ||
| 611 | (0x1FADF, 54), | ||
| 612 | (0x1FAEB, 53), | ||
| 613 | (0x1FAEF, 54), | ||
| 614 | (0x1FAF9, 53), | ||
| 615 | (0x1FC00, 1), | ||
| 616 | (0x20000, 58), | ||
| 617 | (0x2A6E0, 1), | ||
| 618 | (0x2A700, 58), | ||
| 619 | (0x2EE60, 1), | ||
| 620 | (0x2F800, 58), | ||
| 621 | (0x2FA20, 1), | ||
| 622 | (0x30000, 58), | ||
| 623 | (0x33480, 1), | ||
| 624 | ] | ||
| 625 | } | ||
Sources/OrgEditorAppKit/OrgEditor.swift +3
| @@ -184,6 +184,8 @@ public final class OrgEditor: NSObject { | |||
| 184 | /// Called with the pending prefix and what can follow it, and with nil when it ends. | 184 | /// Called with the pending prefix and what can follow it, and with nil when it ends. |
| 185 | /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise. | 185 | /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise. |
| 186 | public var onModeChange: ((String?) -> Void)? | 186 | public var onModeChange: ((String?) -> Void)? |
| 187 | /// The selection moved. | ||
| 188 | public var onSelectionChange: ((NSRange) -> Void)? | ||
| 187 | var reportedMode: String? | 189 | var reportedMode: String? |
| 188 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? | 190 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
| 189 | /// Called with `app.*` commands, which the app carries out. | 191 | /// Called with `app.*` commands, which the app carries out. |
| @@ -617,6 +619,7 @@ public final class OrgEditor: NSObject { | |||
| 617 | fileprivate func selectionChanged() { | 619 | fileprivate func selectionChanged() { |
| 618 | let selection = textView.selectedRange() | 620 | let selection = textView.selectedRange() |
| 619 | defer { previousSelection = selection.location } | 621 | defer { previousSelection = selection.location } |
| 622 | onSelectionChange?(selection) | ||
| 620 | guard selection.length == 0, let range = hidden.containing(selection.location) else { return } | 623 | guard selection.length == 0, let range = hidden.containing(selection.location) else { return } |
| 621 | setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1) | 624 | setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1) |
| 622 | } | 625 | } |
Sources/Orgstar/ContentView.swift +6 −1
| @@ -64,7 +64,12 @@ struct ContentView: View { | |||
| 64 | } detail: { | 64 | } detail: { |
| 65 | if session.path != nil { | 65 | if session.path != nil { |
| 66 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) | 66 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) |
| 67 | .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } | 67 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 68 | VStack(spacing: 0) { | ||
| 69 | ModeLine(session: session, commands: commands) | ||
| 70 | EchoArea(commands: commands) | ||
| 71 | } | ||
| 72 | } | ||
| 68 | } else { | 73 | } else { |
| 69 | ContentUnavailableView( | 74 | ContentUnavailableView( |
| 70 | workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", | 75 | workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", |
Sources/Orgstar/EditorView.swift +1
| @@ -47,6 +47,7 @@ struct EditorView: NSViewRepresentable { | |||
| 47 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } | 47 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
| 48 | } | 48 | } |
| 49 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } | 49 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } |
| 50 | editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } | ||
| 50 | editor.onAppCommand = onAppCommand | 51 | editor.onAppCommand = onAppCommand |
| 51 | editor.onExternal = onExternal | 52 | editor.onExternal = onExternal |
| 52 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } | 53 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
Sources/Orgstar/Keys.swift +5 −9
| @@ -71,15 +71,11 @@ struct EchoArea: View { | |||
| 71 | answerFocused = true | 71 | answerFocused = true |
| 72 | } | 72 | } |
| 73 | .id(request.id) | 73 | .id(request.id) |
| 74 | } else if commands.editorState != nil || commands.pendingKeys != nil || commands.message != nil { | 74 | } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message { |
| 75 | Divider() | 75 | Text(text) |
| 76 | HStack(spacing: 8) { | 76 | .font(.system(.callout, design: .monospaced)) |
| 77 | if let state = commands.editorState { StateTag(state: state) } | 77 | .lineLimit(1) |
| 78 | Text(commands.pendingKeys.map { $0 + "-" } ?? commands.message ?? "") | 78 | .padding(.horizontal, 10) |
| 79 | .font(.system(.callout, design: .monospaced)) | ||
| 80 | .lineLimit(1) | ||
| 81 | } | ||
| 82 | .padding(.horizontal, 10) | ||
| 83 | .padding(.vertical, 4) | 79 | .padding(.vertical, 4) |
| 84 | .frame(maxWidth: .infinity, alignment: .leading) | 80 | .frame(maxWidth: .infinity, alignment: .leading) |
| 85 | } | 81 | } |
Sources/Orgstar/ModeLine.swift added +104
| @@ -0,0 +1,104 @@ | |||
| 1 | import OrgApp | ||
| 2 | import OrgCore | ||
| 3 | import OrgPresentation | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// The line under the editor: evil state, unsaved mark and the headings around the caret on | ||
| 7 | /// the left; the selection's counts, line and column, the file's words and anything unusual | ||
| 8 | /// about its encoding on the right. | ||
| 9 | struct ModeLine: View { | ||
| 10 | let session: DocumentSession | ||
| 11 | let commands: AppCommands | ||
| 12 | @State private var words: Int? | ||
| 13 | @State private var crlf = false | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | let text = commands.editor?.document.text ?? "" | ||
| 17 | let selection = commands.selection | ||
| 18 | HStack(spacing: 10) { | ||
| 19 | if let state = commands.editorState { StateTag(state: state) } | ||
| 20 | if session.isDirty { | ||
| 21 | Image(systemName: "circle.fill") | ||
| 22 | .font(.system(size: 6)) | ||
| 23 | .foregroundStyle(.secondary) | ||
| 24 | .help("Unsaved changes") | ||
| 25 | .accessibilityLabel("Unsaved changes") | ||
| 26 | } | ||
| 27 | breadcrumb(at: selection.location) | ||
| 28 | Spacer(minLength: 12) | ||
| 29 | if selection.length > 0, let region = region(text, selection) { | ||
| 30 | Text("\(region.lines) \(region.lines == 1 ? "line" : "lines"), \(region.words) \(region.words == 1 ? "word" : "words"), \(region.characters) chars") | ||
| 31 | .help("Selection (count-words-region)") | ||
| 32 | } | ||
| 33 | let position = TextStats.position(in: text, at: selection.location + selection.length) | ||
| 34 | Text("\(position.line):\(position.column)") | ||
| 35 | .help("Line \(position.line), column \(position.column)") | ||
| 36 | if let words { | ||
| 37 | Text("\(words.formatted()) \(words == 1 ? "word" : "words")") | ||
| 38 | .help("Words in the file (count-words)") | ||
| 39 | } | ||
| 40 | ForEach(encodingNotes, id: \.self) { note in | ||
| 41 | Text(note).foregroundStyle(.orange) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .font(.system(.caption, design: .monospaced)) | ||
| 45 | .monospacedDigit() | ||
| 46 | .lineLimit(1) | ||
| 47 | .foregroundStyle(.secondary) | ||
| 48 | .padding(.horizontal, 10) | ||
| 49 | .frame(height: 22) | ||
| 50 | .background(.bar) | ||
| 51 | .overlay(alignment: .top) { Divider() } | ||
| 52 | .task(id: WordsKey(path: session.path, version: session.editVersion)) { | ||
| 53 | guard let text = commands.editor?.document.text ?? session.document?.text else { | ||
| 54 | words = nil | ||
| 55 | return | ||
| 56 | } | ||
| 57 | (words, crlf) = await Task.detached(priority: .utility) { (TextStats.words(in: text), text.contains("\r\n")) }.value | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | private struct WordsKey: Hashable { | ||
| 62 | var path: String? | ||
| 63 | var version: Int | ||
| 64 | } | ||
| 65 | |||
| 66 | /// The outline path of the heading containing `offset`, outermost first. | ||
| 67 | @ViewBuilder | ||
| 68 | private func breadcrumb(at offset: Int) -> some View { | ||
| 69 | let path = outlinePath(at: offset) | ||
| 70 | if !path.isEmpty { | ||
| 71 | Text(path.joined(separator: " › ")) | ||
| 72 | .truncationMode(.head) | ||
| 73 | .foregroundStyle(.primary) | ||
| 74 | .help(path.joined(separator: " › ")) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | private func outlinePath(at offset: Int) -> [String] { | ||
| 79 | let outline = session.outline | ||
| 80 | var path: [String] = [] | ||
| 81 | var current = outline.lastIndex { $0.start <= offset } | ||
| 82 | while let i = current { | ||
| 83 | path.insert(outline[i].title.isEmpty ? "(untitled)" : outline[i].title, at: 0) | ||
| 84 | current = outline[i].parent | ||
| 85 | } | ||
| 86 | return path | ||
| 87 | } | ||
| 88 | |||
| 89 | private func region(_ text: String, _ range: NSRange) -> (lines: Int, words: Int, characters: Int)? { | ||
| 90 | let ns = text as NSString | ||
| 91 | guard NSMaxRange(range) <= ns.length else { return nil } | ||
| 92 | return TextStats.region(ns.substring(with: range)) | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Only what differs from UTF-8 with LF endings. | ||
| 96 | private var encodingNotes: [String] { | ||
| 97 | guard let document = session.document else { return [] } | ||
| 98 | var notes: [String] = [] | ||
| 99 | if session.isReadOnly { notes.append("Read-only") } | ||
| 100 | if document.source.hasBOM { notes.append("BOM") } | ||
| 101 | if crlf { notes.append("CRLF") } | ||
| 102 | return notes | ||
| 103 | } | ||
| 104 | } | ||
Sources/Orgstar/OrgstarApp.swift +2
| @@ -23,6 +23,8 @@ final class AppCommands { | |||
| 23 | var pendingKeys: String? | 23 | var pendingKeys: String? |
| 24 | /// The editor's evil state in the Doom preset. | 24 | /// The editor's evil state in the Doom preset. |
| 25 | var editorState: String? | 25 | var editorState: String? |
| 26 | /// The editor's selection, for the modeline. | ||
| 27 | var selection = NSRange(location: 0, length: 0) | ||
| 26 | var keyHints: [KeyHint] = [] | 28 | var keyHints: [KeyHint] = [] |
| 27 | /// A question a command asked, answered in the echo area. | 29 | /// A question a command asked, answered in the echo area. |
| 28 | private(set) var prompt: PromptRequest? | 30 | private(set) var prompt: PromptRequest? |
Tests/OrgCoreTests/TextStatsTests.swift added +79
| @@ -0,0 +1,79 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct TextStatsTests { | ||
| 6 | static let samples = [ | ||
| 7 | "don't stop", "foo_bar baz", "x-y", "café au lait", "日本語 text", "*bold* /it/ =code=", "2026-10-05 Mon", | ||
| 8 | "https://example.com/a-b?c=1", "[[file:x.org][Desc here]]", "* TODO Heading :tag:work:", "a.b,c;d", | ||
| 9 | "it's 3.14 or 1e5", "naïve—dash", "emoji 😀 ok", "über_cool", "«quoted» “smart” ‘single’", "a\u{00A0}b", | ||
| 10 | "\tindented\ttext", "- [X] item\n - sub 2\n", "x × y ÷ z → w", "€5 £3 ©2026 °C ™ ¶ § •", "✓ done ★ star", "¶ § · µ ¬ ¨ ■ ♥ ⌘ ✂ ➜ 🎉", "line one\r\nline two\r\n", "Sony α7 III", "漢字かなカナ ひらがなカタカナ", "e\u{0301}te résumé", "Привет мир", "a\u{0080}\u{0098}b", "", | ||
| 11 | ] | ||
| 12 | |||
| 13 | /// `count-words`, `count-lines` and `current-column` at the end of each sample in an org buffer. | ||
| 14 | static func emacs(_ samples: [String]) throws -> [String] { | ||
| 15 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-stats-\(UUID().uuidString)") | ||
| 16 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 17 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 18 | let input = folder.appendingPathComponent("in.json") | ||
| 19 | let output = folder.appendingPathComponent("out.json") | ||
| 20 | let script = folder.appendingPathComponent("stats.el") | ||
| 21 | try JSONEncoder().encode(samples).write(to: input) | ||
| 22 | try """ | ||
| 23 | ;;; -*- lexical-binding: t -*- | ||
| 24 | (require 'org) | ||
| 25 | (require 'json) | ||
| 26 | (let ((samples (append (json-read-file (getenv "ORACLE_INPUT")) nil)) result) | ||
| 27 | (dolist (s samples) | ||
| 28 | (with-temp-buffer | ||
| 29 | (org-mode) | ||
| 30 | (insert s) | ||
| 31 | (push (format "%d words %d lines %d chars, %d:%d" | ||
| 32 | (count-words (point-min) (point-max)) | ||
| 33 | (count-lines (point-min) (point-max)) | ||
| 34 | (- (point-max) (point-min)) | ||
| 35 | (line-number-at-pos) (current-column)) | ||
| 36 | result))) | ||
| 37 | (let ((coding-system-for-write 'utf-8-unix)) | ||
| 38 | (with-temp-file (getenv "ORACLE_OUTPUT") | ||
| 39 | (insert (json-encode (vconcat (nreverse result))))))) | ||
| 40 | """.write(to: script, atomically: true, encoding: .utf8) | ||
| 41 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path]) | ||
| 42 | guard let data = try? Data(contentsOf: output) else { throw AgendaOracle.OracleError(log: log) } | ||
| 43 | return try JSONDecoder().decode([String].self, from: data) | ||
| 44 | } | ||
| 45 | |||
| 46 | static func ours(_ s: String) -> String { | ||
| 47 | let region = TextStats.region(s) | ||
| 48 | let position = TextStats.position(in: s, at: s.utf16.count) | ||
| 49 | return "\(region.words) words \(region.lines) lines \(region.characters) chars, \(position.line):\(position.column)" | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 53 | func matchesEmacs() throws { | ||
| 54 | let emacs = try Self.emacs(Self.samples) | ||
| 55 | for (sample, expected) in zip(Self.samples, emacs) { | ||
| 56 | #expect(Self.ours(sample) == expected, "\(sample)") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | /// `ORGSTAR_ORACLE_CORPUS=<folder>`: every org file under the folder. | ||
| 61 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | ||
| 62 | func corpusMatchesEmacs() throws { | ||
| 63 | let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!) | ||
| 64 | let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)! | ||
| 65 | .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" } | ||
| 66 | let texts = files.compactMap { try? String(contentsOf: $0, encoding: .utf8) } | ||
| 67 | let emacs = try Self.emacs(texts) | ||
| 68 | for (text, expected) in zip(texts, emacs) { | ||
| 69 | #expect(Self.ours(text) == expected, "\(text.prefix(60))") | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test func positionMidLine() { | ||
| 74 | let text = "* Heading\n\tab日本\n" | ||
| 75 | let offset = (text as NSString).range(of: "本").location | ||
| 76 | #expect(TextStats.position(in: text, at: offset) == (2, 12)) | ||
| 77 | #expect(TextStats.position(in: text, at: 0) == (1, 0)) | ||
| 78 | } | ||
| 79 | } | ||
scripts/update-word-syntax.sh added +43
| @@ -0,0 +1,43 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Writes what `forward-word` needs from Emacs to Sources/OrgCore/Semantic/WordSyntax.swift: | ||
| 3 | # the characters without word syntax in an org-mode buffer, and `char-script-table`. | ||
| 4 | # Usage: scripts/update-word-syntax.sh [emacs] | ||
| 5 | set -eu | ||
| 6 | cd "$(dirname "$0")/.." | ||
| 7 | emacs=${1:-emacs} | ||
| 8 | out=Sources/OrgCore/Semantic/WordSyntax.swift | ||
| 9 | { | ||
| 10 | echo "// Generated by scripts/update-word-syntax.sh from $("$emacs" --version | head -1) and Org $("$emacs" -Q --batch --eval '(progn (require (quote org)) (princ (org-version)))')." | ||
| 11 | echo | ||
| 12 | echo "extension TextStats {" | ||
| 13 | echo " /// Code points without word syntax in org-mode, sorted." | ||
| 14 | echo " static let nonWord: [ClosedRange<UInt32>] = [" | ||
| 15 | "$emacs" -Q --batch --eval ' | ||
| 16 | (progn | ||
| 17 | (require (quote org)) | ||
| 18 | (with-temp-buffer | ||
| 19 | (org-mode) | ||
| 20 | (let ((c 0) start) | ||
| 21 | (while (<= c #x110000) | ||
| 22 | (let ((word (and (< c #x110000) (eq (char-syntax c) ?w)))) | ||
| 23 | (cond ((and (not word) (not start) (< c #x110000)) (setq start c)) | ||
| 24 | ((and (or word (= c #x110000)) start) | ||
| 25 | (princ (format " 0x%X...0x%X,\n" start (1- c))) | ||
| 26 | (setq start nil)))) | ||
| 27 | (setq c (1+ c))))))' | ||
| 28 | echo " ]" | ||
| 29 | echo | ||
| 30 | echo " /// \`char-script-table\`: where each script's range starts, and its script." | ||
| 31 | echo " static let scripts: [(start: UInt32, script: UInt16)] = [" | ||
| 32 | "$emacs" -Q --batch --eval ' | ||
| 33 | (let ((c 0) (ids (make-hash-table)) prev) | ||
| 34 | (while (< c #x110000) | ||
| 35 | (let ((s (aref char-script-table c))) | ||
| 36 | (unless (and (> c 0) (eq s prev)) | ||
| 37 | (unless (gethash s ids) (puthash s (hash-table-count ids) ids)) | ||
| 38 | (princ (format " (0x%X, %d),\n" c (gethash s ids))) | ||
| 39 | (setq prev s))) | ||
| 40 | (setq c (1+ c))))' | ||
| 41 | echo " ]" | ||
| 42 | echo "}" | ||
| 43 | } > "$out" | ||