diff --git a/Sources/SaplingEditor/DocumentLineIndex.swift b/Sources/SaplingEditor/DocumentLineIndex.swift new file mode 100644 index 0000000..b0d9cb2 --- /dev/null +++ b/Sources/SaplingEditor/DocumentLineIndex.swift @@ -0,0 +1,160 @@ +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 + } +} diff --git a/Sources/SaplingEditor/EditorActiveLineTracker.swift b/Sources/SaplingEditor/EditorActiveLineTracker.swift index 904ecd8..5b218bd 100644 --- a/Sources/SaplingEditor/EditorActiveLineTracker.swift +++ b/Sources/SaplingEditor/EditorActiveLineTracker.swift @@ -2,57 +2,11 @@ import Foundation public enum EditorActiveLineTracker { public static func lines(from source: String, activeLineIndex: Int) -> [EditorLine] { - var lines: [EditorLine] = [] - var lineStart = source.startIndex - var utf16Location = 0 - var index = 0 - - while lineStart < source.endIndex { - let lineEnd = source[lineStart...].firstIndex(of: "\n") ?? source.endIndex - let line = String(source[lineStart.. Int { - let clampedLocation = max(0, min(location, source.utf16.count)) - var currentLocation = 0 - - for (index, line) in source.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { - let length = line.utf16.count - if clampedLocation <= currentLocation + length { - return index - } - currentLocation += length + 1 - } - - return 0 + DocumentLineIndex(source: source).lineIndex(containing: location) } public static func clampedSelection(_ selection: EditorSelection, in source: String) -> EditorSelection { diff --git a/Sources/SaplingEditor/EditorPerformanceProfiling.swift b/Sources/SaplingEditor/EditorPerformanceProfiling.swift index cdf2bf4..f81189d 100644 --- a/Sources/SaplingEditor/EditorPerformanceProfiling.swift +++ b/Sources/SaplingEditor/EditorPerformanceProfiling.swift @@ -256,9 +256,11 @@ public enum EditorBenchmarkProfiler { } public static func documentProfile(fileName: String, source: String) -> EditorBenchmarkDocumentProfile { - let lines = source.components(separatedBy: "\n") - let lineLengths = lines.map { $0.utf16.count } - let lineCount = lines.count + let lineIndex = DocumentLineIndex(source: source) + let nsSource = source as NSString + let lineSources = lineIndex.boundaries.map { nsSource.substring(with: $0.contentRange) } + let lineLengths = lineIndex.boundaries.map(\.contentRange.length) + let lineCount = lineIndex.boundaries.count let maxLineLength = lineLengths.max() ?? 0 let totalLineLength = lineLengths.reduce(0, +) @@ -273,9 +275,9 @@ public enum EditorBenchmarkProfiler { orderedListItemCount: countMatches("(?m)^\\d+\\.\\s", in: source), blockquoteCount: countMatches("(?m)^>\\s", in: source), fencedCodeFenceCount: countMatches("(?m)^```", in: source), - inlineCodeLineCount: countMatchingLines("`[^`\\n]+`", in: lines), - boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lines), - italicLineCount: countMatchingLines("(?