diff --git a/.swift-mutation-testing.yml b/.swift-mutation-testing.yml index ed16e15..6d14a0f 100644 --- a/.swift-mutation-testing.yml +++ b/.swift-mutation-testing.yml @@ -5,7 +5,7 @@ test-target: SwiftCPDTests # Per-mutant test timeout in seconds (default: 30 for SPM) -timeout: 45 +timeout: 30 # Disable result cache (re-runs all mutants on every execution) # no-cache: true diff --git a/README.md b/README.md index 0fb06f6..24ede55 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/ericodx/swift-cpd/main-analysis.yml?branch=main&style=flat-square&logo=github&logoColor=white&label=CI&color=4CAF50)](https://github.com/ericodx/swift-cpd/actions) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-cpd&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-cpd) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-cpd&metric=coverage)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-cpd) +![mutation score](https://img.shields.io/badge/mutation%20score-91.4%25-lightgray?logo=jest&logoColor=white) **Detect and eliminate duplicated logic in Swift and Objective-C/C codebases to improve maintainability and code quality.** diff --git a/Tests/SwiftCPDTests/Detection/CloneDetectorDeduplicationTests.swift b/Tests/SwiftCPDTests/Detection/CloneDetectorDeduplicationTests.swift index 4bbf621..553a074 100644 --- a/Tests/SwiftCPDTests/Detection/CloneDetectorDeduplicationTests.swift +++ b/Tests/SwiftCPDTests/Detection/CloneDetectorDeduplicationTests.swift @@ -201,10 +201,6 @@ struct CloneDetectorDeduplicationTests { "Given bidirectional subsumption check, when smaller pair comes first, then still deduplicated" ) func bidirectionalSubsumptionDeduplicates() { - let smallSpecs: [(TokenKind, String)] = [ - (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), - ] - let largeSpecs: [(TokenKind, String)] = [ (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), (.integerLiteral, "1"), (.keyword, "var"), (.identifier, "y"), diff --git a/Tests/SwiftCPDTests/Detection/CloneDetectorMutationBoundaryTests.swift b/Tests/SwiftCPDTests/Detection/CloneDetectorMutationBoundaryTests.swift new file mode 100644 index 0000000..eb14656 --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/CloneDetectorMutationBoundaryTests.swift @@ -0,0 +1,297 @@ +import Testing + +@testable import swift_cpd + +@Suite("CloneDetector Mutation Boundary") +struct CloneDetectorMutationBoundaryTests { + + @Test("Given distance exactly at minimumTokenCount, when < not <=, then not overlapping") + func overlapDistanceExactlyAtMinimum() { + let minimumTokenCount = 3 + + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + ] + + var allTokens: [Token] = [] + for idx in 0 ..< 6 { + allTokens.append( + Token( + kind: specs[idx % 3].0, + text: specs[idx % 3].1, + location: SourceLocation( + file: "A.swift", line: 1 + idx, column: 1 + ) + )) + } + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: allTokens, normalizedTokens: allTokens + ) + ] + + let detector = CloneDetector( + minimumTokenCount: minimumTokenCount, minimumLineCount: 1 + ) + let results = detector.detect(files: files) + + #expect(results.count == 1) + } + + @Test("Given || logic, when one direction subsumed, then still deduplicates") + func deduplicationOrLogicBothDirections() { + let small: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), + ] + + let large: [(TokenKind, String)] = + small + [ + (.keyword, "var"), (.identifier, "b"), + (.operatorToken, "="), (.integerLiteral, "2"), + ] + + let tokensLargeA = makeTokens(large, file: "A.swift") + let tokensLargeB = makeTokens(large, file: "B.swift") + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensLargeA, normalizedTokens: tokensLargeA + ), + FileTokens( + file: "B.swift", source: "", + tokens: tokensLargeB, normalizedTokens: tokensLargeB + ), + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + #expect(results[0].tokenCount == 8) + } + + @Test("Given offset >= check, when inner starts at same offset, then subsumed") + func subsumptionInnerStartsAtSameOffset() { + let base: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), + ] + + let extended: [(TokenKind, String)] = + base + [ + (.identifier, "b"), (.operatorToken, "="), + (.integerLiteral, "2"), + ] + + let tokensA = makeTokens(extended, file: "A.swift") + let tokensB = makeTokens(extended, file: "B.swift") + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensA, normalizedTokens: tokensA + ), + FileTokens( + file: "B.swift", source: "", + tokens: tokensB, normalizedTokens: tokensB + ), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + #expect(results[0].tokenCount == 8) + } + + @Test("Given lineCount endLine - startLine + 1, when + mutated, then wrong") + func lineCountExactArithmetic() { + let locA = { (line: Int) in + SourceLocation(file: "A.swift", line: line, column: 1) + } + let locB = { (line: Int) in + SourceLocation(file: "B.swift", line: line, column: 1) + } + + let tokensA = [ + Token(kind: .keyword, text: "let", location: locA(1)), + Token(kind: .identifier, text: "x", location: locA(2)), + Token(kind: .operatorToken, text: "=", location: locA(3)), + Token(kind: .integerLiteral, text: "1", location: locA(4)), + Token(kind: .keyword, text: "var", location: locA(5)), + ] + + let tokensB = [ + Token(kind: .keyword, text: "let", location: locB(10)), + Token(kind: .identifier, text: "x", location: locB(11)), + Token(kind: .operatorToken, text: "=", location: locB(12)), + Token(kind: .integerLiteral, text: "1", location: locB(13)), + Token(kind: .keyword, text: "var", location: locB(14)), + ] + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensA, normalizedTokens: tokensA + ), + FileTokens( + file: "B.swift", source: "", + tokens: tokensB, normalizedTokens: tokensB + ), + ] + + let detector = CloneDetector(minimumTokenCount: 5, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].lineCount == 5) + } + + @Test("Given AND check, when only A subsumed, then not subsumed") + func subsumptionAndLogicRequiresBothSubsumed() { + let shared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), + ] + + let fileATokens = makeTokens( + shared + [(.identifier, "extra1"), (.operatorToken, "+")], + file: "A.swift" + ) + let fileBTokens = makeTokens(shared, file: "B.swift") + let fileCTokens = makeTokens( + shared + [(.identifier, "extra2"), (.operatorToken, "-")], + file: "C.swift" + ) + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: fileATokens, normalizedTokens: fileATokens + ), + FileTokens( + file: "B.swift", source: "", + tokens: fileBTokens, normalizedTokens: fileBTokens + ), + FileTokens( + file: "C.swift", source: "", + tokens: fileCTokens, normalizedTokens: fileCTokens + ), + ] + + let detector = CloneDetector(minimumTokenCount: 5, minimumLineCount: 1) + let results = detector.detect(files: files) + + let pairsInvolvingB = results.filter { group in + group.fragments.contains { $0.file == "B.swift" } + } + + #expect(pairsInvolvingB.count >= 1) + } + + @Test("Given end computed with + not -, when mutated, then wrong subsumption") + func subsumptionEndArithmeticExactValues() { + let shared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + ] + + let extended: [(TokenKind, String)] = + shared + [ + (.integerLiteral, "1"), (.keyword, "var"), + (.identifier, "b"), + ] + + let tokensA = makeTokens(extended, file: "A.swift") + let tokensB = makeTokens(extended, file: "B.swift") + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensA, normalizedTokens: tokensA + ), + FileTokens( + file: "B.swift", source: "", + tokens: tokensB, normalizedTokens: tokensB + ), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + let maxToken = results.map(\.tokenCount).max() ?? 0 + #expect(maxToken == 6) + } + + @Test("Given abs(a - b), when mutated to abs(a + b), then same-file overlap wrong") + func overlapAbsSubtractionNotAddition() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + (.integerLiteral, "1"), + ] + + var tokensA: [Token] = [] + for idx in 0 ..< 4 { + tokensA.append( + Token( + kind: specs[idx].0, text: specs[idx].1, + location: SourceLocation( + file: "A.swift", line: 1 + idx, column: 1 + ) + )) + } + for idx in 0 ..< 4 { + tokensA.append( + Token( + kind: specs[idx].0, text: specs[idx].1, + location: SourceLocation( + file: "A.swift", line: 10 + idx, column: 1 + ) + )) + } + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensA, normalizedTokens: tokensA + ) + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + } + + @Test("Given <= check on ends, when inner end equals outer end, then subsumed") + func subsumptionEndEquality() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), (.identifier, "b"), + (.operatorToken, "="), (.integerLiteral, "2"), + ] + + let tokensA = makeTokens(specs, file: "A.swift") + let tokensB = makeTokens(specs, file: "B.swift") + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: tokensA, normalizedTokens: tokensA + ), + FileTokens( + file: "B.swift", source: "", + tokens: tokensB, normalizedTokens: tokensB + ), + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + #expect(results[0].tokenCount == 8) + } +} diff --git a/Tests/SwiftCPDTests/Detection/CloneDetectorMutationTests.swift b/Tests/SwiftCPDTests/Detection/CloneDetectorMutationTests.swift new file mode 100644 index 0000000..2822506 --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/CloneDetectorMutationTests.swift @@ -0,0 +1,435 @@ +import Testing + +@testable import swift_cpd + +@Suite("CloneDetector Mutation") +struct CloneDetectorMutationTests { + + @Test("Given unique token sequences per file, when each hash has exactly one location, then no clones detected") + func hashWithExactlyOneLocationProducesNoCandidates() { + let specsA: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), + ] + + let specsB: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "run"), (.punctuation, "("), + (.punctuation, ")"), (.punctuation, "{"), + ] + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: makeTokens(specsA, file: "A.swift"), + normalizedTokens: makeTokens(specsA, file: "A.swift") + ), + FileTokens( + file: "B.swift", source: "", + tokens: makeTokens(specsB, file: "B.swift"), + normalizedTokens: makeTokens(specsB, file: "B.swift") + ), + ] + + let detector = CloneDetector(minimumTokenCount: 5, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.isEmpty) + } + + @Test("Given pair subsumed in one direction only, when deduplicating with OR logic, then removes duplicate") + func deduplicationRemovesOneDirectionSubsumedPair() { + let shared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), (.identifier, "b"), + ] + + let extra: [(TokenKind, String)] = [ + (.operatorToken, "="), (.integerLiteral, "2"), + ] + + let longerA = shared + extra + let longerB = shared + extra + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: makeTokens(longerA, file: "A.swift"), + normalizedTokens: makeTokens(longerA, file: "A.swift") + ), + FileTokens( + file: "B.swift", source: "", + tokens: makeTokens(longerB, file: "B.swift"), + normalizedTokens: makeTokens(longerB, file: "B.swift") + ), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + #expect(results[0].tokenCount == 8) + } + + @Test("Given same-file clones at boundary, when checking overlap, then abs arithmetic correct") + func overlapAbsArithmeticWithReversedOffsets() { + let unit: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + ] + + let padding: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "run"), (.punctuation, "("), + ] + + let specs = unit + padding + unit + + let tokensA = specs.enumerated().map { index, spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "A.swift", line: 1 + index, column: 1) + ) + } + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA) + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + } + + @Test("Given same-file clones one less than minimum, when overlap uses strict less-than, then overlapping") + func overlapStrictLessThanBoundary() { + let unit: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + (.integerLiteral, "1"), + ] + + let padding: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "run"), (.punctuation, "("), + ] + + let specs = unit + padding + unit + + let tokensA = specs.enumerated().map { index, spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "A.swift", line: 1 + index, column: 1) + ) + } + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA) + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + } + + @Test("Given subsumption where pair A matches but pair B does not, when using AND logic, then not subsumed") + func subsumptionRequiresAndNotOr() { + let baseShared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), (.identifier, "b"), + (.operatorToken, "="), (.integerLiteral, "2"), + ] + + let extraA: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "c"), + ] + + let differentB: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "run"), + ] + + let fileATokens = makeTokens(baseShared + extraA, file: "A.swift") + let fileBTokens = makeTokens(baseShared, file: "B.swift") + let fileCTokens = makeTokens(baseShared + differentB, file: "C.swift") + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: fileATokens, normalizedTokens: fileATokens + ), + FileTokens( + file: "B.swift", source: "", + tokens: fileBTokens, normalizedTokens: fileBTokens + ), + FileTokens( + file: "C.swift", source: "", + tokens: fileCTokens, normalizedTokens: fileCTokens + ), + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count >= 2) + } + + @Test("Given tokens spanning multiple lines, when building clone group, then lineCount uses correct arithmetic") + func lineCountArithmeticEndMinusStartPlusOne() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + ] + + let tokensA = specs.enumerated().map { index, spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "A.swift", line: 5 + index, column: 1) + ) + } + + let tokensB = specs.enumerated().map { index, spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "B.swift", line: 15 + index, column: 1) + ) + } + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].lineCount == 3) + } + + @Test("Given single-line tokens, when lineCount computed, then returns 1 not 0 or 2") + func lineCountForSingleLineIsOne() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + ] + + let tokensA = specs.map { spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "A.swift", line: 1, column: 1) + ) + } + + let tokensB = specs.map { spec in + Token( + kind: spec.0, + text: spec.1, + location: SourceLocation(file: "B.swift", line: 1, column: 1) + ) + } + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].lineCount == 1) + } + + @Test("Given tokens with mismatch at last position, when loop compares all, then detects mismatch") + func tokensMatchDetectsMismatchAtLastPosition() { + let specsA: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + (.integerLiteral, "1"), + ] + + let specsB: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + (.integerLiteral, "2"), + ] + + let normalizedA = makeTokens(specsA, file: "A.swift") + let normalizedB = makeTokens(specsB, file: "B.swift") + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: normalizedA, normalizedTokens: normalizedA), + FileTokens(file: "B.swift", source: "", tokens: normalizedB, normalizedTokens: normalizedB), + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.isEmpty) + } + + @Test("Given tokens with offset, when tokensMatch uses addition for index, then accesses correct elements") + func tokensMatchIndexComputationWithOffset() { + let prefix: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "run"), + ] + + let shared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + ] + + let specsA = prefix + shared + let specsB = shared + + let tokensA = makeTokens(specsA, file: "A.swift") + let tokensB = makeTokens(specsB, file: "B.swift") + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].type == .type1) + } + + @Test("Given isSubsumed with end computed via addition, when pair fits inside other, then is subsumed") + func subsumptionEndComputedByAddition() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), (.keyword, "var"), (.identifier, "b"), + ] + + let tokensA = makeTokens(specs, file: "A.swift") + let tokensB = makeTokens(specs, file: "B.swift") + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count == 1) + #expect(results[0].tokenCount == 6) + } + + @Test("Given cross-file clones, when overlap check uses abs, then different files are never overlapping") + func crossFileNeverOverlapping() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "x"), (.operatorToken, "="), + ] + + let tokensA = makeTokens(specs, file: "A.swift") + let tokensB = makeTokens(specs, file: "B.swift") + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + } + + @Test("Given two-line clone, when lineCount computed, then returns 2") + func lineCountTwoLinesReturnsTwo() { + let tokensA = [ + Token(kind: .keyword, text: "let", location: SourceLocation(file: "A.swift", line: 10, column: 1)), + Token(kind: .identifier, text: "x", location: SourceLocation(file: "A.swift", line: 10, column: 5)), + Token(kind: .operatorToken, text: "=", location: SourceLocation(file: "A.swift", line: 11, column: 1)), + ] + + let tokensB = [ + Token(kind: .keyword, text: "let", location: SourceLocation(file: "B.swift", line: 20, column: 1)), + Token(kind: .identifier, text: "x", location: SourceLocation(file: "B.swift", line: 20, column: 5)), + Token(kind: .operatorToken, text: "=", location: SourceLocation(file: "B.swift", line: 21, column: 1)), + ] + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: tokensA, normalizedTokens: tokensA), + FileTokens(file: "B.swift", source: "", tokens: tokensB, normalizedTokens: tokensB), + ] + + let detector = CloneDetector(minimumTokenCount: 3, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].lineCount == 2) + } + + @Test("Given parameterized clone where raw tokens differ at last position, when classifying, then Type-2 detected") + func tokensMatchCountBoundaryForClassification() { + let specs: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "$ID"), (.operatorToken, "="), + (.integerLiteral, "$NUM"), (.keyword, "var"), + ] + + let rawSpecsA: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "foo"), (.operatorToken, "="), + (.integerLiteral, "42"), (.keyword, "var"), + ] + + let rawSpecsB: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "bar"), (.operatorToken, "="), + (.integerLiteral, "99"), (.keyword, "var"), + ] + + let files = [ + FileTokens( + file: "A.swift", source: "", + tokens: makeTokens(rawSpecsA, file: "A.swift"), + normalizedTokens: makeTokens(specs, file: "A.swift") + ), + FileTokens( + file: "B.swift", source: "", + tokens: makeTokens(rawSpecsB, file: "B.swift"), + normalizedTokens: makeTokens(specs, file: "B.swift") + ), + ] + + let detector = CloneDetector(minimumTokenCount: 5, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(!results.isEmpty) + #expect(results[0].type == .type2) + } + + @Test("Given three files where AB and AC are clones, when deduplicating, then both pairs kept") + func deduplicationKeepsNonSubsumedPairsFromDifferentFiles() { + let shared: [(TokenKind, String)] = [ + (.keyword, "let"), (.identifier, "a"), (.operatorToken, "="), + (.integerLiteral, "1"), + ] + + let uniqueB: [(TokenKind, String)] = [ + (.keyword, "func"), (.identifier, "foo"), (.punctuation, "("), + (.punctuation, ")"), + ] + + let uniqueC: [(TokenKind, String)] = [ + (.keyword, "class"), (.identifier, "Bar"), (.punctuation, "{"), + (.punctuation, "}"), + ] + + let fileATokens = makeTokens(shared, file: "A.swift") + let fileBTokens = makeTokens(shared + uniqueB, file: "B.swift") + let fileCTokens = makeTokens(shared + uniqueC, file: "C.swift") + + let files = [ + FileTokens(file: "A.swift", source: "", tokens: fileATokens, normalizedTokens: fileATokens), + FileTokens(file: "B.swift", source: "", tokens: fileBTokens, normalizedTokens: fileBTokens), + FileTokens(file: "C.swift", source: "", tokens: fileCTokens, normalizedTokens: fileCTokens), + ] + + let detector = CloneDetector(minimumTokenCount: 4, minimumLineCount: 1) + let results = detector.detect(files: files) + + #expect(results.count >= 2) + } + +} diff --git a/Tests/SwiftCPDTests/Detection/DetectionMutationTests.swift b/Tests/SwiftCPDTests/Detection/DetectionMutationTests.swift new file mode 100644 index 0000000..0178659 --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/DetectionMutationTests.swift @@ -0,0 +1,512 @@ +import Testing + +@testable import swift_cpd + +@Suite("Detection Mutation Coverage") +struct DetectionMutationTests { + + @Suite("BlockExtraction Boundary") + struct BlockExtractionBoundary { + + @Test("Given block at minimum, when extracting, then block is included") + func blockExactlyAtMinimum() { + let source = """ + func f() { + let a = 1 + let b = 2 + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let allBlocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + let blockCounts = allBlocks.map { + $0.block.endTokenIndex - $0.block.startTokenIndex + 1 + } + + for count in blockCounts { + #expect(count >= 1) + } + + #expect(!allBlocks.isEmpty) + } + + @Test("Given block below minimum, when extracting, then excluded") + func blockOneBelowMinimum() { + let source = """ + func f() { + let a = 1 + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let allBlocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + let maxCount = + allBlocks.map { + $0.block.endTokenIndex - $0.block.startTokenIndex + 1 + }.max() ?? 0 + + let excludedBlocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: maxCount + 1 + ) + + #expect(excludedBlocks.isEmpty) + } + + @Test("Given endTokenIndex - startTokenIndex + 1, when + mutated to -, then wrong") + func tokenCountArithmeticPlusOne() { + let source = """ + func f() { + let a = 1 + let b = 2 + let c = 3 + print(a + b + c) + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let allBlocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + guard + let block = allBlocks.first + else { + Issue.record("Expected at least one block") + return + } + + let tokenCount = + block.block.endTokenIndex - block.block.startTokenIndex + 1 + #expect(tokenCount > 0) + + let blocksAtExact = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: tokenCount + ) + let blocksAboveExact = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: tokenCount + 1 + ) + + #expect(blocksAtExact.count > blocksAboveExact.count) + } + } + + @Suite("BlockExtractor Binary Search") + struct BlockExtractorBinarySearch { + + @Test("Given boundary tokens, when extracting, then included") + func tokensOnBoundaryLinesIncluded() { + let source = """ + func f() { + let x = 1 + let y = 2 + } + """ + + let blocks = extractBlocks(from: source) + + #expect(!blocks.isEmpty) + + for block in blocks { + #expect(block.startTokenIndex <= block.endTokenIndex) + } + } + + @Test("Given token on endLine, then break at > not >=") + func tokenOnEndLineIsIncluded() { + let source = """ + func f() { + let x = 1 + } + """ + + let blocks = extractBlocks(from: source) + + guard + let block = blocks.first + else { + Issue.record("Expected at least one block") + return + } + + let tokenizer = SwiftTokenizer() + let tokens = tokenizer.tokenize(source: source, file: "Test.swift") + let endLineTokens = tokens.filter { + $0.location.line == block.endLine + } + + #expect(!endLineTokens.isEmpty) + #expect(block.endTokenIndex >= block.startTokenIndex) + } + + @Test("Given binary search, when token line equals target, then found") + func binarySearchFindsExactLine() { + let source = """ + let a = 1 + func f() { + let b = 2 + let c = 3 + } + let d = 4 + """ + + let blocks = extractBlocks(from: source) + let functionBlock = blocks.first { $0.startLine == 2 } + + #expect(functionBlock != nil) + + if let block = functionBlock { + let tokenizer = SwiftTokenizer() + let tokens = tokenizer.tokenize( + source: source, file: "Test.swift" + ) + let firstToken = tokens[block.startTokenIndex] + + #expect(firstToken.location.line >= block.startLine) + } + } + } + + @Suite("CloneGroup Init Token Count") + struct CloneGroupInitTokenCount { + + @Test("Given pair, when creating CloneGroup, then lineCount correct") + func lineCountUsesCorrectFormula() { + let source = """ + func f() { + let a = 1 + let b = 2 + let c = 3 + print(a + b + c) + } + func g() { + let x = 1 + let y = 2 + let z = 3 + print(x + y + z) + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let blocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + guard + blocks.count >= 2 + else { + Issue.record("Expected at least two blocks") + return + } + + let pair = IndexedBlockPair(blockA: blocks[0], blockB: blocks[1]) + let group = CloneGroup( + type: .type1, pair: pair, files: [fileTokens], + similarity: 1.0, minimumLineCount: 1 + ) + + #expect(group != nil) + + if let group = group { + let lineCountA = + blocks[0].block.endLine - blocks[0].block.startLine + 1 + let lineCountB = + blocks[1].block.endLine - blocks[1].block.startLine + 1 + let expectedLineCount = max(lineCountA, lineCountB) + + #expect(group.lineCount == expectedLineCount) + + let tokenCountA = + blocks[0].block.endTokenIndex + - blocks[0].block.startTokenIndex + 1 + let tokenCountB = + blocks[1].block.endTokenIndex + - blocks[1].block.startTokenIndex + 1 + let expectedTokenCount = max(tokenCountA, tokenCountB) + + #expect(group.tokenCount == expectedTokenCount) + } + } + + @Test("Given lineCount below minimum, then returns nil") + func lineCountBelowMinimumReturnsNil() { + let source = """ + func f() { let a = 1 } + func g() { let x = 1 } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let blocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + guard + blocks.count >= 2 + else { + Issue.record("Expected at least two blocks") + return + } + + let pair = IndexedBlockPair(blockA: blocks[0], blockB: blocks[1]) + let group = CloneGroup( + type: .type1, pair: pair, files: [fileTokens], + similarity: 1.0, minimumLineCount: 999 + ) + + #expect(group == nil) + } + + @Test("Given lineCount + 1, when + mutated to -, then wrong") + func cloneGroupLineCountArithmeticExact() { + let source = """ + func f() { + let a = 1 + let b = 2 + let c = 3 + } + func g() { + let x = 1 + let y = 2 + let z = 3 + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let blocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + guard blocks.count >= 2 else { + Issue.record("Expected at least two blocks") + return + } + + let pair = IndexedBlockPair(blockA: blocks[0], blockB: blocks[1]) + let group = CloneGroup( + type: .type1, pair: pair, files: [fileTokens], + similarity: 1.0, minimumLineCount: 1 + ) + + guard let group = group else { + Issue.record("Expected a clone group") + return + } + + let lineA = + blocks[0].block.endLine - blocks[0].block.startLine + 1 + let lineB = + blocks[1].block.endLine - blocks[1].block.startLine + 1 + #expect(group.lineCount == max(lineA, lineB)) + #expect(group.lineCount >= 2) + } + + @Test("Given tokenCount - 1, when - mutated to +, then wrong") + func cloneGroupTokenCountArithmeticExact() { + let source = """ + func f() { + let a = 1 + let b = 2 + } + func g() { + let x = 1 + let y = 2 + } + """ + + let fileTokens = makeFileTokens(source: source, file: "Test.swift") + let blocks = BlockExtraction.extractValidBlocks( + files: [fileTokens], minimumTokenCount: 1 + ) + + guard blocks.count >= 2 else { + Issue.record("Expected at least two blocks") + return + } + + let pair = IndexedBlockPair(blockA: blocks[0], blockB: blocks[1]) + let group = CloneGroup( + type: .type1, pair: pair, files: [fileTokens], + similarity: 1.0, minimumLineCount: 1 + ) + + guard let group = group else { + Issue.record("Expected a clone group") + return + } + + let tokA = + blocks[0].block.endTokenIndex + - blocks[0].block.startTokenIndex + 1 + let tokB = + blocks[1].block.endTokenIndex + - blocks[1].block.startTokenIndex + 1 + #expect(group.tokenCount == max(tokA, tokB)) + #expect(group.tokenCount > 2) + } + } + + @Suite("GreedyStringTiler Longest Match") + struct GreedyStringTilerLongestMatch { + + @Test("Given longer match, when tiling, then replaces matches") + func longerMatchReplacesExisting() { + let tiler = GreedyStringTiler(minimumTileSize: 2) + let tokA = makeSimpleTokens(["a", "b", "c", "x", "y"]) + let tokB = makeSimpleTokens(["z", "w", "a", "b", "c"]) + + let similarity = tiler.similarity(between: tokA, and: tokB) + + let covered = 3 + let expected = + (2.0 * Double(covered)) + / Double(tokA.count + tokB.count) + #expect(similarity == expected) + } + + @Test("Given equal match, when tiling, then appends") + func equalMatchAppended() { + let tiler = GreedyStringTiler(minimumTileSize: 2) + let tokA = makeSimpleTokens(["a", "b", "x", "c", "d"]) + let tokB = makeSimpleTokens(["a", "b", "y", "c", "d"]) + + let similarity = tiler.similarity(between: tokA, and: tokB) + + let covered = 4 + let expected = + (2.0 * Double(covered)) + / Double(tokA.count + tokB.count) + #expect(similarity == expected) + } + + @Test("Given match at minimumTileSize, then matched but not longest") + func matchAtMinimumTileSizeBoundary() { + let tiler = GreedyStringTiler(minimumTileSize: 3) + let tokA = makeSimpleTokens(["a", "b", "c", "x"]) + let tokB = makeSimpleTokens(["a", "b", "c", "y"]) + + let similarity = tiler.similarity(between: tokA, and: tokB) + #expect(similarity > 0) + + let tilerStrict = GreedyStringTiler(minimumTileSize: 4) + let simStrict = tilerStrict.similarity(between: tokA, and: tokB) + #expect(simStrict == 0) + } + + @Test("Given > minimumTileSize, when mutated to >=, then changes") + func longestMatchStrictGreaterThan() { + let tiler = GreedyStringTiler(minimumTileSize: 3) + let tokA = makeSimpleTokens(["a", "b", "c", "d", "e", "x"]) + let tokB = makeSimpleTokens(["a", "b", "c", "d", "e", "y"]) + + let similarity = tiler.similarity(between: tokA, and: tokB) + + let covered = 5 + let expected = + (2.0 * Double(covered)) + / Double(tokA.count + tokB.count) + #expect(similarity == expected) + } + } + + @Suite("LCSCalculator Empty Guard") + struct LCSCalculatorEmpty { + + @Test("Given first empty, then returns zero") + func firstSequenceEmpty() { + #expect(LCSCalculator.length([Int](), [1, 2, 3]) == 0) + } + + @Test("Given second empty, then returns zero") + func secondSequenceEmpty() { + #expect(LCSCalculator.length([1, 2, 3], [Int]()) == 0) + } + + @Test("Given single matching, then returns 1") + func singleElementMatch() { + #expect(LCSCalculator.length([42], [42]) == 1) + } + + @Test("Given single in first, then guard passes") + func singleElementFirstSequence() { + #expect(LCSCalculator.length([1], [1, 2, 3]) == 1) + } + } + + @Suite("RollingHash Underflow Guard") + struct RollingHashUnderflow { + + @Test("Given result equals removeValue, then handles boundary") + func resultEqualsRemoveValue() { + let roller = RollingHash() + let tokens = makeSimpleTokens(["a", "b", "c", "d"]) + let windowSize = 2 + let highestPower = roller.power(for: windowSize) + + let initialHash = roller.hash( + tokens, offset: 0, count: windowSize + ) + let updated = roller.rollingUpdate( + hash: initialHash, removing: tokens[0], + adding: tokens[2], highestPower: highestPower + ) + let expected = roller.hash( + tokens, offset: 1, count: windowSize + ) + + #expect(updated == expected) + } + + @Test("Given chained updates, then all match recomputation") + func chainedUpdatesAllMatch() { + let roller = RollingHash() + let tokens = makeSimpleTokens([ + "alpha", "beta", "gamma", "delta", "epsilon", + ]) + let windowSize = 3 + let highestPower = roller.power(for: windowSize) + + var current = roller.hash( + tokens, offset: 0, count: windowSize + ) + + for offset in 1 ... (tokens.count - windowSize) { + current = roller.rollingUpdate( + hash: current, removing: tokens[offset - 1], + adding: tokens[offset + windowSize - 1], + highestPower: highestPower + ) + let expected = roller.hash( + tokens, offset: offset, count: windowSize + ) + + #expect(current == expected) + } + } + + @Test("Given result >= removeValue, when >= mutated to >, then breaks") + func rollingHashBoundaryEqualCase() { + let roller = RollingHash() + let tokens = makeSimpleTokens(["x", "x", "x"]) + let windowSize = 2 + let highestPower = roller.power(for: windowSize) + + let initialHash = roller.hash( + tokens, offset: 0, count: windowSize + ) + let updated = roller.rollingUpdate( + hash: initialHash, removing: tokens[0], + adding: tokens[2], highestPower: highestPower + ) + let expected = roller.hash( + tokens, offset: 1, count: windowSize + ) + + #expect(updated == expected) + } + } +} diff --git a/Tests/SwiftCPDTests/Detection/DetectorMutationTests.swift b/Tests/SwiftCPDTests/Detection/DetectorMutationTests.swift new file mode 100644 index 0000000..fef5cb3 --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/DetectorMutationTests.swift @@ -0,0 +1,316 @@ +import Testing + +@testable import swift_cpd + +@Suite("Detector Mutation Coverage") +struct DetectorMutationTests { + + @Suite("Type3Detector Thresholds") + struct Type3DetectorThresholds { + + @Test("Given two blocks with similarity at threshold, when detecting with >=, then includes pair") + func similarityExactlyAtThreshold() { + let sourceA = """ + func processData() { + let value = fetchValue() + let result = transform(value) + let output = format(result) + save(output) + log(output) + validate(output) + notify(output) + cleanup() + finalize() + } + """ + let sourceB = """ + func handleData() { + let value = fetchValue() + let result = transform(value) + let output = format(result) + save(output) + log(output) + validate(output) + notify(output) + cleanup() + finalize() + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + let detector = Type3Detector( + similarityThreshold: 50.0, + minimumTileSize: 2, + minimumTokenCount: 1, + minimumLineCount: 1, + candidateFilterThreshold: 10.0 + ) + + let results = detector.detect(files: [fileA, fileB]) + + #expect(!results.isEmpty) + } + + @Test("Given two completely different blocks, when detecting, then returns no clones") + func completelyDifferentBlocksReturnEmpty() { + let sourceA = """ + func alpha() { + let x = computeX() + let y = computeY() + let z = computeZ() + saveAll(x, y, z) + validateAll(x, y, z) + } + """ + let sourceB = """ + func beta() { + for i in 0..<100 { + while condition(i) { + process(i) + update(i) + check(i) + } + } + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + let detector = Type3Detector( + similarityThreshold: 95.0, + minimumTileSize: 5, + minimumTokenCount: 1, + minimumLineCount: 1, + candidateFilterThreshold: 90.0 + ) + + let results = detector.detect(files: [fileA, fileB]) + + #expect(results.isEmpty) + } + + @Test("Given very different blocks, when >= used with high threshold, then rejected") + func similarityBelowThresholdRejected() { + let sourceA = """ + func processA() { + let a1 = fetchAlpha() + let a2 = transformAlpha(a1) + let a3 = formatAlpha(a2) + saveAlpha(a3) + logAlpha(a3) + validateAlpha(a3) + cleanupAlpha(a3) + } + """ + let sourceB = """ + func processB() { + for item in items { + while condition(item) { + handleBeta(item) + updateBeta(item) + checkBeta(item) + finalizeBeta(item) + reportBeta(item) + } + } + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + let detector = Type3Detector( + similarityThreshold: 95.0, + minimumTileSize: 3, + minimumTokenCount: 1, + minimumLineCount: 1, + candidateFilterThreshold: 90.0 + ) + + let results = detector.detect(files: [fileA, fileB]) + + #expect(results.isEmpty) + } + } + + @Suite("Type4Detector PreFilter") + struct Type4DetectorPreFilter { + + @Test("Given both blocks with empty control flow, when pre-filtering, then passes") + func emptyControlFlowPassesFilter() { + let sourceA = """ + func simpleA() { + let a = getValue() + let b = getValue() + let c = getValue() + process(a, b, c) + save(a, b, c) + } + """ + let sourceB = """ + func simpleB() { + let x = getValue() + let y = getValue() + let z = getValue() + process(x, y, z) + save(x, y, z) + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + let detector = Type4Detector( + semanticSimilarityThreshold: 10.0, + minimumTokenCount: 1, + minimumLineCount: 1 + ) + + let results = detector.detect(files: [fileA, fileB]) + + #expect(!results.isEmpty) + } + + @Test("Given blocks with very different control flow, when detecting, then rejects") + func veryDifferentControlFlowRejected() { + let sourceA = """ + func complex() { + if a { if b { if c { if d { if e { + process() + } } } } } + } + """ + let sourceB = """ + func simple() { + process() + save() + log() + validate() + cleanup() + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + let detector = Type4Detector( + semanticSimilarityThreshold: 1.0, + minimumTokenCount: 1, + minimumLineCount: 1 + ) + + let sigA = BehaviorSignatureExtractor( + source: sourceA, + file: "A.swift", + startLine: 1, + endLine: 8 + ).extract() + let sigB = BehaviorSignatureExtractor( + source: sourceB, + file: "B.swift", + startLine: 1, + endLine: 7 + ).extract() + + let lenA = sigA.controlFlowShape.count + let lenB = sigB.controlFlowShape.count + + if lenA > 0 || lenB > 0 { + let maxLen = max(lenA, lenB) + let ratio = Double(min(lenA, lenB)) / Double(maxLen) + + if ratio < 0.3 { + let results = detector.detect(files: [fileA, fileB]) + #expect(results.isEmpty) + } + } + } + + @Test("Given one block with flow, one without, when || used, then differs from &&") + func orVsAndInPreFilter() { + let sourceA = """ + func withFlow() { + if condition { + process() + } + save() + validate() + cleanup() + } + """ + let sourceB = """ + func noFlow() { + process() + save() + validate() + cleanup() + finalize() + report() + } + """ + + let sigA = BehaviorSignatureExtractor( + source: sourceA, file: "A.swift", startLine: 1, endLine: 8 + ).extract() + let sigB = BehaviorSignatureExtractor( + source: sourceB, file: "B.swift", startLine: 1, endLine: 8 + ).extract() + + let lenA = sigA.controlFlowShape.count + let lenB = sigB.controlFlowShape.count + + let orResult = lenA > 0 || lenB > 0 + let andResult = lenA > 0 && lenB > 0 + + #expect(orResult != andResult) + } + + @Test("Given ratio >= 0.3, when mutated to >, then exact 0.3 changes") + func preFilterRatioExactlyAtBoundary() { + let lenA = 3 + let lenB = 10 + let maxLen = max(lenA, lenB) + let ratio = Double(min(lenA, lenB)) / Double(maxLen) + + #expect(ratio == 0.3) + + let passesWithGte = ratio >= 0.3 + let passesWithGt = ratio > 0.3 + + #expect(passesWithGte) + #expect(!passesWithGt) + } + + @Test("Given combinedSimilarity >= threshold, when mutated to >, then changes") + func combinedSimilarityExactlyAtThreshold() { + let sourceA = """ + func compute() { + let a = getValue() + let b = getValue() + let c = a + b + print(c) + save(c) + } + """ + let sourceB = """ + func calculate() { + let x = getValue() + let y = getValue() + let z = x + y + print(z) + save(z) + } + """ + + let fileA = makeFileTokens(source: sourceA, file: "A.swift") + let fileB = makeFileTokens(source: sourceB, file: "B.swift") + + let lowThreshold = Type4Detector( + semanticSimilarityThreshold: 1.0, + minimumTokenCount: 1, + minimumLineCount: 1 + ) + let results = lowThreshold.detect(files: [fileA, fileB]) + + #expect(!results.isEmpty) + } + } +} diff --git a/Tests/SwiftCPDTests/Detection/ReportingMutationTests.swift b/Tests/SwiftCPDTests/Detection/ReportingMutationTests.swift new file mode 100644 index 0000000..97d798c --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/ReportingMutationTests.swift @@ -0,0 +1,578 @@ +import Testing + +@testable import swift_cpd + +@Suite("Reporting Mutation Coverage") +struct ReportingMutationTests { + + @Suite("AnalysisResult Sorting") + struct AnalysisResultSorting { + + @Test("Given groups with different types, when sorting, then lower rawValue first") + func sortsByTypeAscending() { + let groupType2 = makeCloneGroup( + type: .type2, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + let groupType1 = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupType2, groupType1]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].type == .type1) + #expect(sorted[1].type == .type2) + } + + @Test("Given same type, different tokenCount, when sorting, then higher first") + func sortsByTokenCountDescending() { + let groupSmall = makeCloneGroup( + type: .type1, tokenCount: 5, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + let groupLarge = makeCloneGroup( + type: .type1, tokenCount: 20, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupSmall, groupLarge]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].tokenCount == 20) + #expect(sorted[1].tokenCount == 5) + } + + @Test("Given same type and tokenCount, different files, then alphabetical") + func sortsByFileAscending() { + let groupB = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "B.swift", startLine: 1, endLine: 5), + makeFragment(file: "B.swift", startLine: 10, endLine: 15), + ] + ) + let groupA = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupB, groupA]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].fragments.first?.file == "A.swift") + #expect(sorted[1].fragments.first?.file == "B.swift") + } + + @Test("Given same file, different startLine, then earlier first") + func sortsByStartLineAscending() { + let groupLate = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 50, endLine: 55), + makeFragment(file: "A.swift", startLine: 60, endLine: 65), + ] + ) + let groupEarly = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupLate, groupEarly]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].fragments.first?.startLine == 1) + #expect(sorted[1].fragments.first?.startLine == 50) + } + + @Test("Given equal type rawValues, when < vs <=, then no swap") + func equalTypeRawValuesNoSwap() { + let groupA = makeCloneGroup( + type: .type1, tokenCount: 15, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + let groupB = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupA, groupB]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].tokenCount == 15) + #expect(sorted[1].tokenCount == 10) + } + + @Test("Given equal tokenCount, when > vs >=, then proceeds to file compare") + func equalTokenCountProceedsToFileCompare() { + let groupB = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "B.swift", startLine: 1, endLine: 5), + makeFragment(file: "B.swift", startLine: 10, endLine: 15), + ] + ) + let groupA = makeCloneGroup( + type: .type1, tokenCount: 10, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 5), + makeFragment(file: "A.swift", startLine: 10, endLine: 15), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [groupB, groupA]) + let sorted = result.sortedCloneGroups + + #expect(sorted[0].fragments.first?.file == "A.swift") + } + + @Test("Given nil first fragment, when sorting, then returns false") + func nilFragmentsReturnFalse() { + let group1 = makeCloneGroup(type: .type1, tokenCount: 10, fragments: []) + let group2 = makeCloneGroup(type: .type1, tokenCount: 10, fragments: []) + + let result = makeAnalysisResult(cloneGroups: [group1, group2]) + let sorted = result.sortedCloneGroups + + #expect(sorted.count == 2) + } + } + + @Suite("BaselineStore Sorting") + struct BaselineStoreSorting { + + @Test("Given different types, when saving and loading, then sorted ascending") + func sortsByTypeAscending() throws { + let tempPath = createTempDirectory(prefix: "baseline-type") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 2, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + #expect(loaded[0].type == 1) + #expect(loaded[1].type == 2) + } + + @Test("Given same type, different tokenCount, then descending") + func sortsByTokenCountDescending() throws { + let tempPath = createTempDirectory(prefix: "baseline-token") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 1, tokenCount: 5, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 20, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + #expect(loaded[0].tokenCount == 20) + #expect(loaded[1].tokenCount == 5) + } + + @Test("Given same type and tokenCount, different files, then alphabetical") + func sortsByFileAscending() throws { + let tempPath = createTempDirectory(prefix: "baseline-file") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "B.swift", startLine: 1, endLine: 5) + ] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + let firstFile = loaded[0].fragmentFingerprints.first!.file + let secondFile = loaded[1].fragmentFingerprints.first!.file + #expect(firstFile == "A.swift") + #expect(secondFile == "B.swift") + } + + @Test("Given empty fingerprints, when sorting, then returns false for stability") + func emptyFingerprintsReturnFalse() throws { + let tempPath = createTempDirectory(prefix: "baseline-empty") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 3, + fragmentFingerprints: [] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + #expect(loaded.count == 2) + } + + @Test("Given equal types, when < vs <=, then equal types preserve order") + func equalTypesPreserveOrder() throws { + let tempPath = createTempDirectory(prefix: "baseline-eq-type") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 1, tokenCount: 20, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + #expect(loaded[0].tokenCount == 20) + #expect(loaded[1].tokenCount == 10) + } + + @Test("Given equal tokenCount, when > vs >=, then proceeds to file sort") + func equalTokenCountGoesToFileSort() throws { + let tempPath = createTempDirectory(prefix: "baseline-eq-tok") + defer { removeTempDirectory(tempPath) } + let filePath = tempPath + "/baseline.json" + let store = BaselineStore() + + let entry1 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "B.swift", startLine: 1, endLine: 5) + ] + ) + let entry2 = BaselineEntry( + type: 1, tokenCount: 10, lineCount: 5, + fragmentFingerprints: [ + FragmentFingerprint(file: "A.swift", startLine: 1, endLine: 5) + ] + ) + + try store.save(Set([entry1, entry2]), to: filePath) + let loaded = try loadOrderedEntries(from: filePath) + + let firstFile = loaded[0].fragmentFingerprints.first!.file + #expect(firstFile == "A.swift") + } + } + + @Suite("SuppressionScanner Boundaries") + struct SuppressionScannerBoundaries { + + @Test("Given tag on last line, when scanning, then suppresses next line") + func suppressionTagOnLastLine() { + let source = "let x = 1\n// swiftcpd:ignore\nlet y = 2" + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(suppressed.contains(3)) + } + + @Test("Given tag followed by block, then suppresses entire block") + func suppressionTagFollowedByBlock() { + let source = """ + // swiftcpd:ignore + func f() { + let x = 1 + } + let after = 2 + """ + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(suppressed.contains(2)) + #expect(suppressed.contains(3)) + #expect(suppressed.contains(4)) + #expect(!suppressed.contains(5)) + } + + @Test("Given tag at end of file, then handles boundary") + func suppressionTagAtEndOfFile() { + let source = "let x = 1\n// swiftcpd:ignore" + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(!suppressed.isEmpty) + } + + @Test("Given startLine at boundary, then <= ensures processing") + func startLineAtBoundary() { + let source = "// swiftcpd:ignore\nlet x = 1" + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(suppressed.contains(2)) + } + + @Test("Given block end at end of lines, then uses line - 1") + func blockEndUsesCorrectLineOffset() { + let source = """ + // swiftcpd:ignore + func f() { + let x = 1 + """ + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(suppressed.contains(2)) + #expect(suppressed.contains(3)) + + let maxSuppressed = suppressed.max() ?? 0 + #expect(maxSuppressed <= 3) + } + + @Test("Given startLine <= lines.count, when <= mutated to <, then last line lost") + func startLineEqualToLinesCount() { + let source = "// swiftcpd:ignore" + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + #expect(!suppressed.isEmpty) + } + + @Test("Given line - 1, when - mutated to +, then range end too large") + func blockEndSubtractionNotAddition() { + let source = """ + // swiftcpd:ignore + func f() { + let a = 1 + let b = 2 + """ + let scanner = SuppressionScanner() + let suppressed = scanner.suppressedLines(in: source) + + let lineCount = source.split( + separator: "\n", + omittingEmptySubsequences: false + ).count + let maxSuppressed = suppressed.max() ?? 0 + #expect(maxSuppressed <= lineCount) + } + } + + @Suite("BehaviorSignatureExtractor Type Collection") + struct BehaviorSignatureExtractorTypeCollection { + + @Test("Given function with param type, then typeSignatures includes it") + func parameterTypeAnnotationCollected() { + let source = """ + func process(value: String) { + print(value) + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 3 + ) + let signature = extractor.extract() + + #expect(signature.typeSignatures.contains("String")) + } + + @Test("Given function with return type, then typeSignatures includes it") + func returnTypeAnnotationCollected() { + let source = """ + func getValue() -> Int { + return 42 + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 3 + ) + let signature = extractor.extract() + + #expect(signature.typeSignatures.contains("Int")) + } + + @Test("Given function outside range, then return type not collected") + func returnClauseOutsideRangeNotCollected() { + let source = """ + func outside() -> String { + return "hello" + } + func inside() { + let x = 1 + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 4, endLine: 6 + ) + let signature = extractor.extract() + + #expect(!signature.typeSignatures.contains("String")) + } + + @Test("Given dataflow patterns, then sorted by rawValue ascending") + func dataFlowPatternsSortedByRawValue() { + let source = """ + func f(param: Int) { + let defined = 1 + let used = defined + param + print(used) + print(globalVar) + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let signature = extractor.extract() + + let rawValues = signature.dataFlowPatterns.map(\.rawValue) + let sortedRawValues = rawValues.sorted() + + #expect(rawValues == sortedRawValues) + + if rawValues.count >= 2 { + for idx in 0 ..< rawValues.count - 1 { + #expect(rawValues[idx] <= rawValues[idx + 1]) + } + } + } + + @Test("Given multiple param types, then all collected via insert()") + func multipleParameterTypesCollected() { + let source = """ + func process(name: String, age: Int) { + print(name) + print(age) + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 4 + ) + let signature = extractor.extract() + + #expect(signature.typeSignatures.contains("String")) + #expect(signature.typeSignatures.contains("Int")) + #expect(signature.typeSignatures.count >= 2) + } + + @Test("Given return type insert, when removed, then type missing") + func returnTypeInsertNotRemoved() { + let source = """ + func compute() -> Double { + return 3.14 + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 3 + ) + let signature = extractor.extract() + + #expect(signature.typeSignatures.contains("Double")) + } + + @Test("Given IdentifierType insert, when removed, then type missing") + func identifierTypeInsertNotRemoved() { + let source = """ + func process(items: [CustomType]) { + let result: CustomType = items.first! + print(result) + } + """ + + let extractor = BehaviorSignatureExtractor( + source: source, file: "Test.swift", startLine: 1, endLine: 4 + ) + let signature = extractor.extract() + + #expect(!signature.typeSignatures.isEmpty) + } + } + + @Suite("JsonReporter Line Arithmetic") + struct JsonReporterLineArithmetic { + + @Test("Given endLine - 1, when mutated to + 1, then endIndex wrong") + func readPreviewEndLineSubtraction() { + let group = makeCloneGroup( + type: .type1, tokenCount: 10, lineCount: 3, + fragments: [ + makeFragment(file: "A.swift", startLine: 1, endLine: 3), + makeFragment(file: "B.swift", startLine: 1, endLine: 3), + ] + ) + + let result = makeAnalysisResult(cloneGroups: [group]) + let reporter = JsonReporter() + let output = reporter.report(result) + + #expect(output.contains("A.swift")) + } + } +} diff --git a/Tests/SwiftCPDTests/Detection/SemanticGraph/SemanticNormalizerMutationTests.swift b/Tests/SwiftCPDTests/Detection/SemanticGraph/SemanticNormalizerMutationTests.swift new file mode 100644 index 0000000..5b4d2f0 --- /dev/null +++ b/Tests/SwiftCPDTests/Detection/SemanticGraph/SemanticNormalizerMutationTests.swift @@ -0,0 +1,455 @@ +import Testing + +@testable import swift_cpd + +@Suite("SemanticNormalizer Mutation Coverage") +struct SemanticNormalizerMutationTests { + + @Test("Given guard with early return, when normalizing, then edge from conditional to guardExit exists") + func guardEarlyReturnProducesControlFlowEdge() { + let source = """ + func f() { + guard let x = optional else { + return + } + print(x) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph = normalizer.normalize() + + let conditionalNodes = graph.nodes.filter { $0.kind == .conditional } + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + + #expect(!conditionalNodes.isEmpty) + #expect(!guardExitNodes.isEmpty) + + let hasEdge = graph.edges.contains { edge in + edge.kind == .controlFlow + && conditionalNodes.contains { $0.id == edge.from } + && guardExitNodes.contains { $0.id == edge.to } + } + + #expect(hasEdge) + } + + @Test("Given negated if-return, then edge from conditional to guardExit") + func ifNegatedEarlyReturnProducesControlFlowEdge() { + let source = """ + func f(value: Int?) { + if !(value != nil) { + return + } + print(value!) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph = normalizer.normalize() + + let conditionalNodes = graph.nodes.filter { $0.kind == .conditional } + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + + #expect(!conditionalNodes.isEmpty) + #expect(!guardExitNodes.isEmpty) + + let hasEdge = graph.edges.contains { edge in + edge.kind == .controlFlow + && conditionalNodes.contains { $0.id == edge.from } + && guardExitNodes.contains { $0.id == edge.to } + } + + #expect(hasEdge) + } + + @Test("Given guard with early return, then edge target is guardExit node") + func guardEdgeTargetsCorrectNodeId() { + let source = """ + func f() { + guard condition else { + return + } + doWork() + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph = normalizer.normalize() + + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + + guard + let guardExitNode = guardExitNodes.first + else { + Issue.record("Expected a guardExit node") + return + } + + let edgesToGuardExit = graph.edges.filter { $0.to == guardExitNode.id && $0.kind == .controlFlow } + + #expect(!edgesToGuardExit.isEmpty) + } + + @Test("Given source with single statement, when normalizing with 1 node, then buildGraph skips sequential edges") + func singleNodeGraphHasNoSequentialEdges() { + let source = """ + func f() { + return + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 3 + ) + let graph = normalizer.normalize() + + if graph.nodes.count <= 1 { + let controlFlowEdges = graph.edges.filter { $0.kind == .controlFlow } + + #expect(controlFlowEdges.isEmpty) + } + } + + @Test("Given source with multiple nodes, when normalizing, then buildGraph adds sequential control flow edges") + func multipleNodesGetSequentialControlFlowEdges() { + let source = """ + func f() { + let x = 1 + if condition { + doWork() + } + return x + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 7 + ) + let graph = normalizer.normalize() + + #expect(graph.nodes.count > 1) + + let controlFlowEdges = graph.edges.filter { $0.kind == .controlFlow } + + #expect(!controlFlowEdges.isEmpty) + } + + @Test("Given edge deduplication where from/to match but kind differs, when normalizing, then both edges kept") + func edgeDeduplicationChecksAllThreeFields() { + let source = """ + func f() { + let x = getValue() + if x > 0 { + let y = x + print(y) + } + return + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 8 + ) + let graph = normalizer.normalize() + + var fromToPairs: [String: Set] = [:] + + for edge in graph.edges { + let key = "\(edge.from)-\(edge.to)" + fromToPairs[key, default: []].insert(edge.kind) + } + + let hasControlFlow = graph.edges.contains { $0.kind == .controlFlow } + let hasDataFlow = graph.edges.contains { $0.kind == .dataFlow } + + #expect(hasControlFlow) + #expect(hasDataFlow) + } + + @Test("Given if with prefix ! operator and early return, when checking negation, then OR logic detects it") + func prefixBangOperatorDetectedByOrLogic() { + let source = """ + func f(value: Bool) { + if !value { + return + } + doWork() + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph = normalizer.normalize() + + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + + #expect(!guardExitNodes.isEmpty) + } + + @Test("Given variable reference with existing definition, when current node differs, then data flow edge added") + func variableReferenceAddsDataFlowEdge() { + let source = """ + func f() { + let value = 42 + print(value) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 4 + ) + let graph = normalizer.normalize() + + let dataFlowEdges = graph.edges.filter { $0.kind == .dataFlow } + + #expect(!dataFlowEdges.isEmpty) + } + + @Test("Given empty range, when normalizing, then returns empty graph without crash") + func emptyRangeReturnsEmptyGraph() { + let source = """ + func f() { + let x = 1 + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 100, endLine: 200 + ) + let graph = normalizer.normalize() + + #expect(graph.nodes.isEmpty) + #expect(graph.edges.isEmpty) + } + + @Test("Given guard addEdge to guardExit, when removed, then no control flow edge to guardExit exists") + func guardAddEdgeIsNotRemoved() { + let source = """ + func f() { + guard condition else { + return + } + let a = 1 + let b = 2 + print(a + b) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 8 + ) + let graph = normalizer.normalize() + + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + let conditionalNodes = graph.nodes.filter { $0.kind == .conditional } + + #expect(!guardExitNodes.isEmpty) + #expect(!conditionalNodes.isEmpty) + + let directEdge = graph.edges.contains { edge in + edge.kind == .controlFlow + && conditionalNodes.contains { $0.id == edge.from } + && guardExitNodes.contains { $0.id == edge.to } + } + + #expect(directEdge) + } + + @Test("Given guard addEdge target uses nodes.count - 1, when mutated to + 1, then edge points to wrong node") + func guardEdgeTargetUsesMinusOneNotPlusOne() { + let source = """ + func f() { + guard let x = opt else { + return + } + print(x) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph = normalizer.normalize() + + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + #expect(guardExitNodes.count == 1) + + let edgesToGuardExit = graph.edges.filter { edge in + edge.kind == .controlFlow && edge.to == guardExitNodes[0].id + } + + #expect(edgesToGuardExit.count >= 1) + + for edge in edgesToGuardExit { + let fromNode = graph.nodes.first { $0.id == edge.from } + #expect(fromNode != nil) + #expect(fromNode?.kind == .conditional) + } + } + + @Test("Given if negated addEdge to guardExit, when removed, then no direct edge from conditional to guardExit") + func ifNegatedAddEdgeIsNotRemoved() { + let source = """ + func f(x: Bool) { + if !x { + return + } + let a = 1 + print(a) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 7 + ) + let graph = normalizer.normalize() + + let conditionalNodes = graph.nodes.filter { $0.kind == .conditional } + let guardExitNodes = graph.nodes.filter { $0.kind == .guardExit } + + #expect(!conditionalNodes.isEmpty) + #expect(!guardExitNodes.isEmpty) + + let directEdge = graph.edges.contains { edge in + edge.kind == .controlFlow + && conditionalNodes.contains { $0.id == edge.from } + && guardExitNodes.contains { $0.id == edge.to } + } + + #expect(directEdge) + } + + @Test("Given DeclReferenceExpr with nodes.count > 0, when mutated to >= 0, then zero case handled") + func declReferenceCountGreaterThanZero() { + let source = """ + func f() { + let value = 42 + print(value) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 4 + ) + let graph = normalizer.normalize() + + #expect(graph.nodes.count > 0) + + let dataFlowEdges = graph.edges.filter { $0.kind == .dataFlow } + #expect(!dataFlowEdges.isEmpty) + + for edge in dataFlowEdges { + #expect(edge.from >= 0) + #expect(edge.to >= 0) + #expect(edge.from != edge.to) + } + } + + @Test("Given buildGraph guard nodes.count > 1, when exactly 1 node, then no sequential edges added") + func buildGraphGuardExactlyOneNode() { + let source = """ + func f() { + return + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 3 + ) + let graph = normalizer.normalize() + + if graph.nodes.count == 1 { + let sequentialEdges = graph.edges.filter { $0.kind == .controlFlow } + #expect(sequentialEdges.isEmpty) + } + } + + @Test("Given buildGraph with exactly 2 nodes, when guard passes, then sequential edge added") + func buildGraphTwoNodesGetsSequentialEdge() { + let source = """ + func f() { + let x = 1 + return x + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 4 + ) + let graph = normalizer.normalize() + + #expect(graph.nodes.count >= 2) + + let controlFlowEdges = graph.edges.filter { $0.kind == .controlFlow } + #expect(!controlFlowEdges.isEmpty) + } + + @Test("Given edge dedup using && for all 3 fields, when mutated to ||, then duplicates not detected") + func edgeDeduplicationAndLogicAllThreeFields() { + let source = """ + func f() { + guard condition else { + return + } + let x = 1 + print(x) + } + """ + + let normalizer = SemanticNormalizer( + source: source, file: "Test.swift", startLine: 1, endLine: 7 + ) + let graph = normalizer.normalize() + + var edgeSet: Set = [] + for edge in graph.edges { + let key = "\(edge.from)-\(edge.to)-\(edge.kind)" + edgeSet.insert(key) + } + + #expect(edgeSet.count == graph.edges.count) + } + + @Test("Given isNegatedEarlyReturn uses || for detection, when mutated to &&, then hasPrefix check alone suffices") + func isNegatedOrLogicDetectsBothForms() { + let sourcePrefixOp = """ + func f(value: Bool) { + if !value { + return + } + doWork() + } + """ + + let normalizer1 = SemanticNormalizer( + source: sourcePrefixOp, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph1 = normalizer1.normalize() + let guardExitNodes1 = graph1.nodes.filter { $0.kind == .guardExit } + #expect(!guardExitNodes1.isEmpty) + + let sourceHasPrefix = """ + func f(value: Int?) { + if !(value != nil) { + return + } + print(value!) + } + """ + + let normalizer2 = SemanticNormalizer( + source: sourceHasPrefix, file: "Test.swift", startLine: 1, endLine: 6 + ) + let graph2 = normalizer2.normalize() + let guardExitNodes2 = graph2.nodes.filter { $0.kind == .guardExit } + #expect(!guardExitNodes2.isEmpty) + } +} diff --git a/Tests/SwiftCPDTests/Pipeline/AnalysisPipelineMutationTests.swift b/Tests/SwiftCPDTests/Pipeline/AnalysisPipelineMutationTests.swift new file mode 100644 index 0000000..77933e8 --- /dev/null +++ b/Tests/SwiftCPDTests/Pipeline/AnalysisPipelineMutationTests.swift @@ -0,0 +1,333 @@ +import Foundation +import Testing + +@testable import swift_cpd + +@Suite("AnalysisPipeline Mutation Coverage") +struct AnalysisPipelineMutationTests { + + @Test("Given clones with different type rawValues, when sorting, then lower type comes first") + func sortByTypeRawValueStrictLessThan() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_type_sort") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let source = """ + func doWork() -> Int { + let a = 1 + let b = 2 + let c = a + b + let d = c * 2 + let e = d - 1 + return e + } + + func doWorkCopy() -> Int { + let a = 1 + let b = 2 + let c = a + b + let d = c * 2 + let e = d - 1 + return e + } + """ + + let fileA = tempDir + "/A.swift" + let fileB = tempDir + "/B.swift" + try source.write(toFile: fileA, atomically: true, encoding: .utf8) + try source.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1, .type2, .type3] + ) + let result = try await pipeline.analyze(files: [fileA, fileB]) + + for index in 0 ..< result.cloneGroups.count - 1 { + let current = result.cloneGroups[index] + let next = result.cloneGroups[index + 1] + + if current.type.rawValue != next.type.rawValue { + #expect(current.type.rawValue < next.type.rawValue) + } + } + } + + @Test("Given clones of same type in different files, when sorting, then alphabetical file order") + func sortByFileStrictLessThan() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_file_sort") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let fileA = tempDir + "/Alpha.swift" + let fileB = tempDir + "/Beta.swift" + try standardDuplicateSource.write(toFile: fileA, atomically: true, encoding: .utf8) + try standardDuplicateSource.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1] + ) + let result = try await pipeline.analyze(files: [fileB, fileA]) + + guard + !result.cloneGroups.isEmpty, + let frag = result.cloneGroups[0].fragments.first + else { + Issue.record("Expected at least one clone group") + return + } + + #expect(frag.file.contains("Alpha")) + } + + @Test("Given clones in same file, when sorting, then earlier startLine comes first") + func sortByStartLineStrictLessThan() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_line_sort") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let source = """ + func first() -> Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + return a + b + c + d + } + + func second() -> Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + return a + b + c + d + } + + func third() -> Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + return a + b + c + d + } + """ + + let fileA = tempDir + "/Same.swift" + let fileB = tempDir + "/Other.swift" + try source.write(toFile: fileA, atomically: true, encoding: .utf8) + try source.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1, .type2] + ) + let result = try await pipeline.analyze(files: [fileA, fileB]) + + for index in 0 ..< result.cloneGroups.count - 1 { + let current = result.cloneGroups[index] + let next = result.cloneGroups[index + 1] + + guard + let curFrag = current.fragments.first, + let nextFrag = next.fragments.first + else { + continue + } + + if current.type == next.type, curFrag.file == nextFrag.file { + #expect(curFrag.startLine <= nextFrag.startLine) + } + } + } + + @Test("Given sorting with nil first fragment, when sorting, then returns false as fallback") + func sortFallbackReturnsFalseForNilFragments() async throws { + let group1 = CloneGroup( + type: .type1, + tokenCount: 10, + lineCount: 5, + similarity: 100.0, + fragments: [] + ) + let group2 = CloneGroup( + type: .type1, + tokenCount: 10, + lineCount: 5, + similarity: 100.0, + fragments: [] + ) + + let sorted = [group1, group2].sorted { + guard + let lhs = $0.fragments.first, + let rhs = $1.fragments.first + else { + return false + } + + if $0.type.rawValue != $1.type.rawValue { return $0.type.rawValue < $1.type.rawValue } + if lhs.file != rhs.file { return lhs.file < rhs.file } + return lhs.startLine < rhs.startLine + } + + #expect(sorted.count == 2) + } + + @Test("Given .c file, when tokenizing, then uses CTokenizer not SwiftTokenizer") + func cFileUsesCTokenizer() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_c_tok") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let cSource = """ + void calculate() { + int a = 1; + int b = 2; + int c = a + b; + int d = c * 2; + int e = d - 1; + printf("%d", e); + } + """ + + let fileA = tempDir + "/code.c" + let fileB = tempDir + "/code2.c" + try cSource.write(toFile: fileA, atomically: true, encoding: .utf8) + try cSource.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, cacheDirectory: cacheDir + ) + let result = try await pipeline.analyze(files: [fileA, fileB]) + + #expect(result.totalTokens > 0) + } + + @Test("Given files sorted by name, when processing, then results are in file order") + func processedFilesAreSortedByName() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_filesorted") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let fileZ = tempDir + "/Z.swift" + let fileA = tempDir + "/A.swift" + try standardDuplicateSource.write(toFile: fileZ, atomically: true, encoding: .utf8) + try standardDuplicateSource.write(toFile: fileA, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1] + ) + let result = try await pipeline.analyze(files: [fileZ, fileA]) + + guard + !result.cloneGroups.isEmpty + else { + Issue.record("Expected clones") + return + } + + let firstFragFile = result.cloneGroups[0].fragments[0].file + #expect(firstFragFile.contains("A.swift")) + } + + @Test("Given .swift file check hasSuffix, when negated, then Swift files use CTokenizer incorrectly") + func hasSuffixSwiftNotNegated() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_suffix") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let swiftSource = """ + func calculate() -> Int { + let a = 1 + let b = 2 + let c = a + b + let d = c * 2 + return d + } + """ + + let fileA = tempDir + "/A.swift" + let fileB = tempDir + "/B.swift" + try swiftSource.write(toFile: fileA, atomically: true, encoding: .utf8) + try swiftSource.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1] + ) + let result = try await pipeline.analyze(files: [fileA, fileB]) + + #expect(result.totalTokens > 0) + #expect(!result.cloneGroups.isEmpty) + } + + @Test("Given file sort with < on same file name, when < mutated to <=, then equal names don't swap") + func fileSortEqualNamesStable() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_eq_file") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let fileA = tempDir + "/Same.swift" + try standardDuplicateSource.write(toFile: fileA, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1] + ) + let result = try await pipeline.analyze(files: [fileA]) + + #expect(result.totalTokens > 0) + } + + @Test("Given sorting fallback returns false, when false mutated to true, then equal-fragment groups swap") + func sortingFallbackReturnsFalse() async throws { + let tempDir = createTempDirectory(prefix: "pipeline_fallback") + let cacheDir = tempDir + "/.swift-cpd-cache" + defer { removeTempDirectory(tempDir) } + + let source = """ + func alpha() -> Int { + let a = 1 + let b = 2 + let c = a + b + return c + } + + func beta() -> Int { + let a = 1 + let b = 2 + let c = a + b + return c + } + """ + + let fileA = tempDir + "/A.swift" + let fileB = tempDir + "/B.swift" + try source.write(toFile: fileA, atomically: true, encoding: .utf8) + try source.write(toFile: fileB, atomically: true, encoding: .utf8) + + let pipeline = AnalysisPipeline( + minimumTokenCount: 5, minimumLineCount: 1, + cacheDirectory: cacheDir, enabledCloneTypes: [.type1, .type2] + ) + let result = try await pipeline.analyze(files: [fileA, fileB]) + + for index in 0 ..< result.cloneGroups.count - 1 { + let current = result.cloneGroups[index] + let next = result.cloneGroups[index + 1] + + guard + let curFrag = current.fragments.first, + let nextFrag = next.fragments.first + else { + continue + } + + if current.type.rawValue == next.type.rawValue, curFrag.file == nextFrag.file { + #expect(curFrag.startLine <= nextFrag.startLine) + } + } + } +} diff --git a/Tests/SwiftCPDTests/Tokenization/CTokenizerMutationTests.swift b/Tests/SwiftCPDTests/Tokenization/CTokenizerMutationTests.swift new file mode 100644 index 0000000..88dd88d --- /dev/null +++ b/Tests/SwiftCPDTests/Tokenization/CTokenizerMutationTests.swift @@ -0,0 +1,261 @@ +import Testing + +@testable import swift_cpd + +@Suite("CTokenizer Mutation Coverage") +struct CTokenizerMutationTests { + + let tokenizer = CTokenizer() + + @Test("Given line comment followed by code, when tokenizing, then code after newline is tokenized") + func lineCommentSkipsToNewline() { + let source = "// comment\nint x;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "x" }) + } + + @Test("Given block comment, when tokenizing, then initial advance past /* works correctly") + func blockCommentInitialAdvance() { + let source = "/* comment */ int y;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "y" }) + #expect(!tokens.contains { $0.text == "comment" }) + } + + @Test("Given block comment with no space after, when tokenizing, then next token parsed correctly") + func blockCommentImmediatelyFollowedByCode() { + let source = "/*x*/int z;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "z" }) + } + + @Test("Given block comment advance() calls removed, when tokenizing, then /* not skipped") + func blockCommentAdvanceNotRemoved() { + let source = "/**/int a;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "a" }) + #expect(!tokens.contains { $0.text == "*" }) + #expect(!tokens.contains { $0.text == "/" }) + } + + @Test("Given block comment with content, when first advance removed, then content leaks into tokens") + func blockCommentFirstAdvanceRequired() { + let source = "/* hello world */int b;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(!tokens.contains { $0.text == "hello" }) + #expect(!tokens.contains { $0.text == "world" }) + } + + @Test("Given preprocessor directive, when tokenizing, then directive is skipped") + func preprocessorDirectiveSkipped() { + let source = "#include \nint main() {}" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "main" }) + #expect(!tokens.contains { $0.text == "#include" }) + } + + @Test("Given preprocessor at end of source, when < mutated to <=, then goes out of bounds") + func preprocessorDirectiveBoundary() { + let source = "#define FOO" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.isEmpty || !tokens.contains { $0.text == "#define" }) + } + + @Test("Given @interface keyword, when tokenizing, then scanned as keyword with letters and no digits needed") + func atKeywordWithLettersOnly() { + let source = "@interface MyClass" + let tokens = tokenizer.tokenize(source: source, file: "test.m") + + #expect(tokens.contains { $0.text == "@interface" && $0.kind == .keyword }) + } + + @Test("Given identifier with number like var1, when tokenizing, then scanned as single identifier") + func identifierWithNumber() { + let source = "int var1 = 5;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "var1" }) + } + + @Test("Given identifier with underscore like my_var, when tokenizing, then scanned as single identifier") + func identifierWithUnderscore() { + let source = "int my_var = 5;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "my_var" }) + } + + @Test("Given char literal with single char, when tokenizing, then parsed correctly") + func charLiteralSingleChar() { + let source = "char c = 'a';" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "a" && $0.kind == .integerLiteral }) + } + + @Test("Given char literal with escape sequence, when tokenizing, then parsed correctly") + func charLiteralEscapeSequence() { + let source = "char c = '\\n';" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let charTokens = tokens.filter { $0.kind == .integerLiteral } + + #expect(!charTokens.isEmpty) + } + + @Test("Given char literal scanCharLiteral < boundary, when mutated to >, then escape breaks") + func charLiteralBoundaryCheck() { + let source = "char a = '\\t'; char b = 'x';" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let charTokens = tokens.filter { $0.kind == .integerLiteral } + #expect(charTokens.count == 2) + } + + @Test("Given number with exponent and sign, when tokenizing, then parses correctly") + func numberWithExponentAndSign() { + let source = "double x = 1.5e+10;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let floatTokens = tokens.filter { $0.kind == .floatingLiteral } + + #expect(!floatTokens.isEmpty) + #expect(floatTokens.first?.text == "1.5e+10") + } + + @Test("Given number with negative exponent, when tokenizing, then parses sign correctly") + func numberWithNegativeExponent() { + let source = "double x = 3.0e-5;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let floatTokens = tokens.filter { $0.kind == .floatingLiteral } + + #expect(!floatTokens.isEmpty) + #expect(floatTokens.first?.text == "3.0e-5") + } + + @Test("Given number with exponent but no sign, when tokenizing, then parses without sign") + func numberWithExponentNoSign() { + let source = "double x = 2.0e3;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let floatTokens = tokens.filter { $0.kind == .floatingLiteral } + + #expect(!floatTokens.isEmpty) + #expect(floatTokens.first?.text == "2.0e3") + } + + @Test("Given number at end of source with exponent sign check, when < mutated to <=, then boundary handled") + func numberExponentSignBoundary() { + let source = "1.5e" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(!tokens.isEmpty) + } + + @Test("Given hex number, when tokenizing, then parsed as integer literal") + func hexNumber() { + let source = "int x = 0xFF;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "0xFF" && $0.kind == .integerLiteral }) + } + + @Test("Given @-prefixed non-keyword, when tokenizing, then @ is punctuation") + func atNonKeywordIsPunctuation() { + let source = "@123" + let tokens = tokenizer.tokenize(source: source, file: "test.m") + + #expect(tokens.first?.text == "@") + #expect(tokens.first?.kind == .punctuation) + } + + @Test("Given ObjC string literal @\"hello\", when tokenizing, then parsed as string literal") + func objcStringLiteral() { + let source = "@\"hello\"" + let tokens = tokenizer.tokenize(source: source, file: "test.m") + + let stringTokens = tokens.filter { $0.kind == .stringLiteral } + + #expect(!stringTokens.isEmpty) + #expect(stringTokens.first?.text == "hello") + } + + @Test("Given string with escape, when tokenizing, then escape character handled") + func stringWithEscape() { + let source = "\"hello\\nworld\"" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + let stringTokens = tokens.filter { $0.kind == .stringLiteral } + + #expect(!stringTokens.isEmpty) + } + + @Test("Given two-char operator, when tokenizing, then scanned as single operator") + func twoCharOperator() { + let source = "x == y" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "==" && $0.kind == .operatorToken }) + } + + @Test("Given line comment at end of file without newline, when tokenizing, then handles boundary") + func lineCommentAtEndNoNewline() { + let source = "int x; // end" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.contains { $0.text == "int" }) + #expect(tokens.contains { $0.text == "x" }) + } + + @Test("Given identifier starting with underscore, when tokenizing, then parsed correctly") + func identifierStartingWithUnderscore() { + let source = "_privateVar" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.count == 1) + #expect(tokens[0].text == "_privateVar") + } + + @Test("Given multiple line comments, when tokenizing, then all skipped correctly") + func multipleLineComments() { + let source = "// first\n// second\nint x;" + let tokens = tokenizer.tokenize(source: source, file: "test.c") + + #expect(tokens.first?.text == "int") + } + + @Test("Given @keyword with mixed chars, when || mutated to &&, then breaks") + func atKeywordOrLogicForCharacterTypes() { + let source = "@implementation_test MyClass" + let tokens = tokenizer.tokenize(source: source, file: "test.m") + + let atTokens = tokens.filter { $0.text.hasPrefix("@") } + #expect(!atTokens.isEmpty) + + let source2 = "@property int x;" + let tokens2 = tokenizer.tokenize(source: source2, file: "test.m") + #expect(tokens2.contains { $0.text == "@property" && $0.kind == .keyword }) + } + + @Test("Given @keyword with digits like @property123, when scanning, then continues past digits") + func atKeywordWithDigitsInBody() { + let source = "@selector123 foo" + let tokens = tokenizer.tokenize(source: source, file: "test.m") + + #expect(!tokens.isEmpty) + } +} diff --git a/Tests/SwiftCPDTests/Tokenization/UnifiedTokenMapperMutationTests.swift b/Tests/SwiftCPDTests/Tokenization/UnifiedTokenMapperMutationTests.swift new file mode 100644 index 0000000..35b56f3 --- /dev/null +++ b/Tests/SwiftCPDTests/Tokenization/UnifiedTokenMapperMutationTests.swift @@ -0,0 +1,257 @@ +import Testing + +@testable import swift_cpd + +@Suite("UnifiedTokenMapper Mutation Coverage") +struct UnifiedTokenMapperMutationTests { + + let mapper = UnifiedTokenMapper() + let location = SourceLocation(file: "test.m", line: 1, column: 1) + + @Test("Given message send with exactly 4 tokens, when mapping, then boundary guard works correctly") + func messageSendBoundaryExactlyFourTokens() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "name", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result.count == 1) + #expect(result[0].text == "$ACCESS") + } + + @Test("Given message send with colon argument, when mapping, then closing bracket not included in result") + func messageSendClosingBracketExcluded() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "setVal", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg1", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + #expect(result.contains { $0.text == "arg1" }) + #expect(!result.contains { $0.text == ":" }) + #expect(!result.contains { $0.text == "]" }) + } + + @Test("Given message send with multiple arguments, when mapping, then colons are excluded but values kept") + func messageSendColonsExcludedValuesKept() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "val1", location: location), + Token(kind: .identifier, text: "key2", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "val2", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + #expect(result.contains { $0.text == "val1" }) + #expect(result.contains { $0.text == "val2" }) + #expect(result.contains { $0.text == "key2" }) + let colonCount = result.filter { $0.text == ":" }.count + #expect(colonCount == 0) + } + + @Test("Given no-arg message send at index+3 boundary, then guard works") + func messageSendWithoutArgsSecondGuardBoundary() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "self", location: location), + Token(kind: .identifier, text: "count", location: location), + Token(kind: .punctuation, text: "]", location: location), + Token(kind: .punctuation, text: ";", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$ACCESS") + #expect(result[1].text == ";") + } + + @Test("Given bracket-identifier-identifier-non-bracket at exact boundary, when mapping, then falls through") + func messageSendSecondGuardFailsCorrectly() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "prop", location: location), + Token(kind: .identifier, text: "extra", location: location), + ] + + let result = mapper.map(tokens) + + #expect(!result.contains { $0.text == "$ACCESS" }) + #expect(!result.contains { $0.text == "$CALL" }) + } + + @Test("Given scan with depth reaching zero, when scanning, then returns correct closing index") + func scanBracketedRegionDepthReachesZero() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result.count == 2) + #expect(result[0].text == "$CALL") + #expect(result[1].text == "arg") + } + + @Test("Given argument loop at closing bracket, then stops before it") + func argumentLoopStopsBeforeClosing() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "doWork", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .integerLiteral, text: "42", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + #expect(result[1].text == "42") + #expect(result.count == 2) + } + + @Test("Given message send with bracket argument, when mapping, then consumed count is correct") + func messageSendConsumedCountCorrect() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "msg", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "val", location: location), + Token(kind: .punctuation, text: "]", location: location), + Token(kind: .punctuation, text: ";", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + #expect(result.last?.text == ";") + } + + @Test("Given index + 3 < tokens.count, when < mutated to <=, then boundary at exactly count fails") + func messageSendFirstGuardBoundary() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "name", location: location), + ] + + let result = mapper.map(tokens) + + #expect(!result.contains { $0.text == "$ACCESS" }) + #expect(!result.contains { $0.text == "$CALL" }) + #expect(result.count == 3) + } + + @Test("Given argument loop while argumentIndex < closing, when < mutated to <=, then closing bracket included") + func argumentLoopBoundary() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg1", location: location), + Token(kind: .identifier, text: "key2", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg2", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(!result.contains { $0.text == "]" }) + #expect(result[0].text == "$CALL") + } + + @Test("Given token.kind != .punctuation check, when != mutated to ==, then non-punctuation tokens excluded") + func tokenKindNotEqualCheck() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg1", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result.contains { $0.text == "arg1" }) + } + + @Test("Given closing - index + 1 for consumed count, when + mutated to -, then consumed is wrong") + func consumedCountArithmetic() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .identifier, text: "arg1", location: location), + Token(kind: .punctuation, text: "]", location: location), + Token(kind: .identifier, text: "afterToken", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + #expect(result.last?.text == "afterToken") + #expect(result.count == 3) + } + + @Test("Given scanBracketedRegion depth > 0, when > mutated to >=, then early exit at depth 0") + func scanBracketedRegionDepthBoundary() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "method", location: location), + Token(kind: .punctuation, text: ":", location: location), + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "inner", location: location), + Token(kind: .identifier, text: "msg", location: location), + Token(kind: .punctuation, text: "]", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result[0].text == "$CALL") + } + + @Test("Given second guard index+3 < count, when < to <=, then fails") + func messageSendSecondGuardBoundary() { + let tokens = [ + Token(kind: .punctuation, text: "[", location: location), + Token(kind: .identifier, text: "obj", location: location), + Token(kind: .identifier, text: "prop", location: location), + Token(kind: .punctuation, text: "]", location: location), + ] + + let result = mapper.map(tokens) + + #expect(result.count == 1) + #expect(result[0].text == "$ACCESS") + } +}