Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 45 additions & 49 deletions Sources/Core/Source/DocumentMetrics.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

struct DocumentMetrics: Sendable, Equatable {
private static let mermaidNeedle: [unichar] = Array("mermaid".utf16)
private static let mermaidNeedle = Array("mermaid".utf8)
private static let editContextLength = mermaidNeedle.count

let utf8ByteCount: Int
Expand All @@ -13,10 +13,12 @@ struct DocumentMetrics: Sendable, Equatable {
}

init(text: String) {
let source = text as NSString
utf8ByteCount = text.utf8.count
lineCount = Self.logicalLineBreakCount(in: source) + 1
mermaidCandidateCount = Self.mermaidCount(in: source)
let counts =
text.utf8.withContiguousStorageIfAvailable(Self.scan)
?? Array(text.utf8).withUnsafeBufferPointer(Self.scan)
utf8ByteCount = counts.bytes
lineCount = counts.lineBreaks + 1
mermaidCandidateCount = counts.mermaid
}

func applying(_ edit: SourceEdit, to previous: String) -> DocumentMetrics {
Expand Down Expand Up @@ -50,6 +52,8 @@ struct DocumentMetrics: Sendable, Equatable {
with: edit.replacement
)

let oldMetrics = DocumentMetrics(text: oldContext as String)
let newMetrics = DocumentMetrics(text: newContext as String)
let removed = source.substring(with: edit.range).utf8.count
return DocumentMetrics(
utf8ByteCount: max(
Expand All @@ -59,14 +63,14 @@ struct DocumentMetrics: Sendable, Equatable {
lineCount: max(
1,
lineCount
- Self.logicalLineBreakCount(in: oldContext)
+ Self.logicalLineBreakCount(in: newContext)
- oldMetrics.lineCount
+ newMetrics.lineCount
),
mermaidCandidateCount: max(
0,
mermaidCandidateCount
- Self.mermaidCount(in: oldContext)
+ Self.mermaidCount(in: newContext)
- oldMetrics.mermaidCandidateCount
+ newMetrics.mermaidCandidateCount
)
)
}
Expand All @@ -81,51 +85,43 @@ struct DocumentMetrics: Sendable, Equatable {
self.mermaidCandidateCount = mermaidCandidateCount
}

private static func logicalLineBreakCount(in text: NSString) -> Int {
var count = 0
var index = 0
while index < text.length {
switch text.character(at: index) {
case 0x000D:
count += 1
if index + 1 < text.length,
text.character(at: index + 1) == 0x000A
{
index += 1
// UTF-8 preserves ASCII delimiters verbatim. Scan the native bytes once,
// including the two Unicode line separators, without bridging the document
// to NSString or sending an Objective-C message for each code unit.
private static func scan(
_ bytes: UnsafeBufferPointer<UInt8>
) -> (bytes: Int, lineBreaks: Int, mermaid: Int) {
var lineBreaks = 0
var mermaid = 0
for index in bytes.indices {
let byte = bytes[index]
if byte == 0x0D {
lineBreaks += 1
} else if byte == 0x0A {
if index == 0 || bytes[index - 1] != 0x0D {
lineBreaks += 1
}
case 0x000A, 0x2028, 0x2029:
count += 1
default:
break
} else if byte == 0xE2, index + 2 < bytes.count,
bytes[index + 1] == 0x80,
bytes[index + 2] == 0xA8 || bytes[index + 2] == 0xA9
{
lineBreaks += 1
}
index += 1
}
return count
}

private static func mermaidCount(in text: NSString) -> Int {
guard text.length >= mermaidNeedle.count else { return 0 }
var count = 0
let lastStart = text.length - mermaidNeedle.count
for start in 0...lastStart {
var matches = true
for offset in mermaidNeedle.indices {
let character = text.character(at: start + offset)
let folded: unichar
if character >= 0x0041, character <= 0x005A {
folded = character + 0x0020
} else {
folded = character
if byte | 0x20 == mermaidNeedle[0],
bytes.count - index >= mermaidNeedle.count
{
var matches = true
for offset in 1..<mermaidNeedle.count {
if bytes[index + offset] | 0x20 != mermaidNeedle[offset] {
matches = false
break
}
}
if folded != mermaidNeedle[offset] {
matches = false
break
if matches {
mermaid += 1
}
}
if matches {
count += 1
}
}
return count
return (bytes.count, lineBreaks, mermaid)
}
}
74 changes: 23 additions & 51 deletions Sources/Core/Source/SourceLineIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,37 +112,32 @@ private struct CompleteSourceLineIndex: @unchecked Sendable {
return reset(to: updated)
}

let startContext = max(0, edit.range.location - 1)
var rescanStartIndex = lineIndex(containing: startContext)
if rescanStartIndex > 0 {
rescanStartIndex -= 1
// Only line breaks inside the edit and its immediate CRLF context can
// change. Starting at a line boundary would rescan an arbitrarily long
// unchanged line on every keystroke.
var rescanStart = max(0, edit.range.location - 1)
if rescanStart > 0,
previous.character(at: rescanStart) == 0x000A,
previous.character(at: rescanStart - 1) == 0x000D
{
rescanStart -= 1
}
guard let rescanStart = value(at: rescanStartIndex) else {
return reset(to: updated)
var oldRescanEnd = min(previous.length, NSMaxRange(edit.range) + 1)
if oldRescanEnd < previous.length,
previous.character(at: oldRescanEnd - 1) == 0x000D,
previous.character(at: oldRescanEnd) == 0x000A
{
oldRescanEnd += 1
}

let endContext = min(
previous.length,
NSMaxRange(edit.range) + 1
)
let firstStartAfterContext = upperBound(of: endContext)
let preservedSuffixIndex = min(
root?.entryCount ?? 0,
firstStartAfterContext + 1
)
let oldRescanEnd =
value(at: preservedSuffixIndex)
?? previous.length
let preservedPrefixCount = upperBound(of: rescanStart)
let preservedSuffixIndex = upperBound(of: oldRescanEnd)
let lengthDelta = updated.length - previous.length
let newRescanEnd = min(
updated.length,
max(rescanStart, oldRescanEnd + lengthDelta)
)
let newRescanEnd = oldRescanEnd + lengthDelta

let (prefix, remaining) = split(root, at: rescanStartIndex)
let (prefix, remaining) = split(root, at: preservedPrefixCount)
let (_, unshiftedSuffix) = split(
remaining,
at: preservedSuffixIndex - rescanStartIndex
at: preservedSuffixIndex - preservedPrefixCount
)
unshiftedSuffix?.applyShift(lengthDelta)
guard
Expand All @@ -154,16 +149,9 @@ private struct CompleteSourceLineIndex: @unchecked Sendable {
else {
return false
}
var rescanned: SourceLineIndexNode? = rescannedRoot
if let rescannedRoot = rescanned,
let unshiftedSuffix,
lastValue(in: rescannedRoot) == firstValue(in: unshiftedSuffix)
{
(rescanned, _) = split(
rescannedRoot,
at: rescannedRoot.entryCount - 1
)
}
// makeTree includes its starting offset; that synthetic entry already
// belongs to the preserved prefix or lies inside an existing line.
let (_, rescanned) = split(rescannedRoot, at: 1)
root = merge(prefix, merge(rescanned, unshiftedSuffix))
textLength = updated.length
return storedEntryCount <= Self.maximumStoredLineStarts
Expand Down Expand Up @@ -268,22 +256,6 @@ private struct CompleteSourceLineIndex: @unchecked Sendable {
)
}

private func firstValue(in node: SourceLineIndexNode) -> Int {
node.pushPendingShift()
guard let left = node.left else {
return node.value(at: 0)
}
return firstValue(in: left)
}

private func lastValue(in node: SourceLineIndexNode) -> Int {
node.pushPendingShift()
guard let right = node.right else {
return node.value(at: node.blockCount - 1)
}
return lastValue(in: right)
}

private mutating func split(
_ node: SourceLineIndexNode?,
at requestedIndex: Int
Expand Down
70 changes: 55 additions & 15 deletions Sources/Core/Source/UTF16TextDifference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,25 @@ struct UTF16TextDifference: Sendable, Equatable {
original: NSString,
updated: NSString
) -> UTF16TextDifference {
var prefix = 0
let sharedLength = min(original.length, updated.length)
while prefix < sharedLength,
original.character(at: prefix)
== updated.character(at: prefix)
{
prefix += 1
}
var prefix = matchingLength(
original: original,
updated: updated,
limit: sharedLength,
backwards: false
)
if splitsSurrogatePair(at: prefix, in: original)
|| splitsSurrogatePair(at: prefix, in: updated)
{
prefix -= 1
}

var suffix = 0
while suffix < original.length - prefix,
suffix < updated.length - prefix,
original.character(at: original.length - suffix - 1)
== updated.character(at: updated.length - suffix - 1)
{
suffix += 1
}
var suffix = matchingLength(
original: original,
updated: updated,
limit: sharedLength - prefix,
backwards: true
)
if splitsSurrogatePair(
at: original.length - suffix,
in: original
Expand All @@ -54,6 +51,49 @@ struct UTF16TextDifference: Sendable, Equatable {
)
}

private static func matchingLength(
original: NSString,
updated: NSString,
limit: Int,
backwards: Bool
) -> Int {
guard limit > 0 else { return 0 }
let capacity = 1_024
// Bound scratch space independently of document size. Bulk extraction
// avoids millions of character(at:) dispatches for unchanged spans.
return withUnsafeTemporaryAllocation(of: unichar.self, capacity: capacity * 2) { buffer in
let oldUnits = buffer.baseAddress!
let newUnits = oldUnits + capacity
var matched = 0
while matched < limit {
let count = min(capacity, limit - matched)
original.getCharacters(
oldUnits,
range: NSRange(
location: backwards ? original.length - matched - count : matched,
length: count
))
updated.getCharacters(
newUnits,
range: NSRange(
location: backwards ? updated.length - matched - count : matched,
length: count
))
if memcmp(oldUnits, newUnits, count * MemoryLayout<unichar>.stride) == 0 {
matched += count
continue
}
for offset in 0..<count {
let index = backwards ? count - offset - 1 : offset
if oldUnits[index] != newUnits[index] {
return matched + offset
}
}
}
return matched
}
}

static func splitsSurrogatePair(
at location: Int,
in text: NSString
Expand Down
10 changes: 10 additions & 0 deletions Sources/Editor/Compatibility/MarkdownEngineCompatibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ enum MarkdownEngineCompatibility {
_ edit: SourceEdit,
from previousPresentation: MarkdownSourcePresentation,
to updatedPresentation: MarkdownSourcePresentation,
restoringSelection selectedRange: NSRange,
in textView: NSTextView
) -> Bool {
guard
Expand Down Expand Up @@ -118,6 +119,12 @@ enum MarkdownEngineCompatibility {
else {
return false
}
// The engine uses editability to decide whether selection reveals
// syntax. Keep peer edits and selection restoration in one focus-aware
// styling scope, just like the full-restyle path below.
let wasEditable = textView.isEditable
textView.isEditable = wasEditable && textView.window?.firstResponder === textView
defer { textView.isEditable = wasEditable }
let unchangedText = textView.string
guard
coordinator.textView(
Expand All @@ -136,6 +143,9 @@ enum MarkdownEngineCompatibility {
)
textStorage.endEditing()
textView.didChangeText()
if textView.selectedRange() != selectedRange {
textView.setSelectedRange(selectedRange)
}
return textView.string == updatedPresentation.text
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/Editor/Composition/EditorPaneStateCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,9 @@ final class EditorPaneStateCoordinator: NSObject {
edit,
from: previousPresentation,
to: newPresentation,
restoringSelection: newPresentation.presentedRange(
forSourceRange: pendingSelectionRestore ?? pane.selectedRange
),
in: textView
)
pane.bindingMutationAccumulator.reset()
Expand Down
Loading