Sapling/Sources/SaplingEditor/EditableRegion.swift

126 lines
3.8 KiB
Swift
Raw Normal View History

import Foundation
public struct EditableRegion: Hashable, Sendable {
public var lineIndexes: [Int]
public init(lineIndexes: some Sequence<Int>) {
self.lineIndexes = Array(Set(lineIndexes)).sorted()
}
public var primaryLineIndex: Int {
lineIndexes.first ?? -1
}
public var isEmpty: Bool {
lineIndexes.isEmpty
}
public func contains(_ lineIndex: Int) -> Bool {
lineIndexes.binarySearch(lineIndex)
}
public static func none() -> EditableRegion {
EditableRegion(lineIndexes: [])
}
public static func selection(_ selection: NSRange, in lineIndex: DocumentLineIndex) -> EditableRegion {
guard lineIndex.lineCount > 0 else {
return .none()
}
let sourceLength = lineIndex.source.utf16.count
let startLocation = max(0, min(selection.location, sourceLength))
let endLocation: Int
if selection.length == 0 {
endLocation = startLocation
} else {
endLocation = max(startLocation, min(selection.upperBound - 1, sourceLength))
}
let startLine = lineIndex.lineIndex(containing: startLocation)
let endLine = lineIndex.lineIndex(containing: endLocation)
let selectedRange = min(startLine, endLine)...max(startLine, endLine)
var editableLines = Set(selectedRange)
guard lineIndex.source.contains("```") || lineIndex.source.contains("~~~") else {
return EditableRegion(lineIndexes: editableLines)
}
for selectedLine in selectedRange {
if let codeBlockRange = lineIndex.fencedCodeBlockLineRange(containing: selectedLine) {
editableLines.formUnion(codeBlockRange)
}
}
return EditableRegion(lineIndexes: editableLines)
}
}
private extension DocumentLineIndex {
func fencedCodeBlockLineRange(containing lineIndex: Int) -> ClosedRange<Int>? {
guard (0..<lineCount).contains(lineIndex) else { return nil }
var openLineIndex: Int?
var openFence: String?
for currentLineIndex in 0..<lineCount {
guard let line = editorLine(at: currentLineIndex, activeLineIndex: -1),
let fence = Self.fenceMarker(in: line.source)
else { continue }
if let start = openLineIndex {
if fence == openFence {
let blockRange = start...currentLineIndex
return blockRange.contains(lineIndex) ? blockRange : nil
}
} else {
openLineIndex = currentLineIndex
openFence = fence
}
}
if let start = openLineIndex {
let blockRange = start...max(start, lineCount - 1)
return blockRange.contains(lineIndex) ? blockRange : nil
}
return nil
}
static func fenceMarker(in source: String) -> String? {
let indentation = source.prefix { $0 == " " || $0 == "\t" }
guard indentation.count <= 3 else { return nil }
let content = source.dropFirst(indentation.count)
if content.hasPrefix("```") {
return "```"
}
if content.hasPrefix("~~~") {
return "~~~"
}
return nil
}
}
private extension Array where Element == Int {
func binarySearch(_ value: Int) -> Bool {
var lowerBound = 0
var upperBound = count - 1
while lowerBound <= upperBound {
let midpoint = (lowerBound + upperBound) / 2
if self[midpoint] == value {
return true
}
if self[midpoint] < value {
lowerBound = midpoint + 1
} else {
upperBound = midpoint - 1
}
}
return false
}
}
private extension NSRange {
var upperBound: Int {
location + length
}
}