From 6394d5cf6c88eeb0239927159ad3758c9dfc46ed Mon Sep 17 00:00:00 2001 From: Kaan Biryol Date: Mon, 4 May 2026 21:35:02 +0200 Subject: [PATCH] feat: add swiftlint support --- .gitignore | 1 + README.md | 5 +- examples/README.md | 1 + examples/swift/.bazelrc | 5 + examples/swift/.gitattributes | 3 + examples/swift/.swiftlint.yml | 4 + examples/swift/BUILD | 6 + examples/swift/MODULE.aspect | 7 + examples/swift/MODULE.bazel | 7 +- examples/swift/README.md | 76 +++- examples/swift/SwiftLintBaseline.json | 13 + examples/swift/src/BUILD | 53 ++- examples/swift/src/formatme.swift | 3 + .../swift/src/{hello.swift => lintme.swift} | 3 +- examples/swift/src/nested/.swiftlint.yml | 4 + .../swift/src/nested/deeper/.swiftlint.yml | 2 + .../swift/src/nested/deeper/nearest.swift | 2 + examples/swift/src/nested/lintme.swift | 2 + examples/swift/src/nested_fix/.swiftlint.yml | 2 + examples/swift/src/nested_fix/lintme.swift | 5 + examples/swift/test/BUILD | 84 ++++ examples/swift/test/lint_test.bats | 60 +++ examples/swift/test/machine_output.bzl | 9 + examples/swift/tools/lint/BUILD | 6 + examples/swift/tools/lint/linters.bzl | 74 ++++ format/test/format_test.bats | 2 +- lint/BUILD.bazel | 14 +- lint/empty_swiftlint.yml | 0 lint/extensions.bzl | 17 + lint/lint.axl | 8 +- lint/swiftlint.bzl | 398 ++++++++++++++++++ 31 files changed, 859 insertions(+), 17 deletions(-) create mode 100644 examples/swift/.bazelrc create mode 100644 examples/swift/.gitattributes create mode 100644 examples/swift/.swiftlint.yml create mode 100644 examples/swift/BUILD create mode 100644 examples/swift/MODULE.aspect create mode 100644 examples/swift/SwiftLintBaseline.json create mode 100644 examples/swift/src/formatme.swift rename examples/swift/src/{hello.swift => lintme.swift} (72%) create mode 100644 examples/swift/src/nested/.swiftlint.yml create mode 100644 examples/swift/src/nested/deeper/.swiftlint.yml create mode 100644 examples/swift/src/nested/deeper/nearest.swift create mode 100644 examples/swift/src/nested/lintme.swift create mode 100644 examples/swift/src/nested_fix/.swiftlint.yml create mode 100644 examples/swift/src/nested_fix/lintme.swift create mode 100644 examples/swift/test/BUILD create mode 100644 examples/swift/test/lint_test.bats create mode 100644 examples/swift/test/machine_output.bzl create mode 100644 examples/swift/tools/lint/BUILD create mode 100644 examples/swift/tools/lint/linters.bzl create mode 100644 lint/empty_swiftlint.yml create mode 100644 lint/swiftlint.bzl diff --git a/.gitignore b/.gitignore index 2856b1f79..df7411d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bazel-* +.DS_Store .bazelrc.user .idea/ .ijwb/ diff --git a/README.md b/README.md index 12ccaa7d6..d5d8029fb 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,14 @@ Linters which are not language-specific: | Pkl | [pkl] | | | Protocol Buffer | [buf] | [buf lint] | | Python | [ruff] | [bandit], [flake8], [pydoclint], [pylint], [ruff], [ty] | -| QML | [qmlformat] | [qmllint] | +| QML | [qmlformat] | [qmllint] | | Ruby | | [RuboCop], [Standard] | | Rust | [rustfmt] | [clippy] | | SQL | [prettier-plugin-sql] | | | Scala | [scalafmt] | | | Shell | [shfmt] | [shellcheck] | | Starlark | [Buildifier] | [Buildifier] | -| Swift | [SwiftFormat] (1) | | +| Swift | [SwiftFormat] (1) | [SwiftLint] (1) | | TOML | [taplo] | | | TSX | [Prettier] | [ESLint] | | TypeScript | [Prettier] | [ESLint] | @@ -75,6 +75,7 @@ Linters which are not language-specific: [buf lint]: https://buf.build/docs/lint/overview [eslint]: https://eslint.org/ [swiftformat]: https://github.com/nicklockwood/SwiftFormat +[swiftlint]: https://github.com/realm/SwiftLint [terraform]: https://github.com/hashicorp/terraform [buf]: https://docs.buf.build/format/usage [keep-sorted]: https://github.com/google/keep-sorted diff --git a/examples/README.md b/examples/README.md index 36e0d87f1..36dfc86d6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ Each example is self-contained and shows: - `proto/` - Protocol Buffer formatting and linting with Buf - `starlark/` - Starlark formatting with Buildifier; linting with Buildifier - `qml/` - QML formatting with qmlformat; linting with qmllint +- `swift/` - Swift formatting with SwiftFormat; linting with SwiftLint ## Multi-language Example diff --git a/examples/swift/.bazelrc b/examples/swift/.bazelrc new file mode 100644 index 000000000..a9b7fecf9 --- /dev/null +++ b/examples/swift/.bazelrc @@ -0,0 +1,5 @@ +build:lint --aspects=//tools/lint:linters.bzl%swiftlint +build:lint --aspects=//tools/lint:linters.bzl%swiftlint_nested_config +build:lint --aspects=//tools/lint:linters.bzl%swiftlint_nested_deeper_nearest_config +build:lint --aspects=//tools/lint:linters.bzl%swiftlint_nested_without_declared_config +build:lint --aspects=//tools/lint:linters.bzl%swiftlint_nested_fix_config diff --git a/examples/swift/.gitattributes b/examples/swift/.gitattributes new file mode 100644 index 000000000..ec1073899 --- /dev/null +++ b/examples/swift/.gitattributes @@ -0,0 +1,3 @@ +# Keep the intentional SwiftLint fixtures stable when running aspect format. +src/lintme.swift rules-lint-ignored +src/nested_fix/lintme.swift rules-lint-ignored diff --git a/examples/swift/.swiftlint.yml b/examples/swift/.swiftlint.yml new file mode 100644 index 000000000..7aae82f64 --- /dev/null +++ b/examples/swift/.swiftlint.yml @@ -0,0 +1,4 @@ +strict: true + +only_rules: + - function_name_whitespace diff --git a/examples/swift/BUILD b/examples/swift/BUILD new file mode 100644 index 000000000..2711e8a15 --- /dev/null +++ b/examples/swift/BUILD @@ -0,0 +1,6 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files([ + ".swiftlint.yml", + "SwiftLintBaseline.json", +]) diff --git a/examples/swift/MODULE.aspect b/examples/swift/MODULE.aspect new file mode 100644 index 000000000..e0f453dde --- /dev/null +++ b/examples/swift/MODULE.aspect @@ -0,0 +1,7 @@ +# Aspect Extension setup for CLI "lint" and "format" commands +# This example is in rules_lint, so we use a local dependency. +axl_local_dep( + name = "rules_lint", + path = "../..", + auto_use_tasks = True, +) diff --git a/examples/swift/MODULE.bazel b/examples/swift/MODULE.bazel index cf70a465d..b5a468e9d 100644 --- a/examples/swift/MODULE.bazel +++ b/examples/swift/MODULE.bazel @@ -1,4 +1,4 @@ -"Bazel dependencies for Swift formatting example" +"Bazel dependencies for Swift formatting and linting example" bazel_dep(name = "aspect_rules_lint") local_path_override( @@ -10,3 +10,8 @@ local_path_override( format_tools = use_extension("@aspect_rules_lint//format:extensions.bzl", "tools") format_tools.swiftformat() use_repo(format_tools, "swiftformat", "swiftformat_mac") + +# Swift linter tools +lint_tools = use_extension("@aspect_rules_lint//lint:extensions.bzl", "tools") +lint_tools.swiftlint() +use_repo(lint_tools, "swiftlint_binary") diff --git a/examples/swift/README.md b/examples/swift/README.md index 63686fb20..7d9cb1bb1 100644 --- a/examples/swift/README.md +++ b/examples/swift/README.md @@ -1,6 +1,6 @@ -# Swift Formatting Example +# Swift Formatting and Linting Example -This example demonstrates how to set up formatting for Swift code using `rules_lint`. +This example demonstrates how to set up formatting and linting for Swift code using `rules_lint`. ## Supported Tools @@ -8,19 +8,81 @@ This example demonstrates how to set up formatting for Swift code using `rules_l - **SwiftFormat** - Code formatter for Swift -Note: No Swift linter is currently available in rules_lint. +### Linters + +- **SwiftLint** - Linter for Swift ## Setup 1. Configure MODULE.bazel with required dependencies 2. Create the MODULE.aspect file to register CLI tasks 3. Configure Format Tools (add swiftformat) -4. Configure Formatters +4. Configure Lint Tools (add swiftlint) +5. Configure Formatters and Linters + +- See `tools/format/BUILD` for how to set up the formatter +- See `tools/lint/linters.bzl` for how to set up the linter aspect + +6. Perform formatting and linting using `aspect format` and `aspect lint` + +## SwiftLint Configuration + +SwiftLint policy such as enabled rules, severity, and human reporter should +live in `.swiftlint.yml`. Bazel target membership determines which files the +aspect lints. SwiftLint `excluded` entries are still honored, but `included` +entries should not be used to narrow explicitly listed Bazel source files. + +The aspect only needs the binary and config labels for a typical setup: -- See `tools/format/BUILD.bazel` for how to set up the formatter +```starlark +swiftlint = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], +) +``` -5. Perform formatting using `aspect format` +For target-specific nested `.swiftlint.yml` files, declare the main config +first, then the nested configs, and set `config_mode = "nested"`: + +```starlark +swiftlint = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [ + Label("//:.swiftlint.yml"), + Label("//src:nested/.swiftlint.yml"), + ], + config_mode = "nested", +) +``` + +rules_lint selects the deepest declared nested config that contains all Swift +sources in the target and passes only the main config plus that nearest nested +config to SwiftLint with `--config`. This mirrors [SwiftLint's default nested +configuration behavior](https://github.com/realm/SwiftLint#nested-configurations); +intermediate ancestor configs are not accumulated for deeper files. SwiftLint +does not auto-discover repository config files at execution time. + +Declare SwiftLint config hierarchy files in `configs`; prefer the aspect's +`baseline` argument over a `baseline` entry in `.swiftlint.yml`: + +```starlark +swiftlint_with_baseline = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], + baseline = Label("//:SwiftLintBaseline.json"), +) +``` ## Example Code -See `src/hello.swift` for a simple example Swift file. +See `src/formatme.swift` for a simple Swift file that SwiftFormat can format. +See `src/lintme.swift` for a separate Swift file with an intentional SwiftLint +violation. That lint fixture is marked `rules-lint-ignored` in `.gitattributes` +so formatting the example does not erase the lint demo. + +## Configuration Files + +- `.swiftlint.yml` - SwiftLint configuration for the example +- `src/nested/.swiftlint.yml` - nested SwiftLint configuration fixture +- `src/nested/deeper/.swiftlint.yml` - nearest nested configuration fixture +- `SwiftLintBaseline.json` - SwiftLint baseline used by the integration test diff --git a/examples/swift/SwiftLintBaseline.json b/examples/swift/SwiftLintBaseline.json new file mode 100644 index 000000000..8d70ce019 --- /dev/null +++ b/examples/swift/SwiftLintBaseline.json @@ -0,0 +1,13 @@ +[ + { + "violation": { + "ruleIdentifier": "function_name_whitespace", + "severity": "error", + "reason": "Too many spaces between 'func' and function name", + "ruleDescription": "There should be consistent whitespace before and after function names and generic parameters.", + "ruleName": "Function Name Whitespace", + "location": { "file": "src/lintme.swift", "character": 9, "line": 2 } + }, + "text": " func printme() {" + } +] diff --git a/examples/swift/src/BUILD b/examples/swift/src/BUILD index 44b05de68..772c29e90 100644 --- a/examples/swift/src/BUILD +++ b/examples/swift/src/BUILD @@ -1,6 +1,55 @@ package(default_visibility = ["//visibility:public"]) +exports_files([ + "nested/.swiftlint.yml", + "nested/deeper/.swiftlint.yml", + "nested_fix/.swiftlint.yml", +]) + +filegroup( + name = "lint", + srcs = ["lintme.swift"], + tags = ["swift"], +) + +filegroup( + name = "baseline", + srcs = ["lintme.swift"], + tags = ["swift-baseline"], +) + +filegroup( + name = "default_config", + srcs = ["lintme.swift"], + tags = ["swift-default-config"], +) + +filegroup( + name = "verbose", + srcs = ["lintme.swift"], + tags = ["swift-verbose"], +) + +filegroup( + name = "nested_config", + srcs = ["nested/lintme.swift"], + tags = ["swift-nested-config"], +) + +filegroup( + name = "nested_deeper_nearest_config", + srcs = ["nested/deeper/nearest.swift"], + tags = ["swift-nested-deeper-nearest-config"], +) + +filegroup( + name = "nested_without_declared_config", + srcs = ["nested/lintme.swift"], + tags = ["swift-nested-without-declared-config"], +) + filegroup( - name = "all", - srcs = ["hello.swift"], + name = "nested_fix_config", + srcs = ["nested_fix/lintme.swift"], + tags = ["swift-nested-fix-config"], ) diff --git a/examples/swift/src/formatme.swift b/examples/swift/src/formatme.swift new file mode 100644 index 000000000..6d771e471 --- /dev/null +++ b/examples/swift/src/formatme.swift @@ -0,0 +1,3 @@ +struct FormatDemo { + let message="Hello, World!" +} diff --git a/examples/swift/src/hello.swift b/examples/swift/src/lintme.swift similarity index 72% rename from examples/swift/src/hello.swift rename to examples/swift/src/lintme.swift index afd7905a6..4b7d6ccc4 100644 --- a/examples/swift/src/hello.swift +++ b/examples/swift/src/lintme.swift @@ -1,6 +1,5 @@ class Controller { - func printme() { + func printme() { print("Hello, World!") } } - diff --git a/examples/swift/src/nested/.swiftlint.yml b/examples/swift/src/nested/.swiftlint.yml new file mode 100644 index 000000000..3750217c3 --- /dev/null +++ b/examples/swift/src/nested/.swiftlint.yml @@ -0,0 +1,4 @@ +strict: true + +only_rules: + - force_unwrapping diff --git a/examples/swift/src/nested/deeper/.swiftlint.yml b/examples/swift/src/nested/deeper/.swiftlint.yml new file mode 100644 index 000000000..ebde81fc7 --- /dev/null +++ b/examples/swift/src/nested/deeper/.swiftlint.yml @@ -0,0 +1,2 @@ +only_rules: + - empty_count diff --git a/examples/swift/src/nested/deeper/nearest.swift b/examples/swift/src/nested/deeper/nearest.swift new file mode 100644 index 000000000..26d0a1d51 --- /dev/null +++ b/examples/swift/src/nested/deeper/nearest.swift @@ -0,0 +1,2 @@ +let name: String? = "swift" +print(name!) diff --git a/examples/swift/src/nested/lintme.swift b/examples/swift/src/nested/lintme.swift new file mode 100644 index 000000000..26d0a1d51 --- /dev/null +++ b/examples/swift/src/nested/lintme.swift @@ -0,0 +1,2 @@ +let name: String? = "swift" +print(name!) diff --git a/examples/swift/src/nested_fix/.swiftlint.yml b/examples/swift/src/nested_fix/.swiftlint.yml new file mode 100644 index 000000000..2f725eddc --- /dev/null +++ b/examples/swift/src/nested_fix/.swiftlint.yml @@ -0,0 +1,2 @@ +only_rules: + - function_name_whitespace diff --git a/examples/swift/src/nested_fix/lintme.swift b/examples/swift/src/nested_fix/lintme.swift new file mode 100644 index 000000000..f0f578d84 --- /dev/null +++ b/examples/swift/src/nested_fix/lintme.swift @@ -0,0 +1,5 @@ +class NestedFixController { + func printme() { + print("Hello, World!") + } +} diff --git a/examples/swift/test/BUILD b/examples/swift/test/BUILD new file mode 100644 index 000000000..9e839229d --- /dev/null +++ b/examples/swift/test/BUILD @@ -0,0 +1,84 @@ +"Demonstrates how to enforce zero-lint-tolerance policy with tests" + +# buildifier: disable=bzl-visibility +load("@aspect_rules_lint//lint/private:machine_report_testing.bzl", "report_test") +load("//test:machine_output.bzl", "machine_swiftlint_nested_config_report", "machine_swiftlint_report", "machine_swiftlint_verbose_report") +load( + "//tools/lint:linters.bzl", + "swiftlint_baseline_test", + "swiftlint_default_config_test", + "swiftlint_nested_config_test", + "swiftlint_nested_deeper_nearest_config_test", + "swiftlint_nested_without_declared_config_test", + "swiftlint_test", +) + +package(default_visibility = ["//visibility:public"]) + +swiftlint_test( + name = "swiftlint", + srcs = ["//src:lint"], + expected_exit_code = 2, +) + +swiftlint_baseline_test( + name = "swiftlint_baseline", + srcs = ["//src:baseline"], +) + +swiftlint_default_config_test( + name = "swiftlint_default_config", + srcs = ["//src:default_config"], +) + +swiftlint_nested_config_test( + name = "swiftlint_nested_config", + srcs = ["//src:nested_config"], + expected_exit_code = 2, +) + +swiftlint_nested_deeper_nearest_config_test( + name = "swiftlint_nested_deeper_nearest_config", + srcs = ["//src:nested_deeper_nearest_config"], +) + +swiftlint_nested_without_declared_config_test( + name = "swiftlint_nested_without_declared_config", + srcs = ["//src:nested_without_declared_config"], +) + +machine_swiftlint_report( + name = "machine_swiftlint_report", + src = "//src:lint", +) + +machine_swiftlint_nested_config_report( + name = "machine_swiftlint_nested_config_report", + src = "//src:nested_config", +) + +machine_swiftlint_verbose_report( + name = "machine_swiftlint_verbose_report", + src = "//src:verbose", +) + +report_test( + name = "swiftlint_machine_output_test", + expected_tool = "SwiftLint", + expected_uri = "src/lintme.swift", + report = "machine_swiftlint_report", +) + +report_test( + name = "swiftlint_nested_config_machine_output_test", + expected_tool = "SwiftLint", + expected_uri = "src/nested/lintme.swift", + report = "machine_swiftlint_nested_config_report", +) + +report_test( + name = "swiftlint_verbose_machine_output_test", + expected_tool = "SwiftLint", + expected_uri = "src/lintme.swift", + report = "machine_swiftlint_verbose_report", +) diff --git a/examples/swift/test/lint_test.bats b/examples/swift/test/lint_test.bats new file mode 100644 index 000000000..3147c225b --- /dev/null +++ b/examples/swift/test/lint_test.bats @@ -0,0 +1,60 @@ +bats_load_library "bats-support" +bats_load_library "bats-assert" + +function assert_swift_lints() { + assert_output --partial '"ruleId" : "function_name_whitespace"' + assert_output --partial '"uri" : "src/lintme.swift"' + assert_output --partial '"text" : "Too many spaces between '\''func'\'' and function name"' +} + +function assert_swift_fix_patch() { + assert_output --partial - <<'EOF' +--- a/src/lintme.swift ++++ b/src/lintme.swift +@@ -1,5 +1,5 @@ + class Controller { +- func printme() { ++ func printme() { + print("Hello, World!") + } + } +EOF +} + +function assert_swift_nested_fix_patch() { + assert_output --partial - <<'EOF' +--- a/src/nested_fix/lintme.swift ++++ b/src/nested_fix/lintme.swift +@@ -1,5 +1,5 @@ + class NestedFixController { +- func printme() { ++ func printme() { + print("Hello, World!") + } + } +EOF +} + +@test "should produce reports" { + run aspect lint //src:all + assert_failure + assert_swift_lints +} + +@test "should produce fix patches" { + run bazel build --config=lint --output_groups=rules_lint_patch --@aspect_rules_lint//lint:fix //src:lint + assert_success + + run cat bazel-bin/src/lint.AspectRulesLintSwiftLint.patch + assert_success + assert_swift_fix_patch +} + +@test "should produce nested config fix patches" { + run bazel build --aspects=//tools/lint:linters.bzl%swiftlint_nested_fix_config --output_groups=rules_lint_patch --@aspect_rules_lint//lint:fix //src:nested_fix_config + assert_success + + run cat bazel-bin/src/nested_fix_config.AspectRulesLintSwiftLint.patch + assert_success + assert_swift_nested_fix_patch +} diff --git a/examples/swift/test/machine_output.bzl b/examples/swift/test/machine_output.bzl new file mode 100644 index 000000000..80a423161 --- /dev/null +++ b/examples/swift/test/machine_output.bzl @@ -0,0 +1,9 @@ +"Swift-specific machine report rules for testing." + +# buildifier: disable=bzl-visibility +load("@aspect_rules_lint//lint/private:machine_report_testing.bzl", "machine_report_rule") +load("//tools/lint:linters.bzl", "swiftlint", "swiftlint_nested_config", "swiftlint_verbose") + +machine_swiftlint_report = machine_report_rule(swiftlint) +machine_swiftlint_nested_config_report = machine_report_rule(swiftlint_nested_config) +machine_swiftlint_verbose_report = machine_report_rule(swiftlint_verbose) diff --git a/examples/swift/tools/lint/BUILD b/examples/swift/tools/lint/BUILD new file mode 100644 index 000000000..bc03a9e19 --- /dev/null +++ b/examples/swift/tools/lint/BUILD @@ -0,0 +1,6 @@ +package(default_visibility = ["//:__subpackages__"]) + +alias( + name = "swiftlint", + actual = "@swiftlint_binary//:swiftlint", +) diff --git a/examples/swift/tools/lint/linters.bzl b/examples/swift/tools/lint/linters.bzl new file mode 100644 index 000000000..83ea3182a --- /dev/null +++ b/examples/swift/tools/lint/linters.bzl @@ -0,0 +1,74 @@ +"Define Swift linter aspects" + +load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") +load("@aspect_rules_lint//lint:swiftlint.bzl", "lint_swiftlint_aspect") + +swiftlint = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], +) + +swiftlint_with_baseline = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], + baseline = Label("//:SwiftLintBaseline.json"), + filegroup_tags = ["swift-baseline"], +) + +swiftlint_default_config = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [], + filegroup_tags = ["swift-default-config"], +) + +swiftlint_verbose = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], + filegroup_tags = ["swift-verbose"], + quiet = False, +) + +swiftlint_nested_config = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [ + Label("//:.swiftlint.yml"), + Label("//src:nested/.swiftlint.yml"), + ], + config_mode = "nested", + filegroup_tags = ["swift-nested-config"], +) + +swiftlint_nested_deeper_nearest_config = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [ + Label("//:.swiftlint.yml"), + Label("//src:nested/.swiftlint.yml"), + Label("//src:nested/deeper/.swiftlint.yml"), + ], + config_mode = "nested", + filegroup_tags = ["swift-nested-deeper-nearest-config"], +) + +swiftlint_nested_without_declared_config = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], + config_mode = "nested", + filegroup_tags = ["swift-nested-without-declared-config"], +) + +swiftlint_nested_fix_config = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [ + Label("//:.swiftlint.yml"), + Label("//src:nested_fix/.swiftlint.yml"), + ], + config_mode = "nested", + filegroup_tags = ["swift-nested-fix-config"], +) + +swiftlint_test = lint_test(aspect = swiftlint) +swiftlint_baseline_test = lint_test(aspect = swiftlint_with_baseline) +swiftlint_default_config_test = lint_test(aspect = swiftlint_default_config) +swiftlint_nested_config_test = lint_test(aspect = swiftlint_nested_config) +swiftlint_nested_deeper_nearest_config_test = lint_test(aspect = swiftlint_nested_deeper_nearest_config) +swiftlint_nested_without_declared_config_test = lint_test(aspect = swiftlint_nested_without_declared_config) diff --git a/format/test/format_test.bats b/format/test/format_test.bats index b102f4c0d..2c56fef06 100644 --- a/format/test/format_test.bats +++ b/format/test/format_test.bats @@ -183,7 +183,7 @@ bats_load_library "bats-assert" run bazel run //format/test:format_Swift_with_swiftformat assert_success - assert_output --partial "+ swiftformat examples/swift/src/hello.swift" + assert_output --partial "+ swiftformat examples/swift/src/formatme.swift" } @test "should run buf on Protobuf" { diff --git a/lint/BUILD.bazel b/lint/BUILD.bazel index c219f596d..7af3fb2c1 100644 --- a/lint/BUILD.bazel +++ b/lint/BUILD.bazel @@ -6,7 +6,10 @@ load("//lint/private:lint_aspect.bzl", "lint_options") package(default_visibility = ["//visibility:public"]) -exports_files(glob(["*.bzl"]) + ["lint_test.sh"]) +exports_files(glob(["*.bzl"]) + [ + "empty_swiftlint.yml", + "lint_test.sh", +]) # Aliases for the built-in linters alias( @@ -263,6 +266,15 @@ bzl_library( deps = ["//lint/private:lint_aspect"], ) +bzl_library( + name = "swiftlint", + srcs = ["swiftlint.bzl"], + deps = [ + "//lint/private:lint_aspect", + "//lint/private:patcher_action", + ], +) + bzl_library( name = "stylelint", srcs = ["stylelint.bzl"], diff --git a/lint/empty_swiftlint.yml b/lint/empty_swiftlint.yml new file mode 100644 index 000000000..e69de29bb diff --git a/lint/extensions.bzl b/lint/extensions.bzl index 5ec90b184..2b548d076 100644 --- a/lint/extensions.bzl +++ b/lint/extensions.bzl @@ -62,6 +62,22 @@ def _lint_extension_impl(mctx): url = "https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint", executable = True, ) + for r in mod.tags.swiftlint: + http_archive( + name = r.name, + build_file_content = _public_build_file_content("\n".join([ + """alias( + name = "swiftlint", + actual = select({ + "@bazel_tools//src/conditions:linux_x86_64": "SwiftLintBinary.artifactbundle/linux/amd64/swiftlint", + "@bazel_tools//src/conditions:linux_aarch64": "SwiftLintBinary.artifactbundle/linux/arm64/swiftlint", + "@bazel_tools//src/conditions:darwin": "SwiftLintBinary.artifactbundle/macos/swiftlint", + }), +)""", + ])), + sha256 = "12befab676fc972ffde2ec295d016d53c3a85f64aabd9c7fee0032d681e307e9", + urls = ["https://github.com/realm/SwiftLint/releases/download/0.63.2/SwiftLintBinary.artifactbundle.zip"], + ) for r in mod.tags.spotbugs: http_archive( name = r.name, @@ -110,6 +126,7 @@ tools = module_extension( "cppcheck": tag_class(attrs = {"linux": attr.string(default = "cppcheck_linux"), "macos": attr.string(default = "cppcheck_macos")}), "spotbugs": tag_class(attrs = {"name": attr.string(default = "spotbugs")}), "ktlint": tag_class(attrs = {"name": attr.string(default = "com_github_pinterest_ktlint")}), + "swiftlint": tag_class(attrs = {"name": attr.string(default = "swiftlint_binary")}), "pmd": tag_class(attrs = {"name": attr.string(default = "net_sourceforge_pmd")}), "checkstyle": tag_class(attrs = {"name": attr.string(default = "com_puppycrawl_tools_checkstyle")}), "vale": tag_class(attrs = {"name": attr.string(default = "vale"), "tag": attr.string(default = VALE_VERSIONS.keys()[0])}), diff --git a/lint/lint.axl b/lint/lint.axl index 26a9e48cc..c8322cfb9 100644 --- a/lint/lint.axl +++ b/lint/lint.axl @@ -18,6 +18,12 @@ Usage: aspect lint --fix """ +def _has_lint_results(report: str) -> bool: + normalized = report + for whitespace in [" ", "\n", "\r", "\t"]: + normalized = normalized.replace(whitespace, "") + return '"results":[{' in normalized + # buildifier: disable=function-docstring def impl(ctx: TaskContext) -> int: flags = [ @@ -51,7 +57,7 @@ def impl(ctx: TaskContext) -> int: ctx.std.io.stderr.write(generated) if file.name.endswith(".report"): report = ctx.std.fs.read_to_string(filepath) - if '"results": [' in report: + if _has_lint_results(report): ctx.std.io.stdout.write(report) if github_output: ctx.std.fs.write(github_output, "lint-report=" + filepath) diff --git a/lint/swiftlint.bzl b/lint/swiftlint.bzl new file mode 100644 index 000000000..8ae4e4118 --- /dev/null +++ b/lint/swiftlint.bzl @@ -0,0 +1,398 @@ +"""API for declaring a SwiftLint lint aspect for Swift sources. + +Typical usage: + +Fetch SwiftLint with the shared lint tools extension in `MODULE.bazel`: + +```starlark +lint_tools = use_extension("@aspect_rules_lint//lint:extensions.bzl", "tools") +lint_tools.swiftlint() +use_repo(lint_tools, "swiftlint_binary") +``` + +Then, create an alias in `tools/lint/BUILD.bazel`: + +```starlark +alias( + name = "swiftlint", + actual = "@swiftlint_binary//:swiftlint", +) +``` + +Then, create the linter aspect, typically in `tools/lint/linters.bzl`: + +```starlark +load("@aspect_rules_lint//lint:swiftlint.bzl", "lint_swiftlint_aspect") + +swiftlint = lint_swiftlint_aspect( + binary = Label("//tools/lint:swiftlint"), + configs = [Label("//:.swiftlint.yml")], +) +``` + +Config files passed in `configs` are declared action inputs and forwarded to +SwiftLint with `--config`, so SwiftLint does not auto-discover undeclared +repository config files. Pass `configs = []` only when no repository +`.swiftlint.yml` should affect the action; rules_lint will pass an empty config +file so SwiftLint still uses its built-in rule defaults. + +Set `config_mode = "nested"` to use target-specific nested `.swiftlint.yml` +files. In nested mode, rules_lint selects the main config plus the deepest child +config containing all Swift source files in the target, matching SwiftLint's +nearest nested config behavior. + +Declare SwiftLint configuration hierarchy files in `configs`. Prefer the +`baseline` argument over a `baseline` entry in `.swiftlint.yml`. Do not use +`write_baseline`, remote config URLs, or `check_for_updates` in Bazel actions +because they introduce undeclared writes, network access, or both. + +Bazel target membership determines the files linted by this aspect. SwiftLint's +`excluded` configuration is still honored because rules_lint passes +`--force-exclude`, but `included` is not a reliable way to narrow explicitly +passed Bazel source files. + +SwiftLint policy should generally live in `.swiftlint.yml`. The aspect only +exposes CLI options that affect Bazel execution behavior or action inputs. +Machine-readable reports always use SwiftLint's SARIF reporter. +""" + +load("//lint/private:lint_aspect.bzl", "LintOptionsInfo", "filter_srcs", "noop_lint_action", "output_files", "patch_and_output_files", "should_visit") +load("//lint/private:patcher_action.bzl", "patcher_attrs", "run_patcher") + +_MNEMONIC = "AspectRulesLintSwiftLint" +_EMPTY_CONFIG = Label("//lint:empty_swiftlint.yml") +_CONFIG_MODE_EXPLICIT = "explicit" +_CONFIG_MODE_NESTED = "nested" +_CONFIG_MODES = [_CONFIG_MODE_EXPLICIT, _CONFIG_MODE_NESTED] + +def _swift_srcs(rule): + return [src for src in filter_srcs(rule) if src.path.endswith(".swift")] + +def _config_args(configs): + args = [] + for config in configs: + args.extend(["--config", config.path]) + return args + +def _dirname(path): + parts = path.split("/") + if len(parts) == 1: + return "" + return "/".join(parts[:-1]) + +def _path_is_under_dir(path, directory): + return directory == "" or path == directory or path.startswith(directory + "/") + +def _format_file_paths(files): + return "[{}]".format(", ".join([f.path for f in files])) + +def _same_file_paths(a, b): + if len(a) != len(b): + return False + for i in range(len(a)): + if a[i].path != b[i].path: + return False + return True + +def _nested_configs_for_src(configs, src): + if not configs: + return [] + + selected = [configs[0]] + src_dir = _dirname(src.path) + selected_child = None + selected_child_dir_len = -1 + for config in configs[1:]: + config_dir = _dirname(config.path) + if _path_is_under_dir(src_dir, config_dir) and len(config_dir) > selected_child_dir_len: + selected_child = config + selected_child_dir_len = len(config_dir) + + if selected_child != None: + selected.append(selected_child) + return selected + +def _nested_configs_for_srcs(configs, srcs, label): + if not srcs: + return configs + + selected = _nested_configs_for_src(configs, srcs[0]) + for src in srcs[1:]: + src_configs = _nested_configs_for_src(configs, src) + if not _same_file_paths(selected, src_configs): + fail("""SwiftLint nested config mode requires all Swift files in a Bazel target to use the same config hierarchy. +Target {label} selects {selected} for {first_src}, but {src} selects {src_configs}. +Split those Swift files into separate Bazel targets, or use config_mode = "explicit".""".format( + label = label, + selected = _format_file_paths(selected), + first_src = srcs[0].path, + src = src.path, + src_configs = _format_file_paths(src_configs), + )) + return selected + +def _effective_configs(configs, srcs, config_mode, label): + if config_mode == _CONFIG_MODE_NESTED: + return _nested_configs_for_srcs(configs, srcs, label) + return configs + +def _baseline_args(baseline): + if not baseline: + return [] + if len(baseline) > 1: + fail("SwiftLint accepts at most one baseline file") + return ["--baseline", baseline[0].path] + +def _swiftlint_options(quiet, reporter, baseline): + args = [ + "--force-exclude", + "--no-cache", + ] + + if quiet: + args.append("--quiet") + if reporter: + args.extend(["--reporter", reporter]) + + args.extend(_baseline_args(baseline)) + + return args + +def swiftlint_action( + ctx, + executable, + srcs, + configs, + stdout, + exit_code = None, + reporter = None, + quiet = True, + baseline = None, + config_mode = _CONFIG_MODE_EXPLICIT, + patch = None): + """Run SwiftLint as an action under Bazel. + + Based on the official SwiftLint CLI: + https://github.com/realm/SwiftLint + + Args: + ctx: Bazel rule or aspect evaluation context. + executable: the SwiftLint executable. + srcs: Swift source files to lint. + configs: SwiftLint config files available to the action. + stdout: output file containing linter output. + exit_code: optional output file containing the linter exit code. + reporter: SwiftLint reporter to use. + quiet: whether to suppress SwiftLint status logs. + baseline: optional SwiftLint baseline file. + config_mode: `explicit` passes configs as `--config`; `nested` selects + the main config and target-specific child config from configs, then + passes the selected hierarchy as `--config`. + patch: optional patch file output when running in fix mode. + """ + if baseline == None: + baseline = [] + + configs = _effective_configs(configs, srcs, config_mode, ctx.label) + inputs = srcs + configs + baseline + config_args = _config_args(configs) + + lint_args = _swiftlint_options( + quiet, + reporter, + baseline, + ) + config_args + [src.path for src in srcs] + + if patch != None: + wrapper = ctx.actions.declare_file(ctx.label.name + ".swiftlint_wrapper.sh") + ctx.actions.write( + output = wrapper, + content = """#!/bin/bash +tmp=$(mktemp) +"{swiftlint}" lint --fix "$@" >/dev/null 2>&1 || true +"{swiftlint}" lint "$@" >"$tmp" 2>&1 +status=$? +sed "s|$PWD/||g" "$tmp" +rm "$tmp" +exit $status +""".format(swiftlint = executable.path), + is_executable = True, + ) + + run_patcher( + ctx, + ctx.executable, + inputs = inputs, + args = lint_args, + files_to_diff = [src.path for src in srcs], + patch_out = patch, + tools = [wrapper, executable], + stdout = stdout, + exit_code = exit_code, + mnemonic = _MNEMONIC, + progress_message = "Fixing %{label} with SwiftLint", + ) + return + + outputs = [stdout] + args = ctx.actions.args() + args.add("lint") + args.add_all(lint_args) + + if exit_code: + command = """ +tmp=$(mktemp) +"{swiftlint}" "$@" >"$tmp" 2>&1 +status=$? +sed "s|$PWD/||g" "$tmp" >{stdout} +rm "$tmp" +echo $status > {exit_code} +""".format( + swiftlint = executable.path, + stdout = stdout.path, + exit_code = exit_code.path, + ) + outputs.append(exit_code) + else: + command = """ +tmp=$(mktemp) +"{swiftlint}" "$@" >"$tmp" 2>&1 +status=$? +sed "s|$PWD/||g" "$tmp" >{stdout} +rm "$tmp" +exit $status +""".format( + swiftlint = executable.path, + stdout = stdout.path, + ) + + ctx.actions.run_shell( + inputs = inputs, + outputs = outputs, + command = command, + arguments = [args], + mnemonic = _MNEMONIC, + progress_message = "Linting %{label} with SwiftLint", + tools = [executable], + ) + +def _swiftlint_aspect_impl(target, ctx): + if not should_visit(ctx.rule, ctx.attr._rule_kinds, ctx.attr._filegroup_tags): + return [] + + files_to_lint = _swift_srcs(ctx.rule) + if ctx.attr._options[LintOptionsInfo].fix: + outputs, info = patch_and_output_files(_MNEMONIC, target, ctx) + else: + outputs, info = output_files(_MNEMONIC, target, ctx) + + if len(files_to_lint) == 0: + noop_lint_action(ctx, outputs) + return [info] + + swiftlint_action( + ctx, + ctx.executable._swiftlint, + files_to_lint, + ctx.files._config_files, + outputs.human.out, + outputs.human.exit_code, + quiet = ctx.attr._quiet, + baseline = ctx.files._baseline, + config_mode = ctx.attr._config_mode, + patch = getattr(outputs, "patch", None), + ) + swiftlint_action( + ctx, + ctx.executable._swiftlint, + files_to_lint, + ctx.files._config_files, + outputs.machine.out, + outputs.machine.exit_code, + reporter = "sarif", + # SwiftLint prints status logs before reporter output unless quiet is + # enabled, which would corrupt the machine-readable SARIF JSON. + quiet = True, + baseline = ctx.files._baseline, + config_mode = ctx.attr._config_mode, + ) + + return [info] + +def lint_swiftlint_aspect( + binary, + configs, + quiet = True, + baseline = None, + config_mode = _CONFIG_MODE_EXPLICIT, + rule_kinds = ["swift_binary", "swift_compiler_plugin", "swift_library", "swift_test"], + filegroup_tags = ["swift", "lint-with-swiftlint"]): + """Create a SwiftLint linter aspect. + + Args: + binary: a SwiftLint executable, for example `//tools/lint:swiftlint`. + configs: SwiftLint config files to pass as action inputs. Pass an empty + list only when repository config files should not be used. + quiet: pass `--quiet`, suppressing SwiftLint status logs. Defaults to + True because SwiftLint writes status logs into report outputs. + baseline: optional SwiftLint baseline file. Prefer this over a + `baseline` entry in `.swiftlint.yml` so Bazel can declare the file + as an action input. + config_mode: `explicit` passes every `configs` entry to every target. + `nested` selects the main config and nearest target-specific child + config. + rule_kinds: which rule kinds should be visited automatically. + filegroup_tags: filegroup tags that opt targets into SwiftLint linting. + + Returns: + An aspect definition for SwiftLint. + """ + if type(configs) == "string": + configs = [configs] + if config_mode not in _CONFIG_MODES: + fail("config_mode must be one of {}, got {}".format(_CONFIG_MODES, config_mode)) + if len(configs) == 0: + configs = [_EMPTY_CONFIG] + if baseline == None: + baseline = [] + elif type(baseline) == "list": + if len(baseline) > 1: + fail("baseline accepts at most one label") + else: + baseline = [baseline] + + return aspect( + implementation = _swiftlint_aspect_impl, + attrs = patcher_attrs | { + "_options": attr.label( + default = "//lint:options", + providers = [LintOptionsInfo], + ), + "_swiftlint": attr.label( + default = binary, + allow_files = True, + executable = True, + cfg = "exec", + ), + "_config_files": attr.label_list( + default = configs, + allow_files = True, + ), + "_quiet": attr.bool( + default = quiet, + ), + "_baseline": attr.label_list( + default = baseline, + allow_files = True, + ), + "_config_mode": attr.string( + default = config_mode, + ), + "_filegroup_tags": attr.string_list( + default = filegroup_tags, + ), + "_rule_kinds": attr.string_list( + default = rule_kinds, + ), + }, + )