Sapling/Sources/SaplingEditor/DocumentLineIndex.swift

160 lines
4.8 KiB
Swift

import Foundation
public enum LineEndingStrategy: String, Hashable, Sendable {
case lf
case crlf
case cr
case none
public var utf16Length: Int {
switch self {
case .lf, .cr:
return 1
case .crlf:
return 2
case .none:
return 0
}
}
}
public struct DocumentLineBoundary: Hashable, Sendable {
public var index: Int
public var contentRange: NSRange
public var lineEnding: LineEndingStrategy
public init(index: Int, contentRange: NSRange, lineEnding: LineEndingStrategy) {
self.index = index
self.contentRange = contentRange
self.lineEnding = lineEnding
}
public var lineEndingRange: NSRange {
NSRange(location: contentRange.upperBound, length: lineEnding.utf16Length)
}
public var nextLineLocation: Int {
contentRange.upperBound + lineEnding.utf16Length
}
}
public struct DocumentLineIndex: Hashable, Sendable {
public var source: String
public var boundaries: [DocumentLineBoundary]
public init(source: String) {
self.source = source
self.boundaries = Self.scanBoundaries(in: source)
}
public func lineIndex(containing location: Int) -> Int {
guard !boundaries.isEmpty else { return 0 }
let clampedLocation = max(0, min(location, source.utf16.count))
for boundary in boundaries.dropLast() {
if clampedLocation < boundary.nextLineLocation {
return boundary.index
}
}
return boundaries[boundaries.count - 1].index
}
public func editorLines(activeLineIndex: Int) -> [EditorLine] {
let nsSource = source as NSString
return boundaries.map { boundary in
EditorLine(
index: boundary.index,
source: nsSource.substring(with: boundary.contentRange),
range: boundary.contentRange,
mode: boundary.index == activeLineIndex ? .source : .rendered
)
}
}
private static func scanBoundaries(in source: String) -> [DocumentLineBoundary] {
let nsSource = source as NSString
let sourceLength = nsSource.length
guard sourceLength > 0 else {
return [
DocumentLineBoundary(
index: 0,
contentRange: NSRange(location: 0, length: 0),
lineEnding: .none
)
]
}
var boundaries: [DocumentLineBoundary] = []
var lineStart = 0
var lineIndex = 0
var endedWithLineEnding = false
while lineStart < sourceLength {
var cursor = lineStart
while cursor < sourceLength,
!isLineEndingStart(nsSource.character(at: cursor)) {
cursor += 1
}
let contentRange = NSRange(location: lineStart, length: cursor - lineStart)
if cursor < sourceLength {
let lineEnding = lineEndingStrategy(at: cursor, in: nsSource)
boundaries.append(DocumentLineBoundary(
index: lineIndex,
contentRange: contentRange,
lineEnding: lineEnding
))
cursor += lineEnding.utf16Length
lineStart = cursor
endedWithLineEnding = cursor == sourceLength
} else {
boundaries.append(DocumentLineBoundary(
index: lineIndex,
contentRange: contentRange,
lineEnding: .none
))
lineStart = sourceLength
endedWithLineEnding = false
}
lineIndex += 1
}
if endedWithLineEnding {
boundaries.append(DocumentLineBoundary(
index: lineIndex,
contentRange: NSRange(location: sourceLength, length: 0),
lineEnding: .none
))
}
return boundaries
}
private static func lineEndingStrategy(at location: Int, in source: NSString) -> LineEndingStrategy {
let character = source.character(at: location)
if character == carriageReturnUTF16 {
let nextLocation = location + 1
if nextLocation < source.length,
source.character(at: nextLocation) == lineFeedUTF16 {
return .crlf
}
return .cr
}
return .lf
}
private static func isLineEndingStart(_ character: unichar) -> Bool {
character == lineFeedUTF16 || character == carriageReturnUTF16
}
private static let lineFeedUTF16: unichar = 10
private static let carriageReturnUTF16: unichar = 13
}
private extension NSRange {
var upperBound: Int {
location + length
}
}