import Foundation import SuggestionBasic public struct CodeDiff { public typealias LineDiff = CollectionDifference public struct SnippetDiff: Equatable { public struct Change: Equatable { public var offset: Int public var element: String } public struct Line: Equatable { public enum Diff: Equatable { case unchanged case mutated(changes: [Change]) } public var text: String public var diff: Diff = .unchanged } public struct Section: Equatable { public var oldSnippet: [Line] public var newSnippet: [Line] public var isEmpty: Bool { oldSnippet.isEmpty && newSnippet.isEmpty } } public var sections: [Section] public func line(at index: Int, in keyPath: KeyPath) -> Line? { var previousSectionEnd = 0 for section in sections { let lines = section[keyPath: keyPath] let index = index - previousSectionEnd if index < lines.endIndex { return lines[index] } previousSectionEnd += lines.endIndex } return nil } } public func diff(text: String, from oldText: String) -> LineDiff { typealias Change = LineDiff.Change let diffByCharacter = text.difference(from: oldText) var result = [Change]() var current: Change? for item in diffByCharacter { if let this = current { switch (this, item) { case let (.insert(offset, element, associatedWith), .insert(offsetB, elementB, _)) where offset + element.count == offsetB: current = .insert( offset: offset, element: element + String(elementB), associatedWith: associatedWith ) continue case let (.remove(offset, element, associatedWith), .remove(offsetB, elementB, _)) where offset - 1 == offsetB: current = .remove( offset: offsetB, element: String(elementB) + element, associatedWith: associatedWith ) continue default: result.append(this) } } current = switch item { case let .insert(offset, element, associatedWith): .insert(offset: offset, element: String(element), associatedWith: associatedWith) case let .remove(offset, element, associatedWith): .remove(offset: offset, element: String(element), associatedWith: associatedWith) } } if let current { result.append(current) } return .init(result) ?? [].difference(from: []) } public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff { let newLines = snippet.splitByNewLine(omittingEmptySubsequences: false) let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false) let diffByLine = newLines.difference(from: oldLines) struct DiffSection: Equatable { var offset: Int var end: Int var lines: [String] } func collect( into all: inout [DiffSection], changes: [CollectionDifference.Change], extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)? ) { var current: DiffSection? for change in changes { guard let (offset, element) = extract(change) else { continue } if var section = current { if offset == section.end + 1 { section.lines.append(String(element)) section.end = offset current = section continue } else { all.append(section) } } current = DiffSection(offset: offset, end: offset, lines: [String(element)]) } if let current { all.append(current) } } var insertions = [DiffSection]() var removals = [DiffSection]() collect(into: &removals, changes: diffByLine.removals) { change in guard case let .remove(offset, element, _) = change else { return nil } return (offset, element) } collect(into: &insertions, changes: diffByLine.insertions) { change in guard case let .insert(offset, element, _) = change else { return nil } return (offset, element) } var oldLineIndex = 0 var newLineIndex = 0 var sectionIndex = 0 var result = SnippetDiff(sections: []) while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex { let removalSection = removals[safe: sectionIndex] let insertionSection = insertions[safe: sectionIndex] // handle lines before sections var beforeSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) while oldLineIndex < (removalSection?.offset ?? oldLines.endIndex) { if oldLineIndex < oldLines.endIndex { beforeSection.oldSnippet.append(.init( text: String(oldLines[oldLineIndex]), diff: .unchanged )) } oldLineIndex += 1 } while newLineIndex < (insertionSection?.offset ?? newLines.endIndex) { if newLineIndex < newLines.endIndex { beforeSection.newSnippet.append(.init( text: String(newLines[newLineIndex]), diff: .unchanged )) } newLineIndex += 1 } if !beforeSection.isEmpty { result.sections.append(beforeSection) } // handle lines inside sections var insideSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) for i in 0.. Element? { guard index >= 0, index < count else { return nil } return self[index] } subscript(safe index: Int, fallback fallback: Element) -> Element { guard index >= 0, index < count else { return fallback } return self[index] } } #if DEBUG import SwiftUI struct SnippetDiffPreview: View { let originalCode: String let newCode: String var body: some View { HStack(alignment: .top) { let (original, new) = generateTexts() block(original) Divider() block(new) } .padding() .font(.body.monospaced()) } @ViewBuilder func block(_ code: [AttributedString]) -> some View { VStack(alignment: .leading) { if !code.isEmpty { ForEach(0.. (original: [AttributedString], new: [AttributedString]) { let diff = CodeDiff().diff(snippet: newCode, from: originalCode) let new = diff.sections.flatMap { $0.newSnippet.map { let text = $0.text.trimmingCharacters(in: .newlines) let string = NSMutableAttributedString(string: text) if case let .mutated(changes) = $0.diff { string.addAttribute( .backgroundColor, value: NSColor.green.withAlphaComponent(0.1), range: NSRange(location: 0, length: text.count) ) for diffItem in changes { string.addAttribute( .backgroundColor, value: NSColor.green.withAlphaComponent(0.5), range: NSRange( location: diffItem.offset, length: min( text.count - diffItem.offset, diffItem.element.count ) ) ) } } return string } } let original = diff.sections.flatMap { $0.oldSnippet.map { let text = $0.text.trimmingCharacters(in: .newlines) let string = NSMutableAttributedString(string: text) if case let .mutated(changes) = $0.diff { string.addAttribute( .backgroundColor, value: NSColor.red.withAlphaComponent(0.1), range: NSRange(location: 0, length: text.count) ) for diffItem in changes { string.addAttribute( .backgroundColor, value: NSColor.red.withAlphaComponent(0.5), range: NSRange( location: diffItem.offset, length: min(text.count - diffItem.offset, diffItem.element.count) ) ) } } return string } } return (original.map(AttributedString.init), new.map(AttributedString.init)) } } struct LineDiffPreview: View { let originalCode: String let newCode: String var body: some View { VStack(alignment: .leading) { let (original, new) = generateTexts() Text(original) Divider() Text(new) } .padding() .font(.body.monospaced()) } func generateTexts() -> (original: AttributedString, new: AttributedString) { let diff = CodeDiff().diff(text: newCode, from: originalCode) let original = NSMutableAttributedString(string: originalCode) let new = NSMutableAttributedString(string: newCode) for item in diff { switch item { case let .insert(offset, element, _): new.addAttribute( .backgroundColor, value: NSColor.green.withAlphaComponent(0.5), range: NSRange(location: offset, length: element.count) ) case let .remove(offset, element, _): original.addAttribute( .backgroundColor, value: NSColor.red.withAlphaComponent(0.5), range: NSRange(location: offset, length: element.count) ) } } return (.init(original), .init(new)) } } #Preview("Line Diff") { let originalCode = """ let foo = Foo() // yes """ let newCode = """ var foo = Bar() """ return LineDiffPreview(originalCode: originalCode, newCode: newCode) } #Preview("Snippet Diff") { let originalCode = """ let foo = Foo() print(foo) // do something foo.foo() func zoo() {} """ let newCode = """ var foo = Bar() // do something foo.bar() func zoo() { print("zoo") } """ return SnippetDiffPreview(originalCode: originalCode, newCode: newCode) } #endif