Skip to content

Commit

Permalink
Refactor LineEnding library
Browse files Browse the repository at this point in the history
  • Loading branch information
1024jp committed Jul 13, 2024
1 parent a2962e9 commit 56ce895
Show file tree
Hide file tree
Showing 13 changed files with 363 additions and 168 deletions.
8 changes: 1 addition & 7 deletions CotEditor.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,6 @@
2A5ADE851D2168FC00F6CE26 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5ADE831D2168FC00F6CE26 /* Collection.swift */; };
2A5ADE881D216D4900F6CE26 /* NSColor+NamedColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5ADE871D216D4900F6CE26 /* NSColor+NamedColors.swift */; };
2A5ADE891D216D4900F6CE26 /* NSColor+NamedColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5ADE871D216D4900F6CE26 /* NSColor+NamedColors.swift */; };
2A5C00342814698000700CAE /* Collection+BinarySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C00332814698000700CAE /* Collection+BinarySearch.swift */; };
2A5C00352814698000700CAE /* Collection+BinarySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C00332814698000700CAE /* Collection+BinarySearch.swift */; };
2A5D130A1D1ED10400D38E6A /* ConsolePanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5D13091D1ED10400D38E6A /* ConsolePanelController.swift */; };
2A5D130B1D1ED10400D38E6A /* ConsolePanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5D13091D1ED10400D38E6A /* ConsolePanelController.swift */; };
2A5D13101D1EE66500D38E6A /* FindProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5D130F1D1EE66500D38E6A /* FindProgressView.swift */; };
Expand Down Expand Up @@ -917,7 +915,6 @@
2A59B7022957089A0094F03B /* LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkButton.swift; sourceTree = "<group>"; };
2A5ADE831D2168FC00F6CE26 /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
2A5ADE871D216D4900F6CE26 /* NSColor+NamedColors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSColor+NamedColors.swift"; sourceTree = "<group>"; };
2A5C00332814698000700CAE /* Collection+BinarySearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+BinarySearch.swift"; sourceTree = "<group>"; };
2A5D13091D1ED10400D38E6A /* ConsolePanelController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConsolePanelController.swift; sourceTree = "<group>"; };
2A5D130F1D1EE66500D38E6A /* FindProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindProgressView.swift; sourceTree = "<group>"; };
2A5D13121D1EE8FF00D38E6A /* HUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HUDView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1396,7 +1393,6 @@
2AA056AC26FCA171000E0CB2 /* Arithmetics.swift */,
2A885E321D5C3A1B00288723 /* Comparable.swift */,
2A5ADE831D2168FC00F6CE26 /* Collection.swift */,
2A5C00332814698000700CAE /* Collection+BinarySearch.swift */,
2A1A4EAF24FB9D9300B50AA0 /* Combine.swift */,
2AA71A522BE366520084EC0A /* Observation.swift */,
);
Expand Down Expand Up @@ -1674,7 +1670,6 @@
2A478F3E22BE743200AEA45E /* NSTextView+Ligature.swift */,
2AFE848522AE71130001C4ED /* TextContainer.swift */,
2A6FD9E61D394F5900A59784 /* LayoutManager.swift */,
2A1125C223F1A86B006A1DB2 /* LineRangeCacheable.swift */,
2A6416A21D2F9F7200FA9E1A /* LineNumberView.swift */,
2A0BF8A71DD8E7F90088961B /* TextSizeTouchBar.swift */,
);
Expand Down Expand Up @@ -1856,6 +1851,7 @@
children = (
2AD7B9AE1D3E832E00E5D6D7 /* EditorCounter.swift */,
2A80BE8C27FFA61700D2F7FF /* LineEndingScanner.swift */,
2A1125C223F1A86B006A1DB2 /* LineRangeCacheable.swift */,
2A1125C523F6EFB2006A1DB2 /* URLDetector.swift */,
);
name = Scanners;
Expand Down Expand Up @@ -2631,7 +2627,6 @@
2A5DCE501D185F1B00D5D74C /* CharacterField.swift in Sources */,
2A42823A2638DAEB00D03C5C /* CharacterInspectorView.swift in Sources */,
2A5ADE851D2168FC00F6CE26 /* Collection.swift in Sources */,
2A5C00342814698000700CAE /* Collection+BinarySearch.swift in Sources */,
2ADA15EF21C5073D00C6608B /* Collection+IndexSet.swift in Sources */,
2A4257B11D22FD490086DAAD /* ColorCodePanelController.swift in Sources */,
2A1A4EB024FB9D9300B50AA0 /* Combine.swift in Sources */,
Expand Down Expand Up @@ -2935,7 +2930,6 @@
2A5DCE4F1D185F1B00D5D74C /* CharacterField.swift in Sources */,
2A42823B2638DAEB00D03C5C /* CharacterInspectorView.swift in Sources */,
2A5ADE841D2168FC00F6CE26 /* Collection.swift in Sources */,
2A5C00352814698000700CAE /* Collection+BinarySearch.swift in Sources */,
2ADA15EE21C5073D00C6608B /* Collection+IndexSet.swift in Sources */,
2A4257B01D22FD490086DAAD /* ColorCodePanelController.swift in Sources */,
2A1A4EB124FB9D9300B50AA0 /* Combine.swift in Sources */,
Expand Down
53 changes: 8 additions & 45 deletions CotEditor/Sources/LineEndingScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,30 @@ import AppKit.NSTextStorage
import LineEnding
import ValueRange

@Observable final class LineEndingScanner {
@Observable final class LineEndingScanner: LineRangeCalculating {

var baseLineEnding: LineEnding

private(set) var lineEndings: [ValueRange<LineEnding>]
private(set) var inconsistentLineEndings: [ValueRange<LineEnding>] = []

var string: NSString { self.textStorage.string as NSString }


// MARK: Private Properties

private let textStorage: NSTextStorage
private var lineEndings: [ValueRange<LineEnding>]

private var storageObserver: AnyCancellable?



// MARK: Lifecycle

required init(textStorage: NSTextStorage, lineEnding: LineEnding) {

self.textStorage = textStorage
self.baseLineEnding = lineEnding

self.textStorage = textStorage
self.lineEndings = textStorage.string.lineEndingRanges()
self.inconsistentLineEndings = self.lineEndings.filter { $0.value != lineEnding }

Expand All @@ -62,7 +64,6 @@ import ValueRange
}



// MARK: Public Methods

/// Cancels all observations.
Expand All @@ -82,22 +83,6 @@ import ValueRange
}


/// Returns the 1-based line number at the given character index.
///
/// - Parameter index: The character index.
/// - Returns: The 1-based line number.
func lineNumber(at index: Int) -> Int {

if let last = self.lineEndings.last?.range, last.upperBound <= index {
self.lineEndings.count + 1
} else if let index = self.lineEndings.binarySearchedFirstIndex(where: { $0.range.upperBound > index }) {
index + 1
} else {
1
}
}


/// Returns whether the character at the given index is a line ending inconsistent with the `baseLineEnding`.
///
/// - Parameter characterIndex: The index of character to test.
Expand All @@ -108,6 +93,8 @@ import ValueRange
}


// MARK: Private Methods

/// Updates inconsistent line endings by assuming the textStorage was edited.
///
/// - Parameters:
Expand All @@ -134,27 +121,3 @@ import ValueRange
self.inconsistentLineEndings.replace(items: inconsistentLineEndings, in: scanRange, changeInLength: delta)
}
}



private extension Array where Element == ValueRange<LineEnding> {

mutating func replace(items: [Element], in editedRange: NSRange, changeInLength delta: Int) {

guard let lowerEditedIndex = self.binarySearchedFirstIndex(where: { $0.lowerBound >= editedRange.lowerBound }) else {
self += items
return
}

if let upperEditedIndex = self[lowerEditedIndex...].firstIndex(where: { $0.lowerBound >= (editedRange.upperBound - delta) }) {
for index in upperEditedIndex..<self.endIndex {
self[index].shift(by: delta)
}
self.removeSubrange(lowerEditedIndex..<upperEditedIndex)
} else {
self.removeSubrange(lowerEditedIndex...)
}

self.insert(contentsOf: items, at: lowerEditedIndex)
}
}
2 changes: 1 addition & 1 deletion Packages/EditorCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ let package = Package(
.target(name: "Invisible"),

.target(name: "LineEnding", dependencies: ["ValueRange"]),
.testTarget(name: "LineEndingTests", dependencies: ["LineEnding"]),
.testTarget(name: "LineEndingTests", dependencies: ["LineEnding", "StringBasics"]),

.target(name: "LineSort", dependencies: ["StringBasics"]),
.testTarget(name: "LineSortTests", dependencies: ["LineSort"]),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//
// Collection+BinarySearch.swift
// LineEnding
//
// CotEditor
// https://coteditor.com
Expand Down
58 changes: 58 additions & 0 deletions Packages/EditorCore/Sources/LineEnding/Collection+ValueRange.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// Collection+ValueRange.swift
// LineEnding
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-07-13.
//
// ---------------------------------------------------------------------------
//
// © 2022-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import ValueRange

public extension Array {

/// Replace the elements in the specified range with the given items.
///
/// This API assumes the elements are sorted by range location.
///
/// - Parameters:
/// - items: The items to replace with.
/// - editedRange: The edited range.
/// - delta: The change in length.
mutating func replace<Value>(items: [Element], in editedRange: NSRange, changeInLength delta: Int) where Element == ValueRange<Value> {

guard let lowerEditedIndex = self.binarySearchedFirstIndex(where: { $0.lowerBound >= editedRange.lowerBound }) else {
self += items
return
}

if let upperEditedIndex = self[lowerEditedIndex...].firstIndex(where: { $0.lowerBound >= (editedRange.upperBound - delta) }) {
for index in upperEditedIndex..<self.endIndex {
self[index].shift(by: delta)
}
self.removeSubrange(lowerEditedIndex..<upperEditedIndex)
} else {
self.removeSubrange(lowerEditedIndex...)
}

self.insert(contentsOf: items, at: lowerEditedIndex)
}
}
76 changes: 0 additions & 76 deletions Packages/EditorCore/Sources/LineEnding/LineEnding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@
// limitations under the License.
//

import Foundation
import ValueRange

public enum LineEnding: Character, Sendable, CaseIterable {

case lf = "\n"
Expand Down Expand Up @@ -76,76 +73,3 @@ public enum LineEnding: Character, Sendable, CaseIterable {
}
}
}



// MARK: -

public extension String {

/// Collects ranges of all line endings per line ending type from the beginning.
///
/// - Parameters:
/// - range: The range to parse.
/// - Returns: Ranges of line endings.
func lineEndingRanges(in range: NSRange? = nil) -> [ValueRange<LineEnding>] {

guard !self.isEmpty else { return [] }

var lineEndingRanges: [ValueRange<LineEnding>] = []
let string = self as NSString
let range = range ?? NSRange(..<self.utf16.count)

string.enumerateSubstrings(in: range, options: [.byLines, .substringNotRequired]) { (_, substringRange, enclosingRange, _) in
guard enclosingRange.length > 0 else { return }

let lineEndingRange = NSRange(substringRange.upperBound..<enclosingRange.upperBound)

guard
lineEndingRange.length > 0,
let lastCharacter = string.substring(with: lineEndingRange).first, // line ending must be a single character
let lineEnding = LineEnding(rawValue: lastCharacter)
else { return }

lineEndingRanges.append(.init(value: lineEnding, range: lineEndingRange))
}

return lineEndingRanges
}
}


public extension StringProtocol {

/// Returns a new string in which all line endings in the receiver are replaced with the given line endings.
///
/// - Parameters:
/// - lineEnding: The line ending type with which to replace the target.
/// - Returns: String replacing line ending characters.
func replacingLineEndings(with lineEnding: LineEnding) -> String {

self.replacingOccurrences(of: LineEnding.allRegexPattern, with: lineEnding.string, options: .regularExpression)
}
}


public extension NSMutableAttributedString {

/// Replaces all line endings in the receiver with given line endings.
///
/// - Parameters:
/// - lineEnding: The line ending type with which to replace the target.
final func replaceLineEndings(with lineEnding: LineEnding) {

// -> Intentionally avoid replacing characters in the mutableString directly,
// because it pots a quantity of small edited notifications,
// which costs high. (2023-11, macOS 14)
self.replaceCharacters(in: NSRange(..<self.length), with: self.string.replacingLineEndings(with: lineEnding))
}
}


private extension LineEnding {

static let allRegexPattern = "\r\n|[\r\n\u{0085}\u{2028}\u{2029}]"
}
56 changes: 56 additions & 0 deletions Packages/EditorCore/Sources/LineEnding/LineRangeCalculating.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// LineRangeCalculating.swift
// LineEnding
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-07-13.
//
// ---------------------------------------------------------------------------
//
// © 2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import ValueRange

public protocol LineRangeCalculating {

/// The text contents.
var string: NSString { get }

/// Line Endings sorted by location.
var lineEndings: [ValueRange<LineEnding>] { get }
}


public extension LineRangeCalculating {

/// Returns the 1-based line number at the given character index.
///
/// - Parameter characterIndex: The character index.
/// - Returns: The 1-based line number.
func lineNumber(at characterIndex: Int) -> Int {

if let index = self.lineEndings.binarySearchedFirstIndex(where: { $0.upperBound > characterIndex }) {
index + 1
} else if let last = self.lineEndings.last, last.upperBound <= characterIndex {
self.lineEndings.endIndex + 1
} else {
1
}
}
}
Loading

0 comments on commit 56ce895

Please sign in to comment.