2026-05-30 18:19:52 +02:00
|
|
|
import XCTest
|
|
|
|
|
@testable import SaplingEditor
|
|
|
|
|
|
|
|
|
|
final class DocumentLineIndexTests: XCTestCase {
|
|
|
|
|
func testLFLineBoundaries() {
|
|
|
|
|
assertLineIndex(
|
|
|
|
|
source: "One\nTwo\nThree",
|
|
|
|
|
expectedSources: ["One", "Two", "Three"],
|
|
|
|
|
expectedRanges: [
|
|
|
|
|
NSRange(location: 0, length: 3),
|
|
|
|
|
NSRange(location: 4, length: 3),
|
|
|
|
|
NSRange(location: 8, length: 5)
|
|
|
|
|
],
|
|
|
|
|
expectedEndings: [.lf, .lf, .none]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testCRLFLineBoundaries() {
|
|
|
|
|
assertLineIndex(
|
|
|
|
|
source: "One\r\nTwo\r\nThree",
|
|
|
|
|
expectedSources: ["One", "Two", "Three"],
|
|
|
|
|
expectedRanges: [
|
|
|
|
|
NSRange(location: 0, length: 3),
|
|
|
|
|
NSRange(location: 5, length: 3),
|
|
|
|
|
NSRange(location: 10, length: 5)
|
|
|
|
|
],
|
|
|
|
|
expectedEndings: [.crlf, .crlf, .none]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testCRLineBoundaries() {
|
|
|
|
|
assertLineIndex(
|
|
|
|
|
source: "One\rTwo\rThree",
|
|
|
|
|
expectedSources: ["One", "Two", "Three"],
|
|
|
|
|
expectedRanges: [
|
|
|
|
|
NSRange(location: 0, length: 3),
|
|
|
|
|
NSRange(location: 4, length: 3),
|
|
|
|
|
NSRange(location: 8, length: 5)
|
|
|
|
|
],
|
|
|
|
|
expectedEndings: [.cr, .cr, .none]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testMixedLineBoundaries() {
|
|
|
|
|
assertLineIndex(
|
|
|
|
|
source: "One\nTwo\r\nThree\rFour",
|
|
|
|
|
expectedSources: ["One", "Two", "Three", "Four"],
|
|
|
|
|
expectedRanges: [
|
|
|
|
|
NSRange(location: 0, length: 3),
|
|
|
|
|
NSRange(location: 4, length: 3),
|
|
|
|
|
NSRange(location: 9, length: 5),
|
|
|
|
|
NSRange(location: 15, length: 4)
|
|
|
|
|
],
|
|
|
|
|
expectedEndings: [.lf, .crlf, .cr, .none]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testTrailingBlankLineForEveryLineEnding() {
|
|
|
|
|
XCTAssertEqual(DocumentLineIndex(source: "One\n").boundaries.map(\.contentRange.location), [0, 4])
|
|
|
|
|
XCTAssertEqual(DocumentLineIndex(source: "One\r\n").boundaries.map(\.contentRange.location), [0, 5])
|
|
|
|
|
XCTAssertEqual(DocumentLineIndex(source: "One\r").boundaries.map(\.contentRange.location), [0, 4])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testActiveLineDetectionAcrossCRLFBoundaries() {
|
|
|
|
|
let source = "One\r\nTwo\r\nThree"
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 0, in: source), 0)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 3, in: source), 0)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 4, in: source), 0)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 5, in: source), 1)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 8, in: source), 1)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 9, in: source), 1)
|
|
|
|
|
XCTAssertEqual(EditorActiveLineTracker.lineIndex(containing: 10, in: source), 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEditorLinesPreserveCRLFSourceRangesAndModes() {
|
|
|
|
|
let lines = EditorActiveLineTracker.lines(from: "One\r\nTwo\r\nThree", activeLineIndex: 1)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(lines.count, 3)
|
|
|
|
|
XCTAssertEqual(lines.map(\.source), ["One", "Two", "Three"])
|
|
|
|
|
XCTAssertEqual(lines.map(\.range), [
|
|
|
|
|
NSRange(location: 0, length: 3),
|
|
|
|
|
NSRange(location: 5, length: 3),
|
|
|
|
|
NSRange(location: 10, length: 5)
|
|
|
|
|
])
|
|
|
|
|
XCTAssertEqual(lines.map(\.mode), [.rendered, .source, .rendered])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:24:48 +02:00
|
|
|
func testOffsetAndLineMappingUsesCachedBoundaries() {
|
|
|
|
|
let source = "One\nTwo\r\nThree\rFour"
|
|
|
|
|
let index = DocumentLineIndex(source: source)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(index.lineCount, 4)
|
|
|
|
|
XCTAssertEqual(index.lineStartOffset(forLine: 0), 0)
|
|
|
|
|
XCTAssertEqual(index.lineStartOffset(forLine: 1), 4)
|
|
|
|
|
XCTAssertEqual(index.lineStartOffset(forLine: 2), 9)
|
|
|
|
|
XCTAssertEqual(index.lineStartOffset(forLine: 3), 15)
|
|
|
|
|
XCTAssertEqual(index.lineIndex(containing: 0), 0)
|
|
|
|
|
XCTAssertEqual(index.lineIndex(containing: 4), 1)
|
|
|
|
|
XCTAssertEqual(index.lineIndex(containing: 14), 2)
|
|
|
|
|
XCTAssertEqual(index.lineIndex(containing: 15), 3)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalInsertionWithoutNewlineMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\nTwo\nThree",
|
|
|
|
|
range: NSRange(location: 5, length: 0),
|
|
|
|
|
replacement: " updated"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalInsertionWithLFMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\nTwo\nThree",
|
|
|
|
|
range: NSRange(location: 7, length: 0),
|
|
|
|
|
replacement: "\nInserted"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalInsertionWithCRLFMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\r\nTwo\r\nThree",
|
|
|
|
|
range: NSRange(location: 8, length: 0),
|
|
|
|
|
replacement: "\r\nInserted"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalDeletionAcrossLinesMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\nTwo\nThree\nFour",
|
|
|
|
|
range: NSRange(location: 2, length: 10),
|
|
|
|
|
replacement: ""
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalReplacementAcrossMixedLineEndingsMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\nTwo\r\nThree\rFour",
|
|
|
|
|
range: NSRange(location: 3, length: 11),
|
|
|
|
|
replacement: "\r\nReplacement\n"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalEditAtCRLFBoundaryMatchesFullRebuild() {
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: "One\r\nTwo\r\nThree",
|
|
|
|
|
range: NSRange(location: 3, length: 2),
|
|
|
|
|
replacement: "\n"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testIncrementalLargeDocumentEditMatchesFullRebuild() {
|
|
|
|
|
let source = (0..<10_000)
|
|
|
|
|
.map { "Line \($0)\r\n" }
|
|
|
|
|
.joined()
|
|
|
|
|
|
|
|
|
|
assertIncrementalEdit(
|
|
|
|
|
source: source,
|
|
|
|
|
range: NSRange(location: 42_000, length: 5),
|
|
|
|
|
replacement: "changed\nwith\nnew lines"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 18:19:52 +02:00
|
|
|
func testBenchmarkDocumentSegmentsIntoPhysicalLines() throws {
|
|
|
|
|
let url = URL(fileURLWithPath: "/Users/feror/Sapling/Docs/Benchmarks/5mb.md")
|
|
|
|
|
let source = try String(contentsOf: url, encoding: .utf8)
|
|
|
|
|
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 0)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(lines.count, 51_482)
|
|
|
|
|
XCTAssertEqual(lines[0].source, "# ExampleFile.com - Example Files")
|
|
|
|
|
XCTAssertEqual(lines[1].source, "")
|
|
|
|
|
XCTAssertEqual(lines[2].source, "- ✨ExampleFile ✨")
|
|
|
|
|
XCTAssertEqual(lines.filter { $0.mode == .source }.count, 1)
|
|
|
|
|
XCTAssertEqual(lines.filter { $0.mode == .rendered }.count, 51_481)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func assertLineIndex(
|
|
|
|
|
source: String,
|
|
|
|
|
expectedSources: [String],
|
|
|
|
|
expectedRanges: [NSRange],
|
|
|
|
|
expectedEndings: [LineEndingStrategy],
|
|
|
|
|
file: StaticString = #filePath,
|
|
|
|
|
line: UInt = #line
|
|
|
|
|
) {
|
|
|
|
|
let index = DocumentLineIndex(source: source)
|
|
|
|
|
let lines = index.editorLines(activeLineIndex: 0)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(lines.map(\.source), expectedSources, file: file, line: line)
|
|
|
|
|
XCTAssertEqual(index.boundaries.map(\.contentRange), expectedRanges, file: file, line: line)
|
|
|
|
|
XCTAssertEqual(index.boundaries.map(\.lineEnding), expectedEndings, file: file, line: line)
|
|
|
|
|
}
|
2026-05-30 19:24:48 +02:00
|
|
|
|
|
|
|
|
private func assertIncrementalEdit(
|
|
|
|
|
source: String,
|
|
|
|
|
range: NSRange,
|
|
|
|
|
replacement: String,
|
|
|
|
|
file: StaticString = #filePath,
|
|
|
|
|
line: UInt = #line
|
|
|
|
|
) {
|
|
|
|
|
var incremental = DocumentLineIndex(source: source)
|
|
|
|
|
incremental.replaceCharacters(in: range, with: replacement)
|
|
|
|
|
|
|
|
|
|
let rebuiltSource = (source as NSString).replacingCharacters(in: range, with: replacement)
|
|
|
|
|
let rebuilt = DocumentLineIndex(source: rebuiltSource)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(incremental.source, rebuiltSource, file: file, line: line)
|
|
|
|
|
XCTAssertEqual(incremental.boundaries, rebuilt.boundaries, file: file, line: line)
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
incremental.editorLines(activeLineIndex: 0).map(\.source),
|
|
|
|
|
rebuilt.editorLines(activeLineIndex: 0).map(\.source),
|
|
|
|
|
file: file,
|
|
|
|
|
line: line
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-30 18:19:52 +02:00
|
|
|
}
|