diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-16 12:08:21 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-16 12:22:47 +0200 |
| commit | 501fdce29e4d2c46c4708ad7f44056878e0db3fa (patch) | |
| tree | db923b77037db9f5b37600a6e34c3bb2e8a971be /Sources/NorgKit/Helpers | |
Initial extraction from Norganize1.0.0
Diffstat (limited to 'Sources/NorgKit/Helpers')
| -rw-r--r-- | Sources/NorgKit/Helpers/ASCIIByteSet.swift | 30 | ||||
| -rw-r--r-- | Sources/NorgKit/Helpers/ASCIIHelper.swift | 5 | ||||
| -rw-r--r-- | Sources/NorgKit/Helpers/TextHelper.swift | 95 |
3 files changed, 130 insertions, 0 deletions
diff --git a/Sources/NorgKit/Helpers/ASCIIByteSet.swift b/Sources/NorgKit/Helpers/ASCIIByteSet.swift new file mode 100644 index 0000000..59efbe7 --- /dev/null +++ b/Sources/NorgKit/Helpers/ASCIIByteSet.swift @@ -0,0 +1,30 @@ +/// A membership test over the ASCII range (bytes `0`–`127`) +/// We use this for performance, since markers are always ASCII, and this this +/// faster than using Character. +struct ASCIIByteSet { + private let low: UInt64 + private let high: UInt64 + + /// Builds a set from the ASCII values. + init(_ characters: String) { + var lo: UInt64 = 0 + var hi: UInt64 = 0 + for byte in characters.utf8 { + if byte < 64 { + lo |= 1 << UInt64(byte) + } else if byte < 128 { + hi |= 1 << UInt64(byte - 64) + } + } + low = lo + high = hi + } + + /// Whether `byte` is in the set. + @inline(__always) + func contains(_ byte: UInt8) -> Bool { + if byte < 64 { return low & (1 << UInt64(byte)) != 0 } + if byte < 128 { return high & (1 << UInt64(byte &- 64)) != 0 } + return false + } +} diff --git a/Sources/NorgKit/Helpers/ASCIIHelper.swift b/Sources/NorgKit/Helpers/ASCIIHelper.swift new file mode 100644 index 0000000..98cce42 --- /dev/null +++ b/Sources/NorgKit/Helpers/ASCIIHelper.swift @@ -0,0 +1,5 @@ +struct ASCIIHelper { + static func isWhitespace(_ b: UInt8) -> Bool { + b == 0x20 || (0x09...0x0D).contains(b) + } +} 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) + } +} |