diff --git a/README.md b/README.md index fe9d7e30..d5754471 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Linters which are not language-specific: | Markdown | [Prettier] | [Vale] | | Protocol Buffer | [buf] | [buf lint] | | Python | [ruff] | [flake8], [pylint], [ruff] | -| Ruby | | [RuboCop] | +| Ruby | | [RuboCop], [Standard] | | Rust | [rustfmt] | | | SQL | [prettier-plugin-sql] | | | Scala | [scalafmt] | | @@ -85,6 +85,7 @@ Linters which are not language-specific: [jsonnetfmt]: https://github.com/google/go-jsonnet [scalafmt]: https://scalameta.org/scalafmt [rubocop]: https://docs.rubocop.org/ +[standard]: https://github.com/standardrb/standard [ruff]: https://docs.astral.sh/ruff/ [pylint]: https://pylint.readthedocs.io/en/stable/ [shellcheck]: https://www.shellcheck.net/ diff --git a/example/.standard.yml b/example/.standard.yml new file mode 100644 index 00000000..f04775ca --- /dev/null +++ b/example/.standard.yml @@ -0,0 +1,18 @@ +# Standard Ruby configuration for rules_lint example +# +# Standard Ruby is a zero-configuration Ruby style guide, linter, and formatter +# based on RuboCop. See https://github.com/standardrb/standard +# +# This file allows minimal customization of Standard Ruby's opinionated defaults. + +# Configure target Ruby version +ruby_version: 3.0 + +# Ignore certain paths (glob patterns) +ignore: + - 'vendor/**/*' + - 'tmp/**/*' + +# You can configure specific cops if needed, though Standard discourages this +# fix: false # Uncomment to prevent auto-fixing +# parallel: true # Uncomment to enable parallel processing diff --git a/example/BUILD.bazel b/example/BUILD.bazel index 30360a42..83c55446 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -21,6 +21,7 @@ exports_files( "checkstyle-suppressions.xml", ".ruff.toml", ".rubocop.yml", + ".standard.yml", ".shellcheckrc", ".scalafmt.conf", ".swcrc", diff --git a/example/Gemfile b/example/Gemfile index d4790124..ab7f81d5 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -3,3 +3,4 @@ source 'https://rubygems.org' gem 'rubocop', '~> 1.50' +gem 'standard', '~> 1.35' diff --git a/example/Gemfile.lock b/example/Gemfile.lock index c48e1460..a99681d3 100644 --- a/example/Gemfile.lock +++ b/example/Gemfile.lock @@ -15,7 +15,7 @@ GEM racc (1.8.1-java) rainbow (3.1.1) regexp_parser (2.11.3) - rubocop (1.81.1) + rubocop (1.80.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -23,13 +23,29 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.47.1) parser (>= 3.3.7.2) prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) + standard (1.51.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.80.2) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) @@ -42,6 +58,7 @@ PLATFORMS DEPENDENCIES rubocop (~> 1.50) + standard (~> 1.35) BUNDLED WITH 2.6.9 diff --git a/example/MODULE.bazel b/example/MODULE.bazel index 9e3ccfa3..66bbcc72 100644 --- a/example/MODULE.bazel +++ b/example/MODULE.bazel @@ -150,9 +150,13 @@ ruby.bundle_fetch( "racc-1.8.1-java": "54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98", "rainbow-3.1.1": "039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a", "regexp_parser-2.11.3": "ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4", - "rubocop-1.81.1": "352a9a6f314a4312f6c305f1f72bc466254d221c95445cd49e1b65d1f9411635", + "rubocop-1.80.2": "6485f30fefcf5c199db3b91e5e253b1ef43f7e564784e2315255809a3dd9abf4", "rubocop-ast-1.47.1": "592682017855408b046a8190689490763aecea175238232b1b526826349d01ae", + "rubocop-performance-1.25.0": "6f7d03568a770054117a78d0a8e191cefeffb703b382871ca7743831b1a52ec1", "ruby-progressbar-1.13.0": "80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33", + "standard-1.51.1": "6d0d98a1fac26d660393f37b3d9c864632bb934b17abfa23811996b20f87faf2", + "standard-custom-1.0.2": "424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b", + "standard-performance-1.8.0": "ed17b7d0e061b2a19a91dd434bef629439e2f32310f22f26acb451addc92b788", "unicode-display_width-3.2.0": "0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42", "unicode-emoji-4.1.0": "4997d2d5df1ed4252f4830a9b6e86f932e2013fbff2182a9ce9ccabda4f325a5", }, diff --git a/example/WORKSPACE.bazel b/example/WORKSPACE.bazel index d7fd57db..03435982 100644 --- a/example/WORKSPACE.bazel +++ b/example/WORKSPACE.bazel @@ -362,9 +362,13 @@ rb_bundle_fetch( "racc-1.8.1-java": "54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98", "rainbow-3.1.1": "039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a", "regexp_parser-2.11.3": "ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4", - "rubocop-1.81.1": "352a9a6f314a4312f6c305f1f72bc466254d221c95445cd49e1b65d1f9411635", + "rubocop-1.80.2": "6485f30fefcf5c199db3b91e5e253b1ef43f7e564784e2315255809a3dd9abf4", "rubocop-ast-1.47.1": "592682017855408b046a8190689490763aecea175238232b1b526826349d01ae", + "rubocop-performance-1.25.0": "6f7d03568a770054117a78d0a8e191cefeffb703b382871ca7743831b1a52ec1", "ruby-progressbar-1.13.0": "80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33", + "standard-1.51.1": "6d0d98a1fac26d660393f37b3d9c864632bb934b17abfa23811996b20f87faf2", + "standard-custom-1.0.2": "424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b", + "standard-performance-1.8.0": "ed17b7d0e061b2a19a91dd434bef629439e2f32310f22f26acb451addc92b788", "unicode-display_width-3.2.0": "0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42", "unicode-emoji-4.1.0": "4997d2d5df1ed4252f4830a9b6e86f932e2013fbff2182a9ce9ccabda4f325a5", }, diff --git a/example/test/BUILD.bazel b/example/test/BUILD.bazel index 8d9edb12..2b52d0bd 100644 --- a/example/test/BUILD.bazel +++ b/example/test/BUILD.bazel @@ -15,6 +15,7 @@ load( "rubocop_test", "ruff_test", "shellcheck_test", + "standardrb_test", ) write_file( @@ -194,3 +195,11 @@ rubocop_test( # Normally you'd fix the file instead of tagging this test. tags = ["manual"], ) + +standardrb_test( + name = "standardrb", + srcs = ["//src:hello_ruby"], + # Expected to fail based on current content of the file. + # Normally you'd fix the file instead of tagging this test. + tags = ["manual"], +) diff --git a/example/test/lint_test.bats b/example/test/lint_test.bats index 0331f274..15778e12 100755 --- a/example/test/lint_test.bats +++ b/example/test/lint_test.bats @@ -67,6 +67,15 @@ EOF C: 1: 1: [Correctable] Style/FrozenStringLiteralComment: Missing frozen string literal comment. W: 4: 1: [Correctable] Lint/UselessAssignment: Useless assignment to variable - unused_variable. C: 6:101: Layout/LineLength: Line is too long. [115/100] +EOF + + # Standard Ruby + echo <<"EOF" | assert_output --partial +== src/hello.rb == +W: 4: 1: [Correctable] Lint/UselessAssignment: Useless assignment to variable - unused_variable. +C: 19: 3: [Correctable] Layout/IndentationWidth: Use 2 (not 4) spaces for indentation. +W: 29: 1: [Correctable] Lint/UselessAssignment: Useless assignment to variable - message. +W: 32: 1: [Correctable] Lint/UselessAssignment: Useless assignment to variable - numbers. EOF # stylelint diff --git a/example/tools/lint/BUILD.bazel b/example/tools/lint/BUILD.bazel index fe62468e..2d78c480 100644 --- a/example/tools/lint/BUILD.bazel +++ b/example/tools/lint/BUILD.bazel @@ -119,3 +119,8 @@ alias( name = "rubocop", actual = "@bundle//bin:rubocop", ) + +alias( + name = "standardrb", + actual = "@bundle//bin:standardrb", +) diff --git a/example/tools/lint/linters.bzl b/example/tools/lint/linters.bzl index 7bb32814..ae24ed6a 100644 --- a/example/tools/lint/linters.bzl +++ b/example/tools/lint/linters.bzl @@ -9,11 +9,12 @@ load("@aspect_rules_lint//lint:keep_sorted.bzl", "lint_keep_sorted_aspect") load("@aspect_rules_lint//lint:ktlint.bzl", "lint_ktlint_aspect") load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") load("@aspect_rules_lint//lint:pmd.bzl", "lint_pmd_aspect") +load("@aspect_rules_lint//lint:pylint.bzl", "lint_pylint_aspect") load("@aspect_rules_lint//lint:rubocop.bzl", "lint_rubocop_aspect") load("@aspect_rules_lint//lint:ruff.bzl", "lint_ruff_aspect") -load("@aspect_rules_lint//lint:pylint.bzl", "lint_pylint_aspect") load("@aspect_rules_lint//lint:shellcheck.bzl", "lint_shellcheck_aspect") load("@aspect_rules_lint//lint:spotbugs.bzl", "lint_spotbugs_aspect") +load("@aspect_rules_lint//lint:standardrb.bzl", "lint_standardrb_aspect") load("@aspect_rules_lint//lint:stylelint.bzl", "lint_stylelint_aspect") load("@aspect_rules_lint//lint:vale.bzl", "lint_vale_aspect") load("@aspect_rules_lint//lint:yamllint.bzl", "lint_yamllint_aspect") @@ -150,3 +151,10 @@ rubocop = lint_rubocop_aspect( ) rubocop_test = lint_test(aspect = rubocop) + +standardrb = lint_standardrb_aspect( + binary = Label("//tools/lint:standardrb"), + configs = [Label("//:.standard.yml")], +) + +standardrb_test = lint_test(aspect = standardrb) diff --git a/lint/BUILD.bazel b/lint/BUILD.bazel index 307263c8..71ed80d4 100644 --- a/lint/BUILD.bazel +++ b/lint/BUILD.bazel @@ -205,6 +205,12 @@ bzl_library( deps = ["//lint/private:lint_aspect"], ) +bzl_library( + name = "standardrb", + srcs = ["standardrb.bzl"], + deps = ["//lint/private:lint_aspect"], +) + bzl_library( name = "ruff", srcs = ["ruff.bzl"], diff --git a/lint/standardrb.bzl b/lint/standardrb.bzl new file mode 100644 index 00000000..a8187823 --- /dev/null +++ b/lint/standardrb.bzl @@ -0,0 +1,403 @@ +"""API for declaring a Standard Ruby lint aspect. + +Visits rb_{binary|library|test} rules. + +Typical usage: + +## Installing Standard Ruby + +The recommended approach is to use Bundler with rules_ruby to manage +Standard Ruby as a gem dependency: + +1. Add Standard Ruby to your `Gemfile`: +```ruby +gem "standard", "~> 1.0" +``` + +2. Run `bundle lock` to generate `Gemfile.lock` + +3. Configure the bundle in your `MODULE.bazel`: +```starlark +ruby = use_extension("@rules_ruby//ruby:extensions.bzl", "ruby") +ruby.toolchain( + name = "ruby", + version = "3.3.0", +) +ruby.bundle_fetch( + name = "bundle", + gemfile = "//:Gemfile", + gemfile_lock = "//:Gemfile.lock", +) +use_repo(ruby, "bundle", "ruby", "ruby_toolchains") +``` + +4. Create an alias to the gem-provided binary in + `tools/lint/BUILD.bazel`: +```starlark +alias( + name = "standardrb", + actual = "@bundle//bin:standardrb", +) +``` + +5. Create the linter aspect, typically in `tools/lint/linters.bzl`: +```starlark +load("@aspect_rules_lint//lint:standardrb.bzl", "lint_standardrb_aspect") + +standardrb = lint_standardrb_aspect( + binary = "//tools/lint:standardrb", + configs = ["//:standard.yml"], +) +``` + +This approach ensures: +- Hermetic builds with pinned gem versions +- Consistent Standard Ruby versions across all developers +- Integration with Bazel's dependency management + +## Configuration + +Standard Ruby will automatically discover `.standard.yml` files according to its +standard configuration hierarchy. +See https://github.com/standardrb/standard for details. + +Note: all config files are passed to the action as inputs. +This means that a change to any config file invalidates the action cache +entries for ALL Standard Ruby actions. +""" + +load( + "//lint/private:lint_aspect.bzl", + "LintOptionsInfo", + "OPTIONAL_SARIF_PARSER_TOOLCHAIN", + "OUTFILE_FORMAT", + "filter_srcs", + "noop_lint_action", + "output_files", + "parse_to_sarif_action", + "patch_and_output_files", + "should_visit", +) + +_MNEMONIC = "AspectRulesLintStandardRB" + +def _build_standardrb_command( + standardrb_path, + stdout_path, + exit_code_path = None): + """Build shell command for running Standard Ruby. + + Args: + standardrb_path: path to the Standard Ruby executable + stdout_path: path where stdout/stderr should be written + exit_code_path: path where exit code should be written. If None, + the command will fail on non-zero exit. + + Returns: + Fully formatted shell command string + """ + cmd_parts = [ + "{standardrb} $@ >{stdout} 2>&1".format( + standardrb = standardrb_path, + stdout = stdout_path, + ), + ] + if exit_code_path: + cmd_parts.append( + "; echo $? >{exit_code}".format(exit_code = exit_code_path), + ) + return "".join(cmd_parts) + +def standardrb_action( + ctx, + executable, + srcs, + config, + stdout, + exit_code = None, + color = False): + """Run Standard Ruby as an action under Bazel. + + Standard Ruby will select the configuration file to use for each + source file, as documented here: + https://github.com/standardrb/standard + + Note: all config files are passed to the action. + This means that a change to any config file invalidates the action cache + entries for ALL Standard Ruby actions. + + However this is needed because Standard Ruby's logic for selecting the + appropriate config needs to traverse the directory hierarchy. + + Args: + ctx: Bazel Rule or Aspect evaluation context + executable: File object for the Standard Ruby executable + srcs: list of File objects for Ruby source files to be linted + config: list of File objects for Standard Ruby config files + (.standard.yml) + stdout: File object where linter output will be written + exit_code: File object where exit code will be written, or None. + If None, the build will fail when Standard Ruby exits non-zero. + See https://github.com/standardrb/standard + color: boolean, whether to enable color output + """ + inputs = srcs + config + outputs = [stdout] + + # Wire command-line options, see + # `standardrb --help` to see available options + args = ctx.actions.args() + + # Force format to simple for human-readable output + args.add("--format", "simple") + + # Honor exclusions in .standard.yml even though we pass explicit list of + # files + args.add("--force-exclusion") + + # Set cache root to /tmp to avoid sandbox permission issues + # StandardRB uses RuboCop internally, which needs a writable cache + # directory. Note: We can't use --cache false with --cache-root, so + # we allow caching to /tmp + # Note: We don't pass --no-server because it causes errors with JRuby + args.add("--cache-root", "/tmp") + + # Disable auto-fix to prevent writing to input files in the sandbox + # Tests should only report violations, not modify source files + args.add("--no-fix") + + # Enable color output if requested + if color: + args.add("--color") + + args.add_all(srcs) + + command = _build_standardrb_command( + executable.path, + stdout.path, + exit_code.path if exit_code else None, + ) + if exit_code: + outputs.append(exit_code) + + ctx.actions.run_shell( + inputs = inputs, + outputs = outputs, + command = command, + arguments = [args], + mnemonic = _MNEMONIC, + progress_message = "Linting %{label} with Standard Ruby", + tools = [executable], + ) + +def standardrb_fix( + ctx, + executable, + srcs, + config, + patch, + stdout, + exit_code, + color = False): + """Create a Bazel Action that spawns Standard Ruby with --fix. + + Args: + ctx: Bazel Rule or Aspect evaluation context + executable: struct with _standardrb and _patcher fields + srcs: list of File objects for Ruby source files to lint + config: list of File objects for Standard Ruby config files + (.standard.yml) + patch: File object where the patch output will be written + stdout: File object where linter output will be written + exit_code: File object where exit code will be written + color: boolean, whether to enable color output + """ + patch_cfg = ctx.actions.declare_file( + "_{}.patch_cfg".format(ctx.label.name), + ) + + # Build args list with color flag if needed + standardrb_args = [ + "--fix", + "--cache", + "false", + ] + if color: + standardrb_args.append("--color") + standardrb_args.extend([s.path for s in srcs]) + + ctx.actions.write( + output = patch_cfg, + content = json.encode({ + "linter": executable._standardrb.path, + "args": standardrb_args, + "files_to_diff": [s.path for s in srcs], + "output": patch.path, + }), + ) + + ctx.actions.run( + inputs = srcs + config + [patch_cfg], + outputs = [patch, exit_code, stdout], + executable = executable._patcher, + arguments = [patch_cfg.path], + env = { + "BAZEL_BINDIR": ".", + "JS_BINARY__EXIT_CODE_OUTPUT_FILE": exit_code.path, + "JS_BINARY__STDOUT_OUTPUT_FILE": stdout.path, + "JS_BINARY__SILENT_ON_SUCCESS": "1", + }, + tools = [executable._standardrb], + mnemonic = _MNEMONIC, + progress_message = "Fixing %{label} with Standard Ruby", + ) + +# buildifier: disable=function-docstring +def _standardrb_aspect_impl(target, ctx): + if not should_visit( + ctx.rule, + ctx.attr._rule_kinds, + ctx.attr._filegroup_tags, + ): + return [] + + files_to_lint = filter_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] + + # Standard Ruby can produce a patch at the same time as reporting the + # unpatched violations + if hasattr(outputs, "patch"): + standardrb_fix( + ctx, + ctx.executable, + files_to_lint, + ctx.files._config_files, + outputs.patch, + outputs.human.out, + outputs.human.exit_code, + color = ctx.attr._options[LintOptionsInfo].color, + ) + else: + standardrb_action( + ctx, + ctx.executable._standardrb, + files_to_lint, + ctx.files._config_files, + outputs.human.out, + outputs.human.exit_code, + color = ctx.attr._options[LintOptionsInfo].color, + ) + + # Generate machine-readable report in JSON format for SARIF conversion + raw_machine_report = ctx.actions.declare_file( + OUTFILE_FORMAT.format( + label = target.label.name, + mnemonic = _MNEMONIC, + suffix = "raw_machine_report", + ), + ) + + # Create separate action for JSON output + json_args = ctx.actions.args() + + # Use JSON format for machine-readable output (converted to SARIF) + json_args.add("--format", "json") + + # Honor exclusions in .standard.yml even though we pass explicit list of + # files + json_args.add("--force-exclusion") + + # Disable caching as Bazel handles caching at the action level + json_args.add("--cache", "false") + json_args.add_all(files_to_lint) + + outputs_list = [raw_machine_report] + command = _build_standardrb_command( + ctx.executable._standardrb.path, + raw_machine_report.path, + outputs.machine.exit_code.path if outputs.machine.exit_code else None, + ) + if outputs.machine.exit_code: + outputs_list.append(outputs.machine.exit_code) + + ctx.actions.run_shell( + inputs = files_to_lint + ctx.files._config_files, + outputs = outputs_list, + command = command, + arguments = [json_args], + mnemonic = _MNEMONIC, + progress_message = """\ +Generating machine-readable report for %{label} with Standard Ruby\ +""", + tools = [ctx.executable._standardrb], + ) + + parse_to_sarif_action( + ctx, + _MNEMONIC, + raw_machine_report, + outputs.machine.out, + ) + + return [info] + +def lint_standardrb_aspect( + binary, + configs, + rule_kinds = ["rb_binary", "rb_library", "rb_test"], + filegroup_tags = ["ruby", "lint-with-standardrb"]): + """A factory function to create a linter aspect. + + Args: + binary: Label of the Standard Ruby executable. + Example: "//tools/lint:standardrb" or "@bundle//bin:standardrb" + configs: Label or list of Labels of Standard Ruby config file(s). + Example: ["//:standard.yml"] or "//:standard.yml" + rule_kinds: list of rule kinds to visit. + See https://bazel.build/query/language#kind + filegroup_tags: list of filegroup tags. Filegroups with these tags + will be visited by the aspect in addition to Ruby rule kinds. + """ + + # syntax-sugar: allow a single config file in addition to a list + if type(configs) == "string": + configs = [configs] + + return aspect( + implementation = _standardrb_aspect_impl, + attrs = { + "_options": attr.label( + default = "//lint:options", + providers = [LintOptionsInfo], + ), + "_standardrb": attr.label( + default = binary, + allow_files = True, + executable = True, + cfg = "exec", + ), + "_patcher": attr.label( + default = "@aspect_rules_lint//lint/private:patcher", + executable = True, + cfg = "exec", + ), + "_config_files": attr.label_list( + default = configs, + allow_files = True, + ), + "_filegroup_tags": attr.string_list( + default = filegroup_tags, + ), + "_rule_kinds": attr.string_list( + default = rule_kinds, + ), + }, + toolchains = [OPTIONAL_SARIF_PARSER_TOOLCHAIN], + )