Skip to content

Add helper function for getting the source edits of two strings #1662

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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
80 changes: 80 additions & 0 deletions Sources/_SwiftSyntaxTestSupport/SourceEditsTestUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import SwiftSyntax
import XCTest

/// Returns the `ConcurrentEdits`s to transition from `base` to `new`.
public func getConcurrentEdits(from base: String, to new: String) -> ConcurrentEdits {
let diffCollection = new.difference(from: base)

let diffArray: [(offset: Int, isInsert: Bool)] =
diffCollection
.map { diff in
switch diff {
case .remove(offset: let offset, element: _, associatedWith: _):
return (offset: offset, isInsert: false)
case .insert(offset: let offset, element: _, associatedWith: _):
return (offset: offset, isInsert: true)
}
}
.sorted {
// Change.remove should prior to Change.insert
if $0.offset < $1.offset {
return true
} else if $0.offset == $1.offset {
return !$0.isInsert
} else {
return false
}
}

let sourceEdits = diffArray.map({
if $0.isInsert {
return SourceEdit(offset: $0.offset, length: 0, replacementLength: 1)
} else {
return SourceEdit(offset: $0.offset, length: 1, replacementLength: 0)
}
})

return ConcurrentEdits(fromSequential: sourceEdits)
}

/// Apply the given edits to `testString` and return the resulting string.
/// `concurrent` specifies whether the edits should be interpreted as being
/// applied sequentially or concurrently.
public func applyEdits(
_ edits: [SourceEdit],
concurrent: Bool,
to testString: String,
replacementChar: Character = "?"
) -> String {
guard let replacementAscii = replacementChar.asciiValue else {
fatalError("replacementChar must be an ASCII character")
}
var edits = edits
if concurrent {
XCTAssert(ConcurrentEdits._isValidConcurrentEditArray(edits))

// If the edits are concurrent, sorted and not overlapping (as guaranteed by
// the check above, we can apply them sequentially to the string in reverse
// order because later edits don't affect earlier edits.
edits = edits.reversed()
}
var bytes = Array(testString.utf8)
for edit in edits {
assert(edit.endOffset <= bytes.count)
bytes.removeSubrange(edit.offset..<edit.endOffset)
bytes.insert(contentsOf: [UInt8](repeating: replacementAscii, count: edit.replacementLength), at: edit.offset)
}
return String(bytes: bytes, encoding: .utf8)!
}
41 changes: 25 additions & 16 deletions Tests/SwiftParserTest/IncrementalParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,43 @@
import XCTest
import SwiftSyntax
import SwiftParser
import _SwiftSyntaxTestSupport

public class IncrementalParsingTests: XCTestCase {

public func testIncrementalInvalid() {
let original = "struct A { func f() {"
let step: (String, (Int, Int, String)) =
("struct AA { func f() {", (8, 0, "A"))

var tree = Parser.parse(source: original)
let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count)
let lookup = IncrementalParseTransition(previousTree: tree, edits: ConcurrentEdits(sourceEdit))
tree = Parser.parse(source: step.0, parseTransition: lookup)
XCTAssertEqual("\(tree)", step.0)
let newSource = "struct AA { func f() {"

let concurrentEdits = getConcurrentEdits(from: original, to: newSource)

let oldTree = Parser.parse(source: original)
let lookup = IncrementalParseTransition(previousTree: oldTree, edits: concurrentEdits)
let newTree = Parser.parse(source: newSource, parseTransition: lookup)

XCTAssertEqual("\(newTree)", newSource)
}

public func testReusedNode() throws {
try XCTSkipIf(true, "Swift parser does not handle node reuse yet")

let original = "struct A {}\nstruct B {}\n"
let step: (String, (Int, Int, String)) =
("struct AA {}\nstruct B {}\n", (8, 0, "A"))
let original =
"""
struct A {}
struct B {}
"""

let newSource =
"""
struct AA {}
struct B {}
"""

let origTree = Parser.parse(source: original)
let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count)
let concurrentEdits = getConcurrentEdits(from: original, to: newSource)
let reusedNodeCollector = IncrementalParseReusedNodeCollector()
let transition = IncrementalParseTransition(previousTree: origTree, edits: ConcurrentEdits(sourceEdit), reusedNodeDelegate: reusedNodeCollector)
let newTree = Parser.parse(source: step.0, parseTransition: transition)
XCTAssertEqual("\(newTree)", step.0)
let transition = IncrementalParseTransition(previousTree: origTree, edits: concurrentEdits, reusedNodeDelegate: reusedNodeCollector)
let newTree = Parser.parse(source: newSource, parseTransition: transition)
XCTAssertEqual("\(newTree)", newSource)

let origStructB = origTree.statements[1]
let newStructB = newTree.statements[1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import XCTest
import SwiftSyntax
import _SwiftSyntaxTestSupport

let longString = """
1234567890abcdefghijklmnopqrstuvwxyz\
Expand All @@ -26,36 +27,6 @@ let longString = """
1234567890abcdefghijklmnopqrstuvwzyz
"""

/// Apply the given edits to `testString` and return the resulting string.
/// `concurrent` specifies whether the edits should be interpreted as being
/// applied sequentially or concurrently.
func applyEdits(
_ edits: [SourceEdit],
concurrent: Bool,
to testString: String = longString,
replacementChar: Character = "?"
) -> String {
guard let replacementAscii = replacementChar.asciiValue else {
fatalError("replacementChar must be an ASCII character")
}
var edits = edits
if concurrent {
XCTAssert(ConcurrentEdits._isValidConcurrentEditArray(edits))

// If the edits are concurrent, sorted and not overlapping (as guaranteed by
// the check above, we can apply them sequentially to the string in reverse
// order because later edits don't affect earlier edits.
edits = edits.reversed()
}
var bytes = Array(testString.utf8)
for edit in edits {
assert(edit.endOffset <= bytes.count)
bytes.removeSubrange(edit.offset..<edit.endOffset)
bytes.insert(contentsOf: [UInt8](repeating: replacementAscii, count: edit.replacementLength), at: edit.offset)
}
return String(bytes: bytes, encoding: .utf8)!
}

/// Verifies that
/// 1. translation of the `sequential` edits results in the
/// `expectedConcurrent` edits
Expand Down Expand Up @@ -364,7 +335,7 @@ final class TranslateSequentialToConcurrentEditsTests: XCTestCase {
}
print(edits)
let normalizedEdits = ConcurrentEdits(fromSequential: edits)
if applyEdits(edits, concurrent: false) != applyEdits(normalizedEdits.edits, concurrent: true) {
if applyEdits(edits, concurrent: false, to: longString) != applyEdits(normalizedEdits.edits, concurrent: true, to: longString) {
print("failed \(i)")
fatalError()
} else {
Expand Down
72 changes: 72 additions & 0 deletions Tests/SwiftSyntaxTestSupportTest/SourceEditsTestUtilsTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import XCTest
import SwiftSyntax
import _SwiftSyntaxTestSupport

public class SourceEditsUtilTest: XCTestCase {
public func testStringDifferenceReturnSequentialEdits() {
let base = "0123"
let new = "a0123bc"

let diffs = getConcurrentEdits(from: base, to: new)
XCTAssertEqual(
diffs.edits,
[
SourceEdit(offset: 0, length: 0, replacementLength: 1),
SourceEdit(offset: 4, length: 0, replacementLength: 2),
]
)

let appliedDiffsBase = applyEdits(diffs.edits, concurrent: true, to: base)

XCTAssertEqual(appliedDiffsBase, "?0123??")
}

public func testDiffOfTwoStringsSimple() throws {
let base = "struct A { func f() {"
let new = "struct AA { func f() {"

let diffs = getConcurrentEdits(from: base, to: new)
XCTAssertEqual(diffs.edits.count, 1)

let firstDiff = try XCTUnwrap(diffs.edits.first)
XCTAssertEqual(firstDiff, SourceEdit(offset: 8, length: 0, replacementLength: 1))
}

public func testDiffOfTwoSameStrings() {
let base = "0123456"

let diffs = getConcurrentEdits(from: base, to: base)
XCTAssert(diffs.edits.isEmpty)
}

public func testDiffOfTwoStrings() {
let base = "0123456"
let new = "x12456yz"

let diffs = getConcurrentEdits(from: base, to: new)

let expectedDiffs: [SourceEdit] = [
SourceEdit(offset: 0, length: 1, replacementLength: 1),
SourceEdit(offset: 3, length: 1, replacementLength: 0),
SourceEdit(offset: 7, length: 0, replacementLength: 2),
]

XCTAssertEqual(diffs.edits, expectedDiffs)

let appliedDiffsBase = applyEdits(expectedDiffs, concurrent: true, to: base)

XCTAssertEqual(appliedDiffsBase, "?12456??")
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add one more test case that has an insertion at the start and an insertion at the end. That would make sure that Collection.difference actually contains sequential edits instead of concurrent edits.

let base = "0123"
let new = "a0123b"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if I wasn’t clear here. What I was looking for is a test case just like the one you have in testDiffOfTwoStrings (which I think has a very good test structure), just with the inputs I gave you. So basically:

  public func testStringDifferenceReturnSequentialEdits() throws {
    let base = "0123"
    let new = "a0123bc"

    let diffs = getConcurrentEdits(from: base, to: new)
    XCTAssertEqual(diffs.edits, [
        SourceEdit(offset: 0, length: 0, replacementLength: 1),
        SourceEdit(offset: 4, length: 0, replacementLength: 2)
      ])

    let appliedDiffsBase = applyEdits(diffs.edits, concurrent: true, to: base)

    XCTAssertEqual(appliedDiffsBase, "?0123??")
  }

Which seems to pass, so that’s good ✅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Already added this.

}