Compare commits

...

2 Commits

Author SHA1 Message Date
JP Simard 91677c8307
fixup! Rewrite `duplicate_imports` with SwiftSyntax 2022-10-24 13:35:42 -04:00
JP Simard a34d3a78f6
Rewrite `duplicate_imports` with SwiftSyntax 2022-10-24 13:34:58 -04:00
2 changed files with 32 additions and 363 deletions

View File

@ -1,7 +1,6 @@
import Foundation import SwiftSyntax
import SourceKittenFramework
public struct DuplicateImportsRule: ConfigurationProviderRule, CorrectableRule { public struct DuplicateImportsRule: ConfigurationProviderRule, SwiftSyntaxRule {
public var configuration = SeverityConfiguration(.warning) public var configuration = SeverityConfiguration(.warning)
// List of all possible import kinds // List of all possible import kinds
@ -23,192 +22,34 @@ public struct DuplicateImportsRule: ConfigurationProviderRule, CorrectableRule {
corrections: DuplicateImportsRuleExamples.corrections corrections: DuplicateImportsRuleExamples.corrections
) )
private func rangesInConditionalCompilation(file: SwiftLintFile) -> [ByteRange] { public func makeVisitor(file: SwiftLintFile) -> ViolationsSyntaxVisitor {
let contents = file.stringView Visitor(viewMode: .sourceAccurate)
}
}
let ranges = file.syntaxMap.tokens private extension DuplicateImportsRule {
.filter { $0.kind == .buildconfigKeyword } final class Visitor: ViolationsSyntaxVisitor {
.map { $0.range } private var imported: Set<String> = []
.filter { range in override func visitPost(_ node: ImportDeclSyntax) {
return ["#if", "#endif"].contains(contents.substringWithByteRange(range)) let ifConfigCondition = node.nearestIfConfigClause()?.condition?.description
let pathDescription = [ifConfigCondition, node.path.withoutTrivia().description]
.compactMap { $0 }
.joined(separator: "/")
if imported.contains(pathDescription) {
violations.append(node.importTok.positionAfterSkippingLeadingTrivia)
} else {
imported.insert(pathDescription)
} }
// Make sure that each #if has corresponding #endif
guard ranges.count.isMultiple(of: 2) else { return [] }
return stride(from: 0, to: ranges.count, by: 2).reduce(into: []) { result, rangeIndex in
result.append(ranges[rangeIndex].union(with: ranges[rangeIndex + 1]))
}
}
private func buildImportLineSlicesByImportSubpath(
importLines: [Line]
) -> [ImportSubpath: [ImportLineSlice]] {
var importLineSlices = [ImportSubpath: [ImportLineSlice]]()
importLines.forEach { importLine in
importLine.importSlices.forEach { slice in
importLineSlices[slice.subpath, default: []].append(
ImportLineSlice(
slice: slice,
line: importLine
)
)
}
}
return importLineSlices
}
private func findDuplicateImports(
file: SwiftLintFile,
importLineSlicesGroupedBySubpath: [[ImportLineSlice]]
) -> [DuplicateImport] {
typealias ImportLocation = Int
var duplicateImportsByLocation = [ImportLocation: DuplicateImport]()
importLineSlicesGroupedBySubpath.forEach { linesImportingSubpath in
guard linesImportingSubpath.count > 1 else { return }
guard let primaryImportIndex = linesImportingSubpath.firstIndex(where: {
$0.slice.type == .complete
}) else { return }
linesImportingSubpath.enumerated().forEach { index, importedLine in
guard index != primaryImportIndex else { return }
let location = Location(
file: file,
characterOffset: importedLine.line.range.location
)
duplicateImportsByLocation[importedLine.line.range.location] = DuplicateImport(
location: location,
range: importedLine.line.range
)
}
}
return Array(duplicateImportsByLocation.values)
}
private struct DuplicateImport {
let location: Location
var range: NSRange
}
private func duplicateImports(file: SwiftLintFile) -> [DuplicateImport] {
let contents = file.stringView
let ignoredRanges = self.rangesInConditionalCompilation(file: file)
let importKinds = Self.importKinds.joined(separator: "|")
// Grammar of import declaration
// attributes(optional) import import-kind(optional) import-path
let regex = "^([a-zA-Z@_]+\\s)?import(\\s(\(importKinds)))?\\s+[a-zA-Z0-9._]+$"
let importRanges = file.match(pattern: regex)
.filter { $0.1.allSatisfy { [.keyword, .identifier, .attributeBuiltin].contains($0) } }
.compactMap { contents.NSRangeToByteRange(start: $0.0.location, length: $0.0.length) }
.filter { importRange -> Bool in
return !importRange.intersects(ignoredRanges)
}
let lines = file.lines
let importLines: [Line] = importRanges.compactMap { range in
guard let line = contents.lineAndCharacter(forByteOffset: range.location)?.line
else { return nil }
return lines[line - 1]
}
let importLineSlices = buildImportLineSlicesByImportSubpath(importLines: importLines)
let duplicateImports = findDuplicateImports(
file: file,
importLineSlicesGroupedBySubpath: Array(importLineSlices.values)
)
return duplicateImports.sorted(by: {
$0.range.lowerBound < $1.range.lowerBound
})
}
public func validate(file: SwiftLintFile) -> [StyleViolation] {
return duplicateImports(file: file).map { duplicateImport in
StyleViolation(
ruleDescription: Self.description,
severity: configuration.severity,
location: duplicateImport.location
)
}
}
public func correct(file: SwiftLintFile) -> [Correction] {
let duplicateImports = duplicateImports(file: file).reversed().filter {
file.ruleEnabled(violatingRange: $0.range, for: self) != nil
}
let violatingRanges = duplicateImports.map(\.range)
let correctedFileContents = violatingRanges.reduce(file.stringView.nsString) { contents, range in
contents.replacingCharacters(
in: range,
with: ""
).bridge()
}
file.write(correctedFileContents.bridge())
return duplicateImports.map { duplicateImport in
Correction(
ruleDescription: Self.description,
location: duplicateImport.location
)
} }
} }
} }
private typealias ImportSubpath = ArraySlice<String> private extension SyntaxProtocol {
func nearestIfConfigClause() -> IfConfigClauseSyntax? {
private struct ImportSlice { guard let parent = parent else {
enum ImportSliceType { return nil
/// For "import A.B.C" parent subpaths are ["A", "B"] and ["A"]
case parent
/// For "import A.B.C" complete subpath is ["A", "B", "C"]
case complete
}
let subpath: ImportSubpath
let type: ImportSliceType
}
private struct ImportLineSlice {
let slice: ImportSlice
let line: Line
}
private extension Line {
/// Returns name of the module being imported.
var importIdentifier: Substring? {
return self.content.split(separator: " ").last
}
/// For "import A.B.C" returns slices [["A", "B", "C"], ["A", "B"], ["A"]]
var importSlices: [ImportSlice] {
guard let importIdentifier = importIdentifier else { return [] }
let importedSubpathParts = importIdentifier.split(separator: ".").map { String($0) }
guard !importedSubpathParts.isEmpty else { return [] }
return [
ImportSlice(
subpath: importedSubpathParts[0..<importedSubpathParts.count],
type: .complete
)
] + (1..<importedSubpathParts.count).map {
ImportSlice(
subpath: importedSubpathParts[0..<importedSubpathParts.count - $0],
type: .parent
)
} }
return parent.as(IfConfigClauseSyntax.self) ?? parent.nearestIfConfigClause()
} }
} }

View File

@ -35,185 +35,13 @@ internal struct DuplicateImportsRuleExamples {
""") """)
] ]
static let triggeringExamples = Array(corrections.keys.sorted()) static let triggeringExamples = [
Example("""
import Foundation
import Dispatch
import Foundation
""")
]
static let corrections: [Example: Example] = { static let corrections: [Example: Example] = [:]
var corrections = [
Example("""
import Foundation
import Dispatch
import Foundation
"""): Example(
"""
import Foundation
import Dispatch
"""),
Example("""
import Foundation
import Foundation.NSString
"""): Example("""
import Foundation
"""),
Example("""
import Foundation.NSString
import Foundation
"""): Example("""
import Foundation
"""),
Example("""
@_implementationOnly import A
@_implementationOnly import A
"""): Example("""
@_implementationOnly import A
"""),
Example("""
@testable import A
@testable import A
"""): Example("""
@testable import A
"""),
Example("""
import A.B.C
import A.B
"""): Example("""
import A.B
"""),
Example("""
import A.B
import A.B.C
"""): Example("""
import A.B
"""),
Example("""
import A
#if DEBUG
@testable import KsApi
#else
import KsApi
#endif
import A
"""): Example("""
import A
#if DEBUG
@testable import KsApi
#else
import KsApi
#endif
"""),
Example("""
import Foundation
import Foundation
import Foundation
"""): Example("""
import Foundation
"""),
Example("""
import A.B.C
import A.B
import A
""", excludeFromDocumentation: true): Example("""
import A
"""),
Example("""
import A.B.C
import A.B.C.D
import A.B.C.E
""", excludeFromDocumentation: true): Example("""
import A.B.C
"""),
Example("""
import A.B.C
import A
import A.B
""", excludeFromDocumentation: true): Example("""
import A
"""),
Example("""
import A.B
import A
import A.B.C
""", excludeFromDocumentation: true): Example("""
import A
"""),
Example("""
import A
import A.B.C
import A.B
""", excludeFromDocumentation: true): Example("""
import A
""")
]
DuplicateImportsRule.importKinds.map { importKind in
Example("""
import A
import \(importKind) A.Foo
""")
}.forEach {
corrections[$0] = Example(
"""
import A
""")
}
DuplicateImportsRule.importKinds.map { importKind in
Example("""
import A
import \(importKind) A.B.Foo
""", excludeFromDocumentation: true)
}.forEach {
corrections[$0] = Example(
"""
import A
""")
}
DuplicateImportsRule.importKinds.map { importKind in
Example("""
import A.B
import \(importKind) A.B.Foo
""", excludeFromDocumentation: true)
}.forEach {
corrections[$0] = Example(
"""
import A.B
""")
}
return corrections
}()
} }