Build your own Swift linter at the syntax level.
Create project-specific lint rules programmatically in pure Swift. No YAML, no regex β just the full power of the AST.
SwiftLint is great for common coding style checks, but falls short when you need:
- Project-specific rules β Write rules in SwiftSyntax to enforce your team's architecture conventions and structural patterns that no generic linter covers.
- Complex structural checks β "Every public class over 50 lines must be in its own file" or "No force-try in production code" β rules that require understanding the code structure, not just matching text.
- AST-level precision β SwiftLint's custom rules are regex-based. Regex can't distinguish a function call from a comment, a type name from a variable. AST can.
With AI coding assistants, writing SwiftSyntax rules has become dramatically easier. Describe the pattern you want to catch in natural language, and your AI writes the rule. What used to require deep SwiftSyntax expertise is now a simple prompt away.
swiftastlinttool initscaffolds a Swift Package with a linter executable- You write lint rules using SwiftSyntax in
Sources/Rules/ swift run swift-ast-lint ./Sourcesruns your rules against your codeswift run swift-ast-lint --fix ./Sourcesauto-fixes what it can
curl -fsSL https://raw.githubusercontent.com/Ryu0118/swift-ast-lint/main/install.sh | bashNest (mtj0928/nest)
nest install Ryu0118/swift-ast-lintMise (jdx/mise)
mise use -g ubi:Ryu0118/swift-ast-lintRequires Swift 6.0+ and macOS 15+.
git clone https://github.com/Ryu0118/swift-ast-lint.git
cd swift-ast-lint
swift buildThe recommended way to add rules is with the rule-creator Agent Skill. Install it, then just describe the rule you want β your AI agent writes the SwiftSyntax code, adds it to the RuleSet, and creates tests.
# via skills CLI (https://github.com/vercel-labs/skills)
npx skills add Ryu0118/swift-ast-lint --skill rule-creator -g
# or download directly to ~/.agents/skills/ (Agent Skills standard)
mkdir -p ~/.agents/skills/rule-creator
curl -fsSL https://raw.githubusercontent.com/Ryu0118/swift-ast-lint/main/.agents/skills/rule-creator/SKILL.md \
-o ~/.agents/skills/rule-creator/SKILL.md
# for Claude Code: also install to ~/.claude/skills/
mkdir -p ~/.claude/skills/rule-creator
curl -fsSL https://raw.githubusercontent.com/Ryu0118/swift-ast-lint/main/.agents/skills/rule-creator/SKILL.md \
-o ~/.claude/skills/rule-creator/SKILL.mdThen tell your agent:
/rule-creator add a rule that detects control flow nested 4+ levels deep
The skill checks your project structure first β if you haven't scaffolded a linter project yet, it walks you through swiftastlinttool init before writing any code.
You can also write rules manually β see Rule API below.
# Scaffold a new linter project
swiftastlinttool init --path ./MyLinter --name MyLinter
cd MyLinterEdit Sources/Rules/Rules.swift:
import SwiftASTLint
import SwiftSyntax
public let rules = RuleSet {
Rule(id: "deep-nesting") { file, context in
checkNesting(in: Syntax(file), depth: 0, context: context)
}
}
private func checkNesting(in node: Syntax, depth: Int, context: LintContext) {
for child in node.children(viewMode: .sourceAccurate) {
let isControlFlow = child.is(IfExprSyntax.self)
|| child.is(GuardStmtSyntax.self)
|| child.is(ForStmtSyntax.self)
|| child.is(WhileStmtSyntax.self)
let newDepth = isControlFlow ? depth + 1 : depth
if isControlFlow, newDepth >= 4 {
context.report(
on: child,
message: "Control flow nested \(newDepth) levels deep. Extract a helper function.",
severity: .error,
)
}
checkNesting(in: child, depth: newDepth, context: context)
}
}Run:
swift run swift-ast-lint ../my-project/SourcesOutput (SwiftLint/Xcode compatible):
/path/to/File.swift:42:9: error: [deep-nesting] Control flow nested 4 levels deep. Extract a helper function.
Severity is specified per-report in the closure, not on the Rule itself:
Rule(id: "rule-id") { file, context in
context.report(on: someNode, message: "Description", severity: .warning)
}struct ThresholdArgs: Codable, Sendable {
var threshold: Int = 50
var severity: Severity = .warning
}
ParameterizedRule(id: "large-type", defaultArguments: ThresholdArgs()) { file, context, args in
// args.threshold and args.severity are overridable via YAML
context.report(on: node, message: "Type too large", severity: args.severity)
}Severity conforms to Codable, so it decodes directly from the YAML string "warning" or "error".
Rules can provide fix-its using SwiftSyntax's FixIt type. When the user runs --fix, these are applied automatically:
import SwiftDiagnostics
Rule(id: "var-to-let") { file, context in
for stmt in file.statements {
guard let varDecl = stmt.item.as(VariableDeclSyntax.self) else { continue }
let keyword = varDecl.bindingSpecifier
guard keyword.tokenKind == .keyword(.var) else { continue }
let newKeyword = keyword.with(\.tokenKind, .keyword(.let))
context.reportWithFix(
on: varDecl,
message: "Use let instead of var",
severity: .warning,
fixIts: [
FixIt.replace(
message: SimpleFixItMessage("Replace var with let"),
oldNode: keyword,
newNode: newKeyword,
),
],
)
}
}Rules without fix-its use context.report() as before β fully backward compatible.
public let rules = RuleSet {
myParameterizedRule
Rule(id: "simple") { file, ctx in ... }
}swift run swift-ast-lint # lint current directory
swift run swift-ast-lint ./Sources # lint specific directory
swift run swift-ast-lint ./Sources ./MyModule # multiple paths
swift run swift-ast-lint ./Sources --config custom.yml # custom config
swift run swift-ast-lint --fix ./Sources # apply autofixesswiftastlinttool init --path ./MyLinter --name MyLinter # non-interactive
swiftastlinttool init # interactive modeOptional YAML file for path filtering and per-rule configuration:
# Project-level path filtering
included_paths:
- "Sources/**/*.swift"
excluded_paths:
- "**/*Generated.swift"
- ".build/**"
# Disable specific rules entirely
disabled_rules:
- "no-force-try"
# Per-rule configuration
rules:
large-type:
args:
threshold: 30 # Override ParameterizedRule defaults
severity: error # "warning" or "error"
include:
- "Sources/**" # Only apply this rule to Sources/
exclude:
- "**/*Generated.swift" # Skip generated files for this ruleRules are filtered in this order:
disabled_rulesβ rules listed here are skipped entirelyincluded_paths/excluded_pathsβ project-wide file filtering- Per-rule
include/excludein therules:YAML section β per-rule file filtering
Each level can only narrow, never widen. Rules not listed in rules: apply to all files that pass step 2.
MIT