Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
52df372
Compare operands by token stream in identical_operands rule
Brett-Best Jul 16, 2026
22917cf
Cache allowedSymbolsAndAlphanumerics in NameConfiguration
Brett-Best Jul 16, 2026
10f9b74
Read comment ranges from token trivia in comment rules
Brett-Best Jul 16, 2026
1ed85f0
Reduce per-rule-per-file overhead in the lint hot path
Brett-Best Jul 16, 2026
1d85475
Return nil from firstIndexAssumingSorted when no element matches
Brett-Best Jul 16, 2026
bc50bfb
Free the remaining lint-mode rules from SourceKit
Brett-Best Jul 16, 2026
8bc9f1d
Run SwiftSyntax-mode custom rules without SourceKit
Brett-Best Jul 16, 2026
a2f8e50
Reduce per-file filesystem and gating overhead
Brett-Best Jul 16, 2026
ad07569
Avoid full-tree work in mark, file_length, and trailing_whitespace
Brett-Best Jul 16, 2026
9ffb4cb
Extend only line comments over their newline in the bridged syntax map
Brett-Best Jul 16, 2026
72b80ce
Cache comment byte ranges per file and compute them in one visitor pass
Brett-Best Jul 16, 2026
d8e491a
Memoize chain suffixes in multiline_function_chains
Brett-Best Jul 16, 2026
a31aabb
Run rules serially per file when many files are linted in parallel
Brett-Best Jul 16, 2026
bde3909
Skip rewriting in corrections when a file has no violations
Brett-Best Jul 16, 2026
ed757a0
Resolve self-linting violations
Brett-Best Jul 16, 2026
44f1c3e
Extend line comments over CRLF and CR line endings in the bridged syn…
Brett-Best Jul 16, 2026
eb6f1ba
Lint the largest files first in parallel runs
Brett-Best Jul 16, 2026
c1eb561
Add changelog entries
Brett-Best Jul 16, 2026
3760508
Replace the per-file cache's concurrent queue with a lock
Brett-Best Jul 17, 2026
2250872
Skip body-line analysis for bodies within the length threshold
Brett-Best Jul 17, 2026
7b43234
Speed up CommentLinesVisitor's per-token work
Brett-Best Jul 17, 2026
b9ec62c
Collect indentation_width's syntax data in a single walk
Brett-Best Jul 17, 2026
7eb4c8c
Cache source lines in vertical_parameter_alignment
Brett-Best Jul 17, 2026
08068d2
Precompute inclusive_language term lists once per file
Brett-Best Jul 17, 2026
b0757c6
Gate comment_spacing's regex behind a cheap pre-check
Brett-Best Jul 17, 2026
9e1f9bb
Optimize colon, comma, closure_end_indentation, redundant_self
Brett-Best Jul 17, 2026
f30cf50
Reduce per-rule violation post-processing in the lint loop
Brett-Best Jul 17, 2026
67a4157
Avoid recomputing the rule identifier in performLint
Brett-Best Jul 17, 2026
70cc45c
Read local_doc_comment's doc comments from token trivia
Brett-Best Jul 25, 2026
d395eb3
Gate object_literal and name validation on cheap pre-checks
Brett-Best Jul 25, 2026
c674158
Cache the SwiftSyntax-derived syntax map per file
Brett-Best Jul 25, 2026
eec7ebf
Check out with a blobless partial clone on CI
Brett-Best Jul 25, 2026
5a7b81f
Revert the pipeline-level git clone flag
Brett-Best Jul 25, 2026
d792f92
Clone oss-check repositories in parallel and narrowly
Brett-Best Jul 25, 2026
c9fb020
Read local_doc_comment's ranges from the shared comment pass
Brett-Best Jul 25, 2026
30755b9
Cache source lines in collection_alignment
Brett-Best Jul 25, 2026
98c8098
Resolve collection_alignment's grapheme columns on demand
Brett-Best Jul 25, 2026
9adfb82
Cache source lines in CommandVisitor
Brett-Best Jul 25, 2026
d65e479
Resolve vertical whitespace rules' kinds without the classifier
Brett-Best Jul 25, 2026
e3ee8d4
Encode the JSON report with JSONEncoder
Brett-Best Jul 25, 2026
d71d541
Encode the JSON report in parallel chunks
Brett-Best Jul 25, 2026
a47e9d7
Default custom rules to SwiftSyntax mode
Brett-Best Jul 25, 2026
bd5d7de
Resolve the working directory once per report
Brett-Best Jul 25, 2026
446273b
Add Brigade to the oss-check repositories
Brett-Best Jul 25, 2026
a54a7c0
Bound how deeply the parser may nest
Brett-Best Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# Checkout tuning cannot live here: `BUILDKITE_GIT_*` are protected variables, and setting them
# in pipeline `env` is ignored with a warning. They have to be set in the agent configuration.
#
# Worth doing there: `git-clone-flags: -v --filter=blob:none`. This repository carries ~965 MB of
# history that is almost entirely historical blobs no job reads, and each of the jobs below checks
# out independently; a blobless clone measured 5s/15 MB against 104s/965 MB for a full one, and
# still holds only 16 MB after oss-check's merge-base, diff and worktree operations.
#
# It must stay a blob filter rather than a `--depth` shallow clone: `tools/oss-check` needs real
# history for `git merge-base origin/main` and `git worktree add --detach` on that merge base, as
# does Danger's PR diff.

steps:
- label: "Bazel"
commands:
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@

### Enhancements

* Significantly reduce lint and correction runtimes: lint runs no longer
launch sourcekitd (the remaining lint-mode rules were migrated onto
SwiftSyntax-derived syntax information), custom rules honor
`execution_mode: swiftsyntax`, rewriter-based corrections skip files
without violations, and numerous per-rule and per-file hot paths were
optimized. SwiftLint's OSS-check suite lints 9-35% faster end to end.
[Brett-Best](https://github.com/Brett-Best)
[#6826](https://github.com/realm/SwiftLint/pull/6826)

* Add `#examples` and `#corrections` macros that expand lists and
dictionaries of code strings into `Example`s, reducing boilerplate when
defining a rule's triggering/non-triggering examples and corrections. Adopt
Expand All @@ -22,6 +31,13 @@

### Bug Fixes

* Fix `execution_mode`/`default_execution_mode` of custom rules being ignored during
linting and dropped when merging parent and child configurations, and fix
`RandomAccessCollection.firstIndexAssumingSorted(where:)` returning `count`
instead of `nil` when no element matches the predicate.
[Brett-Best](https://github.com/Brett-Best)
[#6826](https://github.com/realm/SwiftLint/pull/6826)

* Fix baseline writing to store file locations as paths relative to the current working directory,
restoring baseline portability and avoiding absolute `file://` paths in generated baseline files.
[SimplyDanny](https://github.com/SimplyDanny)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,39 @@ struct ObjectLiteralRule: Rule {

private extension ObjectLiteralRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
// Only `X(...)` and `X.init(...)` can match, so the identifier naming `X` is all that has
// to be looked at to rule a call out. Every other call skips `trimmedDescription`, which
// rebuilds the called expression's subtree just to render it as a string.
private static let imageClasses: Set<String> = ["UIImage", "NSImage"]
private static let colorClasses: Set<String> = ["UIColor", "NSColor"]
private static let imageInits = inits(forClasses: imageClasses)
private static let colorInits = inits(forClasses: colorClasses)

override func visitPost(_ node: FunctionCallExprSyntax) {
guard configuration.colorLiteral || configuration.imageLiteral else {
let checkImage = configuration.imageLiteral
let checkColor = configuration.colorLiteral
guard checkImage || checkColor else {
return
}
guard let baseName = node.calledExpression.baseIdentifier else {
return
}
let couldBeImage = checkImage && Self.imageClasses.contains(baseName)
let couldBeColor = checkColor && Self.colorClasses.contains(baseName)
guard couldBeImage || couldBeColor else {
return
}

let name = node.calledExpression.trimmedDescription
if configuration.imageLiteral, isImageNamedInit(node: node, name: name) {
if couldBeImage, isImageNamedInit(node: node, name: name) {
violations.append(node.positionAfterSkippingLeadingTrivia)
} else if configuration.colorLiteral, isColorInit(node: node, name: name) {
} else if couldBeColor, isColorInit(node: node, name: name) {
violations.append(node.positionAfterSkippingLeadingTrivia)
}
}

private func isImageNamedInit(node: FunctionCallExprSyntax, name: String) -> Bool {
guard inits(forClasses: ["UIImage", "NSImage"]).contains(name),
guard Self.imageInits.contains(name),
node.arguments.compactMap(\.label?.text) == ["named"],
let argument = node.arguments.first?.expression.as(StringLiteralExprSyntax.self),
argument.isConstantString else {
Expand All @@ -60,7 +78,7 @@ private extension ObjectLiteralRule {
}

private func isColorInit(node: FunctionCallExprSyntax, name: String) -> Bool {
guard inits(forClasses: ["UIColor", "NSColor"]).contains(name),
guard Self.colorInits.contains(name),
case let argumentsNames = node.arguments.compactMap(\.label?.text),
argumentsNames == ["red", "green", "blue", "alpha"] || argumentsNames == ["white", "alpha"] else {
return false
Expand All @@ -69,13 +87,13 @@ private extension ObjectLiteralRule {
return node.arguments.allSatisfy(\.expression.canBeExpressedAsColorLiteralParams)
}

private func inits(forClasses names: [String]) -> [String] {
names.flatMap { name in
private static func inits(forClasses names: Set<String>) -> Set<String> {
Set(names.flatMap { name in
[
name,
name + ".init",
]
}
})
}
}
}
Expand All @@ -87,6 +105,21 @@ private extension StringLiteralExprSyntax {
}

private extension ExprSyntax {
/// The identifier naming the callee for the `X` and `X.init` forms, or `nil` for any other
/// expression. Deliberately matches more than the rule accepts — `X.other` and `X . init`
/// resolve to `X` here and are rejected later by the exact name comparison — so that using it
/// to skip work cannot hide a violation.
var baseIdentifier: String? {
if let declReference = `as`(DeclReferenceExprSyntax.self) {
return declReference.baseName.text
}
if let memberAccess = `as`(MemberAccessExprSyntax.self),
let base = memberAccess.base?.as(DeclReferenceExprSyntax.self) {
return base.baseName.text
}
return nil
}

var canBeExpressedAsColorLiteralParams: Bool {
if `is`(FloatLiteralExprSyntax.self) ||
`is`(IntegerLiteralExprSyntax.self) ||
Expand Down
109 changes: 82 additions & 27 deletions Source/SwiftLintBuiltInRules/Rules/Lint/CommentSpacingRule.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Foundation
import SourceKittenFramework
import SwiftIDEUtils
import SwiftLintCore

struct CommentSpacingRule: SourceKitFreeRule, SubstitutionCorrectableRule {
Expand Down Expand Up @@ -120,33 +119,40 @@ struct CommentSpacingRule: SourceKitFreeRule, SubstitutionCorrectableRule {

func violationRanges(in file: SwiftLintFile) -> [NSRange] {
// Find all comment tokens in the file and regex search them for violations
file.syntaxClassifications
.filter(\.kind.isComment)
.map { $0.range.toSourceKittenByteRange() }
.compactMap { (range: ByteRange) -> [NSRange]? in
file.stringView
.substringWithByteRange(range)
.map(StringView.init)
.map { commentBody in
// Look for 2+ slash characters followed immediately by
// a non-colon, non-whitespace character or by a colon
// followed by a non-whitespace character other than #
regex(#"^(?:\/){2,}+(?:[^\s:]|:[^\s#])"#).matches(in: commentBody, options: .anchored)
.compactMap { result in
// Set the location to be directly before the first non-slash,
// non-whitespace character which was matched
file.stringView.byteRangeToNSRange(
ByteRange(
// Safe to mix NSRange offsets with byte offsets here because the regex can't
// contain multi-byte characters
location: ByteCount(range.lowerBound.value + result.range.upperBound - 1),
length: 0
)
)
}
}
let commentRanges = file.commentByteRanges()
if commentRanges.isEmpty {
return []
}
// Hoisted out of the loop: every `stringView` access synchronizes on a queue in SourceKitten.
let stringView = file.stringView
var violations = [NSRange]()
for range in commentRanges {
guard let commentNSRange = stringView.byteRangeToNSRange(range),
Self.mayViolate(commentAt: commentNSRange, in: stringView.nsString) else {
continue
}
// Look for 2+ slash characters followed immediately by
// a non-colon, non-whitespace character or by a colon
// followed by a non-whitespace character other than #
let matches = Self.violationRegex.matches(in: stringView, options: .anchored, range: commentNSRange)
for result in matches {
// Set the location to be directly before the first non-slash,
// non-whitespace character which was matched
let utf16OffsetInComment = result.range.upperBound - commentNSRange.location
let violationRange = stringView.byteRangeToNSRange(
ByteRange(
// Safe to mix NSRange offsets with byte offsets here because the regex can't
// contain multi-byte characters
location: ByteCount(range.lowerBound.value + utf16OffsetInComment - 1),
length: 0
)
)
if let violationRange {
violations.append(violationRange)
}
}
.flatMap(\.self)
}
return violations
}

func validate(file: SwiftLintFile) -> [StyleViolation] {
Expand All @@ -162,4 +168,53 @@ struct CommentSpacingRule: SourceKitFreeRule, SubstitutionCorrectableRule {
func substitution(for violationRange: NSRange, in _: SwiftLintFile) -> (NSRange, String)? {
(violationRange, " ")
}

// Matches 2+ slash characters followed immediately by a non-colon, non-whitespace character
// or by a colon followed by a non-whitespace character other than #.
private static let violationRegex = regex(#"^(?:\/){2,}+(?:[^\s:]|:[^\s#])"#)

// Cheaply decides from a comment's first UTF-16 units whether `violationRegex` could possibly
// match it, so that the regex only runs on candidate comments. Returns `false` only when a
// match is provably impossible: block comments, all-slash lines, and slashes followed by
// ASCII whitespace (or by a colon and then ASCII whitespace or `#`). Non-ASCII characters
// conservatively return `true`, deferring Unicode whitespace semantics to the regex.
//
// Takes an `NSString` because `character(at:)` reads raw UTF-16 units by integer offset in
// O(1), which `String` cannot do.
// swiftlint:disable:next legacy_objc_type
private static func mayViolate(commentAt nsRange: NSRange, in nsString: NSString) -> Bool {
let slash: unichar = 0x2F // "/"
// A match needs at least two slashes and one more character.
guard nsRange.length >= 3,
nsString.character(at: nsRange.location) == slash,
nsString.character(at: nsRange.location + 1) == slash else {
return false
}
let end = NSMaxRange(nsRange)
var index = nsRange.location + 2
while index < end, nsString.character(at: index) == slash {
index += 1
}
guard index < end else {
return false // A comment consisting solely of slashes, like a box drawn around text.
}
var unit = nsString.character(at: index)
if unit == 0x3A { // ":"
index += 1
guard index < end else {
return false
}
unit = nsString.character(at: index)
if unit == 0x23 { // "#"
return false
}
}
return !isASCIIWhitespace(unit)
}

/// Whether the given UTF-16 unit is an ASCII whitespace character. All of these are matched by
/// the regex character class `\s`.
private static func isASCIIWhitespace(_ unit: unichar) -> Bool {
unit == 0x20 || (0x09...0x0D).contains(unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,26 @@ private extension IdenticalOperandsRule {
return
}

if node.leftOperand.normalizedDescription == node.rightOperand.normalizedDescription {
if node.leftOperand.isStructurallyIdentical(to: node.rightOperand) {
violations.append(node.leftOperand.positionAfterSkippingLeadingTrivia)
}
}
}
}

private extension ExprSyntax {
var normalizedDescription: String {
debugDescription(includeTrivia: false)
func isStructurallyIdentical(to other: ExprSyntax) -> Bool {
var lhsTokens = tokens(viewMode: .sourceAccurate).makeIterator()
var rhsTokens = other.tokens(viewMode: .sourceAccurate).makeIterator()
while true {
switch (lhsTokens.next(), rhsTokens.next()) {
case (nil, nil):
return true
case let (lhsToken?, rhsToken?) where lhsToken.tokenKind == rhsToken.tokenKind:
continue
default:
return false
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import SwiftIDEUtils
import SwiftLintCore
import SwiftSyntax

Expand Down Expand Up @@ -42,10 +41,13 @@ struct LocalDocCommentRule: SwiftSyntaxRule, OptInRule {
)

func makeVisitor(file: SwiftLintFile) -> ViolationsSyntaxVisitor<ConfigurationType> {
// Doc comment ranges come from the file's shared comment trivia pass, which several other
// rules already force, rather than from `syntaxClassifications`, which would additionally
// run SwiftSyntax's general-purpose classifier over every token in the file.
Visitor(
configuration: configuration,
file: file,
classifications: file.syntaxClassifications.filter { $0.kind != .none }
docCommentRanges: file.docCommentRanges()
)
}
}
Expand All @@ -56,10 +58,8 @@ private extension LocalDocCommentRule {

init(configuration: ConfigurationType,
file: SwiftLintFile,
classifications: [SyntaxClassifiedRange]) {
self.docCommentRanges = classifications
.filter { $0.kind == .docLineComment || $0.kind == .docBlockComment }
.map(\.range)
docCommentRanges: [Range<AbsolutePosition>]) {
self.docCommentRanges = docCommentRanges
super.init(configuration: configuration, file: file)
}

Expand Down
13 changes: 10 additions & 3 deletions Source/SwiftLintBuiltInRules/Rules/Lint/MarkRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ private struct ViolationResult {
private extension TokenSyntax {
private enum Mark {
static func lint(in text: String) -> [() -> String] {
regex(badPattern).matches(in: text, options: [], range: text.fullNSRange).compactMap { match in
// Every match of `badPattern` contains "MARK", "Mark" or "mark", so a comment without
// "ARK"/"ark" cannot violate. This cheap check avoids running the regex on most comments.
guard text.contains("ark") || text.contains("ARK") else {
return []
}
return regex(badPattern).matches(in: text, options: [], range: text.fullNSRange).compactMap { match in
isIgnoredCases(text, range: match.range) ? nil : {
var corrected = replace(text, range: match.range(at: 2), to: "- ")
corrected = replace(corrected, range: match.range(at: 1), to: "// MARK: ")
Expand Down Expand Up @@ -112,8 +117,10 @@ private extension TokenSyntax {
var utf8Offset = 0
var results: [ViolationResult] = []

for index in leadingTrivia.pieces.indices {
let piece = leadingTrivia.pieces[index]
// Materialize the leading trivia only once; every access to `leadingTrivia` re-parses
// and re-allocates all trivia pieces of the token.
let triviaPieces = leadingTrivia.pieces
for (index, piece) in triviaPieces.enumerated() {
defer { utf8Offset += piece.sourceLength.utf8Length }

switch piece {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Foundation
import SourceKittenFramework
import SwiftIDEUtils
import SwiftLintCore

struct PeriodSpacingRule: SourceKitFreeRule, OptInRule, SubstitutionCorrectableRule {
Expand Down Expand Up @@ -50,9 +49,7 @@ struct PeriodSpacingRule: SourceKitFreeRule, OptInRule, SubstitutionCorrectableR

func violationRanges(in file: SwiftLintFile) -> [NSRange] {
// Find all comment tokens in the file and regex search them for violations
file.syntaxClassifications
.filter(\.kind.isComment)
.map { $0.range.toSourceKittenByteRange() }
file.commentByteRanges()
.compactMap { (range: ByteRange) -> [NSRange]? in
file.stringView
.substringWithByteRange(range)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,24 @@ struct ClosureBodyLengthRule: Rule {

private extension ClosureBodyLengthRule {
final class Visitor: BodyLengthVisitor<ConfigurationType> {
private lazy var skipThreshold: Int = {
let severityConfiguration = configuration.severityConfiguration
return min(severityConfiguration.warning, severityConfiguration.error ?? severityConfiguration.warning)
}()

override func visitPost(_ node: ClosureExprSyntax) {
let leftBraceLine = locationConverter.location(for: node.leftBrace.positionAfterSkippingLeadingTrivia).line
let rightBraceLine = locationConverter.location(
for: node.rightBrace.positionAfterSkippingLeadingTrivia
).line
// The body line count ignoring comments and whitespace can never exceed the physical number of
// lines spanned by the braces. Skip the expensive body line computation when even that upper
// bound cannot trigger a violation.
let startLine = min(leftBraceLine + 1, rightBraceLine - 1)
let endLine = max(rightBraceLine - 1, leftBraceLine + 1)
if 1 + endLine - startLine <= skipThreshold {
return
}
registerViolations(
leftBrace: node.leftBrace,
rightBrace: node.rightBrace,
Expand Down
Loading