aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Helpers/TextHelper.swift
blob: b0d45fec0cbb42597be83b9a86c43d1ba40a9252 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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)
  }
}