diff --git a/.bazelrc b/.bazelrc index d1f17d7..2d6a2c0 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,10 +1,42 @@ +############################################################################### +## rules_shellcheck bazel configuration +############################################################################### + +# Ensure errors are logged to the console test --test_output=errors # Fix the excessive rebuilding when using anything that depends on protobuf rules # See https://github.com/bazelbuild/buildtools/issues/744 common --incompatible_strict_action_env + +# Use modern dependency resolution common --enable_bzlmod +# Speed up builds by skipping the creation of runfile links +build --nobuild_runfile_links + +# https://github.com/bazelbuild/bazel/issues/8195 +build --incompatible_disallow_empty_glob=true + +# https://github.com/bazelbuild/bazel/issues/12821 +build --nolegacy_external_runfiles + +# Disable network access in sandboxes by default. +build --sandbox_default_allow_network=false + +# https://github.com/bazelbuild/bazel/issues/19981 +common --incompatible_auto_exec_groups + +# https://github.com/bazelbuild/bazel/issues/23043 +build --incompatible_autoload_externally= + +# https://github.com/bazelbuild/bazel/issues/12933 +build --incompatible_config_setting_private_default_visibility + +############################################################################### +## Custom user flags +############################################################################### + try-import user.bazelrc # To update these lines, execute diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml index 04562e0..b3b655a 100644 --- a/.bcr/presubmit.yml +++ b/.bcr/presubmit.yml @@ -2,7 +2,7 @@ bcr_test_module: module_path: "examples/check_glob" matrix: - platform: ["debian10", "macos", "ubuntu2004"] + platform: ["debian10", "macos", "ubuntu2004", "windows"] bazel: [7.*, 8.*, 9.*] tasks: run_tests: diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2165d94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# cmd.exe resolves labels unreliably in batch files with LF line endings, so +# batch files are always checked out as CRLF. Shell scripts and shellcheck +# configuration must stay LF, otherwise shellcheck reports the carriage +# returns (and the scripts fail to run) when checked out on Windows. +*.bat text eol=crlf +*.cmd text eol=crlf +*.sh text eol=lf +.shellcheckrc text eol=lf diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index cbdb546..bac23c3 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -15,10 +15,12 @@ jobs: build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - os: macos-latest - os: ubuntu-latest + - os: windows-latest steps: - uses: actions/checkout@v7 @@ -29,7 +31,21 @@ jobs: - name: Test run: bazel test //... + # The examples double as integration tests of the rules as consumed + # from another module. They are run in place here so that every + # platform in the matrix covers them, including Windows where the + # release archive is not produced. + - name: Test example (check_glob) + run: bazel test //... + working-directory: examples/check_glob + + - name: Test example (optional_attributes) + run: bazel test //... + working-directory: examples/optional_attributes + - name: Create release archive and notes + # Packaging shells out to Unix tools; see //internal/pkg. + if: runner.os != 'Windows' # Set by GH actions, see # https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables run: ./ci/package.sh "${{ github.ref_name }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cffb6..90d101c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This document is maintaining changes since the last released version (0.1.1) * Add a `targets` attribute to {obj}`shellcheck_test` for linting `rules_shell` targets and their transitive `deps`. * Add `//shellcheck/settings:check_generated` and a matching tri-state `check_generated` attribute on {obj}`shellcheck_test` for linting generated sources. +* Add Windows support, using [rules_batch](https://github.com/periareon/rules_batch) to locate runfiles. ## v0.6.1 diff --git a/MODULE.bazel b/MODULE.bazel index 8f52035..1ecdd62 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -6,6 +6,7 @@ module( bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "rules_batch", version = "0.1.1") bazel_dep(name = "rules_shell", version = "0.7.1") shellcheck = use_extension("//shellcheck/internal:extensions.bzl", "shellcheck") diff --git a/deps.bzl b/deps.bzl index aa50a9f..fc9c484 100644 --- a/deps.bzl +++ b/deps.bzl @@ -1,6 +1,7 @@ """Provides shellcheck dependencies on all supported platforms: -- Linux 64-bit and ARM64 -- OSX 64-bit +- Linux x86_64, ARM64 and ARMv6 +- macOS x86_64 and ARM64 +- Windows x86_64 """ # buildifier: disable=bzl-visibility diff --git a/internal/pkg/BUILD.bazel b/internal/pkg/BUILD.bazel index 01026bb..8ea52dd 100644 --- a/internal/pkg/BUILD.bazel +++ b/internal/pkg/BUILD.bazel @@ -3,6 +3,13 @@ load("@rules_pkg//pkg:tar.bzl", "pkg_tar") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//:def.bzl", "shellcheck_test") +# Packaging shells out to Unix tools (`gzip`, `grep`, `sed`); dependents such +# as :release and :release.shellcheck inherit the incompatibility. +_NOT_WINDOWS = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], +}) + pkg_files( name = "files", srcs = [ @@ -30,6 +37,7 @@ pkg_tar( ":files", ], out = "rules_shellcheck.tar", + target_compatible_with = _NOT_WINDOWS, ) genrule( @@ -48,6 +56,7 @@ genrule( "sed \"s|{BUILD_EMBED_LABEL}|$$BUILD_EMBED_LABEL|g\" $< >$@", ]), stamp = 1, + target_compatible_with = _NOT_WINDOWS, ) sh_binary( diff --git a/shellcheck/internal/BUILD.bazel b/shellcheck/internal/BUILD.bazel index a624bd3..a79a1fa 100644 --- a/shellcheck/internal/BUILD.bazel +++ b/shellcheck/internal/BUILD.bazel @@ -1,18 +1,22 @@ +_EXECUTABLES = [ + "aspect_runner.sh", + "test_runner.sh", +] + filegroup( name = "distribution", srcs = glob( ["*"], - exclude = ["aspect_runner.sh"], + exclude = _EXECUTABLES, ), visibility = ["//shellcheck:__pkg__"], ) -# Split out so //internal/pkg can package these with mode 0755; -# aspect_runner.sh is invoked as the executable of ctx.actions.run -# and must be +x when consumed from the release tarball. +# Split out so //internal/pkg can package these with mode 0755; they are +# invoked as executables and must be +x when consumed from the release tarball. filegroup( name = "executable_distribution", - srcs = ["aspect_runner.sh"], + srcs = _EXECUTABLES, visibility = ["//shellcheck:__pkg__"], ) @@ -24,3 +28,21 @@ alias( }), visibility = ["//visibility:public"], ) + +alias( + name = "test_runner", + actual = select({ + "@platforms//os:windows": "test_runner.bat", + "//conditions:default": "test_runner.sh", + }), + visibility = ["//visibility:public"], +) + +alias( + name = "runfiles_lib", + actual = select({ + "@platforms//os:windows": "@rules_batch//batch/runfiles", + "//conditions:default": "@rules_shell//shell/runfiles", + }), + visibility = ["//visibility:public"], +) diff --git a/shellcheck/internal/aspect_runner.bat b/shellcheck/internal/aspect_runner.bat index 91ba1a3..ec8f256 100755 --- a/shellcheck/internal/aspect_runner.bat +++ b/shellcheck/internal/aspect_runner.bat @@ -1,24 +1,18 @@ -@echo off +@ECHO OFF setlocal enabledelayedexpansion -set "output=%~1" -shift - -if not "%~1"=="--" ( - echo aspect_runner: expected '--' after output path, got: %~1 1>&2 - exit /b 1 +@REM Usage: aspect_runner.bat -- [args...] +@REM +@REM Arguments are split from the full argument string because cmd.exe also +@REM splits numbered parameters on "=" (as in --rcfile=...). Forward slashes +@REM are replaced since cmd.exe cannot run a command whose path contains them. +set "args=%*" +for /F "usebackq tokens=1,2,3,*" %%A in ('!args!') do ( + set "output=%%~A" + set "shellcheck=%%~C" + set "shellcheck_args=%%D" ) -shift - -break > "%output%" - -set "cmd=" -:loop -if "%~1"=="" goto :run -set "cmd=!cmd! %1" -shift -goto :loop -:run -%cmd% -exit /b %errorlevel% +type nul > "!output:/=\!" +"!shellcheck:/=\!" !shellcheck_args! +exit /b !ERRORLEVEL! diff --git a/shellcheck/internal/rules.bzl b/shellcheck/internal/rules.bzl index f6d7ead..16e0a49 100644 --- a/shellcheck/internal/rules.bzl +++ b/shellcheck/internal/rules.bzl @@ -4,18 +4,20 @@ load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load("@rules_shell//shell:sh_binary_info.bzl", "ShBinaryInfo") load("@rules_shell//shell:sh_info.bzl", "ShInfo") -load(":toolchain.bzl", "TOOLCHAIN_TYPE") +load(":toolchain.bzl", "TOOLCHAIN_TYPE", "rlocationpath") -_SHELL_CONTENT = """\ -#!/bin/sh +# `rules_shell` forwards to the native shell rules where they exist (Bazel 7), +# and those do not advertise `ShInfo` or `ShBinaryInfo`. The providers are only +# required once the native rules are gone; until then targets are matched by +# rule kind instead. +_HAS_NATIVE_SH_RULES = hasattr(native, "sh_library") +_SH_PROVIDERS = [] if _HAS_NATIVE_SH_RULES else [[ShInfo], [ShBinaryInfo]] +_NATIVE_SH_KINDS = ["sh_binary", "sh_library", "sh_test"] -set -eu - -{shellcheck} {args} -""" - -# Batch files must use CRLF line endings. -_BATCH_CONTENT = "@ECHO OFF\r\n\r\n{shellcheck} {args}\r\n" +def _is_shell_target(target, ctx): + if ShInfo in target or ShBinaryInfo in target: + return True + return _HAS_NATIVE_SH_RULES and ctx.rule.kind in _NATIVE_SH_KINDS def shellcheck_test_impl(ctx, expect_fail = False): """The implementation of the `shellcheck_test` rule. @@ -27,25 +29,27 @@ def shellcheck_test_impl(ctx, expect_fail = False): Returns: list: All providers. """ - is_windows = ctx.target_platform_has_constraint( - ctx.attr._windows_constraint[platform_common.ConstraintValueInfo], - ) - toolchain = ctx.toolchains[TOOLCHAIN_TYPE] - executable = ctx.actions.declare_file("{}{}".format( - ctx.label.name, - ".bat" if is_windows else ".sh", - )) + runner = ctx.file._runner + executable = ctx.actions.declare_file("{}.{}".format(ctx.label.name, runner.extension)) + ctx.actions.symlink( + output = executable, + target_file = runner, + is_executable = True, + ) - cmd = [] + opts = [] if ctx.attr.format: - cmd.append("--format={}".format(ctx.attr.format)) + opts.append("--format={}".format(ctx.attr.format)) if ctx.attr.severity: - cmd.append("--severity={}".format(ctx.attr.severity)) + opts.append("--severity={}".format(ctx.attr.severity)) check_generated = ctx.attr.check_generated == 1 or ( ctx.attr.check_generated == -1 and ctx.attr._check_generated[BuildSettingInfo].value ) + for target in ctx.attr.targets: + if ShellcheckSrcsInfo not in target: + fail("{} in `targets` of {} is not a shell rule".format(target.label, ctx.label)) target_srcs = [ src for src in depset(transitive = [ @@ -57,43 +61,31 @@ def shellcheck_test_impl(ctx, expect_fail = False): ] files = ctx.files.data + target_srcs - # Linting runs from the runfiles tree, so `--source-path` is derived from - # `short_path` rather than the exec root paths in `ShellcheckSrcsInfo`. - source_paths = depset([src.short_path.rpartition("/")[0] or "." for src in target_srcs]).to_list() - - shellcheck_path = toolchain.shellcheck.short_path - shellcheck_rc = toolchain.shellcheckrc.short_path - srcs = [f.short_path for f in files] - if is_windows: - shellcheck_path.replace("/", "\\") - shellcheck_rc.replace("/", "\\") - srcs = [src.replace("/", "\\") for src in srcs] - source_paths = [path.replace("/", "\\") for path in source_paths] - - cmd.append("--rcfile={}".format(shellcheck_rc)) - cmd.extend(["--source-path={}".format(path) for path in source_paths]) - cmd.extend(srcs) - - if expect_fail: - cmd.append("|| exit 0; exit 1") - - ctx.actions.write( - output = executable, - content = (_BATCH_CONTENT if is_windows else _SHELL_CONTENT).format( - shellcheck = shellcheck_path, - args = " ".join(cmd), - ), - is_executable = True, + # The runner derives `--source-path` from one file per directory, since + # directories themselves are not listed in the runfiles manifest. + source_files = {src.short_path.rpartition("/")[0]: src for src in target_srcs}.values() + + workspace_name = ctx.workspace_name + env = { + "SHELLCHECK_TEST_BIN": rlocationpath(toolchain.shellcheck, workspace_name), + "SHELLCHECK_TEST_EXPECT_FAIL": "1" if expect_fail else "0", + "SHELLCHECK_TEST_FILES": " ".join([rlocationpath(f, workspace_name) for f in files]), + "SHELLCHECK_TEST_OPTS": " ".join(opts), + "SHELLCHECK_TEST_RCFILE": rlocationpath(toolchain.shellcheckrc, workspace_name), + "SHELLCHECK_TEST_SOURCE_FILES": " ".join([rlocationpath(f, workspace_name) for f in source_files]), + } + + runfiles = ctx.runfiles( + files = [toolchain.shellcheck, toolchain.shellcheckrc] + files, + transitive_files = toolchain.all_files, ) return [ DefaultInfo( executable = executable, - runfiles = ctx.runfiles( - files = [toolchain.shellcheck, toolchain.shellcheckrc] + files, - transitive_files = toolchain.all_files, - ), + runfiles = runfiles.merge(ctx.attr._runfiles_lib[DefaultInfo].default_runfiles), ), + RunEnvironmentInfo(environment = env), ] ShellcheckSrcsInfo = provider( @@ -106,7 +98,10 @@ ShellcheckSrcsInfo = provider( }, ) -def _shellcheck_srcs_aspect_impl(_target, ctx): +def _shellcheck_srcs_aspect_impl(target, ctx): + if not _is_shell_target(target, ctx): + return [] + srcs = getattr(ctx.rule.files, "srcs", []) source_paths = [src.dirname for src in srcs] @@ -159,14 +154,19 @@ ATTRS = { ), "targets": attr.label_list( doc = "`rules_shell` targets whose sources, and those of their transitive `deps`, are linted.", - providers = [[ShInfo], [ShBinaryInfo]], + providers = _SH_PROVIDERS, aspects = [_shellcheck_srcs_aspect], ), "_check_generated": attr.label( default = Label("//shellcheck/settings:check_generated"), ), - "_windows_constraint": attr.label( - default = Label("@platforms//os:windows"), + "_runfiles_lib": attr.label( + doc = "The runfiles library sourced by `_runner`.", + default = Label("//shellcheck/internal:runfiles_lib"), + ), + "_runner": attr.label( + default = Label("//shellcheck/internal:test_runner"), + allow_single_file = True, ), } @@ -278,5 +278,5 @@ shellcheck_aspect = aspect( }, toolchains = [TOOLCHAIN_TYPE], requires = [_shellcheck_srcs_aspect], - required_providers = [[ShInfo], [ShBinaryInfo]], + required_providers = _SH_PROVIDERS, ) diff --git a/shellcheck/internal/test_runner.bat b/shellcheck/internal/test_runner.bat new file mode 100755 index 0000000..077435b --- /dev/null +++ b/shellcheck/internal/test_runner.bat @@ -0,0 +1,57 @@ +@ECHO OFF +setlocal enableextensions enabledelayedexpansion + +@REM Runner for `shellcheck_test` on Windows. The rule passes its inputs as +@REM rlocation paths through `SHELLCHECK_TEST_*` environment variables; see +@REM `rules.bzl`. + +@REM --- begin runfiles.bat initialization v3 --- +set "_rf=batch/runfiles/runfiles.bat" +if not defined RUNFILES_DIR if exist "%~f0.runfiles\" set "RUNFILES_DIR=%~f0.runfiles" +set "_rf_mf=" +if defined RUNFILES_MANIFEST_FILE if exist "!RUNFILES_MANIFEST_FILE!" set "_rf_mf=!RUNFILES_MANIFEST_FILE!" +if not defined _rf_mf if defined RUNFILES_DIR for %%m in ("!RUNFILES_DIR!\MANIFEST" "!RUNFILES_DIR!_manifest") do if not defined _rf_mf if exist "%%~m" set "_rf_mf=%%~m" +if not defined _rf_mf for %%m in ("%~f0.runfiles_manifest" "%~f0.exe.runfiles_manifest") do if not defined _rf_mf if exist "%%~m" set "_rf_mf=%%~m" +if defined _rf_mf (set "_rf_mf=!_rf_mf:/=\!" & set "RUNFILES_MANIFEST_FILE=!_rf_mf!") +@REM Map the apparent repo name rules_batch to its canonical runfiles directory. +set "_rf_rm=" +if defined RUNFILES_DIR if exist "!RUNFILES_DIR!\_repo_mapping" set "_rf_rm=!RUNFILES_DIR!\_repo_mapping" +if not defined _rf_rm if defined _rf_mf for /F "usebackq tokens=1,*" %%i in (`%SYSTEMROOT%\system32\findstr.exe /b /l /c:"_repo_mapping " "!_rf_mf!" 2^>nul`) do set "_rf_rm=%%j" +set "_rf_c=" +if defined _rf_rm for /F "usebackq delims=" %%L in ("!_rf_rm:/=\!") do if not defined _rf_c ( + set "_rf_l=%%L" + if not "!_rf_l:,rules_batch,=!"=="!_rf_l!" (set "_rf_c=!_rf_l:*,=!" & set "_rf_c=!_rf_c:*,=!") +) +if not defined _rf_c set "_rf_c=rules_batch" +set "_rf=!_rf_c!/!_rf!" +set "RLOCATION=" +if defined RUNFILES_DIR if exist "!RUNFILES_DIR!\!_rf:/=\!" set "RLOCATION=!RUNFILES_DIR!\!_rf:/=\!" +if not defined RLOCATION if defined _rf_mf for /F "usebackq tokens=1,*" %%i in (`%SYSTEMROOT%\system32\findstr.exe /b /l /c:"!_rf! " "!_rf_mf!" 2^>nul`) do if not defined RLOCATION set "RLOCATION=%%j" +if not defined RLOCATION (echo>&2 ERROR: cannot find !_rf! in runfiles & exit /b 1) +set "RLOCATION=!RLOCATION:/=\!" +set "_rf=" & set "_rf_mf=" & set "_rf_rm=" & set "_rf_c=" & set "_rf_l=" +@REM --- end runfiles.bat initialization v3 --- + +call "%RLOCATION%" "%SHELLCHECK_TEST_BIN%" shellcheck || exit /b 1 +call "%RLOCATION%" "%SHELLCHECK_TEST_RCFILE%" rcfile || exit /b 1 +set "args=%SHELLCHECK_TEST_OPTS% --rcfile="!rcfile!"" + +@REM The drive and path modifier (~dp) ends with a backslash, which would +@REM escape the closing quote; a dot is appended to avoid that. +if defined SHELLCHECK_TEST_SOURCE_FILES for %%F in (%SHELLCHECK_TEST_SOURCE_FILES%) do ( + call "%RLOCATION%" "%%F" file || exit /b 1 + for %%D in ("!file!") do set "args=!args! --source-path="%%~dpD."" +) + +if defined SHELLCHECK_TEST_FILES for %%F in (%SHELLCHECK_TEST_FILES%) do ( + call "%RLOCATION%" "%%F" file || exit /b 1 + set "args=!args! "!file!"" +) + +if "%SHELLCHECK_TEST_EXPECT_FAIL%"=="1" ( + "!shellcheck!" !args! && (echo>&2 ERROR: expected shellcheck to fail & exit /b 1) + exit /b 0 +) + +"!shellcheck!" !args! +exit /b %ERRORLEVEL% diff --git a/shellcheck/internal/test_runner.sh b/shellcheck/internal/test_runner.sh new file mode 100755 index 0000000..aabd005 --- /dev/null +++ b/shellcheck/internal/test_runner.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Runner for `shellcheck_test`. The rule passes its inputs as rlocation paths +# through `SHELLCHECK_TEST_*` environment variables; see `rules.bzl`. + +# --- begin runfiles.bash initialization v3 --- +# Copy-pasted from the Bazel Bash runfiles library v3. +set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash +# shellcheck disable=SC1090 +source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \ + source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \ + source "$0.runfiles/$f" 2>/dev/null || \ + source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ + source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ + { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e +# --- end runfiles.bash initialization v3 --- + +# shellcheck disable=SC2086 # Options are intentionally split on whitespace. +set -- \ + "$(rlocation "${SHELLCHECK_TEST_BIN}")" \ + ${SHELLCHECK_TEST_OPTS} \ + "--rcfile=$(rlocation "${SHELLCHECK_TEST_RCFILE}")" + +for file in ${SHELLCHECK_TEST_SOURCE_FILES}; do + set -- "$@" "--source-path=$(dirname "$(rlocation "${file}")")" +done + +for file in ${SHELLCHECK_TEST_FILES}; do + set -- "$@" "$(rlocation "${file}")" +done + +if [[ "${SHELLCHECK_TEST_EXPECT_FAIL}" == "1" ]]; then + if "$@"; then + echo "ERROR: expected shellcheck to fail" >&2 + exit 1 + fi + exit 0 +fi + +exec "$@" diff --git a/shellcheck/internal/toolchain.bzl b/shellcheck/internal/toolchain.bzl index c0282e7..6175a7a 100644 --- a/shellcheck/internal/toolchain.bzl +++ b/shellcheck/internal/toolchain.bzl @@ -2,7 +2,16 @@ TOOLCHAIN_TYPE = str(Label("//shellcheck:toolchain_type")) -def _rlocationpath(file, workspace_name): +def rlocationpath(file, workspace_name): + """Compute the runfiles path of a file, e.g. for runfiles library lookups. + + Args: + file (File): The file to compute the path for. + workspace_name (str): The name of the main workspace (`ctx.workspace_name`). + + Returns: + str: The rlocation path. + """ if file.short_path.startswith("../"): return file.short_path[len("../"):] @@ -18,7 +27,7 @@ def _shellcheck_toolchain_impl(ctx): make_variable_info = platform_common.TemplateVariableInfo({ "SHELLCHECK": ctx.file.shellcheck.path, - "SHELLCHECK_RLOCATIONPATH": _rlocationpath(ctx.file.shellcheck, ctx.workspace_name), + "SHELLCHECK_RLOCATIONPATH": rlocationpath(ctx.file.shellcheck, ctx.workspace_name), }) return [