aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Helpers
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKit/Helpers')
-rw-r--r--Sources/NorgKit/Helpers/ASCIIByteSet.swift30
-rw-r--r--Sources/NorgKit/Helpers/ASCIIHelper.swift5
-rw-r--r--Sources/NorgKit/Helpers/TextHelper.swift95
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)
+ }
+}