Skip to content

Ryu0118/swift-ast-lint

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

163 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

swift-ast-lint

Language Platform License

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.

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

  1. swiftastlinttool init scaffolds a Swift Package with a linter executable
  2. You write lint rules using SwiftSyntax in Sources/Rules/
  3. swift run swift-ast-lint ./Sources runs your rules against your code
  4. swift run swift-ast-lint --fix ./Sources auto-fixes what it can

Install

curl -fsSL https://raw.githubusercontent.com/Ryu0118/swift-ast-lint/main/install.sh | bash

Other methods

nest install Ryu0118/swift-ast-lint

Mise (jdx/mise)

mise use -g ubi:Ryu0118/swift-ast-lint

Build from source

Requires Swift 6.0+ and macOS 15+.

git clone https://github.com/Ryu0118/swift-ast-lint.git
cd swift-ast-lint
swift build

Writing 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.

# 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.md

Then 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.

Quick Start

# Scaffold a new linter project
swiftastlinttool init --path ./MyLinter --name MyLinter
cd MyLinter

Edit 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/Sources

Output (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)
}

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)
}

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 autofixes

Scaffolding tool

swiftastlinttool init --path ./MyLinter --name MyLinter  # non-interactive
swiftastlinttool init                                     # interactive mode

Configuration

.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 rule

Filter priority

Rules are filtered in this order:

  1. disabled_rules β€” rules listed here are skipped entirely
  2. included_paths / excluded_paths β€” project-wide file filtering
  3. Per-rule include / exclude in the rules: 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

About

πŸ”¨ Build your own Swift linter at the syntax level.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors