diff options
Diffstat (limited to 'Sources/NorgKit/Helpers/TextHelper.swift')
| -rw-r--r-- | Sources/NorgKit/Helpers/TextHelper.swift | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/Sources/NorgKit/Helpers/TextHelper.swift b/Sources/NorgKit/Helpers/TextHelper.swift new file mode 100644 index 0000000..b0d45fe --- /dev/null +++ b/Sources/NorgKit/Helpers/TextHelper.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Text helpers that avoid `CharacterSet` and allocations for performance. +enum TextHelper { + + /// Splits text into lines as slices. + static func lineSlices(_ text: String) -> [Substring] { + var result: [Substring] = [] + enumerateLines(in: text) { line, _ in result.append(line) } + return result + } + + /// Invokes `body` once per line, passing a `Substring` view and its index. + static func enumerateLines(in text: String, _ body: (Substring, Int) -> Void) { + let utf8 = text.utf8 + let end = utf8.endIndex + var lineStart = utf8.startIndex + var i = utf8.startIndex + var index = 0 + while i < end { + if utf8[i] == 0x0A { + body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)], index) + index += 1 + lineStart = utf8.index(after: i) + } + i = utf8.index(after: i) + } + body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)], index) + } + + /// The end index of a line `[start, newline)`, backed up by one when the last + /// byte is a carriage return. Operating on the UTF-8 view avoids the + /// grapheme decode that `Substring.last` would pay on every line. + private static func strippedLineEnd( + _ utf8: String.UTF8View, from start: String.Index, to newline: String.Index + ) -> String.Index { + guard newline > start else { return newline } + let last = utf8.index(before: newline) + return utf8[last] == 0x0D ? last : newline + } + + /// Returns the line at `index` (zero-based) as a `Substring`, or `nil` when + /// there is no such line. Trailing `\r` is stripped and line counting matches + /// ``lines(_:)`` / ``enumerateLines(in:_:)``. Stops as soon as the line is + /// found, so locating an early line in a large document is cheap. + static func line(in text: String, at index: Int) -> Substring? { + guard index >= 0 else { return nil } + let utf8 = text.utf8 + let end = utf8.endIndex + var lineStart = utf8.startIndex + var i = utf8.startIndex + var current = 0 + while i < end { + if utf8[i] == 0x0A { + if current == index { + return text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)] + } + current += 1 + lineStart = utf8.index(after: i) + } + i = utf8.index(after: i) + } + return current == index + ? text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)] : nil + } + + /// Trims leading and trailing whitespace from a slice using + /// `Character.isWhitespace`, avoiding the `CharacterSet` bridging cost that + /// `trimmingCharacters(in:)` pays per call. The result is a slice of the + /// input, so nothing is copied. This is the single trimming primitive used + /// across the parser, scanner, and detached-modifier recogniser. + static func whitespaceTrimmed(_ s: Substring) -> Substring { + // Trim over the UTF-8 view: Norg indentation and trailing space is always + // ASCII whitespace, whose bytes are all `< 0x80` and so never part of a + // multi-byte scalar — the trimmed bounds stay on scalar boundaries. This + // avoids the grapheme decode `Character.isWhitespace` pays on every line. + let utf8 = s.utf8 + var start = utf8.startIndex + var end = utf8.endIndex + while start < end, ASCIIHelper.isWhitespace(utf8[start]) { start = utf8.index(after: start) } + while start < end { + let prev = utf8.index(before: end) + guard ASCIIHelper.isWhitespace(utf8[prev]) else { break } + end = prev + } + return s[start..<end] + } + + /// Trims leading and trailing whitespace. + static func whitespaceTrimmed(_ s: String) -> String { + let trimmed = whitespaceTrimmed(s[...]) + return trimmed.startIndex == s.startIndex && trimmed.endIndex == s.endIndex + ? s : String(trimmed) + } +} |