blob: 74b6d54544f21ba42cc90904f8637fa04d81aa3b [file]
#!/bin/bash
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#### CATEGORY=Source tree
### runs static analysis checks on modified files
## Usage: fx lint
## [--dry-run] [--verbose] [--fix] [--all]
## [--files=FILES,[FILES ...]]
## [--target=GN_TARGET]
## [--only=CHECK] [--skip=CHECK]
## [--commit-msg] [--json-output=PATH]
## [--git] [--changed-lines] [-- PATTERN]
##
## --dry-run Stops the program short of running the linters
## --all Lints all code in the git repo under the current working
## directory.
## --fix Applies automated fixes/replacements emitted by linters.
## --files Allows the user to specify files. Files are comma separated.
## Does not support globs. To lint all files in a directory, use
## `cd <dir> && fx lint --all`.
## --target Allows the user to specify a gn target.
## --only Runs only the specified SHAC check (e.g. `--only=buildifier_lint`).
## --skip Skips the specified SHAC check (e.g. `--skip=clippy`).
## --commit-msg
## Runs commit message checks against the current commit.
## --json-output
## Path to write SARIF JSON report to.
## --git The default; it uses `git diff` against the newest parent
## commit in the upstream branch (or against HEAD if no such commit
## is found). Files that are locally modified, staged or touched by
## any commits introduced on the local branch are linted.
## --changed-lines
## Lint changed lines only. Supported for C++ clang-tidy.
## --parallel
## Lints all files in parallel where applicable.
## --verbose Print all lint commands prior to execution.
## -- PATTERN
## For --all or --git, passes along -- PATTERN to `git ls-files`
## to filter what files are affected.
##
## Supported Ecosystems: Bazel/Starlark, C/C++, FIDL, Markdown, Python, Rust, Commit Messages
set -e
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"/lib/vars.sh || exit $?
function usage() {
fx-command-help
}
function zap-commas() {
printf %s "$1" | tr ',' '\n'
}
function get-diff-base() {
local upstream=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null)
if [[ -z "${upstream}" ]]; then
# Make sure upstream is always an ancestor commit.
if git merge-base --is-ancestor origin/main HEAD; then
upstream="origin/main"
elif git merge-base --is-ancestor JIRI_HEAD HEAD; then
if git merge-base --is-ancestor JIRI_HEAD origin/main; then
upstream=$(git merge-base HEAD origin/main)
else
upstream="JIRI_HEAD"
fi
else
upstream="HEAD"
fi
fi
local local_commit=$(git rev-list HEAD ^${upstream} -- 2>/dev/null | tail -1)
if [[ -z "${local_commit}" ]]; then
printf "HEAD"
else
git rev-parse "${local_commit}"^
fi
}
function print-and-execute() {
if [[ -n "${VERBOSE}" ]]; then
echo "$@"
fi
"$@"
}
function canonicalize() {
local root_dir="$1"
shift
for fileglob in "${@}"; do
if [[ "${fileglob}" = //* || "${fileglob}" = [^/]* ]]; then
local dir="${root_dir}"/
else
local dir=""
fi
for file in "${dir}""${fileglob#"//"}"; do
printf "${file}\n"
done
done
}
DRY_RUN=
VERBOSE=
FIX=
PARALLEL=
FILES_SPECIFIED=
CHANGED_LINES=
COMMIT_MSG=
SHAC_EXTRA_ARGS=()
fx-config-read
GET_FILES=get_git_files
while [ $# -gt 0 ]; do
ARG="$1"
case "$1" in
--verbose) VERBOSE="1" ;;
--dry-run) DRY_RUN="1" ;;
--fix) FIX="1" ;;
--parallel) PARALLEL="1" ;;
--changed-lines) CHANGED_LINES="1" ;;
--commit-msg|--commit) COMMIT_MSG="1" ;;
--all)
GET_FILES=get_all_files
;;
--git)
GET_FILES=get_git_files
;;
--only=*)
SHAC_EXTRA_ARGS+=("${ARG}")
;;
--skip=*)
SHAC_EXTRA_ARGS+=("${ARG}")
;;
--json-output=*)
SHAC_EXTRA_ARGS+=("${ARG}")
;;
--files=*)
FILES_SPECIFIED="1"
GET_FILES=:
OLDIFS=$IFS && IFS=$'\n' \
&& FILES=( \
$(canonicalize "${PWD}" $(zap-commas "${ARG#--files=}")) \
) \
&& IFS=$OLDIFS
;;
--target=*)
FILES_SPECIFIED="1"
GET_FILES=:
OLDIFS=$IFS && IFS=$'\n' \
&& FILES=( \
$(canonicalize "${FUCHSIA_DIR}" \
$(fx-gn desc "${FUCHSIA_BUILD_DIR}" "${ARG#--target=}" sources)) \
) \
&& IFS=$OLDIFS
;;
--help|-h)
usage
exit 0
;;
--) break ;;
*) usage && printf "Unknown flag %s\n" "${ARG}" && exit 1 ;;
esac
shift
done
GIT_FILTER=(
"$@"
":(top,exclude)third_party/bazel_vendor"
":(top,exclude)third_party/golibs/vendor"
":(top,exclude)third_party/rust_crates"
":(top,exclude)src/devices/tools/fidlgen_banjo/tests"
":(top,exclude)src/devices/tools/fidlgen_banjo/src/backends/templates/rust"
":(top,exclude)build/bazel/fuchsia_idk/validation_data"
":(top,exclude)build/sdk/generate_prebuild_idk/validation_data"
":(exclude)*/goldens/*"
)
get_git_files() {
OLDIFS=$IFS && IFS=$'\n' \
&& FILES=( \
$(canonicalize $(git rev-parse --show-toplevel) \
$(git diff --name-only $(get-diff-base) "${GIT_FILTER[@]}")) \
) \
&& IFS=$OLDIFS
}
get_all_files() {
OLDIFS=$IFS && IFS=$'\n' && FILES=( \
$(canonicalize "${PWD}" $(git ls-files "${GIT_FILTER[@]}")) \
) \
&& IFS=$OLDIFS
}
if [[ -n "${COMMIT_MSG}" ]]; then
SHAC_EXTRA_ARGS+=("--only=commit_msg")
fi
$GET_FILES
if [[ -n "${VERBOSE}" ]]; then
printf "Files to be linted:\n%s\n" "${FILES[@]}"
fi
# Stage 1: Build required host tools just-in-time
compiled_linter_extensions=(
"fidl-lint=.fidl"
"clippy-reporter=.rs"
)
tool_targets=()
for entry in "${compiled_linter_extensions[@]}"; do
tool="${entry%%=*}"
extensions="${entry##*=}"
needed=false
for file in "${FILES[@]}"; do
for ext in ${extensions}; do
if [[ ${file} = *${ext} ]]; then
needed=true
fi
done
done
if $needed; then
tool_path="$( fx-command-run list-build-artifacts --expect-one --name "${tool}" tools 2>/dev/null || true )"
if [[ -n "${tool_path}" && ! -x "${FUCHSIA_BUILD_DIR}/${tool_path}" ]]; then
fx-info "${tool} not built; building now..."
tool_label="$( fx-command-run list-build-artifacts --show-label --expect-one --name "${tool}" tools 2>/dev/null || true )"
if [[ -n "${tool_label}" ]]; then
tool_targets+=("$tool_label")
fi
fi
fi
done
if [[ ${#tool_targets[@]} -gt 0 ]]; then
fx-command-run build "${tool_targets[@]}"
fi
[[ -n "${DRY_RUN}" ]] && exit 0
# Separate files for clang-tidy and SHAC
clang_tidy_files=()
shac_files=()
for file in "${FILES[@]}"; do
if [[ ! -f "${file}" ]]; then
if [[ -n "${FILES_SPECIFIED}" ]]; then
fx-error "no such file: ${file}"
fi
continue
fi
shac_files+=("$file")
case "${file}" in
*.c|*.cc|*.cpp|*.h|*.hh|*.hpp)
clang_tidy_files+=("${file}")
;;
esac
done
# Stage 2: Direct clang-tidy execution on C++ files
if [[ ${#clang_tidy_files[@]} -gt 0 && -z "${COMMIT_MSG}" ]]; then
declare CLANG_TIDY="${PREBUILT_CLANG_DIR}/bin/clang-tidy"
declare CLANG_APPLY_REPLACEMENTS="${PREBUILT_CLANG_DIR}/bin/clang-apply-replacements"
declare RUN_CLANG_TIDY="${PREBUILT_CLANG_DIR}/bin/run-clang-tidy"
if [[ -f "${FUCHSIA_BUILD_DIR}/compile_commands.json" ]]; then
clang_tidy_cmd=(
"${PREBUILT_PYTHON3}"
"${RUN_CLANG_TIDY}"
-clang-tidy-binary "${CLANG_TIDY}"
-clang-apply-replacements-binary "${CLANG_APPLY_REPLACEMENTS}"
-p "${FUCHSIA_BUILD_DIR}"
)
if [[ -n "${FIX}" ]]; then
clang_tidy_cmd+=("-fix")
fi
print-and-execute "${clang_tidy_cmd[@]}" "${clang_tidy_files[@]}" || true
else
fx-warn "compile_commands.json not found in ${FUCHSIA_BUILD_DIR}; skipping clang-tidy. Run 'fx compdb' or 'fx gen' to enable C++ linting."
fi
fi
# Stage 3: Run SHAC for universal static analysis
shac_workdir="$FUCHSIA_DIR"
if [[ "$(git rev-parse --is-inside-work-tree 2> /dev/null)" == "true" ]] && \
[ -f "$(git rev-parse --show-toplevel)/shac.star" ]; then
shac_workdir="$(pwd)"
fi
if [[ -z "${JIRI_PACKAGES_DIR}" ]]; then
if git_common_dir=$(git -C "${FUCHSIA_DIR}" rev-parse --git-common-dir 2>/dev/null) && [[ -d "${git_common_dir}" ]]; then
parent_root=$(cd "${git_common_dir}/.." 2>/dev/null && pwd)
packages_dir="${parent_root}/.jiri_root/packages"
if [[ -d "${packages_dir}" ]]; then
export JIRI_PACKAGES_DIR="${packages_dir}"
fi
fi
fi
shac_subcommand="check"
if [[ -n "${FIX}" ]]; then
shac_subcommand="fix"
fi
if [[ ${#shac_files[@]} -gt 0 || -n "${COMMIT_MSG}" || "${GET_FILES}" == "get_all_files" ]]; then
print-and-execute "${PREBUILT_SHAC}" "${shac_subcommand}" \
-C "${shac_workdir}" \
--var fuchsia_build_dir="${FUCHSIA_BUILD_DIR#"$FUCHSIA_DIR/"}" \
"${SHAC_EXTRA_ARGS[@]}" \
"${shac_files[@]}"
fi
exit 0