ryu0118/swift-ast-lint
Build your own Swift linter at the syntax level.
Motivation
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.
How It Works
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
Install
curl -fsSL https://raw.githubusercontent.com/Ryu0118/swift-ast-lint/main/install.sh | bashOther methods
Nest (mtj0928/nest)
nest install Ryu0118/swift-ast-lintMise (jdx/mise)
mise use -g github:Ryu0118/swift-ast-lintBuild from source
Requires Swift 6.0+ and macOS 15+.
git clone https://github.com/Ryu0118/swift-ast-lint.git
cd swift-ast-lint
swift buildWriting Rules with Agent Skills
The 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.
These commands install the agent skill/plugin metadata, not the swiftastlinttool binary. Install the binary separately with curl, Nest, mise, or from source.
Claude Code
/plugin marketplace add Ryu0118/swift-ast-lint
/plugin install swift-ast-lint@swift-ast-lintCodex
Add the marketplace, then install the plugin:
codex plugin marketplace add Ryu0118/swift-ast-lint
codex plugin add swift-ast-lint@swift-ast-lintTo develop against a local clone instead, point the marketplace at the checkout:
git clone https://github.com/Ryu0118/swift-ast-lint
codex plugin marketplace add ./swift-ast-lint
codex plugin add swift-ast-lint@swift-ast-lintAPM (Agent Package Manager)
With APM, one command installs the skill into any supported harness (Claude Code, Copilot, Cursor, Codex, and more) and pins it in apm.lock.yaml:
apm install Ryu0118/swift-ast-lintGitHub CLI (gh skill)
GitHub CLI v2.90.0+ ships a gh skill command (alias: gh skills). It pins to the latest release tag and records provenance (repo, ref, tree SHA) in the installed SKILL.md:
gh skill install Ryu0118/swift-ast-lint rule-creator --agent claude-codeRun gh skill install Ryu0118/swift-ast-lint without a skill name for interactive selection, and use --agent / --scope to control where skills land.
skills CLI (npx skills)
The skills CLI installs into the shared .agents/skills/ directory used by many agents:
npx skills add Ryu0118/swift-ast-lint --allUse --list to inspect available skills first, or -a claude-code to target a specific agent.
Then tell your agent:
/rule-creator add a rule that detects control flow nested 4+ levels deepThe 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.
Quick Start
# 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.Rule API
Rule (no arguments)
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)
}An optional description summarizes what the rule detects. It is surfaced by the rules subcommand:
Rule(id: "rule-id", description: "Flags force-unwrapped optionals") { file, context in
// ...
}ParameterizedRule (YAML-configurable arguments)
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)
}ParameterizedRule accepts the same optional description parameter as Rule.
Severity conforms to Codable, so it decodes directly from the YAML string "warning" or "error".
Rules with autofix
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.
RuleSet
public let rules = RuleSet {
myParameterizedRule
Rule(id: "simple") { file, ctx in ... }
}CLI Usage
Linter (user-side executable)
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 autofixesLinting is the default subcommand (swift-ast-lint lint ./Sources is equivalent). Note: a first argument named exactly like a subcommand (rules, lint) dispatches to that subcommand — lint a directory with that name via an explicit path (./rules).
Listing rules (rules subcommand)
rules enumerates every registered rule together with its configuration, resolved against .swift-ast-lint.yml (or --config <path>). The default output is a stable JSON document — machine-readable first, so agents and tooling can discover a linter's rules, their default and effective arguments, per-rule path scoping, and disabled state without reading the linter's source:
swift run swift-ast-lint rules # JSON (default)
swift run swift-ast-lint rules --format text # human-readable{
"config_path" : ".swift-ast-lint.yml",
"rules" : [
{
"id" : "large-type",
"description" : "Flags oversized type declarations",
"parameterized" : true,
"enabled" : true,
"default_args" : { "threshold" : 50 },
"effective_args" : { "threshold" : 30 },
"include" : [ "Sources/**" ],
"exclude" : [ "**/*Generated.swift" ]
}
]
}config_path is null and every rule reports defaults when no config file is found. Rules appear in RuleSet registration order; object keys are sorted for stable diffs.
Scaffolding tool
swiftastlinttool init --path ./MyLinter --name MyLinter # non-interactive
swiftastlinttool init # interactive modeConfiguration
.swift-ast-lint.yml
Optional 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 ruleFilter priority
Rules 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.
License
MIT
Package Metadata
Repository: ryu0118/swift-ast-lint
Default branch: main
README: README.md