diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39d8893 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +* +!LICENSE +!Setup.hs +!ShellCheck.cabal +!shellcheck.hs +!src diff --git a/.ghci b/.ghci new file mode 100644 index 0000000..f20fa67 --- /dev/null +++ b/.ghci @@ -0,0 +1 @@ +:set -idist/build/autogen -isrc diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..493b465 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,27 @@ +#### For bugs +- Rule Id (if any, e.g. SC1000): +- My shellcheck version (`shellcheck --version` or "online"): +- [ ] The rule's wiki page does not already cover this (e.g. https://shellcheck.net/wiki/SC2086) +- [ ] I tried on https://www.shellcheck.net/ and verified that this is still a problem on the latest commit + +#### For new checks and feature suggestions +- [ ] https://www.shellcheck.net/ (i.e. the latest commit) currently gives no useful warnings about this +- [ ] I searched through https://github.com/koalaman/shellcheck/issues and didn't find anything related + + +#### Here's a snippet or screenshot that shows the problem: + +```sh + +#!/your/interpreter +your script here + +``` + +#### Here's what shellcheck currently says: + + + +#### Here's what I wanted or expected to see: + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..11b90d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a new bug report +title: '' +labels: '' +assignees: '' + +--- + +#### For bugs with existing features + +- Rule Id (if any, e.g. SC1000): +- My shellcheck version (`shellcheck --version` or "online"): +- [ ] The rule's wiki page does not already cover this (e.g. https://shellcheck.net/wiki/SC2086) +- [ ] I tried on https://www.shellcheck.net/ and verified that this is still a problem on the latest commit + +#### Here's a snippet or screenshot that shows the problem: + +```sh +#!/bin/sh +your script here +``` + +#### Here's what shellcheck currently says: + + + +#### Here's what I wanted or expected to see: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7184769 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +#### For new checks and feature suggestions +- [ ] https://www.shellcheck.net/ (i.e. the latest commit) currently gives no useful warnings about this +- [ ] I searched through https://github.com/koalaman/shellcheck/issues and didn't find anything related + +#### Here's a snippet or screenshot that shows a potential problem: + +```sh +#!/bin/sh +your script here +``` + +#### Here's what shellcheck currently says: + + + +#### Here's what I wanted to see: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..81bae9a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cf5d334 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,159 @@ +name: Build ShellCheck + +# Run this workflow every time a new commit pushed to your repository +on: push + +jobs: + package_source: + name: Package Source Code + runs-on: ubuntu-latest + steps: + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-mark manual ghc # Don't bother installing ghc just to tar up source + sudo apt-get install cabal-install + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Deduce tags + run: | + mkdir source + echo "latest" > source/tags + if tag=$(git describe --exact-match --tags) + then + echo "stable" >> source/tags + echo "$tag" >> source/tags + fi + cat source/tags + + - name: Package Source + run: | + grep "stable" source/tags || ./setgitversion + cabal sdist + mv dist-newstyle/sdist/*.tar.gz source/source.tar.gz + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: source + path: source/ + + run_tests: + name: Run tests + needs: package_source + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Install dependencies + run: | + sudo apt-get update && sudo apt-get install ghc cabal-install + cabal update + + - name: Unpack source + run: | + cd source + tar xvf source.tar.gz --strip-components=1 + + - name: Build and run tests + run: | + cd source + cabal test + + build_source: + name: Build + needs: package_source + strategy: + matrix: + build: [linux.x86_64, linux.aarch64, linux.armv6hf, linux.riscv64, darwin.x86_64, darwin.aarch64, windows.x86_64] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Build source + run: | + mkdir -p bin + mkdir -p bin/${{matrix.build}} + ( cd bin && ../builders/run_builder ../source/source.tar.gz ../builders/${{matrix.build}} ) + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{matrix.build}}.bin + path: bin/ + + package_binary: + name: Package Binaries + needs: build_source + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Work around GitHub permissions bug + run: chmod +x *.bin/*/shellcheck* + + - name: Package binaries + run: | + export TAGS="$(cat source/tags)" + mkdir -p deploy + cp -r *.bin/* deploy + cd deploy + ../.prepare_deploy + rm -rf */ README* LICENSE* + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: deploy + path: deploy/ + + deploy: + name: Deploy binaries + needs: package_binary + runs-on: ubuntu-latest + environment: Deploy + steps: + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install hub + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Upload to GitHub + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + export TAGS="$(cat source/tags)" + ./.github_deploy + + - name: Waiting for GitHub to replicate uploaded releases + run: | + sleep 300 + + - name: Upload to Docker Hub + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_EMAIL: ${{ secrets.DOCKER_EMAIL }} + DOCKER_BASE: ${{ secrets.DOCKER_USERNAME }}/shellcheck + run: | + export TAGS="$(cat source/tags)" + ( source ./.multi_arch_docker && set -eux && multi_arch_docker::main ) diff --git a/.github_deploy b/.github_deploy new file mode 100755 index 0000000..82c8ec5 --- /dev/null +++ b/.github_deploy @@ -0,0 +1,28 @@ +#!/bin/bash +set -x +shopt -s extglob + +export EDITOR="touch" + +# Sanity check +gh --version || exit 1 +hub release show latest || exit 1 + +for tag in $TAGS +do + if ! hub release show "$tag" + then + echo "Creating new release $tag" + git show --no-patch --format='format:%B' > description + hub release create -F description "$tag" + fi + + files=() + for file in deploy/* + do + [[ $file == *.@(xz|gz|zip) ]] || continue + [[ $file == *"$tag"* ]] || continue + files+=("$file") + done + gh release upload "$tag" "${files[@]}" --clobber || exit 1 +done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..050e7f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Created by https://www.gitignore.io + +### Haskell ### +dist +cabal-dev +*.o +*.hi +*.chi +*.chs.h +.virtualenv +.hsenv +.cabal-sandbox/ +cabal.sandbox.config +cabal.config +cabal.project.freeze +.stack-work + +### Snap ### +/snap/.snapcraft/ +/stage/ +/parts/ +/prime/ +*.snap +/dist-newstyle/ diff --git a/.multi_arch_docker b/.multi_arch_docker new file mode 100755 index 0000000..81048a2 --- /dev/null +++ b/.multi_arch_docker @@ -0,0 +1,89 @@ +#!/bin/bash +# This script builds and deploys multi-architecture docker images from the +# binaries previously built and deployed to GitHub. + +function multi_arch_docker::install_docker_buildx() { + # Install QEMU multi-architecture support for docker buildx. + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + + # Instantiate docker buildx builder with multi-architecture support. + docker buildx create --name mybuilder + docker buildx use mybuilder + # Start up buildx and verify that all is OK. + docker buildx inspect --bootstrap +} + +# Log in to Docker Hub for deployment. +function multi_arch_docker::login_to_docker_hub() { + echo "$DOCKER_PASSWORD" | docker login -u="$DOCKER_USERNAME" --password-stdin +} + +# Run buildx build and push. Passed in arguments augment the command line. +function multi_arch_docker::buildx() { + mkdir -p /tmp/empty + docker buildx build \ + --platform "${DOCKER_PLATFORMS// /,}" \ + --push \ + --progress plain \ + -f Dockerfile.multi-arch \ + "$@" \ + /tmp/empty + rmdir /tmp/empty +} + +# Build and push plain and alpine docker images for all tags. +function multi_arch_docker::build_and_push_all() { + for tag in $TAGS; do + multi_arch_docker::buildx -t "$DOCKER_BASE:$tag" --build-arg "tag=$tag" + multi_arch_docker::buildx -t "$DOCKER_BASE-alpine:$tag" \ + --build-arg "tag=$tag" --target alpine + done +} + +# Test all pushed docker images. +function multi_arch_docker::test_all() { + printf '%s\n' "#!/bin/sh" "echo 'hello world'" > myscript + + for platform in $DOCKER_PLATFORMS; do + for tag in $TAGS; do + for ext in '-alpine' ''; do + image="${DOCKER_BASE}${ext}:${tag}" + msg="Testing docker image $image on platform $platform" + line="${msg//?/=}" + printf '\n%s\n%s\n%s\n' "${line}" "${msg}" "${line}" + docker pull -q --platform "$platform" "$image" + if [ -n "$ext" ]; then + echo -n "Image architecture: " + docker run --rm --entrypoint /bin/sh "$image" -c 'uname -m' + version=$(docker run --rm "$image" shellcheck --version \ + | grep 'version:') + else + version=$(docker run --rm "$image" --version | grep 'version:') + fi + version=${version/#version: /v} + echo "shellcheck version: $version" + if [[ ! ("$tag" =~ ^(latest|stable)$) && "$tag" != "$version" ]]; then + echo "Version mismatch: shellcheck $version tagged as $tag" + exit 1 + fi + if [ -n "$ext" ]; then + docker run --rm -v "$PWD:/mnt" -w /mnt "$image" shellcheck myscript + else + docker run --rm -v "$PWD:/mnt" "$image" myscript + fi + done + done + done +} + +function multi_arch_docker::main() { + export DOCKER_PLATFORMS='linux/amd64' + DOCKER_PLATFORMS+=' linux/arm64' + DOCKER_PLATFORMS+=' linux/arm/v6' + DOCKER_PLATFORMS+=' linux/riscv64' + + multi_arch_docker::install_docker_buildx + multi_arch_docker::login_to_docker_hub + multi_arch_docker::build_and_push_all + multi_arch_docker::test_all +} diff --git a/.prepare_deploy b/.prepare_deploy new file mode 100755 index 0000000..9f39912 --- /dev/null +++ b/.prepare_deploy @@ -0,0 +1,54 @@ +#!/bin/bash +# This script packages up compiled binaries +set -ex +shopt -s nullglob extglob + +ls -l + +cp ../LICENSE LICENSE.txt +sed -e $'s/$/\r/' > README.txt << END +This is a precompiled ShellCheck binary. + https://www.shellcheck.net/ + +ShellCheck is a static analysis tool for shell scripts. +It's licensed under the GNU General Public License v3.0. +Information and source code is available on the website. + +This binary was compiled on $(date -u). + + + + ====== Latest commits ====== + +$(git log -n 3) +END + +for dir in */ +do + cp LICENSE.txt README.txt "$dir" +done + +echo "Tags are $TAGS" + +for tag in $TAGS +do + + for dir in windows.*/ + do + ( cd "$dir" && zip "../shellcheck-$tag.zip" * ) + done + + for dir in {linux,darwin}.*/ + do + base="${dir%/}" + ( cd "$dir" && tar -cJf "../shellcheck-$tag.$base.tar.xz" --transform="s:^:shellcheck-$tag/:" * ) + done +done + +for file in ./* +do + [[ -f "$file" ]] || continue + sha512sum "$file" > "$file.sha512sum" +done + +ls -l diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2ac9ef6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,566 @@ +## Git +### Added + +### Changed + +### Fixed + +### Removed + + +## v0.11.0 - 2025-08-03 +### Added +- SC2327/SC2328: Warn about capturing the output of redirected commands. +- SC2329: Warn when (non-escaping) functions are never invoked. +- SC2330: Warn about unsupported glob matches with [[ .. ]] in BusyBox. +- SC2331: Suggest using standard -e instead of unary -a in tests. +- SC2332: Warn about `[ ! -o opt ]` being unconditionally true in Bash. +- SC3062: Warn about bashism `[ -o opt ]`. +- Optional `avoid-negated-conditions`: suggest replacing `[ ! a -eq b ]` + with `[ a -ne b ]`, and similar for -ge/-lt/=/!=/etc (SC2335). +- Precompiled binaries for Linux riscv64 (linux.riscv64) + +### Changed +- SC2002 about Useless Use Of Cat is now disabled by default. It can be + re-enabled with `--enable=useless-use-of-cat` or equivalent directive. +- SC2236/SC2237 about replacing `[ ! -n .. ]` with `[ -z ]` and vice versa + is now optional under `avoid-negated-conditions`. +- SC2015 about `A && B || C` no longer triggers when B is a test command. +- SC3012: Do not warn about `\<` and `\>` in test/[] as specified in POSIX.1-2024 +- Diff output now uses / as path separator on Windows + +### Fixed +- SC2218 about function use-before-define is now more accurate. +- SC2317 about unreachable commands is now less spammy for nested ones. +- SC2292, optional suggestion for [[ ]], now triggers for Busybox. +- Updates for Bash 5.3, including `${| cmd; }` and `source -p` + +### Removed +- SC3013: removed since the operators `-ot/-nt/-ef` are specified in POSIX.1-2024 + + +## v0.10.0 - 2024-03-07 +### Added +- Precompiled binaries for macOS ARM64 (darwin.aarch64) +- Added support for busybox sh +- Added flag --rcfile to specify an rc file by name. +- Added `extended-analysis=true` directive to enable/disable dataflow analysis + (with a corresponding --extended-analysis flag). +- SC2324: Warn when x+=1 appends instead of increments +- SC2325: Warn about multiple `!`s in dash/sh. +- SC2326: Warn about `foo | ! bar` in bash/dash/sh. +- SC3012: Warn about lexicographic-compare bashism in test like in [ ] +- SC3013: Warn bashism `test _ -op/-nt/-ef _` like in [ ] +- SC3014: Warn bashism `test _ == _` like in [ ] +- SC3015: Warn bashism `test _ =~ _` like in [ ] +- SC3016: Warn bashism `test -v _` like in [ ] +- SC3017: Warn bashism `test -a _` like in [ ] + +### Fixed +- source statements with here docs now work correctly +- "(Array.!): undefined array element" error should no longer occur + + +## v0.9.0 - 2022-12-12 +### Added +- SC2316: Warn about 'local readonly foo' and similar (thanks, patrickxia!) +- SC2317: Warn about unreachable commands +- SC2318: Warn about backreferences in 'declare x=1 y=$x' +- SC2319/SC2320: Warn when $? refers to echo/printf/[ ]/[[ ]]/test +- SC2321: Suggest removing $((..)) in array[$((idx))]=val +- SC2322: Suggest collapsing double parentheses in arithmetic contexts +- SC2323: Suggest removing wrapping parentheses in a[(x+1)]=val + +### Fixed +- SC2086: Now uses DFA to make more accurate predictions about values +- SC2086: No longer warns about values declared as integer with declare -i + +### Changed +- ShellCheck now has a Data Flow Analysis engine to make smarter decisions + based on control flow rather than just syntax. Existing checks will + gradually start using it, which may cause them to trigger differently + (but more accurately). +- Values in directives/shellcheckrc can now be quoted with '' or "" + + +## v0.8.0 - 2021-11-06 +### Added +- `disable=all` now conveniently disables all warnings +- `external-sources=true` directive can be added to .shellcheckrc to make + shellcheck behave as if `-x` was specified. +- Optional `check-extra-masked-returns` for pointing out commands with + suppressed exit codes (SC2312). +- Optional `require-double-brackets` for recommending \[\[ ]] (SC2292). +- SC2286-SC2288: Warn when command name ends in a symbol like `/.)'"` +- SC2289: Warn when command name contains tabs or linefeeds +- SC2291: Warn about repeated unquoted spaces between words in echo +- SC2292: Suggest [[ over [ in Bash/Ksh scripts (optional) +- SC2293/SC2294: Warn when calling `eval` with arrays +- SC2295: Warn about "${x#$y}" treating $y as a pattern when not quoted +- SC2296-SC2301: Improved warnings for bad parameter expansions +- SC2302/SC2303: Warn about loops over array values when using them as keys +- SC2304-SC2306: Warn about unquoted globs in expr arguments +- SC2307: Warn about insufficient number of arguments to expr +- SC2308: Suggest other approaches for non-standard expr extensions +- SC2313: Warn about `read` with unquoted, array indexed variable + +### Fixed +- SC2102 about repetitions in ranges no longer triggers on [[ -v arr[xx] ]] +- SC2155 now recognizes `typeset` and local read-only `declare` statements +- SC2181 now tries to avoid triggering for error handling functions +- SC2290: Warn about misused = in declare & co, which were not caught by SC2270+ +- The flag --color=auto no longer outputs color when TERM is "dumb" or unset + +### Changed +- SC2048: Warning about $\* now also applies to ${array[\*]} +- SC2181 now only triggers on single condition tests like `[ $? = 0 ]`. +- Quote warnings are now emitted for declaration utilities in sh +- Leading `_` can now be used to suppress warnings about unused variables +- TTY output now includes warning level in text as well as color + +### Removed +- SC1004: Literal backslash+linefeed in '' was found to be usually correct + + +## v0.7.2 - 2021-04-19 +### Added +- `disable` directives can now be a range, e.g. `disable=SC3000-SC4000` +- SC1143: Warn about line continuations in comments +- SC2259/SC2260: Warn when redirections override pipes +- SC2261: Warn about multiple competing redirections +- SC2262/SC2263: Warn about aliases declared and used in the same parsing unit +- SC2264: Warn about wrapper functions that blatantly recurse +- SC2265/SC2266: Warn when using & or | with test statements +- SC2267: Warn when using xargs -i instead of -I +- SC2268: Warn about unnecessary x-comparisons like `[ x$var = xval ]` + +### Fixed +- SC1072/SC1073 now respond to disable annotations, though ignoring parse errors + is still purely cosmetic and does not allow ShellCheck to continue. +- Improved error reporting for trailing tokens after ]/]] and compound commands +- `#!/usr/bin/env -S shell` is now handled correctly +- Here docs with \r are now parsed correctly and give better warnings + +### Changed +- Assignments are now parsed to spec, without leniency for leading $ or spaces +- POSIX/dash unsupported feature warnings now have individual SC3xxx codes +- SC1090: A leading `$x/` or `$(x)/` is now treated as `./` when locating files +- SC2154: Variables appearing in -z/-n tests are no longer considered unassigned +- SC2270-SC2285: Improved warnings about misused `=`, e.g. `${var}=42` + + +## v0.7.1 - 2020-04-04 +### Fixed +- `-f diff` no longer claims that it found more issues when it didn't +- Known empty variables now correctly trigger SC2086 +- ShellCheck should now be compatible with Cabal 3 +- SC2154 and all command-specific checks now trigger for builtins + called with `builtin` + +### Added +- SC1136: Warn about unexpected characters after ]/]] +- SC2254: Suggest quoting expansions in case statements +- SC2255: Suggest using `$((..))` in `[ 2*3 -eq 6 ]` +- SC2256: Warn about translated strings that are known variables +- SC2257: Warn about arithmetic mutation in redirections +- SC2258: Warn about trailing commas in for loop elements + +### Changed +- SC2230: 'command -v' suggestion is now off by default (-i deprecate-which) +- SC1081: Keywords are now correctly parsed case sensitively, with a warning + + +## v0.7.0 - 2019-07-28 +### Added +- Precompiled binaries for macOS and Linux aarch64 +- Preliminary support for fix suggestions +- New `-f diff` unified diff format for auto-fixes +- Files containing Bats tests can now be checked +- Directory wide directives can now be placed in a `.shellcheckrc` +- Optional checks: Use `--list-optional` to show a list of tests, + Enable with `-o` flags or `enable=name` directives +- Source paths: Use `-P dir1:dir2` or a `source-path=dir1` directive + to specify search paths for sourced files. +- json1 format like --format=json but treats tabs as single characters +- Recognize FLAGS variables created by the shflags library. +- Site-specific changes can now be made in Custom.hs for ease of patching +- SC2154: Also warn about unassigned uppercase variables (optional) +- SC2252: Warn about `[ $a != x ] || [ $a != y ]`, similar to SC2055 +- SC2251: Inform about ineffectual ! in front of commands +- SC2250: Warn about variable references without braces (optional) +- SC2249: Warn about `case` with missing default case (optional) +- SC2248: Warn about unquoted variables without special chars (optional) +- SC2247: Warn about $"(cmd)" and $"{var}" +- SC2246: Warn if a shebang's interpreter ends with / +- SC2245: Warn that Ksh ignores all but the first glob result in `[` +- SC2243/SC2244: Suggest using explicit -n for `[ $foo ]` (optional) +- SC1135: Suggest not ending double quotes just to make $ literal + +### Changed +- If a directive or shebang is not specified, a `.bash/.bats/.dash/.ksh` + extension will be used to infer the shell type when present. +- Disabling SC2120 on a function now disables SC2119 on call sites + +### Fixed +- SC2183 no longer warns about missing printf args for `%()T` + +## v0.6.0 - 2018-12-02 +### Added +- Command line option --severity/-S for filtering by minimum severity +- Command line option --wiki-link-count/-W for showing wiki links +- SC2152/SC2151: Warn about bad `exit` values like `1234` and `"foo"` +- SC2236/SC2237: Suggest -n/-z instead of ! -z/-n +- SC2238: Warn when redirecting to a known command name, e.g. ls > rm +- SC2239: Warn if the shebang is not an absolute path, e.g. #!bin/sh +- SC2240: Warn when passing additional arguments to dot (.) in sh/dash +- SC1133: Better diagnostics when starting a line with |/||/&& + +### Changed +- Most warnings now have useful end positions +- SC1117 about unknown double-quoted escape sequences has been retired + +### Fixed +- SC2021 no longer triggers for equivalence classes like `[=e=]` +- SC2221/SC2222 no longer mistriggers on fall-through case branches +- SC2081 about glob matches in `[ .. ]` now also triggers for `!=` +- SC2086 no longer warns about spaces in `$#` +- SC2164 no longer suggests subshells for `cd ..; cmd; cd ..` +- `read -a` is now correctly considered an array assignment +- SC2039 no longer warns about LINENO now that it's POSIX + +## v0.5.0 - 2018-05-31 +### Added +- SC2233/SC2234/SC2235: Suggest removing or replacing (..) around tests +- SC2232: Warn about invalid arguments to sudo +- SC2231: Suggest quoting expansions in for loop globs +- SC2229: Warn about 'read $var' +- SC2227: Warn about redirections in the middle of 'find' commands +- SC2224/SC2225/SC2226: Warn when using mv/cp/ln without a destination +- SC2223: Quote warning specific to `: ${var=value}` +- SC1131: Warn when using `elseif` or `elsif` +- SC1128: Warn about blanks/comments before shebang +- SC1127: Warn about C-style comments + +### Fixed +- Annotations intended for a command's here documents now work +- Escaped characters inside groups in =~ regexes now parse +- Associative arrays are now respected in arithmetic contexts +- SC1087 about `$var[@]` now correctly triggers on any index +- Bad expansions in here documents are no longer ignored +- FD move operations like {fd}>1- now parse correctly + +### Changed +- Here docs are now terminated as per spec, rather than by presumed intent +- SC1073: 'else if' is now parsed correctly and not like 'elif' +- SC2163: 'export $name' can now be silenced with 'export ${name?}' +- SC2183: Now warns when printf arg count is not a multiple of format count + +## v0.4.7 - 2017-12-08 +### Added +- Statically linked binaries for Linux and Windows (see README.md)! +- `-a` flag to also include warnings in `source`d files +- SC2221/SC2222: Warn about overridden case branches +- SC2220: Warn about unhandled error cases in getopt loops +- SC2218: Warn when using functions before they're defined +- SC2216/SC2217: Warn when piping/redirecting to mv/cp and other non-readers +- SC2215: Warn about commands starting with leading dash +- SC2214: Warn about superfluous getopt flags +- SC2213: Warn about unhandled getopt flags +- SC2212: Suggest `false` over `[ ]` +- SC2211: Warn when using a glob as a command name +- SC2210: Warn when redirecting to an integer, e.g. `foo 1>2` +- SC2206/SC2207: Suggest alternatives when using word splitting in arrays +- SC1117: Warn about double quoted, undefined backslash sequences +- SC1113/SC1114/SC1115: Recognized more malformed shebangs + +### Fixed +- `[ -v foo ]` no longer warns if `foo` is undefined +- SC2037 is now suppressed by quotes, e.g. `PAGER="cat" man foo` +- Ksh nested array declarations now parse correctly +- Parameter Expansion without colons are now recognized, e.g. `${foo+bar}` +- The `lastpipe` option is now respected with regard to subshell warnings +- `\(` is now respected for grouping in `[` +- Leading `\` is now ignored for commands, to allow alias suppression +- Comments are now allowed after directives to e.g. explain 'disable' + + +## v0.4.6 - 2017-03-26 +### Added +- SC2204/SC2205: Warn about `( -z foo )` and `( foo -eq bar )` +- SC2200/SC2201: Warn about brace expansion in [/[[ +- SC2198/SC2199: Warn about arrays in [/[[ +- SC2196/SC2197: Warn about deprecated egrep/fgrep +- SC2195: Warn about unmatchable case branches +- SC2194: Warn about constant 'case' statements +- SC2193: Warn about `[[ file.png == *.mp3 ]]` and other unmatchables +- SC2188/SC2189: Warn about redirections without commands +- SC2186: Warn about deprecated `tempfile` +- SC1109: Warn when finding `&`/`>`/`<` unquoted +- SC1108: Warn about missing spaces in `[ var= foo ]` + +### Changed +- All files are now read as UTF-8 with lenient latin1 fallback, ignoring locale +- Unicode quotes are no longer considered syntactic quotes +- `ash` scripts will now be checked as `dash` with a warning + +### Fixed +- `-c` no longer suggested when using `grep -o | wc` +- Comments and whitespace are now allowed before filewide directives +- Here doc delimiters with esoteric quoting like `foo""` are now handled +- SC2095 about `ssh` in while read loops is now suppressed when using `-n` +- `%(%Y%M%D)T` now recognized as a single formatter in `printf` checks +- `grep -F` now suppresses regex related suggestions +- Command name checks now recognize busybox applet names + + +## v0.4.5 - 2016-10-21 +### Added +- A Docker build (thanks, kpankonen!) +- SC2185: Suggest explicitly adding path for `find` +- SC2184: Warn about unsetting globs (e.g. `unset foo[1]`) +- SC2183: Warn about `printf` with more formatters than variables +- SC2182: Warn about ignored arguments with `printf` +- SC2181: Suggest using command directly instead of `if [ $? -eq 0 ]` +- SC1106: Warn when using `test` operators in `(( 1 -eq 2 ))` + +### Changed +- Unrecognized directives now causes a warning rather than parse failure. + +### Fixed +- Indices in associative arrays are now parsed correctly +- Missing shebang warning squashed when specifying with a directive +- Ksh multidimensional arrays are now supported +- Variables in substring ${a:x:y} expansions now count as referenced +- SC1102 now also handles ambiguous `$((` +- Using `$(seq ..)` will no longer suggest quoting +- SC2148 (missing shebang) is now suppressed when using shell directives +- `[ a '>' b ]` is now recognized as being correctly escaped + + +## v0.4.4 - 2016-05-15 +### Added +- Haskell Stack support (thanks, Arguggi!) +- SC2179/SC2178: Warn when assigning/appending strings to arrays +- SC1102: Warn about ambiguous `$(((` +- SC1101: Warn when \\ linebreaks have trailing spaces + +### Changed +- Directives directly after the shebang now apply to the entire file + +### Fixed +- `{$i..10}` is now flagged similar to `{1..$i}` + + +## v0.4.3 - 2016-01-13 +### Fixed +- Build now works on GHC 7.6.3 as found on Debian Stable/Ubuntu LTS + + +## v0.4.2 - 2016-01-09 +### Added +- First class support for the `dash` shell +- The `--color` flag similar to ls/grep's (thanks, haguenau!) +- SC2174: Warn about unexpected behavior of `mkdir -pm` (thanks, eatnumber1!) +- SC2172: Warn about non-portable use of signal numbers in `trap` +- SC2171: Warn about `]]` without leading `[[` +- SC2168: Warn about `local` outside functions + +### Fixed +- Warnings about unchecked `cd` will no longer trigger with `set -e` +- `[ a -nt/-ot/-ef b ]` no longer warns about being constant +- Quoted test operators like `[ foo "<" bar ]` now parse +- Escaped quotes in backticks now parse correctly + + +## v0.4.1 - 2015-09-05 +### Fixed +- Added missing files to Cabal, fixing the build + + +## v0.4.0 - 2015-09-05 +### Added +- Support for following `source`d files +- Support for setting default flags in `SHELLCHECK_OPTS` +- An `--external-sources` flag for following arbitrary `source`d files +- A `source` directive to override the filename to `source` +- SC2166: Suggest using `[ p ] && [ q ]` over `[ p -a q ]` +- SC2165: Warn when nested `for` loops use the same variable name +- SC2164: Warn when using `cd` without checking that it succeeds +- SC2163: Warn about `export $var` +- SC2162: Warn when using `read` without `-r` +- SC2157: Warn about `[ "$var " ]` and similar never-empty string matches + +### Fixed +- `cat -vnE file` and similar will no longer flag as UUOC +- Nested trinary operators in `(( ))` now parse correctly +- Ksh `${ ..; }` command expansions now parse + + +## v0.3.8 - 2015-06-20 +### Changed +- ShellCheck's license has changed from AGPLv3 to GPLv3. + +### Added +- SC2156: Warn about injecting filenames in `find -exec sh -c "{}" \;` + +### Fixed +- Variables and command substitutions in brace expansions are now parsed +- ANSI colors are now disabled on Windows +- Empty scripts now parse + + +## v0.3.7 - 2015-04-16 +### Fixed +- Build now works on GHC 7.10 +- Use `regex-tdfa` over `regex-compat` since the latter crashes on OS X. + +## v0.3.6 - 2015-03-28 +### Added +- SC2155: Warn about masked return values in `export foo=$(exit 1)` +- SC2154: Warn when a lowercase variable is referenced but not assigned +- SC2152/SC2151: Warn about bad `return` values like `1234` and `"foo"` +- SC2150: Warn about `find -exec "shell command" \;` + +### Fixed +- `coproc` is now supported +- Trinary operator now recognized in `((..))` + +### Removed +- Zsh support has been removed + + +## v0.3.5 - 2014-11-09 +### Added +- SC2148: Warn when not including a shebang +- SC2147: Warn about literal ~ in PATH +- SC1086: Warn about `$` in for loop variables, e.g. `for $i in ..` +- SC1084: Warn when the shebang uses `!#` instead of `#!` + +### Fixed +- Empty and comment-only backtick expansions now parse +- Variables used in PS1/PROMPT\_COMMAND/trap now count as referenced +- ShellCheck now skips unreadable files and directories +- `-f gcc` on empty files no longer crashes +- Variables in $".." are now considered quoted +- Warnings about expansions in single quotes now include backticks + + +## v0.3.4 - 2014-07-08 +### Added +- SC2146: Warn about precedence when combining `find -o` with actions +- SC2145: Warn when concatenating arrays and strings + +### Fixed +- Case statements now support `;&` and `;;&` +- Indices in array declarations now parse correctly +- `let` expressions now parsed as arithmetic expressions +- Escaping is now respected in here documents + +### Changed +- Completely drop Makefile in favor of Cabal (thanks rodrigosetti!) + + +## v0.3.3 - 2014-05-29 +### Added +- SC2144: Warn when using globs in `[/[[` +- SC2143: Suggesting using `grep -q` over `[ "$(.. | grep)" ]` +- SC2142: Warn when referencing positional parameters in aliases +- SC2141: Warn about suspicious IFS assignments like `IFS="\n"` +- SC2140: Warn about bad embedded quotes like `echo "var="value""` +- SC2130: Warn when using `-eq` on strings +- SC2139: Warn about define time expansions in alias definitions +- SC2129: Suggest command grouping over `a >> log; b >> log; c >> log` +- SC2128: Warn when expanding arrays without an index +- SC2126: Suggest `grep -c` over `grep|wc` +- SC2123: Warn about accidentally overriding `$PATH`, e.g. `PATH=/my/dir` +- SC1083: Warn about literal `{/}` outside of quotes +- SC1082: Warn about UTF-8 BOMs + +### Fixed +- SC2051 no longer triggers for `{1,$n}`, only `{1..$n}` +- Improved detection of single quoted `sed` variables, e.g. `sed '$s///'` +- Stop warning about single quoted variables in `PS1` and similar +- Support for Zsh short form loops, `=(..)` + +### Removed +- SC1000 about unescaped lonely `$`, e.g. `grep "^foo$"` + + +## v0.3.2 - 2014-03-22 +### Added +- SC2121: Warn about trying to `set` variables, e.g. `set var = value` +- SC2120/SC2119: Warn when a function uses `$1..` if none are ever passed +- SC2117: Warn when using `su` in interactive mode, e.g. `su foo; whoami` +- SC2116: Detect useless use of echo, e.g. `for i in $(echo $var)` +- SC2115/SC2114: Detect some catastrophic `rm -r "$empty/"` mistakes +- SC1081: Warn when capitalizing keywords like `While` +- SC1077: Warn when using acute accents instead of backticks + +### Fixed +- Shells are now properly recognized in shebangs containing flags +- Stop warning about math on decimals in ksh/zsh +- Stop warning about decimal comparisons with `=`, e.g. `[ $version = 1.2 ]` +- Parsing of `|&` +- `${a[x]}` not counting as a reference of `x` +- `(( x[0] ))` not counting as a reference of `x` + + +## v0.3.1 - 2014-02-03 +### Added +- The `-s` flag to specify shell dialect +- SC2105/SC2104: Warn about `break/continue` outside loops +- SC1076: Detect invalid `[/[[` arithmetic like `[ 1 + 2 = 3 ]` +- SC1075: Suggest using `elif` over `else if` + +### Fixed +- Don't warn when comma separating elements in brace expansions +- Improved detection of single quoted `sed` variables, e.g. `sed '$d'` +- Parsing of arithmetic for loops using `{..}` instead of `do..done` +- Don't treat the last pipeline stage as a subshell in ksh/zsh + + +## v0.3.0 - 2014-01-19 +### Added +- A man page (thanks Dridi!) +- GCC compatible error reporting (`shellcheck -f gcc`) +- CheckStyle compatible XML error reporting (`shellcheck -f checkstyle`) +- Error codes for each warning, e.g. SC1234 +- Allow disabling warnings with `# shellcheck disable=SC1234` +- Allow disabling warnings with `--exclude` +- SC2103: Suggest using subshells over `cd foo; bar; cd ..` +- SC2102: Warn about duplicates in char ranges, e.g. `[10-15]` +- SC2101: Warn about named classes not inside a char range, e.g. `[:digit:]` +- SC2100/SC2099: Warn about bad math expressions like `i=i+5` +- SC2098/SC2097: Warn about `foo=bar echo $foo` +- SC2095: Warn when using `ssh`/`ffmpeg` in `while read` loops +- Better warnings for missing here doc tokens + +### Fixed +- Don't warn when single quoting variables with `ssh/perl/eval` +- `${!var}` is now counted as a variable reference + +### Removed +- Suggestions about using parameter expansion over basename +- The `jsoncheck` binary. Use `shellcheck -f json` instead. + + +## v0.2.0 - 2013-10-27 +### Added +- Suggest `./*` instead of `*` when passing globs to commands +- Suggest `pgrep` over `ps | grep` +- Warn about unicode quotes +- Warn about assigned but unused variables +- Inform about client side expansion when using `ssh` + +### Fixed +- CLI tool now uses exit codes and stderr canonically +- Parsing of extglobs containing empty patterns +- Parsing of bash style `eval foo=(bar)` +- Parsing of expansions in here documents +- Parsing of function names containing :+- +- Don't warn about `find|xargs` when using `-print0` + + +## v0.1.0 - 2013-07-23 +### Added +- First release diff --git a/Dockerfile.multi-arch b/Dockerfile.multi-arch new file mode 100644 index 0000000..24ed18c --- /dev/null +++ b/Dockerfile.multi-arch @@ -0,0 +1,24 @@ +# Alpine image +FROM alpine:latest AS alpine +LABEL maintainer="Vidar Holen " +ARG tag + +# Put the right binary for each architecture into place for the +# multi-architecture docker image. +ARG url_base="https://github.com/koalaman/shellcheck/releases/download/" +RUN set -x; \ + arch="$(uname -m)"; \ + echo "arch is $arch"; \ + if [ "${arch}" = 'armv7l' ]; then \ + arch='armv6hf'; \ + fi; \ + tar_file="${tag}/shellcheck-${tag}.linux.${arch}.tar.xz"; \ + wget "${url_base}${tar_file}" -O - | tar -C /bin --strip-components=1 -xJf - "shellcheck-${tag}/shellcheck" && \ + ls -laF /bin/shellcheck + +# ShellCheck image +FROM scratch +LABEL maintainer="Vidar Holen " +WORKDIR /mnt +COPY --from=alpine /bin/shellcheck /bin/ +ENTRYPOINT ["/bin/shellcheck"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9747e76 --- /dev/null +++ b/README.md @@ -0,0 +1,564 @@ +[![Build Status](https://github.com/koalaman/shellcheck/actions/workflows/build.yml/badge.svg)](https://github.com/koalaman/shellcheck/actions/workflows/build.yml) + + +# ShellCheck - A shell script static analysis tool + +ShellCheck is a GPLv3 tool that gives warnings and suggestions for bash/sh shell scripts: + +![Screenshot of a terminal showing problematic shell script lines highlighted](doc/terminal.png) + +The goals of ShellCheck are + +* To point out and clarify typical beginner's syntax issues that cause a shell + to give cryptic error messages. + +* To point out and clarify typical intermediate level semantic problems that + cause a shell to behave strangely and counter-intuitively. + +* To point out subtle caveats, corner cases and pitfalls that may cause an + advanced user's otherwise working script to fail under future circumstances. + +See [the gallery of bad code](README.md#user-content-gallery-of-bad-code) for examples of what ShellCheck can help you identify! + +## Table of Contents + +* [How to use](#how-to-use) + * [On the web](#on-the-web) + * [From your terminal](#from-your-terminal) + * [In your editor](#in-your-editor) + * [In your build or test suites](#in-your-build-or-test-suites) +* [Installing](#installing) +* [Compiling from source](#compiling-from-source) + * [Installing Cabal](#installing-cabal) + * [Compiling ShellCheck](#compiling-shellcheck) + * [Running tests](#running-tests) +* [Gallery of bad code](#gallery-of-bad-code) + * [Quoting](#quoting) + * [Conditionals](#conditionals) + * [Frequently misused commands](#frequently-misused-commands) + * [Common beginner's mistakes](#common-beginners-mistakes) + * [Style](#style) + * [Data and typing errors](#data-and-typing-errors) + * [Robustness](#robustness) + * [Portability](#portability) + * [Miscellaneous](#miscellaneous) +* [Testimonials](#testimonials) +* [Ignoring issues](#ignoring-issues) +* [Reporting bugs](#reporting-bugs) +* [Contributing](#contributing) +* [Copyright](#copyright) +* [Other Resources](#other-resources) + +## How to use + +There are a number of ways to use ShellCheck! + +### On the web + +Paste a shell script on for instant feedback. + +[ShellCheck.net](https://www.shellcheck.net) is always synchronized to the latest git commit, and is the easiest way to give ShellCheck a go. Tell your friends! + +### From your terminal + +Run `shellcheck yourscript` in your terminal for instant output, as seen above. + +### In your editor + +You can see ShellCheck suggestions directly in a variety of editors. + +* Vim, through [ALE](https://github.com/w0rp/ale), [Neomake](https://github.com/neomake/neomake), or [Syntastic](https://github.com/scrooloose/syntastic): + +![Screenshot of Vim showing inlined shellcheck feedback](doc/vim-syntastic.png). + +* Emacs, through [Flycheck](https://github.com/flycheck/flycheck) or [Flymake](https://github.com/federicotdn/flymake-shellcheck): + +![Screenshot of emacs showing inlined shellcheck feedback](doc/emacs-flycheck.png). + +* Sublime, through [SublimeLinter](https://github.com/SublimeLinter/SublimeLinter-shellcheck). + +* Pulsar Edit (former Atom), through [linter-shellcheck-pulsar](https://github.com/pulsar-cooperative/linter-shellcheck-pulsar). + +* VSCode, through [vscode-shellcheck](https://github.com/timonwong/vscode-shellcheck). + +* Most other editors, through [GCC error compatibility](shellcheck.1.md#user-content-formats). + +### In your build or test suites + +While ShellCheck is mostly intended for interactive use, it can easily be added to builds or test suites. +It makes canonical use of exit codes, so you can just add a `shellcheck` command as part of the process. + +For example, in a Makefile: + +```Makefile +check-scripts: + # Fail if any of these files have warnings + shellcheck myscripts/*.sh +``` + +or in a Travis CI `.travis.yml` file: + +```yaml +script: + # Fail if any of these files have warnings + - shellcheck myscripts/*.sh +``` + +Services and platforms that have ShellCheck pre-installed and ready to use: + +* [Travis CI](https://travis-ci.org/) +* [Codacy](https://www.codacy.com/) +* [Code Climate](https://codeclimate.com/) +* [Code Factor](https://www.codefactor.io/) +* [Codety](https://www.codety.io/) via the [Codety Scanner](https://github.com/codetyio/codety-scanner) +* [CircleCI](https://circleci.com) via the [ShellCheck Orb](https://circleci.com/orbs/registry/orb/circleci/shellcheck) +* [Github](https://github.com/features/actions) (only Linux) +* [Trunk Code Quality](https://trunk.io/code-quality) (universal linter; [allows you to explicitly version your shellcheck install](https://github.com/trunk-io/plugins/blob/bcbb361dcdbe4619af51ea7db474d7fb87540d20/.trunk/trunk.yaml#L32)) via the [shellcheck plugin](https://github.com/trunk-io/plugins/blob/main/linters/shellcheck/plugin.yaml) +* [CodeRabbit](https://coderabbit.ai/) + +Most other services, including [GitLab](https://about.gitlab.com/), let you install +ShellCheck yourself, either through the system's package manager (see [Installing](#installing)), +or by downloading and unpacking a [binary release](#installing-a-pre-compiled-binary). + +It's a good idea to manually install a specific ShellCheck version regardless. This avoids +any surprise build breaks when a new version with new warnings is published. + +For customized filtering or reporting, ShellCheck can output simple JSON, CheckStyle compatible XML, +GCC compatible warnings as well as human readable text (with or without ANSI colors). See the +[Integration](https://github.com/koalaman/shellcheck/wiki/Integration) wiki page for more documentation. + +## Installing + +The easiest way to install ShellCheck locally is through your package manager. + +On systems with Cabal (installs to `~/.cabal/bin`): + + cabal update + cabal install ShellCheck + +On systems with Stack (installs to `~/.local/bin`): + + stack update + stack install ShellCheck + +On Debian based distros: + + sudo apt install shellcheck + +On Arch Linux based distros: + + pacman -S shellcheck + +or get the dependency free [shellcheck-bin](https://aur.archlinux.org/packages/shellcheck-bin/) from the AUR. + +On Gentoo based distros: + + emerge --ask shellcheck + +On EPEL based distros: + + sudo yum -y install epel-release + sudo yum install ShellCheck + +On Fedora based distros: + + dnf install ShellCheck + +On FreeBSD: + + pkg install hs-ShellCheck + +On macOS (OS X) with Homebrew: + + brew install shellcheck + +Or with MacPorts: + + sudo port install shellcheck + +On OpenBSD: + + pkg_add shellcheck + +On openSUSE + + zypper in ShellCheck + +Or use OneClickInstall - + +On Solus: + + eopkg install shellcheck + +On Windows (via [chocolatey](https://chocolatey.org/packages/shellcheck)): + +```cmd +C:\> choco install shellcheck +``` + +Or Windows (via [winget](https://github.com/microsoft/winget-pkgs)): + +```cmd +C:\> winget install --id koalaman.shellcheck +``` + +Or Windows (via [scoop](http://scoop.sh)): + +```cmd +C:\> scoop install shellcheck +``` + +From [conda-forge](https://anaconda.org/conda-forge/shellcheck): + + conda install -c conda-forge shellcheck + +From Snap Store: + + snap install --channel=edge shellcheck + +From Docker Hub: + +```sh +docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable myscript +# Or :v0.4.7 for that version, or :latest for daily builds +``` + +or use `koalaman/shellcheck-alpine` if you want a larger Alpine Linux based image to extend. It works exactly like a regular Alpine image, but has shellcheck preinstalled. + +Using the [nix package manager](https://nixos.org/nix): +```sh +nix-env -iA nixpkgs.shellcheck +``` + +Using the [Flox package manager](https://flox.dev/) +```sh +flox install shellcheck +``` + +Alternatively, you can download pre-compiled binaries for the latest release here: + +* [Linux, x86_64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.x86_64.tar.xz) (statically linked) +* [Linux, armv6hf](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.armv6hf.tar.xz), i.e. Raspberry Pi (statically linked) +* [Linux, aarch64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.aarch64.tar.xz) aka ARM64 (statically linked) +* [macOS, aarch64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.darwin.aarch64.tar.xz) +* [macOS, x86_64](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.darwin.x86_64.tar.xz) +* [Windows, x86](https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.zip) + +or see the [GitHub Releases](https://github.com/koalaman/shellcheck/releases) for other releases +(including the [latest](https://github.com/koalaman/shellcheck/releases/tag/latest) meta-release for daily git builds). + +There are currently no official binaries for Apple Silicon, but third party builds are available via +[ShellCheck for Visual Studio Code](https://github.com/vscode-shellcheck/shellcheck-binaries/releases). + +Distro packages already come with a `man` page. If you are building from source, it can be installed with: + +```console +pandoc -s -f markdown-smart -t man shellcheck.1.md -o shellcheck.1 +sudo mv shellcheck.1 /usr/share/man/man1 +``` + +### pre-commit + +To run ShellCheck via [pre-commit](https://pre-commit.com/), add the hook to your `.pre-commit-config.yaml`: + +``` +repos: +- repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.7.2 + hooks: + - id: shellcheck +# args: ["--severity=warning"] # Optionally only show errors and warnings +``` + +### Travis CI + +Travis CI has now integrated ShellCheck by default, so you don't need to manually install it. + +If you still want to do so in order to upgrade at your leisure or ensure you're +using the latest release, follow the steps below to install a binary version. + +### Installing a pre-compiled binary + +The pre-compiled binaries come in `tar.xz` files. To decompress them, make sure +`xz` is installed. +On Debian/Ubuntu/Mint, you can `apt install xz-utils`. +On Redhat/Fedora/CentOS, `yum -y install xz`. + +A simple installer may do something like: + +```bash +scversion="stable" # or "v0.4.7", or "latest" +wget -qO- "https://github.com/koalaman/shellcheck/releases/download/${scversion?}/shellcheck-${scversion?}.linux.x86_64.tar.xz" | tar -xJv +cp "shellcheck-${scversion}/shellcheck" /usr/bin/ +shellcheck --version +``` + +## Compiling from source + +This section describes how to build ShellCheck from a source directory. ShellCheck is written in Haskell and requires 2GB of RAM to compile. + +### Installing Cabal + +ShellCheck is built and packaged using Cabal. Install the package `cabal-install` from your system's package manager (with e.g. `apt-get`, `brew`, `emerge`, `yum`, or `zypper`). + +On macOS (OS X), you can do a fast install of Cabal using brew, which takes a couple of minutes instead of more than 30 minutes if you try to compile it from source. + + $ brew install cabal-install + +On MacPorts, the package is instead called `hs-cabal-install`, while native Windows users should install the latest version of the Haskell platform from + +Verify that `cabal` is installed and update its dependency list with + + $ cabal update + +### Compiling ShellCheck + +`git clone` this repository, and `cd` to the ShellCheck source directory to build/install: + + $ cabal install + +This will compile ShellCheck and install it to your `~/.cabal/bin` directory. + +Add this directory to your `PATH` (for bash, add this to your `~/.bashrc`): + +```sh +export PATH="$HOME/.cabal/bin:$PATH" +``` + +Log out and in again, and verify that your PATH is set up correctly: + +```sh +$ which shellcheck +~/.cabal/bin/shellcheck +``` + +On native Windows, the `PATH` should already be set up, but the system +may use a legacy codepage. In `cmd.exe`, `powershell.exe` and Powershell ISE, +make sure to use a TrueType font, not a Raster font, and set the active +codepage to UTF-8 (65001) with `chcp`: + +```cmd +chcp 65001 +``` + +In Powershell ISE, you may need to additionally update the output encoding: + +```powershell +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +``` + +### Running tests + +To run the unit test suite: + + $ cabal test + +## Gallery of bad code + +So what kind of things does ShellCheck look for? Here is an incomplete list of detected issues. + +### Quoting + +ShellCheck can recognize several types of incorrect quoting: + +```sh +echo $1 # Unquoted variables +find . -name *.ogg # Unquoted find/grep patterns +rm "~/my file.txt" # Quoted tilde expansion +v='--verbose="true"'; cmd $v # Literal quotes in variables +for f in "*.ogg" # Incorrectly quoted 'for' loops +touch $@ # Unquoted $@ +echo 'Don't forget to restart!' # Singlequote closed by apostrophe +echo 'Don\'t try this at home' # Attempting to escape ' in '' +echo 'Path is $PATH' # Variables in single quotes +trap "echo Took ${SECONDS}s" 0 # Prematurely expanded trap +unset var[i] # Array index treated as glob +``` + +### Conditionals + +ShellCheck can recognize many types of incorrect test statements. + +```sh +[[ n != 0 ]] # Constant test expressions +[[ -e *.mpg ]] # Existence checks of globs +[[ $foo==0 ]] # Always true due to missing spaces +[[ -n "$foo " ]] # Always true due to literals +[[ $foo =~ "fo+" ]] # Quoted regex in =~ +[ foo =~ re ] # Unsupported [ ] operators +[ $1 -eq "shellcheck" ] # Numerical comparison of strings +[ $n && $m ] # && in [ .. ] +[ grep -q foo file ] # Command without $(..) +[[ "$$file" == *.jpg ]] # Comparisons that can't succeed +(( 1 -lt 2 )) # Using test operators in ((..)) +[ x ] & [ y ] | [ z ] # Accidental backgrounding and piping +``` + +### Frequently misused commands + +ShellCheck can recognize instances where commands are used incorrectly: + +```sh +grep '*foo*' file # Globs in regex contexts +find . -exec foo {} && bar {} \; # Prematurely terminated find -exec +sudo echo 'Var=42' > /etc/profile # Redirecting sudo +time --format=%s sleep 10 # Passing time(1) flags to time builtin +while read h; do ssh "$h" uptime # Commands eating while loop input +alias archive='mv $1 /backup' # Defining aliases with arguments +tr -cd '[a-zA-Z0-9]' # [] around ranges in tr +exec foo; echo "Done!" # Misused 'exec' +find -name \*.bak -o -name \*~ -delete # Implicit precedence in find +# find . -exec foo > bar \; # Redirections in find +f() { whoami; }; sudo f # External use of internal functions +``` + +### Common beginner's mistakes + +ShellCheck recognizes many common beginner's syntax errors: + +```sh +var = 42 # Spaces around = in assignments +$foo=42 # $ in assignments +for $var in *; do ... # $ in for loop variables +var$n="Hello" # Wrong indirect assignment +echo ${var$n} # Wrong indirect reference +var=(1, 2, 3) # Comma separated arrays +array=( [index] = value ) # Incorrect index initialization +echo $var[14] # Missing {} in array references +echo "Argument 10 is $10" # Positional parameter misreference +if $(myfunction); then ..; fi # Wrapping commands in $() +else if othercondition; then .. # Using 'else if' +f; f() { echo "hello world; } # Using function before definition +[ false ] # 'false' being true +if ( -f file ) # Using (..) instead of test +``` + +### Style + +ShellCheck can make suggestions to improve style: + +```sh +[[ -z $(find /tmp | grep mpg) ]] # Use grep -q instead +a >> log; b >> log; c >> log # Use a redirection block instead +echo "The time is `date`" # Use $() instead +cd dir; process *; cd ..; # Use subshells instead +echo $[1+2] # Use standard $((..)) instead of old $[] +echo $(($RANDOM % 6)) # Don't use $ on variables in $((..)) +echo "$(date)" # Useless use of echo +cat file | grep foo # Useless use of cat +``` + +### Data and typing errors + +ShellCheck can recognize issues related to data and typing: + +```sh +args="$@" # Assigning arrays to strings +files=(foo bar); echo "$files" # Referencing arrays as strings +declare -A arr=(foo bar) # Associative arrays without index +printf "%s\n" "Arguments: $@." # Concatenating strings and arrays +[[ $# > 2 ]] # Comparing numbers as strings +var=World; echo "Hello " var # Unused lowercase variables +echo "Hello $name" # Unassigned lowercase variables +cmd | read bar; echo $bar # Assignments in subshells +cat foo | cp bar # Piping to commands that don't read +printf '%s: %s\n' foo # Mismatches in printf argument count +eval "${array[@]}" # Lost word boundaries in array eval +for i in "${x[@]}"; do ${x[$i]} # Using array value as key +``` + +### Robustness + +ShellCheck can make suggestions for improving the robustness of a script: + +```sh +rm -rf "$STEAMROOT/"* # Catastrophic rm +touch ./-l; ls * # Globs that could become options +find . -exec sh -c 'a && b {}' \; # Find -exec shell injection +printf "Hello $name" # Variables in printf format +for f in $(ls *.txt); do # Iterating over ls output +export MYVAR=$(cmd) # Masked exit codes +case $version in 2.*) :;; 2.6.*) # Shadowed case branches +``` + +### Portability + +ShellCheck will warn when using features not supported by the shebang. For example, if you set the shebang to `#!/bin/sh`, ShellCheck will warn about portability issues similar to `checkbashisms`: + +```sh +echo {1..$n} # Works in ksh, but not bash/dash/sh +echo {1..10} # Works in ksh and bash, but not dash/sh +echo -n 42 # Works in ksh, bash and dash, undefined in sh +expr match str regex # Unportable alias for `expr str : regex` +trap 'exit 42' sigint # Unportable signal spec +cmd &> file # Unportable redirection operator +read foo < /dev/tcp/host/22 # Unportable intercepted files +foo-bar() { ..; } # Undefined/unsupported function name +[ $UID = 0 ] # Variable undefined in dash/sh +local var=value # local is undefined in sh +time sleep 1 | sleep 5 # Undefined uses of 'time' +``` + +### Miscellaneous + +ShellCheck recognizes a menagerie of other issues: + +```sh +PS1='\e[0;32m\$\e[0m ' # PS1 colors not in \[..\] +PATH="$PATH:~/bin" # Literal tilde in $PATH +rm “file” # Unicode quotes +echo "Hello world" # Carriage return / DOS line endings +echo hello \ # Trailing spaces after \ +var=42 echo $var # Expansion of inlined environment +!# bin/bash -x -e # Common shebang errors +echo $((n/180*100)) # Unnecessary loss of precision +ls *[:digit:].txt # Bad character class globs +sed 's/foo/bar/' file > file # Redirecting to input +var2=$var2 # Variable assigned to itself +[ x$var = xval ] # Antiquated x-comparisons +ls() { ls -l "$@"; } # Infinitely recursive wrapper +alias ls='ls -l'; ls foo # Alias used before it takes effect +for x; do for x; do # Nested loop uses same variable +while getopts "a" f; do case $f in "b") # Unhandled getopts flags +``` + +## Testimonials + +> At first you're like "shellcheck is awesome" but then you're like "wtf are we still using bash" + +Alexander Tarasikov, +[via Twitter](https://twitter.com/astarasikov/status/568825996532707330) + +## Ignoring issues + +Issues can be ignored via environmental variable, command line, individually or globally within a file: + + + +## Reporting bugs + +Please use the GitHub issue tracker for any bugs or feature suggestions: + + + +## Contributing + +Please submit patches to code or documentation as GitHub pull requests! Check +out the [DevGuide](https://github.com/koalaman/shellcheck/wiki/DevGuide) on the +ShellCheck Wiki. + +Contributions must be licensed under the GNU GPLv3. +The contributor retains the copyright. + +## Copyright + +ShellCheck is licensed under the GNU General Public License, v3. A copy of this license is included in the file [LICENSE](LICENSE). + +Copyright 2012-2019, [Vidar 'koala_man' Holen](https://github.com/koalaman/) and contributors. + +Happy ShellChecking! + +## Other Resources + +* The wiki has [long form descriptions](https://github.com/koalaman/shellcheck/wiki/Checks) for each warning, e.g. [SC2221](https://github.com/koalaman/shellcheck/wiki/SC2221). +* ShellCheck does not attempt to enforce any kind of formatting or indenting style, so also check out [shfmt](https://github.com/mvdan/sh)! diff --git a/ShellCheck.cabal b/ShellCheck.cabal new file mode 100644 index 0000000..6e21526 --- /dev/null +++ b/ShellCheck.cabal @@ -0,0 +1,146 @@ +Name: ShellCheck +Version: 0.11.0 +Synopsis: Shell script analysis tool +License: GPL-3 +License-file: LICENSE +Category: Static Analysis +Author: Vidar Holen +Maintainer: vidar@vidarholen.net +Homepage: https://www.shellcheck.net/ +Build-Type: Simple +Cabal-Version: 1.18 +Bug-reports: https://github.com/koalaman/shellcheck/issues +Description: + The goals of ShellCheck are: + . + * To point out and clarify typical beginner's syntax issues, + that causes a shell to give cryptic error messages. + . + * To point out and clarify typical intermediate level semantic problems, + that causes a shell to behave strangely and counter-intuitively. + . + * To point out subtle caveats, corner cases and pitfalls, that may cause an + advanced user's otherwise working script to fail under future circumstances. + +Extra-Doc-Files: + README.md + CHANGELOG.md +Extra-Source-Files: + -- documentation + shellcheck.1.md + -- A script to build the man page using pandoc + manpage + -- convenience script for stripping tests + striptests + -- tests + test/shellcheck.hs + +source-repository head + type: git + location: git://github.com/koalaman/shellcheck.git + +library + hs-source-dirs: src + if impl(ghc < 8.0) + build-depends: + semigroups + build-depends: + -- The lower bounds are based on GHC 7.10.3 + -- The upper bounds are based on GHC 9.12.1 + aeson >= 1.4.0 && < 2.3, + array >= 0.5.1 && < 0.6, + base >= 4.8.0.0 && < 5, + bytestring >= 0.10.6 && < 0.13, + containers >= 0.5.6 && < 0.9, + deepseq >= 1.4.1 && < 1.6, + Diff >= 0.4.0 && < 1.1, + fgl (>= 5.7.0 && < 5.8.1.0) || (>= 5.8.1.1 && < 5.9), + filepath >= 1.4.0 && < 1.6, + mtl >= 2.2.2 && < 2.4, + parsec >= 3.1.14 && < 3.2, + QuickCheck >= 2.14.2 && < 2.17, + regex-tdfa >= 1.2.0 && < 1.4, + transformers >= 0.4.2 && < 0.7, + + -- getXdgDirectory from 1.2.3.0 + directory >= 1.2.3 && < 1.4, + + -- When cabal supports it, move this to setup-depends: + process + exposed-modules: + ShellCheck.AST + ShellCheck.ASTLib + ShellCheck.Analytics + ShellCheck.Analyzer + ShellCheck.AnalyzerLib + ShellCheck.CFG + ShellCheck.CFGAnalysis + ShellCheck.Checker + ShellCheck.Checks.Commands + ShellCheck.Checks.ControlFlow + ShellCheck.Checks.Custom + ShellCheck.Checks.ShellSupport + ShellCheck.Data + ShellCheck.Debug + ShellCheck.Fixer + ShellCheck.Formatter.Format + ShellCheck.Formatter.CheckStyle + ShellCheck.Formatter.Diff + ShellCheck.Formatter.GCC + ShellCheck.Formatter.JSON + ShellCheck.Formatter.JSON1 + ShellCheck.Formatter.TTY + ShellCheck.Formatter.Quiet + ShellCheck.Interface + ShellCheck.Parser + ShellCheck.Prelude + ShellCheck.Regex + other-modules: + Paths_ShellCheck + default-language: Haskell98 + +executable shellcheck + if impl(ghc < 8.0) + build-depends: + semigroups + build-depends: + aeson, + array, + base, + bytestring, + containers, + deepseq, + Diff, + directory, + fgl, + mtl, + filepath, + parsec, + QuickCheck, + regex-tdfa, + transformers, + ShellCheck + default-language: Haskell98 + main-is: shellcheck.hs + +test-suite test-shellcheck + type: exitcode-stdio-1.0 + build-depends: + aeson, + array, + base, + bytestring, + containers, + deepseq, + Diff, + directory, + fgl, + filepath, + mtl, + parsec, + QuickCheck, + regex-tdfa, + transformers, + ShellCheck + default-language: Haskell98 + main-is: test/shellcheck.hs diff --git a/builders/README.md b/builders/README.md new file mode 100644 index 0000000..31e8607 --- /dev/null +++ b/builders/README.md @@ -0,0 +1,17 @@ +This directory contains Dockerfiles for all builds. + +A build image will: + +* Run on Linux x86\_64 with vanilla Docker (no exceptions) +* Not contain any software that would restrict easy modification or copying +* Take a `cabal sdist` style tar.gz of the ShellCheck directory on stdin +* Output a tar.gz of artifacts on stdout, in a directory named for the arch + +This makes it simple to build any release without exotic hardware or software. + +An image can be built and tagged using `build_builder`, +and run on a source tarball using `run_builder`. + +Tip: Are you developing an image that relies on QEmu usermode emulation? + It's easy to accidentally depend on binfmt\_misc on the host OS. + Do a `echo 0 | sudo tee /proc/sys/fs/binfmt_misc/status` before testing. diff --git a/builders/build_builder b/builders/build_builder new file mode 100755 index 0000000..b34b996 --- /dev/null +++ b/builders/build_builder @@ -0,0 +1,12 @@ +#!/bin/sh +if [ $# -eq 0 ] +then + echo >&2 "No build image directories specified" + echo >&2 "Example: $0 build/*/" + exit 1 +fi + +for dir +do + ( cd "$dir" && docker build -t "$(cat tag)" . ) || exit 1 +done diff --git a/builders/darwin.aarch64/Dockerfile b/builders/darwin.aarch64/Dockerfile new file mode 100644 index 0000000..95c1cbe --- /dev/null +++ b/builders/darwin.aarch64/Dockerfile @@ -0,0 +1,40 @@ +FROM ghcr.io/shepherdjerred/macos-cross-compiler@sha256:7d40c5e179d5d15453cf2a6b1bba3392bb1448b8257ee6b86021fc905c59dad6 + +ENV TARGET=aarch64-apple-darwin22 +ENV TARGETNAME=darwin.aarch64 + +# Build dependencies +USER root +ENV DEBIAN_FRONTEND=noninteractive +ENV LC_ALL=C.utf8 + +# Install basic deps +RUN apt-get update && apt-get install -y automake autoconf build-essential curl xz-utils qemu-user-static + +# Install a more suitable host compiler +WORKDIR /host-ghc +RUN curl -L "https://downloads.haskell.org/~cabal/cabal-install-3.9.0.0/cabal-install-3.9-x86_64-linux-alpine.tar.xz" | tar xJv -C /usr/local/bin +RUN curl -L 'https://downloads.haskell.org/~ghc/8.10.7/ghc-8.10.7-x86_64-deb10-linux.tar.xz' | tar xJ --strip-components=1 +RUN ./configure && make install + +# Build GHC. We have to use an old version because cross-compilation across OS has since broken. +WORKDIR /ghc +RUN curl -L "https://downloads.haskell.org/~ghc/8.10.7/ghc-8.10.7-src.tar.xz" | tar xJ --strip-components=1 +RUN apt-get install -y llvm-12 +RUN ./boot && ./configure --host x86_64-linux-gnu --build x86_64-linux-gnu --target "$TARGET" +RUN cp mk/flavours/quick-cross.mk mk/build.mk && make -j "$(nproc)" +RUN make install + +# Due to an apparent cabal bug, we specify our options directly to cabal +# It won't reuse caches if ghc-options are specified in ~/.cabal/config +ENV CABALOPTS="--ghc-options;-optc-Os -optc-fPIC;--with-ghc=$TARGET-ghc;--with-hc-pkg=$TARGET-ghc-pkg;--constraint=hashable==1.3.5.0" + +# Prebuild the dependencies +RUN cabal update +RUN IFS=';' && cabal install --dependencies-only $CABALOPTS ShellCheck + +# Copy the build script +COPY build /usr/bin + +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/darwin.aarch64/build b/builders/darwin.aarch64/build new file mode 100755 index 0000000..ff522ff --- /dev/null +++ b/builders/darwin.aarch64/build @@ -0,0 +1,16 @@ +#!/bin/sh +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + ( IFS=';'; cabal build $CABALOPTS ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + # Stripping invalidates the code signature and the build image does + # not appear to have anything similar to the 'codesign' tool. + # "$TARGET-strip" "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" + file "$TARGETNAME/shellcheck" | grep "Mach-O 64-bit arm64 executable" +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/darwin.aarch64/tag b/builders/darwin.aarch64/tag new file mode 100644 index 0000000..ae93ef3 --- /dev/null +++ b/builders/darwin.aarch64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-darwin-aarch64 diff --git a/builders/darwin.x86_64/Dockerfile b/builders/darwin.x86_64/Dockerfile new file mode 100644 index 0000000..ceef155 --- /dev/null +++ b/builders/darwin.x86_64/Dockerfile @@ -0,0 +1,33 @@ +FROM liushuyu/osxcross@sha256:fa32af4677e2860a1c5950bc8c360f309e2a87e2ddfed27b642fddf7a6093b76 + +ENV TARGET=x86_64-apple-darwin18 +ENV TARGETNAME=darwin.x86_64 + +# Build dependencies +USER root +ENV DEBIAN_FRONTEND=noninteractive +RUN sed -e 's/focal/kinetic/g' -e 's/archive\|security/old-releases/' -i /etc/apt/sources.list +RUN apt-get update +RUN apt-get dist-upgrade -y +RUN apt-get install -y ghc automake autoconf llvm curl alex happy + +# Build GHC +WORKDIR /ghc +RUN curl -L "https://downloads.haskell.org/~ghc/9.2.8/ghc-9.2.8-src.tar.xz" | tar xJ --strip-components=1 +RUN ./configure --host x86_64-linux-gnu --build x86_64-linux-gnu --target "$TARGET" +RUN cp mk/flavours/quick-cross.mk mk/build.mk && make -j "$(nproc)" +RUN make install +RUN curl -L "https://downloads.haskell.org/~cabal/cabal-install-3.9.0.0/cabal-install-3.9-x86_64-linux-alpine.tar.xz" | tar xJv -C /usr/local/bin + +# Due to an apparent cabal bug, we specify our options directly to cabal +# It won't reuse caches if ghc-options are specified in ~/.cabal/config +ENV CABALOPTS="--with-ghc=$TARGET-ghc;--with-hc-pkg=$TARGET-ghc-pkg" + +# Prebuild the dependencies +RUN cabal update && IFS=';' && cabal install --dependencies-only $CABALOPTS ShellCheck + +# Copy the build script +COPY build /usr/bin + +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/darwin.x86_64/build b/builders/darwin.x86_64/build new file mode 100755 index 0000000..058cece --- /dev/null +++ b/builders/darwin.x86_64/build @@ -0,0 +1,13 @@ +#!/bin/sh +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + ( IFS=';'; cabal build $CABALOPTS ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + "$TARGET-strip" -Sx "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/darwin.x86_64/tag b/builders/darwin.x86_64/tag new file mode 100644 index 0000000..237a65c --- /dev/null +++ b/builders/darwin.x86_64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-darwin-x86_64 diff --git a/builders/linux.aarch64/Dockerfile b/builders/linux.aarch64/Dockerfile new file mode 100644 index 0000000..e783bf7 --- /dev/null +++ b/builders/linux.aarch64/Dockerfile @@ -0,0 +1,35 @@ +FROM ubuntu:25.04 + +ENV TARGET=aarch64-linux-gnu +ENV TARGETNAME=linux.aarch64 + +# Build dependencies +USER root +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y llvm-20 "gcc-$TARGET" "g++-$TARGET" ghc alex happy automake autoconf build-essential curl qemu-user-static +RUN curl -L "https://downloads.haskell.org/~cabal/cabal-install-3.16.0.0/cabal-install-3.16.0.0-x86_64-linux-alpine3_20.tar.xz" | tar xJv -C /usr/local/bin && cabal update + +# Build GHC +WORKDIR /ghc +RUN curl -L "https://downloads.haskell.org/~ghc/9.12.2/ghc-9.12.2-src.tar.xz" | tar xJ --strip-components=1 +RUN ./boot.source && ./configure --host x86_64-linux-gnu --build x86_64-linux-gnu --target "$TARGET" +# GHC fails to build if it can't encode non-ascii +ENV LC_CTYPE=C.utf8 +# We have to do a binary-dist instead of a direct install, otherwise the targest won't have +# cross compilation prefixes in /usr/local/lib/aarch64-linux-gnu-ghc-*/lib/settings +RUN ./hadrian/build --flavour=quickest --bignum=native -V -j --prefix=/usr/local install +# Hadrian just outputs "gcc" as the name of gcc, without accounting for $TARGET. Manually fix up the paths: +RUN sed -e 's/"\(gcc\|g++\|ld\)"/"'"$TARGET"'-\1"/g' -i /usr/local/lib/$TARGET-ghc-*/lib/settings + +# Due to an apparent cabal bug, we specify our options directly to cabal +# It won't reuse caches if ghc-options are specified in ~/.cabal/config +ENV CABALOPTS="--ghc-options;-split-sections -optc-Os -optc-Wl,--gc-sections -optc-fPIC;--with-compiler=$TARGET-ghc;--with-hc-pkg=$TARGET-ghc-pkg;-c;hashable -arch-native" + +# Prebuild the dependencies +RUN cabal update && IFS=';' && cabal install --dependencies-only $CABALOPTS ShellCheck + +# Copy the build script +COPY build /usr/bin + +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/linux.aarch64/build b/builders/linux.aarch64/build new file mode 100755 index 0000000..c68f7b2 --- /dev/null +++ b/builders/linux.aarch64/build @@ -0,0 +1,14 @@ +#!/bin/sh +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + ( IFS=';'; cabal build $CABALOPTS --enable-executable-static ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + "$TARGET-strip" -s "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" + "qemu-${TARGET%%-*}-static" "$TARGETNAME/shellcheck" --version +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/linux.aarch64/tag b/builders/linux.aarch64/tag new file mode 100644 index 0000000..6788e14 --- /dev/null +++ b/builders/linux.aarch64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-linux-aarch64 diff --git a/builders/linux.armv6hf/Dockerfile b/builders/linux.armv6hf/Dockerfile new file mode 100644 index 0000000..70a1148 --- /dev/null +++ b/builders/linux.armv6hf/Dockerfile @@ -0,0 +1,42 @@ +# This Docker file uses a custom QEmu fork with patches to follow execve +# to build all of ShellCheck emulated. + +FROM ubuntu:25.04 + +ENV TARGETNAME linux.armv6hf + +# Build QEmu with execve follow support +USER root +ENV DEBIAN_FRONTEND noninteractive +RUN apt-get update +RUN apt-get install -y --no-install-recommends build-essential git ninja-build python3 pkg-config libglib2.0-dev libpixman-1-dev python3-setuptools ca-certificates debootstrap +WORKDIR /qemu +RUN git clone --depth 1 https://github.com/koalaman/qemu . +RUN ./configure --static --disable-werror && cd build && ninja qemu-arm +ENV QEMU_EXECVE 1 + +# Convenience utility +COPY scutil /bin/scutil +COPY scutil /chroot/bin/scutil +RUN chmod +x /bin/scutil /chroot/bin/scutil + +# Set up an armv6 userspace +WORKDIR / +RUN debootstrap --arch armhf --variant=minbase --foreign bookworm /chroot http://mirrordirector.raspbian.org/raspbian +RUN cp /qemu/build/qemu-arm /chroot/bin/qemu +RUN scutil emu /debootstrap/debootstrap --second-stage + +# Install deps in the chroot +RUN scutil emu apt-get update +RUN scutil emu apt-get install -y --no-install-recommends ghc cabal-install +RUN scutil emu cabal update + +# Finally we can build the current dependencies. This takes hours. +ENV CABALOPTS "--ghc-options;-split-sections -optc-Os -optc-Wl,--gc-sections;--gcc-options;-Os -Wl,--gc-sections -ffunction-sections -fdata-sections" +# Generated with `cabal freeze --constraint 'hashable -arch-native'` +COPY cabal.project.freeze /chroot/etc +RUN IFS=";" && scutil install_from_freeze /chroot/etc/cabal.project.freeze emu cabal install $CABALOPTS + +# Copy the build script +COPY build /chroot/bin +ENTRYPOINT ["/bin/scutil", "emu", "/bin/build"] diff --git a/builders/linux.armv6hf/build b/builders/linux.armv6hf/build new file mode 100755 index 0000000..1d496ae --- /dev/null +++ b/builders/linux.armv6hf/build @@ -0,0 +1,17 @@ +#!/bin/sh +set -xe +mkdir /scratch && cd /scratch +{ + tar xzv --strip-components=1 + cp /etc/cabal.project.freeze . + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + # This script does not cabal update because compiling anything new is slow + ( IFS=';'; cabal build $CABALOPTS --enable-executable-static ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + strip -s "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" + "$TARGETNAME/shellcheck" --version +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/linux.armv6hf/cabal.project.freeze b/builders/linux.armv6hf/cabal.project.freeze new file mode 100644 index 0000000..43388ec --- /dev/null +++ b/builders/linux.armv6hf/cabal.project.freeze @@ -0,0 +1,105 @@ +active-repositories: hackage.haskell.org:merge +constraints: any.Diff ==1.0.2, + any.OneTuple ==0.4.2, + any.QuickCheck ==2.16.0.0, + QuickCheck -old-random +templatehaskell, + any.StateVar ==1.2.2, + any.aeson ==2.2.3.0, + aeson +ordered-keymap, + any.ansi-terminal ==1.1.3, + ansi-terminal -example, + any.ansi-terminal-types ==1.1.3, + any.array ==0.5.4.0, + any.assoc ==1.1.1, + assoc -tagged, + any.base ==4.15.1.0, + any.base-orphans ==0.9.3, + any.bifunctors ==5.6.2, + bifunctors +tagged, + any.binary ==0.8.8.0, + any.bytestring ==0.10.12.1, + any.character-ps ==0.1, + any.colour ==2.3.6, + any.comonad ==5.0.9, + comonad +containers +distributive +indexed-traversable, + any.containers ==0.6.4.1, + any.contravariant ==1.5.5, + contravariant +semigroups +statevar +tagged, + any.data-array-byte ==0.1.0.1, + any.data-fix ==0.3.4, + any.deepseq ==1.4.5.0, + any.directory ==1.3.6.2, + any.distributive ==0.6.2.1, + distributive +semigroups +tagged, + any.dlist ==1.0, + dlist -werror, + any.exceptions ==0.10.4, + any.fgl ==5.8.3.0, + fgl +containers042, + any.filepath ==1.4.2.1, + any.foldable1-classes-compat ==0.1.2, + foldable1-classes-compat +tagged, + any.generically ==0.1.1, + any.ghc-bignum ==1.1, + any.ghc-boot-th ==9.0.2, + any.ghc-prim ==0.7.0, + any.hashable ==1.4.7.0, + hashable -arch-native +integer-gmp -random-initial-seed, + any.indexed-traversable ==0.1.4, + any.indexed-traversable-instances ==0.1.2, + any.integer-conversion ==0.1.1, + any.integer-logarithms ==1.0.4, + integer-logarithms -check-bounds +integer-gmp, + any.mtl ==2.2.2, + any.network-uri ==2.6.4.2, + any.optparse-applicative ==0.19.0.0, + optparse-applicative +process, + any.parsec ==3.1.14.0, + any.pretty ==1.1.3.6, + any.prettyprinter ==1.7.1, + prettyprinter -buildreadme +text, + any.prettyprinter-ansi-terminal ==1.1.3, + any.primitive ==0.9.1.0, + any.process ==1.6.13.2, + any.random ==1.3.1, + any.regex-base ==0.94.0.3, + any.regex-tdfa ==1.3.2.4, + regex-tdfa +doctest -force-o2, + any.rts ==1.0.2, + any.scientific ==0.3.8.0, + scientific -integer-simple, + any.semialign ==1.3.1, + semialign +semigroupoids, + any.semigroupoids ==6.0.1, + semigroupoids +comonad +containers +contravariant +distributive +tagged +unordered-containers, + any.splitmix ==0.1.3.1, + splitmix -optimised-mixer, + any.stm ==2.5.0.0, + any.strict ==0.5.1, + any.tagged ==0.8.9, + tagged +deepseq +transformers, + any.tasty ==1.5.3, + tasty +unix, + any.template-haskell ==2.17.0.0, + any.text ==1.2.5.0, + any.text-iso8601 ==0.1.1, + any.text-short ==0.1.6, + text-short -asserts, + any.th-abstraction ==0.7.1.0, + any.th-compat ==0.1.6, + any.these ==1.2.1, + any.time ==1.9.3, + any.time-compat ==1.9.8, + any.transformers ==0.5.6.2, + any.transformers-compat ==0.7.2, + transformers-compat -five +five-three -four +generic-deriving +mtl -three -two, + any.unbounded-delays ==0.1.1.1, + any.unix ==2.7.2.2, + any.unordered-containers ==0.2.20, + unordered-containers -debug, + any.uuid-types ==1.0.6, + any.vector ==0.13.2.0, + vector +boundschecks -internalchecks -unsafechecks -wall, + any.vector-stream ==0.1.0.1, + any.witherable ==0.5 +index-state: hackage.haskell.org 2025-07-22T18:12:16Z diff --git a/builders/linux.armv6hf/scutil b/builders/linux.armv6hf/scutil new file mode 100644 index 0000000..96d5216 --- /dev/null +++ b/builders/linux.armv6hf/scutil @@ -0,0 +1,48 @@ +#!/bin/dash +# Various ShellCheck build utility functions + +# Generally set a ulimit to avoid QEmu using too much memory +ulimit -v "$((10*1024*1024))" +# If we happen to invoke or run under QEmu, make sure to follow execve. +# This requires a patched QEmu. +export QEMU_EXECVE=1 + +# Retry a command until it succeeds +# Usage: scutil retry 3 mycmd +retry() { + n="$1" + ret=1 + shift + while [ "$n" -gt 0 ] + do + "$@" + ret=$? + [ "$ret" = 0 ] && break + n=$((n-1)) + done + return "$ret" +} + +# Install all dependencies from a freeze file +# Usage: scutil install_from_freeze /path/cabal.project.freeze cabal install +install_from_freeze() { + linefeed=$(printf '\nx') + linefeed=${linefeed%x} + flags=$( + sed 's/constraints:/&\n /' "$1" | + grep -vw -e rts -e base -e ghc | + sed -n -e 's/^ *\([^,]*\).*/\1/p' | + sed -e 's/any\.\([^ ]*\) ==\(.*\)/\1-\2/; te; s/.*/--constraint\n&/; :e') + shift + # shellcheck disable=SC2086 + ( IFS=$linefeed; set -x; "$@" $flags ) +} + +# Run a command under emulation. +# This assumes the correct emulator is named 'qemu' and the chroot is /chroot +# Usage: scutil emu echo "Hello World" +emu() { + chroot /chroot /bin/qemu /usr/bin/env "$@" +} + +"$@" diff --git a/builders/linux.armv6hf/tag b/builders/linux.armv6hf/tag new file mode 100644 index 0000000..9172c5c --- /dev/null +++ b/builders/linux.armv6hf/tag @@ -0,0 +1 @@ +koalaman/scbuilder-linux-armv6hf diff --git a/builders/linux.riscv64/Dockerfile b/builders/linux.riscv64/Dockerfile new file mode 100644 index 0000000..b7bd961 --- /dev/null +++ b/builders/linux.riscv64/Dockerfile @@ -0,0 +1,35 @@ +FROM ubuntu:25.04 + +ENV TARGETNAME=linux.riscv64 +ENV TARGET=riscv64-linux-gnu + +# Build dependencies +USER root +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y llvm-20 "gcc-$TARGET" "g++-$TARGET" ghc alex happy automake autoconf build-essential curl qemu-user-static +RUN curl -L "https://downloads.haskell.org/~cabal/cabal-install-3.16.0.0/cabal-install-3.16.0.0-x86_64-linux-alpine3_20.tar.xz" | tar xJv -C /usr/local/bin && cabal update + +# Build GHC +WORKDIR /ghc +RUN curl -L "https://downloads.haskell.org/~ghc/9.12.2/ghc-9.12.2-src.tar.xz" | tar xJ --strip-components=1 +RUN ./boot.source && ./configure --host x86_64-linux-gnu --build x86_64-linux-gnu --target "$TARGET" +# GHC fails to build if it can't encode non-ascii +ENV LC_CTYPE=C.utf8 +# We have to do a binary-dist instead of a direct install, otherwise the targest won't have +# cross compilation prefixes in /usr/local/lib/aarch64-linux-gnu-ghc-*/lib/settings +RUN ./hadrian/build --flavour=quickest --bignum=native -V -j --prefix=/usr/local install +# Hadrian just outputs "gcc" as the name of gcc, without accounting for $TARGET. Manually fix up the paths: +RUN sed -e 's/"\(gcc\|g++\|ld\)"/"'"$TARGET"'-\1"/g' -i /usr/local/lib/$TARGET-ghc-*/lib/settings + +# Due to an apparent cabal bug, we specify our options directly to cabal +# It won't reuse caches if ghc-options are specified in ~/.cabal/config +ENV CABALOPTS="--ghc-options;-split-sections -optc-Os -optc-Wl,--gc-sections -optc-fPIC;--with-compiler=$TARGET-ghc;--with-hc-pkg=$TARGET-ghc-pkg;-c;hashable -arch-native" + +# Prebuild the dependencies +RUN cabal update && IFS=';' && cabal install --dependencies-only $CABALOPTS ShellCheck + +# Copy the build script +COPY build /usr/bin + +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/linux.riscv64/build b/builders/linux.riscv64/build new file mode 100755 index 0000000..c68f7b2 --- /dev/null +++ b/builders/linux.riscv64/build @@ -0,0 +1,14 @@ +#!/bin/sh +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + ( IFS=';'; cabal build $CABALOPTS --enable-executable-static ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + "$TARGET-strip" -s "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" + "qemu-${TARGET%%-*}-static" "$TARGETNAME/shellcheck" --version +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/linux.riscv64/tag b/builders/linux.riscv64/tag new file mode 100644 index 0000000..901eaaa --- /dev/null +++ b/builders/linux.riscv64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-linux-riscv64 diff --git a/builders/linux.x86_64/Dockerfile b/builders/linux.x86_64/Dockerfile new file mode 100644 index 0000000..33aea13 --- /dev/null +++ b/builders/linux.x86_64/Dockerfile @@ -0,0 +1,30 @@ +FROM alpine:3.22 +# alpine:3.16 (GHC 9.0.1): 5.8 megabytes (certs expired) +# alpine:3.17 (GHC 9.0.2): 15.0 megabytes (certs expired) +# alpine:3.18 (GHC 9.4.4): 29.0 megabytes (certs expired) +# alpine:3.19 (GHC 9.4.7): 29.0 megabytes (certs expired) +# alpine:3.20 (GHC 9.8.2): 16.0 megabytes +# alpine:3.21 (GHC 9.8.2): 16.0 megabytes +# alpine:3.22 (GHC 9.8.2): 16.0 megabytes + +ENV TARGETNAME=linux.x86_64 + +# Install GHC and cabal +USER root +RUN apk add ghc cabal g++ libffi-dev curl bash gmp gmp-static + +# Cabal has failed to cache if options are not specified on the command line, +# so do that explicitly. +ENV CABALOPTS="--ghc-options;-split-sections -optc-Os -optc-Wl,--gc-sections" + +# Verify that we have the certificates in place to successfully update cabal +RUN cabal update && rm -rf ~/.cabal + +# Other archs pre-build dependencies here, but this one doesn't to detect ecosystem movement +RUN true + +# Copy the build script +COPY build /usr/bin + +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/linux.x86_64/build b/builders/linux.x86_64/build new file mode 100755 index 0000000..099f127 --- /dev/null +++ b/builders/linux.x86_64/build @@ -0,0 +1,15 @@ +#!/bin/sh +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + cabal update + ( IFS=';'; cabal build $CABALOPTS --enable-executable-static ) + find . -name shellcheck -type f -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + strip -s "$TARGETNAME/shellcheck" + ls -l "$TARGETNAME" + "$TARGETNAME/shellcheck" --version +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/linux.x86_64/tag b/builders/linux.x86_64/tag new file mode 100644 index 0000000..f0224de --- /dev/null +++ b/builders/linux.x86_64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-linux-x86_64 diff --git a/builders/run_builder b/builders/run_builder new file mode 100755 index 0000000..d6de27b --- /dev/null +++ b/builders/run_builder @@ -0,0 +1,30 @@ +#!/bin/bash +if [ $# -lt 2 ] +then + echo >&2 "This script builds a source archive (as produced by cabal sdist)" + echo >&2 "Usage: $0 sourcefile.tar.gz builddir..." + exit 1 +fi + +file=$(realpath "$1") +shift + +if [ ! -e "$file" ] +then + echo >&2 "$file does not exist" + exit 1 +fi + +set -ex -o pipefail + +for dir +do + tagfile="$dir/tag" + if [ ! -e "$tagfile" ] + then + echo >&2 "$tagfile does not exist" + exit 2 + fi + + docker run -i "$(< "$tagfile")" < "$file" | tar xz +done diff --git a/builders/windows.x86_64/Dockerfile b/builders/windows.x86_64/Dockerfile new file mode 100644 index 0000000..366cbc1 --- /dev/null +++ b/builders/windows.x86_64/Dockerfile @@ -0,0 +1,34 @@ +FROM ubuntu:25.04 + +ENV TARGETNAME=windows.x86_64 + +# We don't need wine32, even though it complains +USER root +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y curl busybox wine winbind xz-utils + +# Fetch Windows version, will be available under z:\haskell +WORKDIR /haskell +# 9.12.2 produces a 37M binary +# 9.0.2 produces a 28M binary +# 8.10.4 produces a 16M binary +# We don't want to be stuck on old versions forever though, so just go with the latest version +RUN curl -L "https://downloads.haskell.org/~ghc/9.12.2/ghc-9.12.2-x86_64-unknown-mingw32.tar.xz" | tar xJ --strip-components=1 + +# Fetch dependencies +WORKDIR /haskell/bin +RUN curl -L "https://downloads.haskell.org/~cabal/cabal-install-3.16.0.0/cabal-install-3.16.0.0-x86_64-windows.zip" | busybox unzip - +RUN curl -L "https://curl.se/windows/dl-8.15.0_2/curl-8.15.0_2-win64-mingw.zip" | busybox unzip - && mv curl-*-win64-mingw/bin/* . +RUN wine /haskell/bin/cabal.exe update +ENV WINEPATH=/haskell/bin:/haskell/mingw/bin + +# None of these actually seem to have an effect on GHC on Windows anymore, +# but we'll leave them in place anyways. +ENV CABALOPTS="--ghc-options;-split-sections -optc-Os -optc-Wl,--gc-sections" + +# Precompile some deps to speed up later builds +RUN IFS=';' && wine /haskell/bin/cabal.exe install --lib --dependencies-only $CABALOPTS ShellCheck + +COPY build /usr/bin +WORKDIR /scratch +ENTRYPOINT ["/usr/bin/build"] diff --git a/builders/windows.x86_64/build b/builders/windows.x86_64/build new file mode 100755 index 0000000..22e5b42 --- /dev/null +++ b/builders/windows.x86_64/build @@ -0,0 +1,18 @@ +#!/bin/sh +cabal() { + wine /haskell/bin/cabal.exe "$@" +} + +set -xe +{ + tar xzv --strip-components=1 + chmod +x striptests && ./striptests + mkdir "$TARGETNAME" + ( IFS=';'; cabal build $CABALOPTS ) + find dist*/ -name shellcheck.exe -type f -ls -exec mv {} "$TARGETNAME/" \; + ls -l "$TARGETNAME" + wine "/haskell/mingw/bin/strip.exe" -s "$TARGETNAME/shellcheck.exe" + ls -l "$TARGETNAME" + wine "$TARGETNAME/shellcheck.exe" --version +} >&2 +tar czv "$TARGETNAME" diff --git a/builders/windows.x86_64/tag b/builders/windows.x86_64/tag new file mode 100644 index 0000000..a85921b --- /dev/null +++ b/builders/windows.x86_64/tag @@ -0,0 +1 @@ +koalaman/scbuilder-windows-x86_64 diff --git a/doc/emacs-flycheck.png b/doc/emacs-flycheck.png new file mode 100644 index 0000000..98d9211 Binary files /dev/null and b/doc/emacs-flycheck.png differ diff --git a/doc/shellcheck_logo.svg b/doc/shellcheck_logo.svg new file mode 100644 index 0000000..836aa63 --- /dev/null +++ b/doc/shellcheck_logo.svg @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/terminal.png b/doc/terminal.png new file mode 100644 index 0000000..9ce2a8d Binary files /dev/null and b/doc/terminal.png differ diff --git a/doc/vim-syntastic.png b/doc/vim-syntastic.png new file mode 100644 index 0000000..59ee722 Binary files /dev/null and b/doc/vim-syntastic.png differ diff --git a/manpage b/manpage new file mode 100755 index 0000000..0898092 --- /dev/null +++ b/manpage @@ -0,0 +1,4 @@ +#!/bin/sh +echo >&2 "Generating man page using pandoc" +pandoc -s -f markdown-smart -t man shellcheck.1.md -o shellcheck.1 || exit +echo >&2 "Done. You can read it with: man ./shellcheck.1" diff --git a/nextnumber b/nextnumber new file mode 100755 index 0000000..3bdf10a --- /dev/null +++ b/nextnumber @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# TODO: Find a less trashy way to get the next available error code +if ! shopt -s globstar +then + echo "Error: This script depends on Bash 4." >&2 + exit 1 +fi + +for i in 1 2 3 +do + last=$(grep -hv "^prop" ./**/*.hs | grep -Ewo "${i}[0-9]{3}" | sort -n | tail -n 1) + echo "Next ${i}xxx: $((last+1))" +done diff --git a/quickrun b/quickrun new file mode 100755 index 0000000..e0e0547 --- /dev/null +++ b/quickrun @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# quickrun runs ShellCheck in an interpreted mode. +# This allows testing changes without recompiling. + +path=$(find . -type f -path './dist*/Paths_ShellCheck.hs' | sort | head -n 1) +if [ -z "$path" ] +then + echo >&2 "Unable to find Paths_ShellCheck.hs. Please 'cabal build' once." + exit 1 +fi +path="${path%/*}" + +exec runghc -isrc -i"$path" shellcheck.hs "$@" diff --git a/quicktest b/quicktest new file mode 100755 index 0000000..6a1cf61 --- /dev/null +++ b/quicktest @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# quicktest runs the ShellCheck unit tests in an interpreted mode. +# This allows running tests without compiling, which can be faster. +# 'cabal test' remains the source of truth. + +path=$(find . -type f -path './dist*/Paths_ShellCheck.hs' | sort | head -n 1) +if [ -z "$path" ] +then + echo >&2 "Unable to find Paths_ShellCheck.hs. Please 'cabal build' once." + exit 1 +fi +path="${path%/*}" + + +( +var=$(echo 'main' | ghci -isrc -i"$path" test/shellcheck.hs 2>&1 | tee /dev/stderr) +if [[ $var == *ExitSuccess* ]] +then + exit 0 +else + grep -C 3 -e "Fail" -e "Tracing" <<< "$var" + exit 1 +fi +) 2>&1 diff --git a/setgitversion b/setgitversion new file mode 100755 index 0000000..3afad61 --- /dev/null +++ b/setgitversion @@ -0,0 +1,11 @@ +#!/bin/sh -xe +# This script hardcodes the `git describe` version as ShellCheck's version number. +# This is done to allow shellcheck --version to differ from the cabal version when +# building git snapshots. + +file="src/ShellCheck/Data.hs" +test -e "$file" +tmp=$(mktemp) +version=$(git describe) +sed -e "s/=.*VERSIONSTRING.*/= \"$version\" -- VERSIONSTRING, DO NOT SUBMIT/" "$file" > "$tmp" +mv "$tmp" "$file" diff --git a/shellcheck.1.md b/shellcheck.1.md new file mode 100644 index 0000000..abe4c22 --- /dev/null +++ b/shellcheck.1.md @@ -0,0 +1,406 @@ +% SHELLCHECK(1) Shell script analysis tool + +# NAME + +shellcheck - Shell script analysis tool + +# SYNOPSIS + +**shellcheck** [*OPTIONS*...] *FILES*... + +# DESCRIPTION + +ShellCheck is a static analysis and linting tool for sh/bash scripts. It's +mainly focused on handling typical beginner and intermediate level syntax +errors and pitfalls where the shell just gives a cryptic error message or +strange behavior, but it also reports on a few more advanced issues where +corner cases can cause delayed failures. + +ShellCheck gives shell specific advice. Consider this line: + + (( area = 3.14*r*r )) + ++ For scripts starting with `#!/bin/sh` (or when using `-s sh`), ShellCheck +will warn that `(( .. ))` is not POSIX compliant (similar to checkbashisms). + ++ For scripts starting with `#!/bin/bash` (or using `-s bash`), ShellCheck +will warn that decimals are not supported. + ++ For scripts starting with `#!/bin/ksh` (or using `-s ksh`), ShellCheck will +not warn at all, as `ksh` supports decimals in arithmetic contexts. + +# OPTIONS + +**-a**,\ **--check-sourced** + +: Emit warnings in sourced files. Normally, `shellcheck` will only warn + about issues in the specified files. With this option, any issues in + sourced files will also be reported. + +**-C**[*WHEN*],\ **--color**[=*WHEN*] + +: For TTY output, enable colors *always*, *never* or *auto*. The default + is *auto*. **--color** without an argument is equivalent to + **--color=always**. + +**-i**\ *CODE1*[,*CODE2*...],\ **--include=***CODE1*[,*CODE2*...] + +: Explicitly include only the specified codes in the report. Subsequent **-i** + options are cumulative, but all the codes can be specified at once, + comma-separated as a single argument. Include options override any provided + exclude options. + +**-e**\ *CODE1*[,*CODE2*...],\ **--exclude=***CODE1*[,*CODE2*...] + +: Explicitly exclude the specified codes from the report. Subsequent **-e** + options are cumulative, but all the codes can be specified at once, + comma-separated as a single argument. + +**--extended-analysis=true/false** + +: Enable/disable Dataflow Analysis to identify more issues (default true). If + ShellCheck uses too much CPU/RAM when checking scripts with several + thousand lines of code, extended analysis can be disabled with this flag + or a directive. This flag overrides directives and rc files. + +**-f** *FORMAT*, **--format=***FORMAT* + +: Specify the output format of shellcheck, which prints its results in the + standard output. Subsequent **-f** options are ignored, see **FORMATS** + below for more information. + +**--list-optional** + +: Output a list of known optional checks. These can be enabled with **-o** + flags or **enable** directives. + +**--norc** + +: Don't try to look for .shellcheckrc configuration files. + +**--rcfile** *RCFILE* + +: Prefer the specified configuration file over searching for one + in the default locations. + +**-o**\ *NAME1*[,*NAME2*...],\ **--enable=***NAME1*[,*NAME2*...] + +: Enable optional checks. The special name *all* enables all of them. + Subsequent **-o** options accumulate. This is equivalent to specifying + **enable** directives. + +**-P**\ *SOURCEPATH*,\ **--source-path=***SOURCEPATH* + +: Specify paths to search for sourced files, separated by `:` on Unix and + `;` on Windows. This is equivalent to specifying `search-path` + directives. + +**-s**\ *shell*,\ **--shell=***shell* + +: Specify Bourne shell dialect. Valid values are *sh*, *bash*, *dash*, *ksh*, + and *busybox*. + The default is to deduce the shell from the file's `shell` directive, + shebang, or `.bash/.bats/.dash/.ksh` extension, in that order. *sh* refers to + POSIX `sh` (not the system's), and will warn of portability issues. + +**-S**\ *SEVERITY*,\ **--severity=***severity* + +: Specify minimum severity of errors to consider. Valid values in order of + severity are *error*, *warning*, *info* and *style*. + The default is *style*. + +**-V**,\ **--version** + +: Print version information and exit. + +**-W** *NUM*,\ **--wiki-link-count=NUM** + +: For TTY output, show *NUM* wiki links to more information about mentioned + warnings. Set to 0 to disable them entirely. + +**-x**,\ **--external-sources** + +: Follow `source` statements even when the file is not specified as input. + By default, `shellcheck` will only follow files specified on the command + line (plus `/dev/null`). This option allows following any file the script + may `source`. + + This option may also be enabled using `external-sources=true` in + `.shellcheckrc`. This flag takes precedence. + +**FILES...** + +: One or more script files to check, or "-" for standard input. + + +# FORMATS + +**tty** + +: Plain text, human readable output. This is the default. + +**gcc** + +: GCC compatible output. Useful for editors that support compiling and + showing syntax errors. + + For example, in Vim, `:set makeprg=shellcheck\ -f\ gcc\ %` will allow + using `:make` to check the script, and `:cnext` to jump to the next error. + + ::: : + +**checkstyle** + +: Checkstyle compatible XML output. Supported directly or through plugins + by many IDEs and build monitoring systems. + + + + + + ... + + ... + + +**diff** + +: Auto-fixes in unified diff format. Can be piped to `git apply` or `patch -p1` + to automatically apply fixes. + + --- a/test.sh + +++ b/test.sh + @@ -2,6 +2,6 @@ + ## Example of a broken script. + for f in $(ls *.m3u) + do + - grep -qi hq.*mp3 $f \ + + grep -qi hq.*mp3 "$f" \ + && echo -e 'Playlist $f contains a HQ file in mp3 format' + done + + +**json1** + +: Json is a popular serialization format that is more suitable for web + applications. ShellCheck's json is compact and contains only the bare + minimum. Tabs are counted as 1 character. + + { + comments: [ + { + "file": "filename", + "line": lineNumber, + "column": columnNumber, + "level": "severitylevel", + "code": errorCode, + "message": "warning message" + }, + ... + ] + } + +**json** + +: This is a legacy version of the **json1** format. It's a raw array of + comments, and all offsets have a tab stop of 8. + +**quiet** + +: Suppress all normal output. Exit with zero if no issues are found, + otherwise exit with one. Stops processing after the first issue. + + +# DIRECTIVES + +ShellCheck directives can be specified as comments in the shell script. +If they appear before the first command, they are considered file-wide. +Otherwise, they apply to the immediately following command or block: + + # shellcheck key=value key=value + command-or-structure + +For example, to suppress SC2035 about using `./*.jpg`: + + # shellcheck disable=SC2035 + echo "Files: " *.jpg + +To tell ShellCheck where to look for an otherwise dynamically determined file: + + # shellcheck source=./lib.sh + source "$(find_install_dir)/lib.sh" + +Here a shell brace group is used to suppress a warning on multiple lines: + + # shellcheck disable=SC2016 + { + echo 'Modifying $PATH' + echo 'PATH=foo:$PATH' >> ~/.bashrc + } + +Valid keys are: + +**disable** +: Disables a comma separated list of error codes for the following command. + The command can be a simple command like `echo foo`, or a compound command + like a function definition, subshell block or loop. A range can be + be specified with a dash, e.g. `disable=SC3000-SC4000` to exclude 3xxx. + All warnings can be disabled with `disable=all`. + +**enable** +: Enable an optional check by name, as listed with **--list-optional**. + Only file-wide `enable` directives are considered. + +**extended-analysis** +: Set to true/false to enable/disable dataflow analysis. Specifying + `# shellcheck extended-analysis=false` in particularly large (2000+ line) + auto-generated scripts will reduce ShellCheck's resource usage at the + expense of certain checks. Extended analysis is enabled by default. + +**external-sources** +: Set to `true` in `.shellcheckrc` to always allow ShellCheck to open + arbitrary files from 'source' statements (the way most tools do). + + This option defaults to `false` only due to ShellCheck's origin as a + remote service for checking untrusted scripts. It can safely be enabled + for normal development. + +**source** +: Overrides the filename included by a `source`/`.` statement. This can be + used to tell shellcheck where to look for a file whose name is determined + at runtime, or to skip a source by telling it to use `/dev/null`. + +**source-path** +: Add a directory to the search path for `source`/`.` statements (by default, + only ShellCheck's working directory is included). Absolute paths will also + be rooted in these paths. The special path `SCRIPTDIR` can be used to + specify the currently checked script's directory, as in + `source-path=SCRIPTDIR` or `source-path=SCRIPTDIR/../libs`. Multiple + paths accumulate, and `-P` takes precedence over them. + +**shell** +: Overrides the shell detected from the shebang. This is useful for + files meant to be included (and thus lacking a shebang), or possibly + as a more targeted alternative to 'disable=SC2039'. + +# RC FILES + +Unless `--norc` is used, ShellCheck will look for a file `.shellcheckrc` or +`shellcheckrc` in the script's directory and each parent directory. If found, +it will read `key=value` pairs from it and treat them as file-wide directives. + +Here is an example `.shellcheckrc`: + + # Look for 'source'd files relative to the checked script, + # and also look for absolute paths in /mnt/chroot + source-path=SCRIPTDIR + source-path=/mnt/chroot + + # Since 0.9.0, values can be quoted with '' or "" to allow spaces + source-path="My Documents/scripts" + + # Allow opening any 'source'd file, even if not specified as input + external-sources=true + + # Turn on warnings for unquoted variables with safe values + enable=quote-safe-variables + + # Turn on warnings for unassigned uppercase variables + enable=check-unassigned-uppercase + + # Allow [ ! -z foo ] instead of suggesting -n + disable=SC2236 + +If no `.shellcheckrc` is found in any of the parent directories, ShellCheck +will look in `~/.shellcheckrc` followed by the `$XDG_CONFIG_HOME` +(usually `~/.config/shellcheckrc`) on Unix, or `%APPDATA%/shellcheckrc` on +Windows. Only the first file found will be used. + +Note for Snap users: the Snap sandbox disallows access to hidden files. +Use `shellcheckrc` without the dot instead. + +Note for Docker users: ShellCheck will only be able to look for files that +are mounted in the container, so `~/.shellcheckrc` will not be read. + + +# ENVIRONMENT VARIABLES + +The environment variable `SHELLCHECK_OPTS` can be set with default flags: + + export SHELLCHECK_OPTS='--shell=bash --exclude=SC2016' + +Its value will be split on spaces and prepended to the command line on each +invocation. + +# RETURN VALUES + +ShellCheck uses the following exit codes: + ++ 0: All files successfully scanned with no issues. ++ 1: All files successfully scanned with some issues. ++ 2: Some files could not be processed (e.g. file not found). ++ 3: ShellCheck was invoked with bad syntax (e.g. unknown flag). ++ 4: ShellCheck was invoked with bad options (e.g. unknown formatter). + +# LOCALE + +This version of ShellCheck is only available in English. All files are +leniently decoded as UTF-8, with a fallback of ISO-8859-1 for invalid +sequences. `LC_CTYPE` is respected for output, and defaults to UTF-8 for +locales where encoding is unspecified (such as the `C` locale). + +Windows users seeing `commitBuffer: invalid argument (invalid character)` +should set their terminal to use UTF-8 with `chcp 65001`. + +# KNOWN INCOMPATIBILITIES + +(If nothing in this section makes sense, you are unlikely to be affected by it) + +To avoid confusing and misguided suggestions, ShellCheck requires function +bodies to be either `{ brace groups; }` or `( subshells )`, and function names +containing `[]*=!` are only recognized after a `function` keyword. + +The following unconventional function definitions are identical in Bash, +but ShellCheck only recognizes the latter. + + [x!=y] () [[ $1 ]] + function [x!=y] () { [[ $1 ]]; } + +Shells without the `function` keyword do not allow these characters in function +names to begin with. Function names containing `{}` are not supported at all. + +Further, if ShellCheck sees `[x!=y]` it will assume this is an invalid +comparison. To invoke the above function, quote the command as in `'[x!=y]'`, +or to retain the same globbing behavior, use `command [x!=y]`. + +ShellCheck imposes additional restrictions on the `[` command to help diagnose +common invalid uses. While `[ $x= 1 ]` is defined in POSIX, ShellCheck will +assume it was intended as the much more likely comparison `[ "$x" = 1 ]` and +fail accordingly. For unconventional or dynamic uses of the `[` command, use +`test` or `\[` instead. + +# REPORTING BUGS + +Bugs and issues can be reported on GitHub: + +https://github.com/koalaman/shellcheck/issues + +# AUTHORS + +ShellCheck is developed and maintained by Vidar Holen, with assistance from a +long list of wonderful contributors. + +# COPYRIGHT + +Copyright 2012-2025, Vidar Holen and contributors. +Licensed under the GNU General Public License version 3 or later, +see https://gnu.org/licenses/gpl.html + +# SEE ALSO + +sh(1), bash(1), dash(1), ksh(1) diff --git a/shellcheck.hs b/shellcheck.hs new file mode 100644 index 0000000..def3654 --- /dev/null +++ b/shellcheck.hs @@ -0,0 +1,635 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +import qualified ShellCheck.Analyzer +import ShellCheck.Checker +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Regex + +import qualified ShellCheck.Formatter.CheckStyle +import ShellCheck.Formatter.Format +import qualified ShellCheck.Formatter.Diff +import qualified ShellCheck.Formatter.GCC +import qualified ShellCheck.Formatter.JSON +import qualified ShellCheck.Formatter.JSON1 +import qualified ShellCheck.Formatter.TTY +import qualified ShellCheck.Formatter.Quiet + +import Control.Exception +import Control.Monad +import Control.Monad.IO.Class +import Control.Monad.Trans.Class +import Control.Monad.Except +import Data.Bits +import Data.Char +import Data.Either +import Data.Functor +import Data.IORef +import Data.List +import qualified Data.Map as Map +import Data.Maybe +import Data.Monoid +import Data.Semigroup (Semigroup (..)) +import Prelude hiding (catch) +import System.Console.GetOpt +import System.Directory +import System.Environment +import System.Exit +import System.FilePath +import System.IO + +data Flag = Flag String String +data Status = + NoProblems + | SomeProblems + | SupportFailure + | SyntaxFailure + | RuntimeException + deriving (Ord, Eq, Show) + +instance Semigroup Status where + (<>) = max + +instance Monoid Status where + mempty = NoProblems + mappend = (Data.Semigroup.<>) + +data Options = Options { + checkSpec :: CheckSpec, + externalSources :: Bool, + sourcePaths :: [FilePath], + formatterOptions :: FormatterOptions, + minSeverity :: Severity, + rcfile :: Maybe FilePath +} + +defaultOptions = Options { + checkSpec = emptyCheckSpec, + externalSources = False, + sourcePaths = [], + formatterOptions = newFormatterOptions { + foColorOption = ColorAuto + }, + minSeverity = StyleC, + rcfile = Nothing +} + +usageHeader = "Usage: shellcheck [OPTIONS...] FILES..." +options = [ + Option "a" ["check-sourced"] + (NoArg $ Flag "sourced" "false") "Include warnings from sourced files", + Option "C" ["color"] + (OptArg (maybe (Flag "color" "always") (Flag "color")) "WHEN") + "Use color (auto, always, never)", + Option "i" ["include"] + (ReqArg (Flag "include") "CODE1,CODE2..") "Consider only given types of warnings", + Option "e" ["exclude"] + (ReqArg (Flag "exclude") "CODE1,CODE2..") "Exclude types of warnings", + Option "" ["extended-analysis"] + (ReqArg (Flag "extended-analysis") "bool") "Perform dataflow analysis (default true)", + Option "f" ["format"] + (ReqArg (Flag "format") "FORMAT") $ + "Output format (" ++ formatList ++ ")", + Option "" ["list-optional"] + (NoArg $ Flag "list-optional" "true") "List checks disabled by default", + Option "" ["norc"] + (NoArg $ Flag "norc" "true") "Don't look for .shellcheckrc files", + Option "" ["rcfile"] + (ReqArg (Flag "rcfile") "RCFILE") + "Prefer the specified configuration file over searching for one", + Option "o" ["enable"] + (ReqArg (Flag "enable") "check1,check2..") + "List of optional checks to enable (or 'all')", + Option "P" ["source-path"] + (ReqArg (Flag "source-path") "SOURCEPATHS") + "Specify path when looking for sourced files (\"SCRIPTDIR\" for script's dir)", + Option "s" ["shell"] + (ReqArg (Flag "shell") "SHELLNAME") + "Specify dialect (sh, bash, dash, ksh, busybox)", + Option "S" ["severity"] + (ReqArg (Flag "severity") "SEVERITY") + "Minimum severity of errors to consider (error, warning, info, style)", + Option "V" ["version"] + (NoArg $ Flag "version" "true") "Print version information", + Option "W" ["wiki-link-count"] + (ReqArg (Flag "wiki-link-count") "NUM") + "The number of wiki links to show, when applicable", + Option "x" ["external-sources"] + (NoArg $ Flag "externals" "true") "Allow 'source' outside of FILES", + Option "" ["help"] + (NoArg $ Flag "help" "true") "Show this usage summary and exit" + ] +getUsageInfo = usageInfo usageHeader options + +printErr = lift . hPutStrLn stderr + +parseArguments :: [String] -> ExceptT Status IO ([Flag], [FilePath]) +parseArguments argv = + case getOpt Permute options argv of + (opts, files, []) -> return (opts, files) + (_, _, errors) -> do + printErr $ concat errors ++ "\n" ++ getUsageInfo + throwError SyntaxFailure + +formats :: FormatterOptions -> Map.Map String (IO Formatter) +formats options = Map.fromList [ + ("checkstyle", ShellCheck.Formatter.CheckStyle.format), + ("diff", ShellCheck.Formatter.Diff.format options), + ("gcc", ShellCheck.Formatter.GCC.format), + ("json", ShellCheck.Formatter.JSON.format), + ("json1", ShellCheck.Formatter.JSON1.format), + ("tty", ShellCheck.Formatter.TTY.format options), + ("quiet", ShellCheck.Formatter.Quiet.format options) + ] + +formatList = intercalate ", " names + where + names = Map.keys $ formats (formatterOptions defaultOptions) + +getOption [] _ = Nothing +getOption (Flag var val:_) name | name == var = return val +getOption (_:rest) flag = getOption rest flag + +getOptions options name = + map (\(Flag _ val) -> val) . filter (\(Flag var _) -> var == name) $ options + +split char str = + split' str [] + where + split' (a:rest) element = + if a == char + then reverse element : split' rest [] + else split' rest (a:element) + split' [] element = [reverse element] + +toStatus = fmap (either id id) . runExceptT + +getEnvArgs = do + opts <- getEnv "SHELLCHECK_OPTS" `catch` cantWaitForLookupEnv + return . filter (not . null) $ opts `splitOn` mkRegex " +" + where + cantWaitForLookupEnv :: IOException -> IO String + cantWaitForLookupEnv = const $ return "" + +main = do + params <- getArgs + envOpts <- getEnvArgs + let args = envOpts ++ params + status <- toStatus $ do + (flags, files) <- parseArguments args + process flags files + exitWith $ statusToCode status + +statusToCode status = + case status of + NoProblems -> ExitSuccess + SomeProblems -> ExitFailure 1 + SyntaxFailure -> ExitFailure 3 + SupportFailure -> ExitFailure 4 + RuntimeException -> ExitFailure 2 + +process :: [Flag] -> [FilePath] -> ExceptT Status IO Status +process flags files = do + options <- foldM (flip parseOption) defaultOptions flags + verifyFiles files + let format = fromMaybe "tty" $ getOption flags "format" + let formatters = formats $ formatterOptions options + formatter <- + case Map.lookup format formatters of + Nothing -> do + printErr $ "Unknown format " ++ format + printErr "Supported formats:" + mapM_ (printErr . write) $ Map.keys formatters + throwError SupportFailure + where write s = " " ++ s + Just f -> ExceptT $ fmap Right f + sys <- lift $ ioInterface options files + lift $ runFormatter sys formatter options files + +runFormatter :: SystemInterface IO -> Formatter -> Options -> [FilePath] + -> IO Status +runFormatter sys format options files = do + header format + result <- foldM f NoProblems files + footer format + return result + where + f :: Status -> FilePath -> IO Status + f status file = do + newStatus <- process file `catch` handler file + return $! status `mappend` newStatus + handler :: FilePath -> IOException -> IO Status + handler file e = reportFailure file (show e) + reportFailure file str = do + onFailure format file str + return RuntimeException + + process :: FilePath -> IO Status + process filename = do + input <- siReadFile sys Nothing filename + either (reportFailure filename) check input + where + check contents = do + let checkspec = (checkSpec options) { + csFilename = filename, + csScript = contents + } + result <- checkScript sys checkspec + onResult format result sys + return $ + if null (crComments result) + then NoProblems + else SomeProblems + +parseEnum name value list = + case lookup value list of + Just value -> return value + Nothing -> do + printErr $ "Unknown value for --" ++ name ++ ". " ++ + "Valid options are: " ++ (intercalate ", " $ map fst list) + throwError SupportFailure + +parseColorOption value = + parseEnum "color" value [ + ("auto", ColorAuto), + ("always", ColorAlways), + ("never", ColorNever) + ] + +parseSeverityOption value = + parseEnum "severity" value [ + ("error", ErrorC), + ("warning", WarningC), + ("info", InfoC), + ("style", StyleC) + ] + +parseOption flag options = + case flag of + Flag "shell" str -> + fromMaybe (die $ "Unknown shell: " ++ str) $ do + shell <- shellForExecutable str + return $ return options { + checkSpec = (checkSpec options) { + csShellTypeOverride = Just shell + } + } + + Flag "exclude" str -> do + new <- mapM parseNum $ filter (not . null) $ split ',' str + let old = csExcludedWarnings . checkSpec $ options + return options { + checkSpec = (checkSpec options) { + csExcludedWarnings = new ++ old + } + } + + Flag "include" str -> do + new <- mapM parseNum $ filter (not . null) $ split ',' str + let old = csIncludedWarnings . checkSpec $ options + return options { + checkSpec = (checkSpec options) { + csIncludedWarnings = + if null new + then old + else Just new `mappend` old + } + } + + Flag "version" _ -> do + liftIO printVersion + throwError NoProblems + + Flag "list-optional" _ -> do + liftIO printOptional + throwError NoProblems + + Flag "help" _ -> do + liftIO $ putStrLn getUsageInfo + throwError NoProblems + + Flag "externals" _ -> + return options { + externalSources = True + } + + Flag "color" color -> do + option <- parseColorOption color + return options { + formatterOptions = (formatterOptions options) { + foColorOption = option + } + } + + Flag "source-path" str -> do + let paths = splitSearchPath str + return options { + sourcePaths = (sourcePaths options) ++ paths + } + + Flag "sourced" _ -> + return options { + checkSpec = (checkSpec options) { + csCheckSourced = True + } + } + + Flag "severity" severity -> do + option <- parseSeverityOption severity + return options { + checkSpec = (checkSpec options) { + csMinSeverity = option + } + } + + Flag "wiki-link-count" countString -> do + count <- parseNum countString + return options { + formatterOptions = (formatterOptions options) { + foWikiLinkCount = count + } + } + + Flag "norc" _ -> + return options { + checkSpec = (checkSpec options) { + csIgnoreRC = True + } + } + + Flag "rcfile" str -> do + return options { + rcfile = Just str + } + + Flag "enable" value -> + let cs = checkSpec options in return options { + checkSpec = cs { + csOptionalChecks = (csOptionalChecks cs) ++ split ',' value + } + } + + Flag "extended-analysis" str -> do + value <- parseBool str + return options { + checkSpec = (checkSpec options) { + csExtendedAnalysis = Just value + } + } + + -- This flag is handled specially in 'process' + Flag "format" _ -> return options + + Flag str _ -> do + printErr $ "Internal error for --" ++ str ++ ". Please file a bug :(" + return options + where + die s = do + printErr s + throwError SupportFailure + parseNum ('S':'C':str) = parseNum str + parseNum num = do + unless (all isDigit num) $ do + printErr $ "Invalid number: " ++ num + throwError SyntaxFailure + return (Prelude.read num :: Integer) + + parseBool str = do + case str of + "true" -> return True + "false" -> return False + _ -> do + printErr $ "Invalid boolean, expected true/false: " ++ str + throwError SyntaxFailure + +ioInterface :: Options -> [FilePath] -> IO (SystemInterface IO) +ioInterface options files = do + inputs <- mapM normalize files + cache <- newIORef emptyCache + configCache <- newIORef ("", Nothing) + return (newSystemInterface :: SystemInterface IO) { + siReadFile = get cache inputs, + siFindSource = findSourceFile inputs (sourcePaths options), + siGetConfig = getConfig configCache + } + where + emptyCache :: Map.Map FilePath String + emptyCache = Map.empty + + get cache inputs rcSuggestsExternal file = do + map <- readIORef cache + case Map.lookup file map of + Just x -> return $ Right x + Nothing -> fetch cache inputs rcSuggestsExternal file + + fetch cache inputs rcSuggestsExternal file = do + ok <- allowable rcSuggestsExternal inputs file + if ok + then (do + (contents, shouldCache) <- inputFile file + when shouldCache $ + modifyIORef cache $ Map.insert file contents + return $ Right contents + ) `catch` handler + else + if rcSuggestsExternal == Just False + then return $ Left (file ++ " was not specified as input, and external files were disabled via directive.") + else return $ Left (file ++ " was not specified as input (see shellcheck -x).") + where + handler :: IOException -> IO (Either ErrorMessage String) + handler ex = return . Left $ show ex + + allowable rcSuggestsExternal inputs x = + if fromMaybe (externalSources options) rcSuggestsExternal + then return True + else do + path <- normalize x + return $ path `elem` inputs + + normalize x = + canonicalizePath x `catch` fallback x + where + fallback :: FilePath -> IOException -> IO FilePath + fallback path _ = return path + + + -- Returns the name and contents of .shellcheckrc for the given file + getConfig cache filename = + case rcfile options of + Just file -> do + -- We have a specified rcfile. Ignore normal rcfile resolution. + (path, result) <- readIORef cache + if path == "/" + then return result + else do + result <- readConfig file + when (isNothing result) $ + hPutStrLn stderr $ "Warning: unable to read --rcfile " ++ file + writeIORef cache ("/", result) + return result + + Nothing -> do + path <- normalize filename + let dir = takeDirectory path + (previousPath, result) <- readIORef cache + if dir == previousPath + then return result + else do + paths <- getConfigPaths dir + result <- findConfig paths + writeIORef cache (dir, result) + return result + + findConfig paths = + case paths of + (file:rest) -> do + contents <- readConfig file + if isJust contents + then return contents + else findConfig rest + [] -> return Nothing + + -- Get a list of candidate filenames. This includes .shellcheckrc + -- in all parent directories, plus the user's home dir and xdg dir. + -- The dot is optional for Windows and Snap users. + getConfigPaths dir = do + let next = takeDirectory dir + rest <- if next /= dir + then getConfigPaths next + else defaultPaths `catch` + ((const $ return []) :: IOException -> IO [FilePath]) + return $ (dir ".shellcheckrc") : (dir "shellcheckrc") : rest + + defaultPaths = do + home <- getAppUserDataDirectory "shellcheckrc" + xdg <- getXdgDirectory XdgConfig "shellcheckrc" + return [home, xdg] + + readConfig file = do + exists <- doesFileExist file + if exists + then do + (contents, _) <- inputFile file `catch` handler file + return $ Just (file, contents) + else + return Nothing + where + handler :: FilePath -> IOException -> IO (String, Bool) + handler file err = do + hPutStrLn stderr $ file ++ ": " ++ show err + return ("", True) + + andM a b arg = do + first <- a arg + if not first then return False else b arg + + findM p = foldr go (pure Nothing) + where + go x acc = do + b <- p x + if b then pure (Just x) else acc + + findSourceFile inputs sourcePathFlag currentScript rcSuggestsExternal sourcePathAnnotation original = + if isAbsolute original + then + let (_, relative) = splitDrive original + in find relative original + else + find original original + where + find filename deflt = do + sources <- findM ((allowable rcSuggestsExternal inputs) `andM` doesFileExist) $ + (adjustPath filename):(map (( filename) . adjustPath) $ sourcePathFlag ++ sourcePathAnnotation) + case sources of + Nothing -> return deflt + Just first -> return first + scriptdir = dropFileName currentScript + adjustPath str = + case (splitDirectories str) of + ("SCRIPTDIR":rest) -> joinPath (scriptdir:rest) + _ -> str + +inputFile file = do + (handle, shouldCache) <- + if file == "-" + then return (stdin, True) + else do + h <- openBinaryFile file ReadMode + reopenable <- hIsSeekable h + return (h, not reopenable) + + hSetBinaryMode handle True + contents <- decodeString <$> hGetContents handle -- closes handle + + seq (length contents) $ + return (contents, shouldCache) + +-- Decode a char8 string into a utf8 string, with fallback on +-- ISO-8859-1. This avoids depending on additional libraries. +decodeString = decode + where + decode [] = [] + decode (c:rest) | isAscii c = c : decode rest + decode (c:rest) = + let num = (fromIntegral $ ord c) :: Int + next = case num of + _ | num >= 0xF8 -> Nothing + | num >= 0xF0 -> construct (num .&. 0x07) 3 rest + | num >= 0xE0 -> construct (num .&. 0x0F) 2 rest + | num >= 0xC0 -> construct (num .&. 0x1F) 1 rest + | True -> Nothing + in + case next of + Just (n, remainder) -> chr n : decode remainder + Nothing -> c : decode rest + + construct x 0 rest = do + guard $ x <= 0x10FFFF + return (x, rest) + construct x n (c:rest) = + let num = (fromIntegral $ ord c) :: Int in + if num >= 0x80 && num <= 0xBF + then construct ((x `shiftL` 6) .|. (num .&. 0x3f)) (n-1) rest + else Nothing + construct _ _ _ = Nothing + + +verifyFiles files = + when (null files) $ do + printErr "No files specified.\n" + printErr $ usageInfo usageHeader options + throwError SyntaxFailure + +printVersion = do + putStrLn "ShellCheck - shell script analysis tool" + putStrLn $ "version: " ++ shellcheckVersion + putStrLn "license: GNU General Public License, version 3" + putStrLn "website: https://www.shellcheck.net" + +printOptional = do + mapM f list + where + list = sortOn cdName ShellCheck.Analyzer.optionalChecks + f item = do + putStrLn $ "name: " ++ cdName item + putStrLn $ "desc: " ++ cdDescription item + putStrLn $ "example: " ++ cdPositive item + putStrLn $ "fix: " ++ cdNegative item + putStrLn "" diff --git a/shpell.hs b/shpell.hs deleted file mode 100644 index dec310c..0000000 --- a/shpell.hs +++ /dev/null @@ -1,934 +0,0 @@ -{-# LANGUAGE NoMonomorphismRestriction #-} - --- Shpell Check, by Vidar 'koala_man' Holen --- Sorry about the code. It was a week's worth of hacking. - -import Text.Parsec -import Text.Parsec.Pos (initialPos) -import Debug.Trace -import Control.Monad -import Data.Char -import Data.List (isInfixOf, partition, sortBy, intercalate) -import qualified Control.Monad.State as Ms -import Data.Maybe -import Prelude hiding (readList) -import System.IO - - - -backslash = char '\\' -linefeed = char '\n' -singleQuote = char '\'' -doubleQuote = char '"' -variableStart = upper <|> lower <|> oneOf "_" -variableChars = upper <|> lower <|> digit <|> oneOf "_" -specialVariable = oneOf "@*#?-$!" -tokenDelimiter = oneOf "&|;<> \t\n" -quotable = oneOf "#|&;<>()$`\\ \"'\t\n" -doubleQuotable = oneOf "\"$`" -whitespace = oneOf " \t\n" -linewhitespace = oneOf " \t" - -spacing = do - x <- many (many1 linewhitespace <|> (try $ string "\\\n")) - optional readComment - return $ concat x - -allspacing = do - spacing - x <- option False ((linefeed <|> carriageReturn) >> return True) - when x allspacing - -carriageReturn = do - parseNote ErrorC "Literal carriage return. Run script through tr -d '\\r' " - char '\r' - - ---------- Message/position annotation on top of user state -data Annotated a = Annotated SourcePos [Note] a deriving (Show, Eq) -data Note = ParseNote SourcePos Severity String | Note Severity String deriving (Show, Eq) -data MessageStack = StackNode Note MessageStack | StackMark String SourcePos MessageStack | StackEmpty -data ParseProblem = ParseProblem SourcePos Severity String deriving (Show, Eq) -data OutputNote = OutputNote SourcePos Severity String deriving (Show, Eq) -data Severity = ErrorC | WarningC | InfoC | StyleC deriving (Show, Eq, Ord) - -instance Functor Annotated where - fmap f (Annotated p n a) = Annotated p n (f a) - -markStack msg = do - pos <- getPosition - modifyState (StackMark msg pos) - -getMessages r (StackMark _ _ s) = (r, s) -getMessages r (StackNode n s) = getMessages (n:r) s -popStack = do - f <- getState - let (notes, stack) = getMessages [] f - putState stack - return notes - --- Store potential parse problems outside of parsec -parseProblem level msg = do - pos <- getPosition - parseProblemAt pos level msg - -parseProblemAt pos level msg = do - Ms.modify ((ParseProblem pos level msg):) - -pushNote n = modifyState (StackNode n) - -parseNote l a = do - pos <- getPosition - parseNoteAt pos l a - -parseNoteAt pos l a = pushNote $ ParseNote pos l a - - -annotated msg parser = do - pos <- getPosition - markStack msg - result <- parser - messages <- popStack - return $ Annotated pos messages result - -dropAnnotation (Annotated _ _ s) = s -blankAnnotation pos t = Annotated pos [] t - -merge (Annotated pos messages result) = do - mapM pushNote messages - return result - -merging p = p >>= merge - -getOutputNotes (Annotated p notes _) = map (makeOutputNote p) notes - -makeOutputNote _ (ParseNote p l s) = OutputNote p l s -makeOutputNote p (Note l s) = OutputNote p l s - ---------- Convenient combinators - -thenSkip main follow = do - r <- main - optional follow - return r - -disregard x = x >> return () - -reluctantlyTill p end = do -- parse p until end <|> eof matches ahead - (lookAhead ((disregard $ try end) <|> eof) >> return []) <|> do - x <- p - more <- reluctantlyTill p end - return $ x:more - <|> return [] - -reluctantlyTill1 p end = do - notFollowedBy end - x <- p - more <- reluctantlyTill p end - return $ x:more - -attempting rest branch = do - ((try branch) >> rest) <|> rest - -wasIncluded p = option False (p >> return True) - --- Horrifying AST -data Token = T_AND_IF | T_OR_IF | T_DSEMI | T_Semi | T_DLESS | T_DGREAT | T_LESSAND | T_GREATAND | T_LESSGREAT | T_DLESSDASH | T_CLOBBER | T_If | T_Then | T_Else | T_Elif | T_Fi | T_Do | T_Done | T_Case | T_Esac | T_While | T_Until | T_For | T_Lbrace | T_Rbrace | T_Lparen | T_Rparen | T_Bang | T_In | T_NEWLINE | T_EOF | T_Less | T_Greater | T_SingleQuoted String | T_Literal String | T_NormalWord [Annotated Token] | T_DoubleQuoted [Annotated Token] | T_DollarExpansion [Token] | T_DollarBraced String | T_DollarVariable String | T_DollarArithmetic String | T_BraceExpansion String | T_IoFile Token Token | T_HereDoc Bool Bool String | T_HereString Token | T_FdRedirect String Token | T_Assignment String Token | T_Redirecting [Annotated Token] Token | T_SimpleCommand [Annotated Token] [Annotated Token] | T_Pipeline [Annotated Token] | T_Banged Token | T_AndIf (Annotated Token) (Annotated Token) | T_OrIf (Annotated Token) (Annotated Token) | T_Backgrounded Token | T_IfExpression [([Token],[Token])] [Token] | T_Subshell [Token] | T_BraceGroup [Token] | T_WhileExpression [Token] [Token] | T_UntilExpression [Token] [Token] | T_ForIn String [Token] [Token] | T_CaseExpression Token [([Token],[Token])] |T_Function String Token | T_Command (Annotated Token) | T_Script [Token] - deriving (Show) - -extractNotes' list = modifyFlag ((++) $ concatMap getOutputNotes list) >> return () -extractNotes (T_NormalWord list) = extractNotes' list -extractNotes (T_DoubleQuoted list) = extractNotes' list -extractNotes (T_Redirecting list f) = extractNotes' list -extractNotes (T_Pipeline list) = extractNotes' list -extractNotes (T_Command list) = extractNotes' [list] -extractNotes (T_SimpleCommand list1 list2) = do - extractNotes' list1 - extractNotes' list2 -extractNotes t = return () - - -postMessage level s = Ms.modify $ \(x, l) -> (x, Note level s : l) -warn s = postMessage WarningC s -inform s = postMessage InfoC s -style s = postMessage StyleC s - - -putFlag v = modifyFlag (const v) >> return () -getFlag = modifyFlag id -modifyFlag f = do - Ms.modify $ \(x, l) -> (f x, l) - v <- Ms.get - return $ fst v - - -analyzeScopes f i = mapM (analyzeScope f i) -analyzeScope f i (Annotated pos notes t) = do - v <- getFlag - let (ret, (flag, list)) = Ms.runState (analyze f i t) (v, []) - putFlag flag - return $ Annotated pos (notes++list) ret - -analyze f i s@(T_NormalWord list) = do - f s - a <- analyzeScopes f i list - return . i $ T_NormalWord a - -analyze f i s@(T_DoubleQuoted list) = do - f s - a <- analyzeScopes f i list - return . i $ T_DoubleQuoted a - -analyze f i s@(T_DollarExpansion l) = do - f s - nl <- mapM (analyze f i) l - return . i $ T_DollarExpansion nl - -analyze f i s@(T_IoFile op file) = do - f s - a <- analyze f i op - b <- analyze f i file - return . i $ T_IoFile a b - -analyze f i s@(T_HereString word) = do - f s - a <- analyze f i word - return . i $ T_HereString a - -analyze f i s@(T_FdRedirect v t) = do - f s - a <- analyze f i t - return . i $ T_FdRedirect v a - -analyze f i s@(T_Assignment v t) = do - f s - a <- analyze f i t - return . i $ T_Assignment v a - -analyze f i s@(T_Redirecting redirs cmd) = do - f s - newRedirs <- analyzeScopes f i redirs - newCmd <- analyze f i $ cmd - return . i $ (T_Redirecting newRedirs newCmd) - -analyze f i s@(T_SimpleCommand vars cmds) = do - f s - a <- analyzeScopes f i vars - b <- analyzeScopes f i cmds - return . i $ T_SimpleCommand a b - -analyze f i s@(T_Pipeline l) = do - f s - a <- analyzeScopes f i l - return . i $ T_Pipeline a - -analyze f i s@(T_Banged l) = do - f s - a <- analyze f i l - return . i $ T_Banged a - -analyze f i s@(T_AndIf t u) = do - f s - a <- analyzeScope f i t - b <- analyzeScope f i u - return . i $ T_AndIf a b - -analyze f i s@(T_OrIf t u) = do - f s - a <- analyzeScope f i t - b <- analyzeScope f i u - return . i $ T_OrIf a b - -analyze f i s@(T_Backgrounded l) = do - f s - a <- analyze f i l - return . i $ T_Backgrounded a - -analyze f i s@(T_IfExpression conditions elses) = do - f s - newConds <- mapM (\(c, t) -> do - x <- mapM (analyze f i) c - y <- mapM (analyze f i) t - return (x, y) - ) conditions - newElses <- mapM (analyze f i) elses - return . i $ T_IfExpression newConds newElses - -analyze f i s@(T_Subshell l) = do - f s - a <- mapM (analyze f i) l - return . i $ T_Subshell a - -analyze f i s@(T_BraceGroup l) = do - f s - a <- mapM (analyze f i) l - return . i $ T_BraceGroup a - -analyze f i s@(T_WhileExpression c l) = do - f s - a <- mapM (analyze f i) c - b <- mapM (analyze f i) l - return . i $ T_WhileExpression a b - -analyze f i s@(T_UntilExpression c l) = do - f s - a <- mapM (analyze f i) c - b <- mapM (analyze f i) l - return . i $ T_UntilExpression a b - -analyze f i s@(T_ForIn v w l) = do - f s - a <- mapM (analyze f i) w - b <- mapM (analyze f i) l - return . i $ T_ForIn v a b - -analyze f i s@(T_CaseExpression word cases) = do - f s - newWord <- analyze f i word - newCases <- mapM (\(c, t) -> do - x <- mapM (analyze f i) c - y <- mapM (analyze f i) t - return (x, y) - ) cases - return . i $ T_CaseExpression newWord newCases - -analyze f i s@(T_Script l) = do - f s - a <- mapM (analyze f i) l - return . i $ T_Script a - -analyze f i s@(T_Function name body) = do - f s - a <- analyze f i body - return . i $ T_Function name a - -analyze f i s@(T_Command c) = do - f s - a <- analyzeScope f i c - return . i $ T_Command a - -analyze f i t = do - f t - return . i $ t - -doAnalysis f t = fst $ Ms.runState (analyze f id t) ((), []) -explore f d t = fst . snd $ Ms.runState (analyze f id t) (d, []) -transform i t = fst $ Ms.runState (analyze (const $ return ()) i t) ((), []) - -findNotes t = explore extractNotes [] t -sortNotes l = sortBy compareNotes l -compareNotes (OutputNote pos1 level1 _) (OutputNote pos2 level2 _) = compare (pos1, level1) (pos2, level2) -findParseNotes l = map (\(ParseProblem p level s) -> OutputNote p level s) l --- T_UntilExpression [Token] [Token] | T_ForIn String [Token] [Token] - -getNotes s = - case rp readScript s of - (Right x, p) -> sortNotes $ (findNotes $ doAllAnalysis x) ++ (findParseNotes p) - (Left _, p) -> sortNotes $ (OutputNote (initialPos "-") ErrorC "Parsing failed"):(findParseNotes p) - -readComment = do - char '#' - anyChar `reluctantlyTill` linefeed - -readNormalWord = do - x <- many1 readNormalWordPart - return $ T_NormalWord x - -readNormalWordPart = readSingleQuoted <|> readDoubleQuoted <|> readDollar <|> readBraced <|> readBackTicked <|> (annotated "normal literal" $ readNormalLiteral) - -readSingleQuoted = annotated "single quoted string" $ do - singleQuote - s <- readSingleQuotedPart `reluctantlyTill` singleQuote - singleQuote "End single quoted string" - - let string = concat s - return (T_SingleQuoted string) `attempting` do - x <- lookAhead anyChar - when (isAlpha x && isAlpha (last string)) $ parseProblem WarningC "This apostrophe terminated the single quoted string." - -readSingleQuotedLiteral = do - singleQuote - strs <- many1 readSingleQuotedPart - singleQuote - return $ concat strs - -readSingleQuotedPart = - readSingleEscaped - <|> anyChar `reluctantlyTill1` (singleQuote <|> backslash) - -readBackTicked = annotated "backtick expansion" $ do - parseNote StyleC "`..` style expansion is deprecated, use $(..) instead if you want my help" - pos <- getPosition - char '`' - f <- readGenericLiteral (char '`') - char '`' `attempting` (eof >> parseProblemAt pos ErrorC "Can't find terminating backtick for this one") - return $ T_Literal f - - -readDoubleQuoted = annotated "double quoted string" $ do - doubleQuote - x <- many doubleQuotedPart - doubleQuote "End double quoted" - return $ T_DoubleQuoted x - -doubleQuotedPart = readDoubleLiteral <|> readDollar <|> readBackTicked - -readDoubleQuotedLiteral = do - doubleQuote - x <- readDoubleLiteral - doubleQuote - return $ dropAnnotation x - -readDoubleLiteral = annotated "double literal" $ do - s <- many1 readDoubleLiteralPart - return $ T_Literal (concat s) - -readDoubleLiteralPart = do - x <- (readDoubleEscaped <|> (anyChar >>= \x -> return [x])) `reluctantlyTill1` doubleQuotable - return $ concat x - -readNormalLiteral = do - s <- many1 readNormalLiteralPart - return $ T_Literal (concat s) - -readNormalLiteralPart = do - readNormalEscaped <|> (anyChar `reluctantlyTill1` quotable) - -readNormalEscaped = do - backslash - pos <- getPosition - do - next <- (quotable <|> oneOf "?*[]") - return $ if next == '\n' then "" else [next] - <|> - do - next <- anyChar "No character after \\" - parseNoteAt pos WarningC $ "This character doesn't need escaping here, the \\ is ignored" - return [next] - -readSingleEscaped = do - s <- backslash - let attempt level p msg = do { try $ parseNote level msg; x <- p; return [s,x]; } - - do { - x <- singleQuote; - parseProblem InfoC "Are you trying to escape a single quote? echo 'You'\\''re doing it wrong'."; - return [s,x]; - } - <|> attempt InfoC linefeed "You don't break lines with \\ in single quotes, it results in literal backslash-linefeed." - <|> do - x <- anyChar - return [s,x] - - -readDoubleEscaped = do - bs <- backslash - (linefeed >> return "") - <|> (doubleQuotable >>= return . return) - <|> (anyChar >>= (return . \x -> [bs, x])) - - -readGenericLiteral endExp = do - strings <- many (readGenericEscaped <|> anyChar `reluctantlyTill1` endExp) - return $ concat strings - -readGenericLiteral1 endExp = do - strings <- many1 (readGenericEscaped <|> anyChar `reluctantlyTill1` endExp) - return $ concat strings - -readGenericEscaped = do - backslash - x <- anyChar - return $ if x == '\n' then [] else [x] - -readBraced = annotated "{1,2..3} expression" $ try $ do - let strip (T_Literal s) = return ("\"" ++ s ++ "\"") - char '{' - str <- many1 ((readDoubleQuotedLiteral >>= (strip )) <|> readGenericLiteral1 (oneOf "}" <|> whitespace)) - char '}' - return $ T_BraceExpansion $ concat str - -readDollar = readDollarArithmetic <|> readDollarBraced <|> readDollarExpansion <|> readDollarVariable <|> readDollarLonely - - -readParenLiteralHack = do - strs <- ((anyChar >>= \x -> return [x]) <|> readParenHack) `reluctantlyTill1` (string "))") - return $ concat strs - -readParenHack = do - char '(' - x <- many anyChar - char ')' - return $ "(" ++ x ++ ")" - -readDollarArithmetic = annotated "$(( )) expression" $ do - try (string "$((") - -- TODO - str <- readParenLiteralHack - string "))" - return (T_DollarArithmetic str) - -readDollarBraced = annotated "${ } expression" $ do - try (string "${") - -- TODO - str <- readGenericLiteral (char '}') - char '}' "matching }" - return $ (T_DollarBraced str) - -readDollarExpansion = annotated "$( )" $ do - try (string "$(") - cmds <- readCompoundList - char ')' - return $ (T_DollarExpansion cmds) - -readDollarVariable = annotated "$variable" $ do - let singleCharred p = do - n <- p - return (T_DollarVariable [n]) `attempting` do - pos <- getPosition - num <- lookAhead $ many1 p - parseNoteAt pos ErrorC $ "$" ++ (n:num) ++ " is equivalent to ${" ++ [n] ++ "}"++ num - - let positional = singleCharred digit - let special = singleCharred specialVariable - - let regular = do - name <- readVariableName - return $ T_DollarVariable (name) - - char '$' - positional <|> special <|> regular - -readVariableName = do - f <- variableStart - rest <- many variableChars - return (f:rest) - -readDollarLonely = annotated "lonely $" $ do - parseNote ErrorC "$ is not used specially and should therefore be escaped" - char '$' - return $ T_Literal "$" - -readHereDoc = annotated "here document" $ do - let stripLiteral (T_Literal x) = x - stripLiteral (T_SingleQuoted x) = x - try $ string "<<" - dashed <- (char '-' >> return True) <|> return False - tokenPosition <- getPosition - spacing - (quoted, endToken) <- (readNormalLiteral >>= (\x -> return (False, stripLiteral x)) ) - <|> (readDoubleQuotedLiteral >>= return . (\x -> (True, stripLiteral x))) - <|> (readSingleQuotedLiteral >>= return . (\x -> (True, x))) - spacing - - hereInfo <- anyChar `reluctantlyTill` (linefeed >> spacing >> (string endToken) >> (disregard whitespace <|> eof)) - - do - linefeed - spaces <- spacing - verifyHereDoc dashed quoted spaces hereInfo - token <- string endToken - return $ T_FdRedirect "" $ T_HereDoc dashed quoted hereInfo - `attempting` (eof >> debugHereDoc tokenPosition endToken hereInfo) - -verifyHereDoc dashed quoted spacing hereInfo = do - when (not dashed && spacing /= "") $ parseNote ErrorC "When using << instead of <<-, the end tokens can't be indented" - when (dashed && filter (/= '\t') spacing /= "" ) $ parseNote ErrorC "When using <<-, you can only indent with tabs" - return () - -debugHereDoc pos endToken doc = - if endToken `isInfixOf` doc - then parseProblemAt pos ErrorC (endToken ++ " was part of the here document, but not by itself at the start of the line") - else if (map toLower endToken) `isInfixOf` (map toLower doc) - then parseProblemAt pos ErrorC (endToken ++ " appears in the here document, but with different case") - else parseProblemAt pos ErrorC ("Couldn't find end token `" ++ endToken ++ "' in the here document ") - - -readFilename = readNormalWord -readIoFileOp = choice [g_LESSAND, g_GREATAND, g_DGREAT, g_LESSGREAT, g_CLOBBER, string "<" >> return T_Less, string ">" >> return T_Greater ] -readIoFile = do - op <- readIoFileOp - spacing - file <- readFilename - return $ T_FdRedirect "" $ T_IoFile op file -readIoNumber = try $ do - x <- many1 digit - lookAhead readIoFileOp - return x -readIoNumberRedirect = annotated "fd io redirect" $ do - n <- readIoNumber - op <- merging readHereString <|> merging readHereDoc <|> readIoFile - let actualOp = case op of T_FdRedirect "" x -> x - spacing - return $ T_FdRedirect n actualOp - -readIoRedirect = annotated "io redirect" $ choice [ merging readIoNumberRedirect, merging readHereString, merging readHereDoc, readIoFile ] `thenSkip` spacing - -readRedirectList = many1 readIoRedirect - -readHereString = annotated "here string" $ do - try $ string "<<<" - spacing - word <- readNormalWord - return $ T_FdRedirect "" $ T_HereString word - -readNewlineList = many1 ((newline <|> carriageReturn) `thenSkip` spacing) -readLineBreak = optional readNewlineList - -readSeparatorOp = do - notFollowedBy (g_AND_IF <|> g_DSEMI) - f <- char ';' <|> char '&' - spacing - return f - -readSequentialSep = (disregard $ g_Semi >> readLineBreak) <|> (disregard readNewlineList) -readSeparator = - do - separator <- readSeparatorOp - readLineBreak - return separator - <|> - do - readNewlineList - return '\n' - -makeSimpleCommand tokens = - let (assignment, rest) = partition (\x -> case dropAnnotation x of T_Assignment _ _ -> True; _ -> False) tokens - in let (redirections, rest2) = partition (\x -> case dropAnnotation x of T_FdRedirect _ _ -> True; _ -> False) rest - in T_Redirecting redirections $ T_SimpleCommand assignment rest2 - -readSimpleCommand = annotated "simple command" $ do - prefix <- option [] readCmdPrefix - cmd <- option [] $ do { f <- annotated "command name" readCmdName; return [f]; } - when (null prefix && null cmd) $ fail "No command" - if null cmd - then return $ makeSimpleCommand prefix - else do - suffix <- option [] readCmdSuffix - return $ makeSimpleCommand (prefix ++ cmd ++ suffix) - -readPipeline = annotated "Pipeline" $ do - notFollowedBy $ try readKeyword - do - g_Bang `thenSkip` spacing - pipe <- readPipeSequence - return $ T_Banged pipe - <|> do - readPipeSequence - -readAndOr = (flip (>>=)) (return . T_Command) $ chainr1 readPipeline $ do - pos <- getPosition - op <- g_AND_IF <|> g_OR_IF - readLineBreak - return $ \a b -> - blankAnnotation pos $ - case op of T_AND_IF -> T_AndIf a b - T_OR_IF -> T_OrIf a b - -readTerm = do - m <- readAndOr - readTerm' m - -readTerm' current = - do - sep <- readSeparator - more <- (option T_EOF $ readAndOr) - case more of T_EOF -> return [transformWithSeparator sep current] - _ -> do - list <- readTerm' more - return $ (transformWithSeparator sep current : list) - <|> - return [current] - -transformWithSeparator '&' = T_Backgrounded -transformWithSeparator _ = id - - -readPipeSequence = do - list <- readCommand `sepBy1` (readPipe `thenSkip` (spacing >> readLineBreak)) - spacing - return $ T_Pipeline list - -readPipe = do - notFollowedBy g_OR_IF - char '|' `thenSkip` spacing - -readCommand = (readCompoundCommand <|> readSimpleCommand) - -readCmdName = do - f <- readNormalWord - spacing - return f - -readCmdWord = do - f <- readNormalWord - spacing - return f - -readIfClause = annotated "if statement" $ do - (condition, action) <- readIfPart - elifs <- many readElifPart - elses <- option [] readElsePart - g_Fi - return $ T_IfExpression ((condition, action):elifs) elses - -readIfPart = do - g_If - allspacing - condition <- readTerm - g_Then - allspacing - action <- readTerm - return (condition, action) - -readElifPart = do - g_Elif - allspacing - condition <- readTerm - g_Then - allspacing - action <- readTerm - return (condition, action) - -readElsePart = do - g_Else - allspacing - readTerm - -readSubshell = annotated "subshell group" $ do - char '(' - allspacing - list <- readCompoundList - allspacing - char ')' - return $ T_Subshell list - -readBraceGroup = annotated "brace group" $ do - char '{' - allspacing - list <- readTerm - allspacing - char '}' - return $ T_BraceGroup list - -readWhileClause = annotated "while loop" $ do - g_While - condition <- readTerm - statements <- readDoGroup - return $ T_WhileExpression condition statements - -readUntilClause = annotated "until loop" $ do - g_Until - condition <- readTerm - statements <- readDoGroup - return $ T_UntilExpression condition statements - -readDoGroup = do - pos <- getPosition - g_Do - allspacing - (eof >> return []) <|> - do - commands <- readCompoundList - disregard g_Done <|> eof -- stunted support - return commands - <|> do - parseProblemAt pos ErrorC "Can't find the 'done' for this 'do'" - fail "No done" - -readForClause = annotated "for loop" $ do - g_For - spacing - name <- readVariableName - allspacing - values <- readInClause <|> (readSequentialSep >> return []) - group <- readDoGroup <|> (allspacing >> eof >> return []) -- stunted support - return $ T_ForIn name values group - -readInClause = do - g_In - things <- (readCmdWord) `reluctantlyTill` - (disregard (g_Semi) <|> disregard linefeed <|> disregard g_Do) - - do { - lookAhead (g_Do); - parseNote ErrorC "You need a line feed or semicolon before the 'do' (in Bash)"; - } <|> do { - optional $ g_Semi; - disregard allspacing; - } - - return things - -readCaseClause = annotated "case statement" $ do - g_Case - word <- readNormalWord - spacing - g_In - readLineBreak - list <- readCaseList - g_Esac - return $ T_CaseExpression word list - -readCaseList = many readCaseItem - -readCaseItem = do - notFollowedBy g_Esac - optional g_Lparen - spacing - pattern <- readPattern - g_Rparen - readLineBreak - list <- ((lookAhead g_DSEMI >> return []) <|> readCompoundList) - (g_DSEMI <|> lookAhead (readLineBreak >> g_Esac)) - readLineBreak - return (pattern, list) - -readFunctionDefinition = annotated "function definition" $ do - name <- try readFunctionSignature - allspacing - (disregard (lookAhead g_Lbrace) <|> parseProblem ErrorC "Expected a { to open the function definition") - group <- merging readBraceGroup - return $ T_Function name group - - -readFunctionSignature = do - (optional $ try (string "function " >> parseNote StyleC "Don't use 'function' in front of function definitions")) - name <- readVariableName - spacing - g_Lparen - g_Rparen - return name - - -readPattern = (readNormalWord `thenSkip` spacing) `sepBy1` (char '|' `thenSkip` spacing) - - -readCompoundCommand = annotated "compound command" $ do - cmd <- merging $ choice [ readBraceGroup, readSubshell, readWhileClause, readUntilClause, readIfClause, readForClause, readCaseClause, readFunctionDefinition] - spacing - redirs <- many readIoRedirect - return $ T_Redirecting redirs $ cmd - - -readCompoundList = readTerm - -readCmdPrefix = many1 (readIoRedirect <|> readAssignmentWord) -readCmdSuffix = many1 (readIoRedirect <|> annotated "normal word" readCmdWord) - -readAssignmentWord = annotated "assignment" $ try $ do - optional (char '$' >> parseNote ErrorC "Don't use $ on the left side of assignments") - variable <- readVariableName - space <- spacing - pos <- getPosition - char '=' - space2 <- spacing - value <- readNormalWord - spacing - when (space ++ space2 /= "") $ parseNoteAt pos ErrorC "Don't put spaces around the = in assignments" - return $ T_Assignment variable value - - -tryToken s t = try (string s >> spacing >> return t) -tryWordToken s t = tryParseWordToken (string s) t `thenSkip` spacing -tryParseWordToken parser t = try (parser >> (lookAhead (eof <|> disregard whitespace))) >> return t - -g_AND_IF = tryToken "&&" T_AND_IF -g_OR_IF = tryToken "||" T_OR_IF -g_DSEMI = tryToken ";;" T_DSEMI -g_DLESS = tryToken "<<" T_DLESS -g_DGREAT = tryToken ">>" T_DGREAT -g_LESSAND = tryToken "<&" T_LESSAND -g_GREATAND = tryToken ">&" T_GREATAND -g_LESSGREAT = tryToken "<>" T_LESSGREAT -g_DLESSDASH = tryToken "<<-" T_DLESSDASH -g_CLOBBER = tryToken ">|" T_CLOBBER -g_OPERATOR = g_AND_IF <|> g_OR_IF <|> g_DSEMI <|> g_DLESSDASH <|> g_DLESS <|> g_DGREAT <|> g_LESSAND <|> g_GREATAND <|> g_LESSGREAT - -g_If = tryWordToken "if" T_If -g_Then = tryWordToken "then" T_Then -g_Else = tryWordToken "else" T_Else -g_Elif = tryWordToken "elif" T_Elif -g_Fi = tryWordToken "fi" T_Fi -g_Do = tryWordToken "do" T_Do -g_Done = tryWordToken "done" T_Done -g_Case = tryWordToken "case" T_Case -g_Esac = tryWordToken "esac" T_Esac -g_While = tryWordToken "while" T_While -g_Until = tryWordToken "until" T_Until -g_For = tryWordToken "for" T_For -g_In = tryWordToken "in" T_In -g_Lbrace = tryWordToken "{" T_Lbrace -g_Rbrace = tryWordToken "}" T_Rbrace - -g_Lparen = tryToken "(" T_Lparen -g_Rparen = tryToken ")" T_Rparen -g_Bang = tryToken "!" T_Bang - -g_Semi = do - notFollowedBy g_DSEMI - tryToken ";" T_Semi - -readKeyword = choice [ g_Then, g_Else, g_Elif, g_Fi, g_Do, g_Done, g_Esac, g_Rbrace, g_Rparen, g_DSEMI ] - -ifParse p t f = do - (lookAhead (try p) >> t) <|> f - -wtf = do - x <- many anyChar - parseProblem ErrorC x - -readScript = do - do { - allspacing; - commands <- readTerm; - eof <|> (parseProblem WarningC "Stopping here, because I can't parse this command"); - return $ T_Script commands; - } <|> do { - parseProblem WarningC "Couldn't read any commands"; - wtf; - return T_EOF; - } - -shpell s = rp readScript s -rp p s = Ms.runState (runParserT p StackEmpty "-" s) [] - --------- Destructively simplify AST - -simplify (T_Redirecting [] t) = t -simplify (T_Pipeline [x]) = dropAnnotation x -simplify (T_NormalWord [x]) = dropAnnotation x -simplify t = t - --------- Analytics -doAllAnalysis t = foldl (\v f -> doAnalysis f v) t checks - -getAst s = case rp readScript s of (Right parsed, _) -> parsed -getAst2 s = case rp readScript s of (Right parsed, _) -> transform simplify parsed -lol (Right x, _) = x - -deadSimple (T_NormalWord l) = [concat (concatMap (deadSimple . dropAnnotation) l)] -deadSimple (T_DoubleQuoted l) = ["\"" ++(concat (concatMap (deadSimple . dropAnnotation) l)) ++ "\""] -deadSimple (T_SingleQuoted s) = [s] -deadSimple (T_DollarVariable _) = ["${VAR}"] -deadSimple (T_DollarBraced _) = ["${VAR}"] -deadSimple (T_DollarArithmetic _) = ["${VAR}"] -deadSimple (T_DollarExpansion _) = ["${VAR}"] -deadSimple (T_Literal x) = [x] -deadSimple (T_SimpleCommand vars words) = concatMap (deadSimple . dropAnnotation) words -deadSimple (T_Redirecting _ foo) = deadSimple foo -deadSimple _ = [] - - -checks = [checkUuoc] -checkUuoc (T_Pipeline ((Annotated _ _ x):_:_)) = case (deadSimple x) of ["cat", _] -> style "UUOC: Instead of 'cat a | b', use 'b < a'" - _ -> return () -checkUuoc _ = return () - - -main = do - s <- getContents --- case rp readScript s of (Right parsed, _) -> putStrLn . show $ transform simplify parsed --- (Left x, y) -> putStrLn $ "Can't parse: " ++ (show (x,y)) - - mapM (putStrLn . show) $ getNotes s diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..83ac1e7 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,57 @@ +name: shellcheck +summary: A shell script static analysis tool +description: | + ShellCheck is a GPLv3 tool that gives warnings and suggestions for bash/sh + shell scripts. + + The goals of ShellCheck are + + - To point out and clarify typical beginner's syntax issues that cause a + shell to give cryptic error messages. + + - To point out and clarify typical intermediate level semantic problems that + cause a shell to behave strangely and counter-intuitively. + + - To point out subtle caveats, corner cases and pitfalls that may cause an + advanced user's otherwise working script to fail under future + circumstances. + + By default ShellCheck can only check non-hidden files under /home, to make + ShellCheck be able to check files under /media and /run/media you must + connect it to the `removable-media` interface manually: + + # snap connect shellcheck:removable-media + +version: git +base: core24 +grade: stable +confinement: strict + +apps: + shellcheck: + command: usr/bin/shellcheck + plugs: [home, removable-media] + environment: + LANG: C.UTF-8 + +parts: + shellcheck: + plugin: dump + source: . + build-packages: + - cabal-install + override-build: | + # Give ourselves enough memory to build + fallocate -l 2G /tmp/swap + chmod 0600 /tmp/swap + mkswap /tmp/swap + if ! swapon /tmp/swap; then + echo "Could not enable swap file, continuing anyway" + rm /tmp/swap + fi + + cabal update + cabal install -j + + install -d "${CRAFT_PART_INSTALL}/usr/bin" + install --strip ~/.cabal/bin/shellcheck "${CRAFT_PART_INSTALL}/usr/bin" diff --git a/src/ShellCheck/AST.hs b/src/ShellCheck/AST.hs new file mode 100644 index 0000000..b04abee --- /dev/null +++ b/src/ShellCheck/AST.hs @@ -0,0 +1,289 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE DeriveGeneric, DeriveAnyClass, DeriveTraversable, PatternSynonyms #-} +module ShellCheck.AST where + +import GHC.Generics (Generic) +import Control.Monad.Identity +import Control.DeepSeq +import Text.Parsec +import qualified ShellCheck.Regex as Re +import Prelude hiding (id) + +newtype Id = Id Int deriving (Show, Eq, Ord, Generic, NFData) + +data Quoted = Quoted | Unquoted deriving (Show, Eq) +data Dashed = Dashed | Undashed deriving (Show, Eq) +data Piped = Piped | Unpiped deriving (Show, Eq) +data AssignmentMode = Assign | Append deriving (Show, Eq) +newtype FunctionKeyword = FunctionKeyword Bool deriving (Show, Eq) +newtype FunctionParentheses = FunctionParentheses Bool deriving (Show, Eq) +data CaseType = CaseBreak | CaseFallThrough | CaseContinue deriving (Show, Eq) + +newtype Root = Root Token +data Token = OuterToken Id (InnerToken Token) deriving (Show) + +data InnerToken t = + Inner_TA_Binary String t t + | Inner_TA_Assignment String t t + | Inner_TA_Variable String [t] + | Inner_TA_Expansion [t] + | Inner_TA_Sequence [t] + | Inner_TA_Parenthesis t + | Inner_TA_Trinary t t t + | Inner_TA_Unary String t + | Inner_TC_And ConditionType String t t + | Inner_TC_Binary ConditionType String t t + | Inner_TC_Group ConditionType t + | Inner_TC_Nullary ConditionType t + | Inner_TC_Or ConditionType String t t + | Inner_TC_Unary ConditionType String t + | Inner_TC_Empty ConditionType + | Inner_T_AND_IF + | Inner_T_AndIf t t + | Inner_T_Arithmetic t + | Inner_T_Array [t] + | Inner_T_IndexedElement [t] t + -- Store the index as string, and parse as arithmetic or string later + | Inner_T_UnparsedIndex SourcePos String + | Inner_T_Assignment AssignmentMode String [t] t + | Inner_T_Backgrounded t + | Inner_T_Backticked [t] + | Inner_T_Bang + | Inner_T_Banged t + | Inner_T_BraceExpansion [t] + | Inner_T_BraceGroup [t] + | Inner_T_CLOBBER + | Inner_T_Case + | Inner_T_CaseExpression t [(CaseType, [t], [t])] + | Inner_T_Condition ConditionType t + | Inner_T_DGREAT + | Inner_T_DLESS + | Inner_T_DLESSDASH + | Inner_T_DSEMI + | Inner_T_Do + | Inner_T_DollarArithmetic t + | Inner_T_DollarBraced Bool t + | Inner_T_DollarBracket t + | Inner_T_DollarDoubleQuoted [t] + | Inner_T_DollarExpansion [t] + | Inner_T_DollarSingleQuoted String + | Inner_T_DollarBraceCommandExpansion Piped [t] + | Inner_T_Done + | Inner_T_DoubleQuoted [t] + | Inner_T_EOF + | Inner_T_Elif + | Inner_T_Else + | Inner_T_Esac + | Inner_T_Extglob String [t] + | Inner_T_FdRedirect String t + | Inner_T_Fi + | Inner_T_For + | Inner_T_ForArithmetic t t t [t] + | Inner_T_ForIn String [t] [t] + | Inner_T_Function FunctionKeyword FunctionParentheses String t + | Inner_T_GREATAND + | Inner_T_Glob String + | Inner_T_Greater + | Inner_T_HereDoc Dashed Quoted String [t] + | Inner_T_HereString t + | Inner_T_If + | Inner_T_IfExpression [([t],[t])] [t] + | Inner_T_In + | Inner_T_IoFile t t + | Inner_T_IoDuplicate t String + | Inner_T_LESSAND + | Inner_T_LESSGREAT + | Inner_T_Lbrace + | Inner_T_Less + | Inner_T_Literal String + | Inner_T_Lparen + | Inner_T_NEWLINE + | Inner_T_NormalWord [t] + | Inner_T_OR_IF + | Inner_T_OrIf t t + | Inner_T_ParamSubSpecialChar String -- e.g. '%' in ${foo%bar} or '/' in ${foo/bar/baz} + | Inner_T_Pipeline [t] [t] -- [Pipe separators] [Commands] + | Inner_T_ProcSub String [t] + | Inner_T_Rbrace + | Inner_T_Redirecting [t] t + | Inner_T_Rparen + | Inner_T_Script t [t] -- Shebang T_Literal, followed by script. + | Inner_T_Select + | Inner_T_SelectIn String [t] [t] + | Inner_T_Semi + | Inner_T_SimpleCommand [t] [t] + | Inner_T_SingleQuoted String + | Inner_T_Subshell [t] + | Inner_T_Then + | Inner_T_Until + | Inner_T_UntilExpression [t] [t] + | Inner_T_While + | Inner_T_WhileExpression [t] [t] + | Inner_T_Annotation [Annotation] t + | Inner_T_Pipe String + | Inner_T_CoProc (Maybe Token) t + | Inner_T_CoProcBody t + | Inner_T_Include t + | Inner_T_SourceCommand t t + | Inner_T_BatsTest String t + deriving (Show, Eq, Functor, Foldable, Traversable) + +data Annotation = + DisableComment Integer Integer -- [from, to) + | EnableComment String + | SourceOverride String + | ShellOverride String + | SourcePath String + | ExternalSources Bool + | ExtendedAnalysis Bool + deriving (Show, Eq) +data ConditionType = DoubleBracket | SingleBracket deriving (Show, Eq) + +pattern T_AND_IF id = OuterToken id Inner_T_AND_IF +pattern T_Bang id = OuterToken id Inner_T_Bang +pattern T_Case id = OuterToken id Inner_T_Case +pattern TC_Empty id typ = OuterToken id (Inner_TC_Empty typ) +pattern T_CLOBBER id = OuterToken id Inner_T_CLOBBER +pattern T_DGREAT id = OuterToken id Inner_T_DGREAT +pattern T_DLESS id = OuterToken id Inner_T_DLESS +pattern T_DLESSDASH id = OuterToken id Inner_T_DLESSDASH +pattern T_Do id = OuterToken id Inner_T_Do +pattern T_DollarSingleQuoted id str = OuterToken id (Inner_T_DollarSingleQuoted str) +pattern T_Done id = OuterToken id Inner_T_Done +pattern T_DSEMI id = OuterToken id Inner_T_DSEMI +pattern T_Elif id = OuterToken id Inner_T_Elif +pattern T_Else id = OuterToken id Inner_T_Else +pattern T_EOF id = OuterToken id Inner_T_EOF +pattern T_Esac id = OuterToken id Inner_T_Esac +pattern T_Fi id = OuterToken id Inner_T_Fi +pattern T_For id = OuterToken id Inner_T_For +pattern T_Glob id str = OuterToken id (Inner_T_Glob str) +pattern T_GREATAND id = OuterToken id Inner_T_GREATAND +pattern T_Greater id = OuterToken id Inner_T_Greater +pattern T_If id = OuterToken id Inner_T_If +pattern T_In id = OuterToken id Inner_T_In +pattern T_Lbrace id = OuterToken id Inner_T_Lbrace +pattern T_Less id = OuterToken id Inner_T_Less +pattern T_LESSAND id = OuterToken id Inner_T_LESSAND +pattern T_LESSGREAT id = OuterToken id Inner_T_LESSGREAT +pattern T_Literal id str = OuterToken id (Inner_T_Literal str) +pattern T_Lparen id = OuterToken id Inner_T_Lparen +pattern T_NEWLINE id = OuterToken id Inner_T_NEWLINE +pattern T_OR_IF id = OuterToken id Inner_T_OR_IF +pattern T_ParamSubSpecialChar id str = OuterToken id (Inner_T_ParamSubSpecialChar str) +pattern T_Pipe id str = OuterToken id (Inner_T_Pipe str) +pattern T_Rbrace id = OuterToken id Inner_T_Rbrace +pattern T_Rparen id = OuterToken id Inner_T_Rparen +pattern T_Select id = OuterToken id Inner_T_Select +pattern T_Semi id = OuterToken id Inner_T_Semi +pattern T_SingleQuoted id str = OuterToken id (Inner_T_SingleQuoted str) +pattern T_Then id = OuterToken id Inner_T_Then +pattern T_UnparsedIndex id pos str = OuterToken id (Inner_T_UnparsedIndex pos str) +pattern T_Until id = OuterToken id Inner_T_Until +pattern T_While id = OuterToken id Inner_T_While +pattern TA_Assignment id op t1 t2 = OuterToken id (Inner_TA_Assignment op t1 t2) +pattern TA_Binary id op t1 t2 = OuterToken id (Inner_TA_Binary op t1 t2) +pattern TA_Expansion id t = OuterToken id (Inner_TA_Expansion t) +pattern T_AndIf id t u = OuterToken id (Inner_T_AndIf t u) +pattern T_Annotation id anns t = OuterToken id (Inner_T_Annotation anns t) +pattern T_Arithmetic id c = OuterToken id (Inner_T_Arithmetic c) +pattern T_Array id t = OuterToken id (Inner_T_Array t) +pattern TA_Sequence id l = OuterToken id (Inner_TA_Sequence l) +pattern TA_Parenthesis id t = OuterToken id (Inner_TA_Parenthesis t) +pattern T_Assignment id mode var indices value = OuterToken id (Inner_T_Assignment mode var indices value) +pattern TA_Trinary id t1 t2 t3 = OuterToken id (Inner_TA_Trinary t1 t2 t3) +pattern TA_Unary id op t1 = OuterToken id (Inner_TA_Unary op t1) +pattern TA_Variable id str t = OuterToken id (Inner_TA_Variable str t) +pattern T_Backgrounded id l = OuterToken id (Inner_T_Backgrounded l) +pattern T_Backticked id list = OuterToken id (Inner_T_Backticked list) +pattern T_Banged id l = OuterToken id (Inner_T_Banged l) +pattern T_BatsTest id name t = OuterToken id (Inner_T_BatsTest name t) +pattern T_BraceExpansion id list = OuterToken id (Inner_T_BraceExpansion list) +pattern T_BraceGroup id l = OuterToken id (Inner_T_BraceGroup l) +pattern TC_And id typ str t1 t2 = OuterToken id (Inner_TC_And typ str t1 t2) +pattern T_CaseExpression id word cases = OuterToken id (Inner_T_CaseExpression word cases) +pattern TC_Binary id typ op lhs rhs = OuterToken id (Inner_TC_Binary typ op lhs rhs) +pattern TC_Group id typ token = OuterToken id (Inner_TC_Group typ token) +pattern TC_Nullary id typ token = OuterToken id (Inner_TC_Nullary typ token) +pattern T_Condition id typ token = OuterToken id (Inner_T_Condition typ token) +pattern T_CoProcBody id t = OuterToken id (Inner_T_CoProcBody t) +pattern T_CoProc id var body = OuterToken id (Inner_T_CoProc var body) +pattern TC_Or id typ str t1 t2 = OuterToken id (Inner_TC_Or typ str t1 t2) +pattern TC_Unary id typ op token = OuterToken id (Inner_TC_Unary typ op token) +pattern T_DollarArithmetic id c = OuterToken id (Inner_T_DollarArithmetic c) +pattern T_DollarBraceCommandExpansion id pipe list = OuterToken id (Inner_T_DollarBraceCommandExpansion pipe list) +pattern T_DollarBraced id braced op = OuterToken id (Inner_T_DollarBraced braced op) +pattern T_DollarBracket id c = OuterToken id (Inner_T_DollarBracket c) +pattern T_DollarDoubleQuoted id list = OuterToken id (Inner_T_DollarDoubleQuoted list) +pattern T_DollarExpansion id list = OuterToken id (Inner_T_DollarExpansion list) +pattern T_DoubleQuoted id list = OuterToken id (Inner_T_DoubleQuoted list) +pattern T_Extglob id str l = OuterToken id (Inner_T_Extglob str l) +pattern T_FdRedirect id v t = OuterToken id (Inner_T_FdRedirect v t) +pattern T_ForArithmetic id a b c group = OuterToken id (Inner_T_ForArithmetic a b c group) +pattern T_ForIn id v w l = OuterToken id (Inner_T_ForIn v w l) +pattern T_Function id a b name body = OuterToken id (Inner_T_Function a b name body) +pattern T_HereDoc id d q str l = OuterToken id (Inner_T_HereDoc d q str l) +pattern T_HereString id word = OuterToken id (Inner_T_HereString word) +pattern T_IfExpression id conditions elses = OuterToken id (Inner_T_IfExpression conditions elses) +pattern T_Include id script = OuterToken id (Inner_T_Include script) +pattern T_IndexedElement id indices t = OuterToken id (Inner_T_IndexedElement indices t) +pattern T_IoDuplicate id op num = OuterToken id (Inner_T_IoDuplicate op num) +pattern T_IoFile id op file = OuterToken id (Inner_T_IoFile op file) +pattern T_NormalWord id list = OuterToken id (Inner_T_NormalWord list) +pattern T_OrIf id t u = OuterToken id (Inner_T_OrIf t u) +pattern T_Pipeline id l1 l2 = OuterToken id (Inner_T_Pipeline l1 l2) +pattern T_ProcSub id typ l = OuterToken id (Inner_T_ProcSub typ l) +pattern T_Redirecting id redirs cmd = OuterToken id (Inner_T_Redirecting redirs cmd) +pattern T_Script id shebang list = OuterToken id (Inner_T_Script shebang list) +pattern T_SelectIn id v w l = OuterToken id (Inner_T_SelectIn v w l) +pattern T_SimpleCommand id vars cmds = OuterToken id (Inner_T_SimpleCommand vars cmds) +pattern T_SourceCommand id includer t_include = OuterToken id (Inner_T_SourceCommand includer t_include) +pattern T_Subshell id l = OuterToken id (Inner_T_Subshell l) +pattern T_UntilExpression id c l = OuterToken id (Inner_T_UntilExpression c l) +pattern T_WhileExpression id c l = OuterToken id (Inner_T_WhileExpression c l) + +{-# COMPLETE T_AND_IF, T_Bang, T_Case, TC_Empty, T_CLOBBER, T_DGREAT, T_DLESS, T_DLESSDASH, T_Do, T_DollarSingleQuoted, T_Done, T_DSEMI, T_Elif, T_Else, T_EOF, T_Esac, T_Fi, T_For, T_Glob, T_GREATAND, T_Greater, T_If, T_In, T_Lbrace, T_Less, T_LESSAND, T_LESSGREAT, T_Literal, T_Lparen, T_NEWLINE, T_OR_IF, T_ParamSubSpecialChar, T_Pipe, T_Rbrace, T_Rparen, T_Select, T_Semi, T_SingleQuoted, T_Then, T_UnparsedIndex, T_Until, T_While, TA_Assignment, TA_Binary, TA_Expansion, T_AndIf, T_Annotation, T_Arithmetic, T_Array, TA_Sequence, TA_Parenthesis, T_Assignment, TA_Trinary, TA_Unary, TA_Variable, T_Backgrounded, T_Backticked, T_Banged, T_BatsTest, T_BraceExpansion, T_BraceGroup, TC_And, T_CaseExpression, TC_Binary, TC_Group, TC_Nullary, T_Condition, T_CoProcBody, T_CoProc, TC_Or, TC_Unary, T_DollarArithmetic, T_DollarBraceCommandExpansion, T_DollarBraced, T_DollarBracket, T_DollarDoubleQuoted, T_DollarExpansion, T_DoubleQuoted, T_Extglob, T_FdRedirect, T_ForArithmetic, T_ForIn, T_Function, T_HereDoc, T_HereString, T_IfExpression, T_Include, T_IndexedElement, T_IoDuplicate, T_IoFile, T_NormalWord, T_OrIf, T_Pipeline, T_ProcSub, T_Redirecting, T_Script, T_SelectIn, T_SimpleCommand, T_SourceCommand, T_Subshell, T_UntilExpression, T_WhileExpression #-} + +instance Eq Token where + OuterToken _ a == OuterToken _ b = a == b + +analyze :: Monad m => (Token -> m ()) -> (Token -> m ()) -> (Token -> m Token) -> Token -> m Token +analyze f g i = + round + where + round t@(OuterToken id it) = do + f t + newIt <- traverse round it + g t + i (OuterToken id newIt) + +getId :: Token -> Id +getId (OuterToken id _) = id + +blank :: Monad m => Token -> m () +blank = const $ return () +doAnalysis :: Monad m => (Token -> m ()) -> Token -> m Token +doAnalysis f = analyze f blank return +doStackAnalysis :: Monad m => (Token -> m ()) -> (Token -> m ()) -> Token -> m Token +doStackAnalysis startToken endToken = analyze startToken endToken return +doTransform :: (Token -> Token) -> Token -> Token +doTransform i = runIdentity . analyze blank blank (return . i) + diff --git a/src/ShellCheck/ASTLib.hs b/src/ShellCheck/ASTLib.hs new file mode 100644 index 0000000..7ddebe4 --- /dev/null +++ b/src/ShellCheck/ASTLib.hs @@ -0,0 +1,926 @@ +{- + Copyright 2012-2021 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.ASTLib where + +import ShellCheck.AST +import ShellCheck.Prelude +import ShellCheck.Regex + +import Control.Monad.Writer +import Control.Monad +import Data.Char +import Data.Functor +import Data.Functor.Identity +import Data.List +import Data.Maybe +import qualified Data.List.NonEmpty as NE +import qualified Data.Map as Map +import Numeric (showHex) + +import Test.QuickCheck + +arguments (T_SimpleCommand _ _ (cmd:args)) = args + +-- Is this a type of loop? +isLoop t = case t of + T_WhileExpression {} -> True + T_UntilExpression {} -> True + T_ForIn {} -> True + T_ForArithmetic {} -> True + T_SelectIn {} -> True + _ -> False + +-- Will this split into multiple words when used as an argument? +willSplit x = + case x of + T_DollarBraced {} -> True + T_DollarExpansion {} -> True + T_Backticked {} -> True + T_BraceExpansion {} -> True + T_Glob {} -> True + T_Extglob {} -> True + T_DoubleQuoted _ l -> any willBecomeMultipleArgs l + T_NormalWord _ l -> any willSplit l + _ -> False + +isGlob t = case t of + T_Extglob {} -> True + T_Glob {} -> True + T_NormalWord _ l -> any isGlob l || hasSplitRange l + _ -> False + where + -- foo[x${var}y] gets parsed as foo,[,x,$var,y], + -- so check if there's such an interval + hasSplitRange l = + let afterBracket = dropWhile (not . isHalfOpenRange) l + in any isClosingRange afterBracket + + isHalfOpenRange t = + case t of + T_Literal _ "[" -> True + _ -> False + + isClosingRange t = + case t of + T_Literal _ str -> ']' `elem` str + _ -> False + + +-- Is this shell word a constant? +isConstant token = + case token of + -- This ignores some cases like ~"foo": + T_NormalWord _ (T_Literal _ ('~':_) : _) -> False + T_NormalWord _ l -> all isConstant l + T_DoubleQuoted _ l -> all isConstant l + T_SingleQuoted _ _ -> True + T_Literal _ _ -> True + _ -> False + +-- Is this an empty literal? +isEmpty token = + case token of + T_NormalWord _ l -> all isEmpty l + T_DoubleQuoted _ l -> all isEmpty l + T_SingleQuoted _ "" -> True + T_Literal _ "" -> True + _ -> False + +-- Quick&lazy oversimplification of commands, throwing away details +-- and returning a list like ["find", ".", "-name", "${VAR}*" ]. +oversimplify token = + case token of + (T_NormalWord _ l) -> [concat (concatMap oversimplify l)] + (T_DoubleQuoted _ l) -> [concat (concatMap oversimplify l)] + (T_SingleQuoted _ s) -> [s] + (T_DollarBraced _ _ _) -> ["${VAR}"] + (T_DollarArithmetic _ _) -> ["${VAR}"] + (T_DollarExpansion _ _) -> ["${VAR}"] + (T_Backticked _ _) -> ["${VAR}"] + (T_Glob _ s) -> [s] + (T_Pipeline _ _ [x]) -> oversimplify x + (T_Literal _ x) -> [x] + (T_ParamSubSpecialChar _ x) -> [x] + (T_SimpleCommand _ vars words) -> concatMap oversimplify words + (T_Redirecting _ _ foo) -> oversimplify foo + (T_DollarSingleQuoted _ s) -> [s] + (T_Annotation _ _ s) -> oversimplify s + -- Workaround for let "foo = bar" parsing + (TA_Sequence _ [TA_Expansion _ v]) -> concatMap oversimplify v + _ -> [] + + +-- Turn a SimpleCommand foo -avz --bar=baz into args "a", "v", "z", "bar", +-- each in a tuple of (token, stringFlag). Non-flag arguments are added with +-- stringFlag == "". +getFlagsUntil stopCondition (T_SimpleCommand _ _ (_:args)) = + let tokenAndText = map (\x -> (x, concat $ oversimplify x)) args + (flagArgs, rest) = break (stopCondition . snd) tokenAndText + in + concatMap flag flagArgs ++ map (\(t, _) -> (t, "")) rest + where + flag (x, '-':'-':arg) = [ (x, takeWhile (/= '=') arg) ] + flag (x, '-':args) = map (\v -> (x, [v])) args + flag (x, _) = [ (x, "") ] +getFlagsUntil _ _ = error $ pleaseReport "getFlags on non-command" + +-- Get all flags in a GNU way, up until -- +getAllFlags :: Token -> [(Token, String)] +getAllFlags = getFlagsUntil (== "--") +-- Get all flags in a BSD way, up until first non-flag argument or -- +getLeadingFlags = getFlagsUntil (\x -> x == "--" || (not $ "-" `isPrefixOf` x)) + +-- Check if a command has a flag. +hasFlag cmd str = str `elem` (map snd $ getAllFlags cmd) + +-- Is this token a word that starts with a dash? +isFlag token = + case getWordParts token of + T_Literal _ ('-':_) : _ -> True + _ -> False + +-- Is this token a flag where the - is unquoted? +isUnquotedFlag token = + case getLeadingUnquotedString token of + Just ('-':_) -> True + _ -> False + +-- getGnuOpts "erd:u:" will parse a list of arguments tokens like `read` +-- -re -d : -u 3 bar +-- into +-- Just [("r", (-re, -re)), ("e", (-re, -re)), ("d", (-d,:)), ("u", (-u,3)), ("", (bar,bar))] +-- +-- Each string flag maps to a tuple of (flag, argument), where argument=flag if it +-- doesn't take a specific one. +-- +-- Any unrecognized flag will result in Nothing. The exception is if arbitraryLongOpts +-- is set, in which case --anything will map to "anything". +getGnuOpts :: String -> [Token] -> Maybe [(String, (Token, Token))] +getGnuOpts str args = getOpts (True, False) str [] args + +-- As above, except the first non-arg string will treat the rest as arguments +getBsdOpts :: String -> [Token] -> Maybe [(String, (Token, Token))] +getBsdOpts str args = getOpts (False, False) str [] args + +-- Tests for this are in Commands.hs where it's more frequently used +getOpts :: + -- Behavioral config: gnu style, allow arbitrary long options + (Bool, Bool) + -- A getopts style string + -> String + -- List of long options and whether they take arguments + -> [(String, Bool)] + -- List of arguments (excluding command) + -> [Token] + -- List of flags to tuple of (optionToken, valueToken) + -> Maybe [(String, (Token, Token))] + +getOpts (gnu, arbitraryLongOpts) string longopts args = process args + where + flagList (c:':':rest) = ([c], True) : flagList rest + flagList (c:rest) = ([c], False) : flagList rest + flagList [] = longopts + flagMap = Map.fromList $ ("", False) : flagList string + + process [] = return [] + process (token:rest) = do + case getLiteralStringDef "\0" token of + "--" -> return $ listToArgs rest + '-':'-':word -> do + let (name, arg) = span (/= '=') word + needsArg <- + if arbitraryLongOpts + then return $ Map.findWithDefault False name flagMap + else Map.lookup name flagMap + + if needsArg && null arg + then + case rest of + (arg:rest2) -> do + more <- process rest2 + return $ (name, (token, arg)) : more + _ -> fail "Missing arg" + else do + more <- process rest + -- Consider splitting up token to get arg + return $ (name, (token, token)) : more + '-':opts -> shortToOpts opts token rest + arg -> + if gnu + then do + more <- process rest + return $ ("", (token, token)):more + else return $ listToArgs (token:rest) + + shortToOpts opts token args = + case opts of + c:rest -> do + needsArg <- Map.lookup [c] flagMap + case () of + _ | needsArg && null rest -> do + (next:restArgs) <- return args + more <- process restArgs + return $ ([c], (token, next)):more + _ | needsArg -> do + more <- process args + return $ ([c], (token, token)):more + _ -> do + more <- shortToOpts rest token args + return $ ([c], (token, token)):more + [] -> process args + + listToArgs = map (\x -> ("", (x, x))) + + +-- Generic getOpts that doesn't rely on a format string, but may also be inaccurate. +-- This provides a best guess interpretation instead of failing when new options are added. +-- +-- "--" is treated as end of arguments +-- "--anything[=foo]" is treated as a long option without argument +-- "-any" is treated as -a -n -y, with the next arg as an option to -y unless it starts with - +-- anything else is an argument +getGenericOpts :: [Token] -> [(String, (Token, Token))] +getGenericOpts = process + where + process (token:rest) = + case getLiteralStringDef "\0" token of + "--" -> map (\c -> ("", (c,c))) rest + '-':'-':word -> (takeWhile (`notElem` "\0=") word, (token, token)) : process rest + '-':optString -> + let opts = takeWhile (/= '\0') optString + in + case rest of + next:_ | "-" `isPrefixOf` getLiteralStringDef "\0" next -> + map (\c -> ([c], (token, token))) opts ++ process rest + next:remainder -> + case reverse opts of + last:initial -> + map (\c -> ([c], (token, token))) (reverse initial) + ++ [([last], (token, next))] + ++ process remainder + [] -> process remainder + [] -> map (\c -> ([c], (token, token))) opts + _ -> ("", (token, token)) : process rest + process [] = [] + + +-- Is this an expansion of multiple items of an array? +isArrayExpansion (T_DollarBraced _ _ l) = + let string = concat $ oversimplify l in + "@" `isPrefixOf` string || + not ("#" `isPrefixOf` string) && "[@]" `isInfixOf` string +isArrayExpansion _ = False + +-- Is it possible that this arg becomes multiple args? +mayBecomeMultipleArgs t = willBecomeMultipleArgs t || f False t + where + f quoted (T_DollarBraced _ _ l) = + let string = concat $ oversimplify l in + not quoted || "!" `isPrefixOf` string + f quoted (T_DoubleQuoted _ parts) = any (f True) parts + f quoted (T_NormalWord _ parts) = any (f quoted) parts + f _ _ = False + +-- Is it certain that this word will becomes multiple words? +willBecomeMultipleArgs t = willConcatInAssignment t || f t + where + f T_Extglob {} = True + f T_Glob {} = True + f T_BraceExpansion {} = True + f (T_NormalWord _ parts) = any f parts + f _ = False + +-- This does token cause implicit concatenation in assignments? +willConcatInAssignment token = + case token of + t@T_DollarBraced {} -> isArrayExpansion t + (T_DoubleQuoted _ parts) -> any willConcatInAssignment parts + (T_NormalWord _ parts) -> any willConcatInAssignment parts + _ -> False + +-- Maybe get the literal string corresponding to this token +getLiteralString :: Token -> Maybe String +getLiteralString = getLiteralStringExt (const Nothing) + +-- Definitely get a literal string, with a given default for all non-literals +getLiteralStringDef :: String -> Token -> String +getLiteralStringDef x = runIdentity . getLiteralStringExt (const $ return x) + +-- Definitely get a literal string, skipping over all non-literals +onlyLiteralString :: Token -> String +onlyLiteralString = getLiteralStringDef "" + +-- Maybe get a literal string, but only if it's an unquoted argument. +getUnquotedLiteral (T_NormalWord _ list) = + concat <$> mapM str list + where + str (T_Literal _ s) = return s + str _ = Nothing +getUnquotedLiteral _ = Nothing + +isQuotes t = + case t of + T_DoubleQuoted {} -> True + T_SingleQuoted {} -> True + _ -> False + +-- Get the last unquoted T_Literal in a word like "${var}foo"THIS +-- or nothing if the word does not end in an unquoted literal. +getTrailingUnquotedLiteral :: Token -> Maybe Token +getTrailingUnquotedLiteral t = + case t of + (T_NormalWord _ list@(_:_)) -> + from (last list) + _ -> Nothing + where + from t = + case t of + T_Literal {} -> return t + _ -> Nothing + +-- Get the leading, unquoted, literal string of a token (if any). +getLeadingUnquotedString :: Token -> Maybe String +getLeadingUnquotedString t = + case t of + T_NormalWord _ ((T_Literal _ s) : rest) -> return $ s ++ from rest + _ -> Nothing + where + from ((T_Literal _ s):rest) = s ++ from rest + from _ = "" + +-- Maybe get the literal string of this token and any globs in it. +getGlobOrLiteralString = getLiteralStringExt f + where + f (T_Glob _ str) = return str + f _ = Nothing + + +prop_getLiteralString1 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\x01") == Just "\1" +prop_getLiteralString2 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\xyz") == Just "\\xyz" +prop_getLiteralString3 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\x1") == Just "\x1" +prop_getLiteralString4 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\x1y") == Just "\x1y" +prop_getLiteralString5 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\xy") == Just "\\xy" +prop_getLiteralString6 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\x") == Just "\\x" +prop_getLiteralString7 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\1x") == Just "\1x" +prop_getLiteralString8 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\12x") == Just "\o12x" +prop_getLiteralString9 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\123x") == Just "\o123x" +prop_getLiteralString10 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\1234") == Just "\o123\&4" +prop_getLiteralString11 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\1") == Just "\1" +prop_getLiteralString12 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\12") == Just "\o12" +prop_getLiteralString13 = getLiteralString (T_DollarSingleQuoted (Id 0) "\\123") == Just "\o123" + +-- Maybe get the literal value of a token, using a custom function +-- to map unrecognized Tokens into strings. +getLiteralStringExt :: Monad m => (Token -> m String) -> Token -> m String +getLiteralStringExt more = g + where + allInList = fmap concat . mapM g + g (T_DoubleQuoted _ l) = allInList l + g (T_DollarDoubleQuoted _ l) = allInList l + g (T_NormalWord _ l) = allInList l + g (TA_Expansion _ l) = allInList l + g (T_SingleQuoted _ s) = return s + g (T_Literal _ s) = return s + g (T_ParamSubSpecialChar _ s) = return s + g (T_DollarSingleQuoted _ s) = return $ decodeEscapes s + g x = more x + + -- Bash style $'..' decoding + decodeEscapes ('\\':c:cs) = + case c of + 'a' -> '\a' : rest + 'b' -> '\b' : rest + 'e' -> '\x1B' : rest + 'f' -> '\f' : rest + 'n' -> '\n' : rest + 'r' -> '\r' : rest + 't' -> '\t' : rest + 'v' -> '\v' : rest + '\'' -> '\'' : rest + '"' -> '"' : rest + '\\' -> '\\' : rest + 'x' -> + case cs of + (x:y:more) | isHexDigit x && isHexDigit y -> + chr (16*(digitToInt x) + (digitToInt y)) : decodeEscapes more + (x:more) | isHexDigit x -> + chr (digitToInt x) : decodeEscapes more + more -> '\\' : 'x' : decodeEscapes more + _ | isOctDigit c -> + let (digits, more) = spanMax isOctDigit 3 (c:cs) + num = (parseOct digits) `mod` 256 + in (chr num) : decodeEscapes more + _ -> '\\' : c : rest + where + rest = decodeEscapes cs + parseOct = f 0 + where + f n "" = n + f n (c:rest) = f (n * 8 + digitToInt c) rest + spanMax f n list = + let (first, second) = span f list + (prefix, suffix) = splitAt n first + in + (prefix, suffix ++ second) + decodeEscapes (c:cs) = c : decodeEscapes cs + decodeEscapes [] = [] + +-- Is this token a string literal? +isLiteral t = isJust $ getLiteralString t + +-- Is this token a string literal number? +isLiteralNumber t = fromMaybe False $ do + s <- getLiteralString t + guard $ all isDigit s + return True + +-- Escape user data for messages. +-- Messages generally avoid repeating user data, but sometimes it's helpful. +e4m = escapeForMessage +escapeForMessage :: String -> String +escapeForMessage str = concatMap f str + where + f '\\' = "\\\\" + f '\n' = "\\n" + f '\r' = "\\r" + f '\t' = "\\t" + f '\x1B' = "\\e" + f c = + if shouldEscape c + then + if ord c < 256 + then "\\x" ++ (pad0 2 $ toHex c) + else "\\U" ++ (pad0 4 $ toHex c) + else [c] + + shouldEscape c = + (not $ isPrint c) + || (not (isAscii c) && not (isLetter c)) + + pad0 :: Int -> String -> String + pad0 n s = + let l = length s in + if l < n + then (replicate (n-l) '0') ++ s + else s + toHex :: Char -> String + toHex c = map toUpper $ showHex (ord c) "" + +-- Turn a NormalWord like foo="bar $baz" into a series of constituent elements like [foo=,bar ,$baz] +getWordParts (T_NormalWord _ l) = concatMap getWordParts l +getWordParts (T_DoubleQuoted _ l) = l +-- TA_Expansion is basically T_NormalWord for arithmetic expressions +getWordParts (TA_Expansion _ l) = concatMap getWordParts l +getWordParts other = [other] + +-- Return a list of NormalWords that would result from brace expansion +braceExpand (T_NormalWord id list) = take 1000 $ do + items <- mapM part list + return $ T_NormalWord id items + where + part (T_BraceExpansion id items) = do + item <- items + braceExpand item + part x = return x + +-- Maybe get a SimpleCommand from immediate wrappers like T_Redirections +getCommand t = + case t of + T_Redirecting _ _ w -> getCommand w + T_SimpleCommand _ _ (w:_) -> return t + T_Annotation _ _ t -> getCommand t + _ -> Nothing + +-- Maybe get the command name string of a token representing a command +getCommandName :: Token -> Maybe String +getCommandName = fst . getCommandNameAndToken False + +-- Maybe get the name+arguments of a command. +getCommandArgv t = do + (T_SimpleCommand _ _ args@(_:_)) <- getCommand t + return args + +-- Get the command name token from a command, i.e. +-- the token representing 'ls' in 'ls -la 2> foo'. +-- If it can't be determined, return the original token. +getCommandTokenOrThis = snd . getCommandNameAndToken False + +-- Given a command, get the string and token that represents the command name. +-- If direct, return the actual command (e.g. exec in 'exec ls') +-- If not, return the logical command (e.g. 'ls' in 'exec ls') + +getCommandNameAndToken :: Bool -> Token -> (Maybe String, Token) +getCommandNameAndToken direct t = fromMaybe (Nothing, t) $ do + cmd@(T_SimpleCommand _ _ (w:rest)) <- getCommand t + s <- getLiteralString w + return $ fromMaybe (Just s, w) $ do + guard $ not direct + actual <- getEffectiveCommandToken s cmd rest + return (getLiteralString actual, actual) + where + getEffectiveCommandToken str cmd args = + let + firstArg = do + arg <- listToMaybe args + guard . not $ isFlag arg + return arg + in + case str of + "busybox" -> firstArg + "builtin" -> firstArg + "command" -> firstArg + "run" -> firstArg -- Used by bats + "exec" -> do + opts <- getBsdOpts "cla:" args + (_, (t, _)) <- find (null . fst) opts + return t + _ -> fail "" + +-- If a command substitution is a single command, get its name. +-- $(date +%s) = Just "date" +getCommandNameFromExpansion :: Token -> Maybe String +getCommandNameFromExpansion t = + case t of + T_DollarExpansion _ [c] -> extract c + T_Backticked _ [c] -> extract c + T_DollarBraceCommandExpansion _ _ [c] -> extract c + _ -> Nothing + where + extract (T_Pipeline _ _ [cmd]) = getCommandName cmd + extract _ = Nothing + +-- Get the basename of a token representing a command +getCommandBasename = fmap basename . getCommandName + +basename = reverse . takeWhile (/= '/') . reverse + +isAssignment t = + case t of + T_Redirecting _ _ w -> isAssignment w + T_SimpleCommand _ (w:_) [] -> True + T_Assignment {} -> True + T_Annotation _ _ w -> isAssignment w + _ -> False + +isOnlyRedirection t = + case t of + T_Pipeline _ _ [x] -> isOnlyRedirection x + T_Annotation _ _ w -> isOnlyRedirection w + T_Redirecting _ (_:_) c -> isOnlyRedirection c + T_SimpleCommand _ [] [] -> True + _ -> False + +isFunction t = case t of T_Function {} -> True; _ -> False + +-- Bats tests are functions for the purpose of 'local' and such +isFunctionLike t = + case t of + T_Function {} -> True + T_BatsTest {} -> True + _ -> False + + +isBraceExpansion t = case t of T_BraceExpansion {} -> True; _ -> False + +-- Get the lists of commands from tokens that contain them, such as +-- the conditions and bodies of while loops or branches of if statements. +getCommandSequences :: Token -> [[Token]] +getCommandSequences t = + case t of + T_Script _ _ cmds -> [cmds] + T_BraceGroup _ cmds -> [cmds] + T_Subshell _ cmds -> [cmds] + T_WhileExpression _ cond cmds -> [cond, cmds] + T_UntilExpression _ cond cmds -> [cond, cmds] + T_ForIn _ _ _ cmds -> [cmds] + T_ForArithmetic _ _ _ _ cmds -> [cmds] + T_IfExpression _ thens elses -> (concatMap (\(a,b) -> [a,b]) thens) ++ [elses] + T_Annotation _ _ t -> getCommandSequences t + + T_DollarExpansion _ cmds -> [cmds] + T_DollarBraceCommandExpansion _ _ cmds -> [cmds] + T_Backticked _ cmds -> [cmds] + _ -> [] + +-- Get a list of names of associative arrays +getAssociativeArrays t = + nub . execWriter $ doAnalysis f t + where + f :: Token -> Writer [String] () + f t@T_SimpleCommand {} = sequence_ $ do + name <- getCommandName t + let assocNames = ["declare","local","typeset"] + guard $ name `elem` assocNames + let flags = getAllFlags t + guard $ "A" `elem` map snd flags + let args = [arg | (arg, "") <- flags] + let names = mapMaybe (getLiteralStringExt nameAssignments) args + return $ tell names + f _ = return () + + nameAssignments t = + case t of + T_Assignment _ _ name _ _ -> return name + _ -> Nothing + +-- A Pseudoglob is a wildcard pattern used for checking if a match can succeed. +-- For example, [[ $(cmd).jpg == [a-z] ]] will give the patterns *.jpg and ?, which +-- can be proven never to match. +data PseudoGlob = PGAny | PGMany | PGChar Char + deriving (Eq, Show) + +-- Turn a word into a PG pattern, replacing all unknown/runtime values with +-- PGMany. +wordToPseudoGlob :: Token -> [PseudoGlob] +wordToPseudoGlob = fromMaybe [PGMany] . wordToPseudoGlob' False + +-- Turn a word into a PG pattern, but only if we can preserve +-- exact semantics. +wordToExactPseudoGlob :: Token -> Maybe [PseudoGlob] +wordToExactPseudoGlob = wordToPseudoGlob' True + +wordToPseudoGlob' :: Bool -> Token -> Maybe [PseudoGlob] +wordToPseudoGlob' exact word = + simplifyPseudoGlob <$> toGlob word + where + toGlob :: Token -> Maybe [PseudoGlob] + toGlob word = + case word of + T_NormalWord _ (T_Literal _ ('~':str):rest) -> do + guard $ not exact + let this = (PGMany : (map PGChar $ dropWhile (/= '/') str)) + tail <- concat <$> (mapM f $ concatMap getWordParts rest) + return $ this ++ tail + _ -> concat <$> (mapM f $ getWordParts word) + + f x = case x of + T_Literal _ s -> return $ map PGChar s + T_SingleQuoted _ s -> return $ map PGChar s + T_Glob _ "?" -> return [PGAny] + T_Glob _ "*" -> return [PGMany] + T_Glob _ ('[':_) | not exact -> return [PGAny] + _ -> if exact then fail "" else return [PGMany] + + +-- Reorder a PseudoGlob for more efficient matching, e.g. +-- f?*?**g -> f??*g +simplifyPseudoGlob :: [PseudoGlob] -> [PseudoGlob] +simplifyPseudoGlob = f + where + f [] = [] + f (x@(PGChar _) : rest ) = x : f rest + f list = + let (anys, rest) = span (\x -> x == PGMany || x == PGAny) list in + order anys ++ f rest + + order s = let (any, many) = partition (== PGAny) s in + any ++ take 1 many + +-- Check whether the two patterns can ever overlap. +pseudoGlobsCanOverlap :: [PseudoGlob] -> [PseudoGlob] -> Bool +pseudoGlobsCanOverlap = matchable + where + matchable x@(xf:xs) y@(yf:ys) = + case (xf, yf) of + (PGMany, _) -> matchable x ys || matchable xs y + (_, PGMany) -> matchable x ys || matchable xs y + (PGAny, _) -> matchable xs ys + (_, PGAny) -> matchable xs ys + (_, _) -> xf == yf && matchable xs ys + + matchable [] [] = True + matchable (PGMany : rest) [] = matchable rest [] + matchable (_:_) [] = False + matchable [] r = matchable r [] + +-- Check whether the first pattern always overlaps the second. +pseudoGlobIsSuperSetof :: [PseudoGlob] -> [PseudoGlob] -> Bool +pseudoGlobIsSuperSetof = matchable + where + matchable x@(xf:xs) y@(yf:ys) = + case (xf, yf) of + (PGMany, PGMany) -> matchable x ys + (PGMany, _) -> matchable x ys || matchable xs y + (_, PGMany) -> False + (PGAny, _) -> matchable xs ys + (_, PGAny) -> False + (_, _) -> xf == yf && matchable xs ys + + matchable [] [] = True + matchable (PGMany : rest) [] = matchable rest [] + matchable _ _ = False + +wordsCanBeEqual x y = pseudoGlobsCanOverlap (wordToPseudoGlob x) (wordToPseudoGlob y) + +-- Is this an expansion that can be quoted, +-- e.g. $(foo) `foo` $foo (but not {foo,})? +isQuoteableExpansion t = case t of + T_DollarBraced {} -> True + _ -> isCommandSubstitution t + +isCommandSubstitution t = case t of + T_DollarExpansion {} -> True + T_DollarBraceCommandExpansion {} -> True + T_Backticked {} -> True + _ -> False + +-- Is this an expansion that results in a simple string? +isStringExpansion t = isCommandSubstitution t || case t of + T_DollarArithmetic {} -> True + T_DollarBraced {} -> not (isArrayExpansion t) + _ -> False + +-- Is this a T_Annotation that ignores a specific code? +isAnnotationIgnoringCode code t = + case t of + T_Annotation _ anns _ -> any hasNum anns + _ -> False + where + hasNum (DisableComment from to) = code >= from && code < to + hasNum _ = False + +prop_executableFromShebang1 = executableFromShebang "/bin/sh" == "sh" +prop_executableFromShebang2 = executableFromShebang "/bin/bash" == "bash" +prop_executableFromShebang3 = executableFromShebang "/usr/bin/env ksh" == "ksh" +prop_executableFromShebang4 = executableFromShebang "/usr/bin/env -S foo=bar bash -x" == "bash" +prop_executableFromShebang5 = executableFromShebang "/usr/bin/env --split-string=bash -x" == "bash" +prop_executableFromShebang6 = executableFromShebang "/usr/bin/env --split-string=foo=bar bash -x" == "bash" +prop_executableFromShebang7 = executableFromShebang "/usr/bin/env --split-string bash -x" == "bash" +prop_executableFromShebang8 = executableFromShebang "/usr/bin/env --split-string foo=bar bash -x" == "bash" +prop_executableFromShebang9 = executableFromShebang "/usr/bin/env foo=bar dash" == "dash" +prop_executableFromShebang10 = executableFromShebang "/bin/busybox sh" == "busybox sh" +prop_executableFromShebang11 = executableFromShebang "/bin/busybox ash" == "busybox ash" + +-- Get the shell executable from a string like '/usr/bin/env bash' +executableFromShebang :: String -> String +executableFromShebang = shellFor + where + re = mkRegex "/env +(-S|--split-string=?)? *(.*)" + shellFor s | s `matches` re = + case matchRegex re s of + Just [flag, shell] -> fromEnvArgs (words shell) + _ -> "" + shellFor sb = + case words sb of + [] -> "" + [x] -> basename x + (first:second:args) | basename first == "busybox" -> + case basename second of + "sh" -> "busybox sh" + "ash" -> "busybox ash" + x -> x + (first:args) | basename first == "env" -> + fromEnvArgs args + (first:_) -> basename first + + fromEnvArgs args = fromMaybe "" $ find (notElem '=') $ skipFlags args + basename s = reverse . takeWhile (/= '/') . reverse $ s + skipFlags = dropWhile ("-" `isPrefixOf`) + + +-- Determining if a name is a variable +isVariableStartChar x = x == '_' || isAsciiLower x || isAsciiUpper x +isVariableChar x = isVariableStartChar x || isDigit x +isSpecialVariableChar = (`elem` "*@#?-$!") +variableNameRegex = mkRegex "[_a-zA-Z][_a-zA-Z0-9]*" + +prop_isVariableName1 = isVariableName "_fo123" +prop_isVariableName2 = not $ isVariableName "4" +prop_isVariableName3 = not $ isVariableName "test: " +isVariableName (x:r) = isVariableStartChar x && all isVariableChar r +isVariableName _ = False + + +-- Get the variable name from an expansion like ${var:-foo} +prop_getBracedReference1 = getBracedReference "foo" == "foo" +prop_getBracedReference2 = getBracedReference "#foo" == "foo" +prop_getBracedReference3 = getBracedReference "#" == "#" +prop_getBracedReference4 = getBracedReference "##" == "#" +prop_getBracedReference5 = getBracedReference "#!" == "!" +prop_getBracedReference6 = getBracedReference "!#" == "#" +prop_getBracedReference7 = getBracedReference "!foo#?" == "foo" +prop_getBracedReference8 = getBracedReference "foo-bar" == "foo" +prop_getBracedReference9 = getBracedReference "foo:-bar" == "foo" +prop_getBracedReference10 = getBracedReference "foo: -1" == "foo" +prop_getBracedReference11 = getBracedReference "!os*" == "" +prop_getBracedReference11b = getBracedReference "!os@" == "" +prop_getBracedReference12 = getBracedReference "!os?bar**" == "" +prop_getBracedReference13 = getBracedReference "foo[bar]" == "foo" +getBracedReference s = fromMaybe s $ + nameExpansion s `mplus` takeName noPrefix `mplus` getSpecial noPrefix `mplus` getSpecial s + where + noPrefix = dropPrefix s + dropPrefix (c:rest) | c `elem` "!#" = rest + dropPrefix cs = cs + takeName s = do + let name = takeWhile isVariableChar s + guard . not $ null name + return name + getSpecial (c:_) | isSpecialVariableChar c = return [c] + getSpecial _ = fail "empty or not special" + + nameExpansion ('!':next:rest) = do -- e.g. ${!foo*bar*} + guard $ isVariableChar next -- e.g. ${!@} + first <- find (not . isVariableChar) rest + guard $ first `elem` "*?@" + return "" + nameExpansion _ = Nothing + +-- Get the variable modifier like /a/b in ${var/a/b} +prop_getBracedModifier1 = getBracedModifier "foo:bar:baz" == ":bar:baz" +prop_getBracedModifier2 = getBracedModifier "!var:-foo" == ":-foo" +prop_getBracedModifier3 = getBracedModifier "foo[bar]" == "[bar]" +prop_getBracedModifier4 = getBracedModifier "foo[@]@Q" == "[@]@Q" +prop_getBracedModifier5 = getBracedModifier "@@Q" == "@Q" +getBracedModifier s = headOrDefault "" $ do + let var = getBracedReference s + a <- dropModifier s + dropPrefix var a + where + dropPrefix [] t = return t + dropPrefix (a:b) (c:d) | a == c = dropPrefix b d + dropPrefix _ _ = [] + + dropModifier (c:rest) | c `elem` "#!" = [rest, c:rest] + dropModifier x = [x] + +-- Get the variables from indices like ["x", "y"] in ${var[x+y+1]} +prop_getIndexReferences1 = getIndexReferences "var[x+y+1]" == ["x", "y"] +getIndexReferences s = fromMaybe [] $ do + index:_ <- matchRegex re s + return $ matchAllStrings variableNameRegex index + where + re = mkRegex "(\\[.*\\])" + +prop_getOffsetReferences1 = getOffsetReferences ":bar" == ["bar"] +prop_getOffsetReferences2 = getOffsetReferences ":bar:baz" == ["bar", "baz"] +prop_getOffsetReferences3 = getOffsetReferences "[foo]:bar" == ["bar"] +prop_getOffsetReferences4 = getOffsetReferences "[foo]:bar:baz" == ["bar", "baz"] +getOffsetReferences mods = fromMaybe [] $ do +-- if mods start with [, then drop until ] + _:offsets:_ <- matchRegex re mods + return $ matchAllStrings variableNameRegex offsets + where + re = mkRegex "^(\\[.+\\])? *:([^-=?+].*)" + + +-- Returns whether a token is a parameter expansion without any modifiers. +-- True for $var ${var} $1 $# +-- False for ${#var} ${var[x]} ${var:-0} +isUnmodifiedParameterExpansion t = + case t of + T_DollarBraced _ False _ -> True + T_DollarBraced _ _ list -> + let str = concat $ oversimplify list + in getBracedReference str == str + _ -> False + +-- Return the referenced variable if (and only if) it's an unmodified parameter expansion. +getUnmodifiedParameterExpansion t = + case t of + T_DollarBraced _ _ list -> do + let str = concat $ oversimplify list + guard $ getBracedReference str == str + return str + _ -> Nothing + +--- A list of the element and all its parents up to the root node. +getPath tree = NE.unfoldr $ \t -> (t, Map.lookup (getId t) tree) + +isClosingFileOp op = + case op of + T_IoDuplicate _ (T_GREATAND _) "-" -> True + T_IoDuplicate _ (T_LESSAND _) "-" -> True + _ -> False + +getEnableDirectives root = + case root of + T_Annotation _ list _ -> [s | EnableComment s <- list] + _ -> [] + +getExtendedAnalysisDirective :: Token -> Maybe Bool +getExtendedAnalysisDirective root = + case root of + T_Annotation _ list _ -> listToMaybe $ [s | ExtendedAnalysis s <- list] + _ -> Nothing + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Analytics.hs b/src/ShellCheck/Analytics.hs new file mode 100644 index 0000000..373d495 --- /dev/null +++ b/src/ShellCheck/Analytics.hs @@ -0,0 +1,5242 @@ +{- + Copyright 2012-2024 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE PatternGuards #-} +module ShellCheck.Analytics (checker, optionalChecks, ShellCheck.Analytics.runTests) where + +import ShellCheck.AST +import ShellCheck.ASTLib +import ShellCheck.AnalyzerLib hiding (producesComments) +import ShellCheck.CFG +import qualified ShellCheck.CFGAnalysis as CF +import ShellCheck.Data +import ShellCheck.Parser +import ShellCheck.Prelude +import ShellCheck.Interface +import ShellCheck.Regex + +import Control.Arrow (first) +import Control.Monad +import Control.Monad.Identity +import Control.Monad.State +import Control.Monad.Writer hiding ((<>)) +import Control.Monad.Reader +import Data.Char +import Data.Functor +import Data.Function (on) +import Data.List +import Data.Maybe +import Data.Ord +import Data.Semigroup +import Debug.Trace -- STRIP +import qualified Data.List.NonEmpty as NE +import qualified Data.Map.Strict as Map +import qualified Data.Set as S +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (quickCheckWithResult, stdArgs, maxSuccess) + +-- Checks that are run on the AST root +treeChecks :: [Parameters -> Token -> [TokenComment]] +treeChecks = [ + nodeChecksToTreeCheck nodeChecks + ,subshellAssignmentCheck + ,checkQuotesInLiterals + ,checkShebangParameters + ,checkFunctionsUsedExternally + ,checkUnusedAssignments + ,checkUnpassedInFunctions + ,checkArrayWithoutIndex + ,checkShebang + ,checkUnassignedReferences + ,checkUncheckedCdPushdPopd + ,checkArrayAssignmentIndices + ,checkUseBeforeDefinition + ,checkAliasUsedInSameParsingUnit + ,checkArrayValueUsedAsIndex + ] + +checker spec params = mkChecker spec params treeChecks + +mkChecker spec params checks = + Checker { + perScript = \(Root root) -> do + tell $ concatMap (\f -> f params root) all, + perToken = const $ return () + } + where + all = checks ++ optionals + optionalKeys = asOptionalChecks spec + optionals = + if "all" `elem` optionalKeys + then map snd optionalTreeChecks + else mapMaybe (\c -> Map.lookup c optionalCheckMap) optionalKeys + + +checkList l t = concatMap (\f -> f t) l + +-- Checks that are run on each node in the AST +runNodeAnalysis f p t = execWriter (doAnalysis (f p) t) + +-- Perform multiple node checks in a single iteration over the tree +nodeChecksToTreeCheck checkList = + runNodeAnalysis + (\p t -> (mapM_ ((\ f -> f t) . (\ f -> f p)) + checkList)) + +nodeChecks :: [Parameters -> Token -> Writer [TokenComment] ()] +nodeChecks = [ + checkPipePitfalls + ,checkForInQuoted + ,checkForInLs + ,checkShorthandIf + ,checkDollarStar + ,checkUnquotedDollarAt + ,checkStderrRedirect + ,checkUnquotedN + ,checkNumberComparisons + ,checkSingleBracketOperators + ,checkDoubleBracketOperators + ,checkLiteralBreakingTest + ,checkConstantNullary + ,checkDivBeforeMult + ,checkArithmeticDeref + ,checkArithmeticBadOctal + ,checkComparisonAgainstGlob + ,checkCaseAgainstGlob + ,checkCommarrays + ,checkOrNeq + ,checkAndEq + ,checkEchoWc + ,checkConstantIfs + ,checkPipedAssignment + ,checkAssignAteCommand + ,checkUuoeVar + ,checkQuotedCondRegex + ,checkForInCat + ,checkFindExec + ,checkValidCondOps + ,checkGlobbedRegex + ,checkTestRedirects + ,checkBadParameterSubstitution + ,checkPS1Assignments + ,checkBackticks + ,checkInexplicablyUnquoted + ,checkTildeInQuotes + ,checkLonelyDotDash + ,checkSpuriousExec + ,checkSpuriousExpansion + ,checkDollarBrackets + ,checkSshHereDoc + ,checkGlobsAsOptions + ,checkWhileReadPitfalls + ,checkArithmeticOpCommand + ,checkCharRangeGlob + ,checkUnquotedExpansions + ,checkSingleQuotedVariables + ,checkRedirectToSame + ,checkPrefixAssignmentReference + ,checkLoopKeywordScope + ,checkCdAndBack + ,checkWrongArithmeticAssignment + ,checkConditionalAndOrs + ,checkFunctionDeclarations + ,checkStderrPipe + ,checkOverridingPath + ,checkArrayAsString + ,checkUnsupported + ,checkMultipleAppends + ,checkSuspiciousIFS + ,checkShouldUseGrepQ + ,checkTestArgumentSplitting + ,checkConcatenatedDollarAt + ,checkTildeInPath + ,checkReadWithoutR + ,checkLoopVariableReassignment + ,checkTrailingBracket + ,checkReturnAgainstZero + ,checkRedirectedNowhere + ,checkUnmatchableCases + ,checkSubshellAsTest + ,checkSplittingInArrays + ,checkRedirectionToNumber + ,checkGlobAsCommand + ,checkFlagAsCommand + ,checkEmptyCondition + ,checkPipeToNowhere + ,checkForLoopGlobVariables + ,checkSubshelledTests + ,checkRedirectionToCommand + ,checkDollarQuoteParen + ,checkUselessBang + ,checkTranslatedStringVariable + ,checkModifiedArithmeticInRedirection + ,checkBlatantRecursion + ,checkBadTestAndOr + ,checkAssignToSelf + ,checkEqualsInCommand + ,checkSecondArgIsComparison + ,checkComparisonWithLeadingX + ,checkCommandWithTrailingSymbol + ,checkUnquotedParameterExpansionPattern + ,checkBatsTestDoesNotUseNegation + ,checkCommandIsUnreachable + ,checkSpacefulnessCfg + ,checkOverwrittenExitCode + ,checkUnnecessaryArithmeticExpansionIndex + ,checkUnnecessaryParens + ,checkPlusEqualsNumber + ,checkExpansionWithRedirection + ,checkUnaryTestA + ] + +optionalChecks = map fst optionalTreeChecks + + +prop_verifyOptionalExamples = all check optionalTreeChecks + where + check (desc, check) = + verifyTree check (cdPositive desc) + && verifyNotTree check (cdNegative desc) + +optionalTreeChecks :: [(CheckDescription, (Parameters -> Token -> [TokenComment]))] +optionalTreeChecks = [ + (newCheckDescription { + cdName = "quote-safe-variables", + cdDescription = "Suggest quoting variables without metacharacters", + cdPositive = "var=hello; echo $var", + cdNegative = "var=hello; echo \"$var\"" + }, nodeChecksToTreeCheck [checkVerboseSpacefulnessCfg]) + + ,(newCheckDescription { + cdName = "avoid-nullary-conditions", + cdDescription = "Suggest explicitly using -n in `[ $var ]`", + cdPositive = "[ \"$var\" ]", + cdNegative = "[ -n \"$var\" ]" + }, nodeChecksToTreeCheck [checkNullaryExpansionTest]) + + ,(newCheckDescription { + cdName = "avoid-negated-conditions", + cdDescription = "Suggest removing unnecessary comparison negations", + cdPositive = "[ ! \"$var\" -eq 1 ]", + cdNegative = "[ \"$var\" -ne 1 ]" + }, nodeChecksToTreeCheck [checkUnnecessarilyInvertedTest]) + + ,(newCheckDescription { + cdName = "add-default-case", + cdDescription = "Suggest adding a default case in `case` statements", + cdPositive = "case $? in 0) echo 'Success';; esac", + cdNegative = "case $? in 0) echo 'Success';; *) echo 'Fail' ;; esac" + }, nodeChecksToTreeCheck [checkDefaultCase]) + + ,(newCheckDescription { + cdName = "require-variable-braces", + cdDescription = "Suggest putting braces around all variable references", + cdPositive = "var=hello; echo $var", + cdNegative = "var=hello; echo ${var}" + }, nodeChecksToTreeCheck [checkVariableBraces]) + + ,(newCheckDescription { + cdName = "check-unassigned-uppercase", + cdDescription = "Warn when uppercase variables are unassigned", + cdPositive = "echo $VAR", + cdNegative = "VAR=hello; echo $VAR" + }, checkUnassignedReferences' True) + + ,(newCheckDescription { + cdName = "require-double-brackets", + cdDescription = "Require [[ and warn about [ in Bash/Ksh", + cdPositive = "[ -e /etc/issue ]", + cdNegative = "[[ -e /etc/issue ]]" + }, checkRequireDoubleBracket) + + ,(newCheckDescription { + cdName = "check-set-e-suppressed", + cdDescription = "Notify when set -e is suppressed during function invocation", + cdPositive = "set -e; func() { cp *.txt ~/backup; rm *.txt; }; func && echo ok", + cdNegative = "set -e; func() { cp *.txt ~/backup; rm *.txt; }; func; echo ok" + }, checkSetESuppressed) + + ,(newCheckDescription { + cdName = "check-extra-masked-returns", + cdDescription = "Check for additional cases where exit codes are masked", + cdPositive = "rm -r \"$(get_chroot_dir)/home\"", + cdNegative = "set -e; dir=\"$(get_chroot_dir)\"; rm -r \"$dir/home\"" + }, checkExtraMaskedReturns) + + ,(newCheckDescription { + cdName = "useless-use-of-cat", + cdDescription = "Check for Useless Use Of Cat (UUOC)", + cdPositive = "cat foo | grep bar", + cdNegative = "grep bar foo" + }, nodeChecksToTreeCheck [checkUuoc]) + ] + +optionalCheckMap :: Map.Map String (Parameters -> Token -> [TokenComment]) +optionalCheckMap = Map.fromList $ map item optionalTreeChecks + where + item (desc, check) = (cdName desc, check) + +wouldHaveBeenGlob s = '*' `elem` s + +verify :: (Parameters -> Token -> Writer [TokenComment] ()) -> String -> Bool +verify f s = checkNode f s == Just True + +verifyNot :: (Parameters -> Token -> Writer [TokenComment] ()) -> String -> Bool +verifyNot f s = checkNode f s == Just False + +verifyTree :: (Parameters -> Token -> [TokenComment]) -> String -> Bool +verifyTree f s = producesComments f s == Just True + +verifyNotTree :: (Parameters -> Token -> [TokenComment]) -> String -> Bool +verifyNotTree f s = producesComments f s == Just False + +checkCommand str f t@(T_SimpleCommand id _ (cmd:rest)) + | t `isCommand` str = f cmd rest +checkCommand _ _ _ = return () + +checkUnqualifiedCommand str f t@(T_SimpleCommand id _ (cmd:rest)) + | t `isUnqualifiedCommand` str = f cmd rest +checkUnqualifiedCommand _ _ _ = return () + +verifyCodes :: (Parameters -> Token -> Writer [TokenComment] ()) -> [Code] -> String -> Bool +verifyCodes f l s = codes == Just l + where + treeCheck = runNodeAnalysis f + comments = runAndGetComments treeCheck s + codes = map (cCode . tcComment) <$> comments + +checkNode f = producesComments (runNodeAnalysis f) +producesComments :: (Parameters -> Token -> [TokenComment]) -> String -> Maybe Bool +producesComments f s = not . null <$> runAndGetComments f s + +runAndGetComments f s = do + let pr = pScript s + root <- prRoot pr + let spec = defaultSpec pr + let params = makeParameters spec + return $ + filterByAnnotation spec params $ + f params root + +-- Copied from https://wiki.haskell.org/Edit_distance +dist :: Eq a => [a] -> [a] -> Int +dist a b + = last (if lab == 0 then mainDiag + else if lab > 0 then lowers !! (lab - 1) + else{- < 0 -} uppers !! (-1 - lab)) + where mainDiag = oneDiag a b (head uppers) (-1 : head lowers) + uppers = eachDiag a b (mainDiag : uppers) -- upper diagonals + lowers = eachDiag b a (mainDiag : lowers) -- lower diagonals + eachDiag a [] diags = [] + eachDiag a (bch:bs) (lastDiag:diags) = oneDiag a bs nextDiag lastDiag : eachDiag a bs diags + where nextDiag = head (tail diags) + oneDiag a b diagAbove diagBelow = thisdiag + where doDiag [] b nw n w = [] + doDiag a [] nw n w = [] + doDiag (ach:as) (bch:bs) nw n w = me : doDiag as bs me (tail n) (tail w) + where me = if ach == bch then nw else 1 + min3 (head w) nw (head n) + firstelt = 1 + head diagBelow + thisdiag = firstelt : doDiag a b firstelt diagAbove (tail diagBelow) + lab = length a - length b + min3 x y z = if x < y then x else min y z + +hasFloatingPoint params = shellType params == Ksh + +-- Checks whether the current parent path is part of a condition +isCondition (x NE.:| xs) = foldr go (const False) xs x + where + go _ _ T_BatsTest{} = True -- count anything in a @test as conditional + go parent go_rest child = + getId child `elem` map getId (getConditionChildren parent) || go_rest parent + getConditionChildren t = + case t of + T_AndIf _ left right -> [left] + T_OrIf id left right -> [left] + T_IfExpression id conditions elses -> concatMap (take 1 . reverse . fst) conditions + T_WhileExpression id c l -> take 1 . reverse $ c + T_UntilExpression id c l -> take 1 . reverse $ c + _ -> [] + +-- helpers to build replacements +replaceStart id params n r = + let tp = tokenPositions params + (start, _) = tp Map.! id + new_end = start { + posColumn = posColumn start + n + } + depth = length $ getPath (parentMap params) (T_EOF id) + in + newReplacement { + repStartPos = start, + repEndPos = new_end, + repString = r, + repPrecedence = depth, + repInsertionPoint = InsertAfter + } +replaceEnd id params n r = + let tp = tokenPositions params + (_, end) = tp Map.! id + new_start = end { + posColumn = posColumn end - n + } + new_end = end { + posColumn = posColumn end + } + depth = length $ getPath (parentMap params) (T_EOF id) + in + newReplacement { + repStartPos = new_start, + repEndPos = new_end, + repString = r, + repPrecedence = depth, + repInsertionPoint = InsertBefore + } +replaceToken id params r = + let tp = tokenPositions params + (start, end) = tp Map.! id + depth = length $ getPath (parentMap params) (T_EOF id) + in + newReplacement { + repStartPos = start, + repEndPos = end, + repString = r, + repPrecedence = depth, + repInsertionPoint = InsertBefore + } + +surroundWith id params s = fixWith [replaceStart id params 0 s, replaceEnd id params 0 s] +fixWith fixes = newFix { fixReplacements = fixes } + +analyse f t = execState (doAnalysis f t) [] + +-- Make a map from functions to definition IDs +functions t = Map.fromList $ analyse findFunctions t +findFunctions (T_Function id _ _ name _) + = modify ((name, id):) +findFunctions _ = return () + +-- Make a map from aliases to definition IDs +aliases t = Map.fromList $ analyse findAliases t +findAliases t@(T_SimpleCommand _ _ (_:args)) + | t `isUnqualifiedCommand` "alias" = mapM_ getAlias args +findAliases _ = return () +getAlias arg = + let string = onlyLiteralString arg + in when ('=' `elem` string) $ + modify ((takeWhile (/= '=') string, getId arg):) + +prop_checkEchoWc3 = verify checkEchoWc "n=$(echo $foo | wc -c)" +checkEchoWc _ (T_Pipeline id _ [a, b]) = + when (acmd == ["echo", "${VAR}"]) $ + case bcmd of + ["wc", "-c"] -> countMsg + ["wc", "-m"] -> countMsg + _ -> return () + where + acmd = oversimplify a + bcmd = oversimplify b + countMsg = style id 2000 "See if you can use ${#variable} instead." +checkEchoWc _ _ = return () + +prop_checkPipedAssignment1 = verify checkPipedAssignment "A=ls | grep foo" +prop_checkPipedAssignment2 = verifyNot checkPipedAssignment "A=foo cmd | grep foo" +prop_checkPipedAssignment3 = verifyNot checkPipedAssignment "A=foo" +checkPipedAssignment _ (T_Pipeline _ _ (T_Redirecting _ _ (T_SimpleCommand id (_:_) []):_:_)) = + warn id 2036 "If you wanted to assign the output of the pipeline, use a=$(b | c) ." +checkPipedAssignment _ _ = return () + +prop_checkAssignAteCommand1 = verify checkAssignAteCommand "A=ls -l" +prop_checkAssignAteCommand2 = verify checkAssignAteCommand "A=ls --sort=$foo" +prop_checkAssignAteCommand3 = verify checkAssignAteCommand "A=cat foo | grep bar" +prop_checkAssignAteCommand4 = verifyNot checkAssignAteCommand "A=foo ls -l" +prop_checkAssignAteCommand5 = verify checkAssignAteCommand "PAGER=cat grep bar" +prop_checkAssignAteCommand6 = verifyNot checkAssignAteCommand "PAGER=\"cat\" grep bar" +prop_checkAssignAteCommand7 = verify checkAssignAteCommand "here=pwd" +checkAssignAteCommand _ (T_SimpleCommand id [T_Assignment _ _ _ _ assignmentTerm] list) = + -- Check if first word is intended as an argument (flag or glob). + if firstWordIsArg list + then + err id 2037 "To assign the output of a command, use var=$(cmd) ." + else + -- Check if it's a known, unquoted command name. + when (isCommonCommand $ getUnquotedLiteral assignmentTerm) $ + warn id 2209 "Use var=$(command) to assign output (or quote to assign string)." + where + isCommonCommand (Just s) = s `elem` commonCommands + isCommonCommand _ = False + firstWordIsArg (head:_) = isGlob head || isUnquotedFlag head + firstWordIsArg [] = False + +checkAssignAteCommand _ _ = return () + +prop_checkArithmeticOpCommand1 = verify checkArithmeticOpCommand "i=i + 1" +prop_checkArithmeticOpCommand2 = verify checkArithmeticOpCommand "foo=bar * 2" +prop_checkArithmeticOpCommand3 = verifyNot checkArithmeticOpCommand "foo + opts" +checkArithmeticOpCommand _ (T_SimpleCommand id [T_Assignment {}] (firstWord:_)) = + mapM_ check $ getGlobOrLiteralString firstWord + where + check op = + when (op `elem` ["+", "-", "*", "/"]) $ + warn (getId firstWord) 2099 $ + "Use $((..)) for arithmetics, e.g. i=$((i " ++ op ++ " 2))" +checkArithmeticOpCommand _ _ = return () + +prop_checkWrongArit = verify checkWrongArithmeticAssignment "i=i+1" +prop_checkWrongArit2 = verify checkWrongArithmeticAssignment "n=2; i=n*2" +checkWrongArithmeticAssignment params (T_SimpleCommand id [T_Assignment _ _ _ _ val] []) = + sequence_ $ do + str <- getNormalString val + var:op:_ <- matchRegex regex str + guard $ S.member var references + return . warn (getId val) 2100 $ + "Use $((..)) for arithmetics, e.g. i=$((i " ++ op ++ " 2))" + where + regex = mkRegex "^([_a-zA-Z][_a-zA-Z0-9]*)([+*-]).+$" + references = S.fromList [name | Assignment (_, _, name, _) <- variableFlow params] + + getNormalString (T_NormalWord _ words) = do + parts <- mapM getLiterals words + return $ concat parts + getNormalString _ = Nothing + + getLiterals (T_Literal _ s) = return s + getLiterals (T_Glob _ s) = return s + getLiterals _ = Nothing +checkWrongArithmeticAssignment _ _ = return () + + +prop_checkUuoc1 = verify checkUuoc "cat foo | grep bar" +prop_checkUuoc2 = verifyNot checkUuoc "cat * | grep bar" +prop_checkUuoc3 = verify checkUuoc "cat \"$var\" | grep bar" +prop_checkUuoc3b = verifyNot checkUuoc "cat $var | grep bar" +prop_checkUuoc3c = verifyNot checkUuoc "cat \"${!var}\" | grep bar" +prop_checkUuoc4 = verifyNot checkUuoc "cat $var" +prop_checkUuoc5 = verifyNot checkUuoc "cat \"$@\"" +prop_checkUuoc6 = verifyNot checkUuoc "cat -n | grep bar" +checkUuoc _ (T_Pipeline _ _ (T_Redirecting _ _ cmd:_:_)) = + checkCommand "cat" (const f) cmd + where + f [word] | not (mayBecomeMultipleArgs word || isOption word) = + style (getId word) 2002 "Useless cat. Consider 'cmd < file | ..' or 'cmd file | ..' instead." + f _ = return () + isOption word = "-" `isPrefixOf` onlyLiteralString word +checkUuoc _ _ = return () + +prop_checkPipePitfalls3 = verify checkPipePitfalls "ls | grep -v mp3" +prop_checkPipePitfalls4 = verifyNot checkPipePitfalls "find . -print0 | xargs -0 foo" +prop_checkPipePitfalls5 = verifyNot checkPipePitfalls "ls -N | foo" +prop_checkPipePitfalls6 = verify checkPipePitfalls "find . | xargs foo" +prop_checkPipePitfalls7 = verifyNot checkPipePitfalls "find . -printf '%s\\n' | xargs foo" +prop_checkPipePitfalls8 = verify checkPipePitfalls "foo | grep bar | wc -l" +prop_checkPipePitfalls9 = verifyNot checkPipePitfalls "foo | grep -o bar | wc -l" +prop_checkPipePitfalls10 = verifyNot checkPipePitfalls "foo | grep -o bar | wc" +prop_checkPipePitfalls11 = verifyNot checkPipePitfalls "foo | grep bar | wc" +prop_checkPipePitfalls12 = verifyNot checkPipePitfalls "foo | grep -o bar | wc -c" +prop_checkPipePitfalls13 = verifyNot checkPipePitfalls "foo | grep bar | wc -c" +prop_checkPipePitfalls14 = verifyNot checkPipePitfalls "foo | grep -o bar | wc -cmwL" +prop_checkPipePitfalls15 = verifyNot checkPipePitfalls "foo | grep bar | wc -cmwL" +prop_checkPipePitfalls16 = verifyNot checkPipePitfalls "foo | grep -r bar | wc -l" +prop_checkPipePitfalls17 = verifyNot checkPipePitfalls "foo | grep -l bar | wc -l" +prop_checkPipePitfalls18 = verifyNot checkPipePitfalls "foo | grep -L bar | wc -l" +prop_checkPipePitfalls19 = verifyNot checkPipePitfalls "foo | grep -A2 bar | wc -l" +prop_checkPipePitfalls20 = verifyNot checkPipePitfalls "foo | grep -B999 bar | wc -l" +prop_checkPipePitfalls21 = verifyNot checkPipePitfalls "foo | grep --after-context 999 bar | wc -l" +prop_checkPipePitfalls22 = verifyNot checkPipePitfalls "foo | grep -B 1 --after-context 999 bar | wc -l" +prop_checkPipePitfalls23 = verifyNot checkPipePitfalls "ps -o pid,args -p $(pgrep java) | grep -F net.shellcheck.Test" +checkPipePitfalls _ (T_Pipeline id _ commands) = do + for ["find", "xargs"] $ + \(find:xargs:_) -> + let args = oversimplify xargs ++ oversimplify find + in + unless (any ($ args) [ + hasShortParameter '0', + hasParameter "null", + hasParameter "print0", + hasParameter "printf" + ]) $ warn (getId find) 2038 + "Use 'find .. -print0 | xargs -0 ..' or 'find .. -exec .. +' to allow non-alphanumeric filenames." + + for ["ps", "grep"] $ + \(ps:grep:_) -> + let + psFlags = maybe [] (map snd . getAllFlags) $ getCommand ps + in + -- There are many ways to specify a pid: 1, -1, p 1, wup 1, -q 1, -p 1, --pid 1. + -- For simplicity we only deal with the most canonical looking flags: + unless (any (`elem` ["p", "pid", "q", "quick-pid"]) psFlags) $ + info (getId ps) 2009 "Consider using pgrep instead of grepping ps output." + + for ["grep", "wc"] $ + \(grep:wc:_) -> + let flagsGrep = maybe [] (map snd . getAllFlags) $ getCommand grep + flagsWc = maybe [] (map snd . getAllFlags) $ getCommand wc + in + unless (any (`elem` ["l", "files-with-matches", "L", "files-without-matches", "o", "only-matching", "r", "R", "recursive", "A", "after-context", "B", "before-context"]) flagsGrep + || any (`elem` ["m", "chars", "w", "words", "c", "bytes", "L", "max-line-length"]) flagsWc + || null flagsWc) $ + style (getId grep) 2126 "Consider using 'grep -c' instead of 'grep|wc -l'." + + didLs <- fmap or . sequence $ [ + for' ["ls", "grep"] $ + \x -> warn x 2010 "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames.", + for' ["ls", "xargs"] $ + \x -> warn x 2011 "Use 'find .. -print0 | xargs -0 ..' or 'find .. -exec .. +' to allow non-alphanumeric filenames." + ] + unless didLs $ void $ + for ["ls", "?"] $ + \(ls:_) -> unless (hasShortParameter 'N' (oversimplify ls)) $ + info (getId ls) 2012 "Use find instead of ls to better handle non-alphanumeric filenames." + where + for l f = + let indices = indexOfSublists l (map (headOrDefault "" . oversimplify) commands) + in do + mapM_ (f . (\ n -> take (length l) $ drop n commands)) indices + return . not . null $ indices + for' l f = for l (first f) + first func (x:_) = func (getId $ getCommandTokenOrThis x) + first _ _ = return () + hasShortParameter char = any (\x -> "-" `isPrefixOf` x && char `elem` x) + hasParameter string = + any (isPrefixOf string . dropWhile (== '-')) +checkPipePitfalls _ _ = return () + +indexOfSublists sub = f 0 + where + f _ [] = [] + f n a@(r:rest) = + let others = f (n+1) rest in + if match sub a + then n:others + else others + match ("?":r1) (_:r2) = match r1 r2 + match (x1:r1) (x2:r2) | x1 == x2 = match r1 r2 + match [] _ = True + match _ _ = False + + +prop_checkShebangParameters1 = verifyTree checkShebangParameters "#!/usr/bin/env bash -x\necho cow" +prop_checkShebangParameters2 = verifyNotTree checkShebangParameters "#! /bin/sh -l " +prop_checkShebangParameters3 = verifyNotTree checkShebangParameters "#!/usr/bin/env -S bash -x\necho cow" +prop_checkShebangParameters4 = verifyNotTree checkShebangParameters "#!/usr/bin/env --split-string bash -x\necho cow" +checkShebangParameters p (T_Annotation _ _ t) = checkShebangParameters p t +checkShebangParameters _ (T_Script _ (T_Literal id sb) _) = + [makeComment ErrorC id 2096 "On most OS, shebangs can only specify a single parameter." | isMultiWord] + where + isMultiWord = length (words sb) > 2 && not (sb `matches` re) + re = mkRegex "env +(-S|--split-string)" + +prop_checkShebang1 = verifyNotTree checkShebang "#!/usr/bin/env bash -x\necho cow" +prop_checkShebang2 = verifyNotTree checkShebang "#! /bin/sh -l " +prop_checkShebang3 = verifyTree checkShebang "ls -l" +prop_checkShebang4 = verifyNotTree checkShebang "#shellcheck shell=sh\nfoo" +prop_checkShebang5 = verifyTree checkShebang "#!/usr/bin/env ash" +prop_checkShebang6 = verifyNotTree checkShebang "#!/usr/bin/env ash\n# shellcheck shell=dash\n" +prop_checkShebang7 = verifyNotTree checkShebang "#!/usr/bin/env ash\n# shellcheck shell=sh\n" +prop_checkShebang8 = verifyTree checkShebang "#!bin/sh\ntrue" +prop_checkShebang9 = verifyNotTree checkShebang "# shellcheck shell=sh\ntrue" +prop_checkShebang10 = verifyNotTree checkShebang "#!foo\n# shellcheck shell=sh ignore=SC2239\ntrue" +prop_checkShebang11 = verifyTree checkShebang "#!/bin/sh/\ntrue" +prop_checkShebang12 = verifyTree checkShebang "#!/bin/sh/ -xe\ntrue" +prop_checkShebang13 = verifyNotTree checkShebang "#!/bin/busybox sh" +prop_checkShebang14 = verifyNotTree checkShebang "#!/bin/busybox sh\n# shellcheck shell=sh\n" +prop_checkShebang15 = verifyNotTree checkShebang "#!/bin/busybox sh\n# shellcheck shell=dash\n" +prop_checkShebang16 = verifyNotTree checkShebang "#!/bin/busybox ash" +prop_checkShebang17 = verifyNotTree checkShebang "#!/bin/busybox ash\n# shellcheck shell=dash\n" +prop_checkShebang18 = verifyNotTree checkShebang "#!/bin/busybox ash\n# shellcheck shell=sh\n" +checkShebang params (T_Annotation _ list t) = + if any isOverride list then [] else checkShebang params t + where + isOverride (ShellOverride _) = True + isOverride _ = False +checkShebang params (T_Script _ (T_Literal id sb) _) = execWriter $ do + unless (shellTypeSpecified params) $ do + when (null sb) $ + err id 2148 "Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive." + when (executableFromShebang sb == "ash") $ + warn id 2187 "Ash scripts will be checked as Dash. Add '# shellcheck shell=dash' to silence." + unless (null sb) $ do + unless ("/" `isPrefixOf` sb) $ + err id 2239 "Ensure the shebang uses an absolute path to the interpreter." + when ("/" `isSuffixOf` head (words sb)) $ + err id 2246 "This shebang specifies a directory. Ensure the interpreter is a file." + + +prop_checkForInQuoted = verify checkForInQuoted "for f in \"$(ls)\"; do echo foo; done" +prop_checkForInQuoted2 = verifyNot checkForInQuoted "for f in \"$@\"; do echo foo; done" +prop_checkForInQuoted2a = verifyNot checkForInQuoted "for f in *.mp3; do echo foo; done" +prop_checkForInQuoted2b = verify checkForInQuoted "for f in \"*.mp3\"; do echo foo; done" +prop_checkForInQuoted3 = verify checkForInQuoted "for f in 'find /'; do true; done" +prop_checkForInQuoted4 = verify checkForInQuoted "for f in 1,2,3; do true; done" +prop_checkForInQuoted4a = verifyNot checkForInQuoted "for f in foo{1,2,3}; do true; done" +prop_checkForInQuoted5 = verify checkForInQuoted "for f in ls; do true; done" +prop_checkForInQuoted6 = verifyNot checkForInQuoted "for f in \"${!arr}\"; do true; done" +prop_checkForInQuoted7 = verify checkForInQuoted "for f in ls, grep, mv; do true; done" +prop_checkForInQuoted8 = verify checkForInQuoted "for f in 'ls', 'grep', 'mv'; do true; done" +prop_checkForInQuoted9 = verifyNot checkForInQuoted "for f in 'ls,' 'grep,' 'mv'; do true; done" +checkForInQuoted _ (T_ForIn _ f [T_NormalWord _ [word@(T_DoubleQuoted id list)]] _) + | any willSplit list && not (mayBecomeMultipleArgs word) + || maybe False wouldHaveBeenGlob (getLiteralString word) = + err id 2066 "Since you double quoted this, it will not word split, and the loop will only run once." +checkForInQuoted _ (T_ForIn _ f [T_NormalWord _ [T_SingleQuoted id _]] _) = + warn id 2041 "This is a literal string. To run as a command, use $(..) instead of '..' . " +checkForInQuoted _ (T_ForIn _ _ [single] _) + | maybe False (',' `elem`) $ getUnquotedLiteral single = + warn (getId single) 2042 "Use spaces, not commas, to separate loop elements." + | not (willSplit single || mayBecomeMultipleArgs single) = + warn (getId single) 2043 "This loop will only ever run once. Bad quoting or missing glob/expansion?" +checkForInQuoted params (T_ForIn _ _ multiple _) = + forM_ multiple $ \arg -> sequence_ $ do + suffix <- getTrailingUnquotedLiteral arg + string <- getLiteralString suffix + guard $ "," `isSuffixOf` string + return $ + warnWithFix (getId arg) 2258 + "The trailing comma is part of the value, not a separator. Delete or quote it." + (fixWith [replaceEnd (getId suffix) params 1 ""]) +checkForInQuoted _ _ = return () + +prop_checkForInCat1 = verify checkForInCat "for f in $(cat foo); do stuff; done" +prop_checkForInCat1a = verify checkForInCat "for f in `cat foo`; do stuff; done" +prop_checkForInCat2 = verify checkForInCat "for f in $(cat foo | grep lol); do stuff; done" +prop_checkForInCat2a = verify checkForInCat "for f in `cat foo | grep lol`; do stuff; done" +prop_checkForInCat3 = verifyNot checkForInCat "for f in $(cat foo | grep bar | wc -l); do stuff; done" +checkForInCat _ (T_ForIn _ f [T_NormalWord _ w] _) = mapM_ checkF w + where + checkF (T_DollarExpansion id [T_Pipeline _ _ r]) + | all isLineBased r = + info id 2013 "To read lines rather than words, pipe/redirect to a 'while read' loop." + checkF (T_Backticked id cmds) = checkF (T_DollarExpansion id cmds) + checkF _ = return () + isLineBased cmd = any (cmd `isCommand`) + ["grep", "fgrep", "egrep", "sed", "cat", "awk", "cut", "sort"] +checkForInCat _ _ = return () + +prop_checkForInLs = verify checkForInLs "for f in $(ls *.mp3); do mplayer \"$f\"; done" +prop_checkForInLs2 = verify checkForInLs "for f in `ls *.mp3`; do mplayer \"$f\"; done" +prop_checkForInLs3 = verify checkForInLs "for f in `find / -name '*.mp3'`; do mplayer \"$f\"; done" +checkForInLs _ = try + where + try (T_ForIn _ f [T_NormalWord _ [T_DollarExpansion id [x]]] _) = + check id f x + try (T_ForIn _ f [T_NormalWord _ [T_Backticked id [x]]] _) = + check id f x + try _ = return () + check id f x = + case oversimplify x of + ("ls":n) -> + let warntype = if any ("-" `isPrefixOf`) n then warn else err in + warntype id 2045 "Iterating over ls output is fragile. Use globs." + ("find":_) -> warn id 2044 "For loops over find output are fragile. Use find -exec or a while read loop." + _ -> return () + + +prop_checkFindExec1 = verify checkFindExec "find / -name '*.php' -exec rm {};" +prop_checkFindExec2 = verify checkFindExec "find / -exec touch {} && ls {} \\;" +prop_checkFindExec3 = verify checkFindExec "find / -execdir cat {} | grep lol +" +prop_checkFindExec4 = verifyNot checkFindExec "find / -name '*.php' -exec foo {} +" +prop_checkFindExec5 = verifyNot checkFindExec "find / -execdir bash -c 'a && b' \\;" +prop_checkFindExec6 = verify checkFindExec "find / -type d -execdir rm *.jpg \\;" +checkFindExec _ cmd@(T_SimpleCommand _ _ t@(h:r)) | cmd `isCommand` "find" = do + c <- broken r False + when c $ + let wordId = getId $ last t in + err wordId 2067 "Missing ';' or + terminating -exec. You can't use |/||/&&, and ';' has to be a separate, quoted argument." + + where + broken [] v = return v + broken (w:r) v = do + when v (mapM_ warnFor $ fromWord w) + case getLiteralString w of + Just "-exec" -> broken r True + Just "-execdir" -> broken r True + Just "+" -> broken r False + Just ";" -> broken r False + _ -> broken r v + + shouldWarn x = + case x of + T_DollarExpansion _ _ -> True + T_Backticked _ _ -> True + T_Glob _ _ -> True + T_Extglob {} -> True + _ -> False + + warnFor x = + when(shouldWarn x) $ + info (getId x) 2014 "This will expand once before find runs, not per file found." + + fromWord (T_NormalWord _ l) = l + fromWord _ = [] +checkFindExec _ _ = return () + + +prop_checkUnquotedExpansions1 = verify checkUnquotedExpansions "rm $(ls)" +prop_checkUnquotedExpansions1a = verify checkUnquotedExpansions "rm `ls`" +prop_checkUnquotedExpansions2 = verify checkUnquotedExpansions "rm foo$(date)" +prop_checkUnquotedExpansions3 = verify checkUnquotedExpansions "[ $(foo) == cow ]" +prop_checkUnquotedExpansions3a = verify checkUnquotedExpansions "[ ! $(foo) ]" +prop_checkUnquotedExpansions4 = verifyNot checkUnquotedExpansions "[[ $(foo) == cow ]]" +prop_checkUnquotedExpansions5 = verifyNot checkUnquotedExpansions "for f in $(cmd); do echo $f; done" +prop_checkUnquotedExpansions6 = verifyNot checkUnquotedExpansions "$(cmd)" +prop_checkUnquotedExpansions7 = verifyNot checkUnquotedExpansions "cat << foo\n$(ls)\nfoo" +prop_checkUnquotedExpansions8 = verifyNot checkUnquotedExpansions "set -- $(seq 1 4)" +prop_checkUnquotedExpansions9 = verifyNot checkUnquotedExpansions "echo foo `# inline comment`" +prop_checkUnquotedExpansions10 = verify checkUnquotedExpansions "#!/bin/sh\nexport var=$(val)" +prop_checkUnquotedExpansions11 = verifyNot checkUnquotedExpansions "ps -p $(pgrep foo)" +checkUnquotedExpansions params = + check + where + check t@(T_DollarExpansion _ c) = examine t c + check t@(T_Backticked _ c) = examine t c + check t@(T_DollarBraceCommandExpansion _ _ c) = examine t c + check _ = return () + tree = parentMap params + examine t contents = + unless (null contents || shouldBeSplit t || isQuoteFree (shellType params) tree t || usedAsCommandName tree t) $ + warn (getId t) 2046 "Quote this to prevent word splitting." + + shouldBeSplit t = + getCommandNameFromExpansion t `elem` [Just "seq", Just "pgrep"] + + +prop_checkRedirectToSame = verify checkRedirectToSame "cat foo > foo" +prop_checkRedirectToSame2 = verify checkRedirectToSame "cat lol | sed -e 's/a/b/g' > lol" +prop_checkRedirectToSame3 = verifyNot checkRedirectToSame "cat lol | sed -e 's/a/b/g' > foo.bar && mv foo.bar lol" +prop_checkRedirectToSame4 = verifyNot checkRedirectToSame "foo /dev/null > /dev/null" +prop_checkRedirectToSame5 = verifyNot checkRedirectToSame "foo > bar 2> bar" +prop_checkRedirectToSame6 = verifyNot checkRedirectToSame "echo foo > foo" +prop_checkRedirectToSame7 = verifyNot checkRedirectToSame "sed 's/foo/bar/g' file | sponge file" +prop_checkRedirectToSame8 = verifyNot checkRedirectToSame "while read -r line; do _=\"$fname\"; done <\"$fname\"" +prop_checkRedirectToSame9 = verifyNot checkRedirectToSame "while read -r line; do cat < \"$fname\"; done <\"$fname\"" +prop_checkRedirectToSame10 = verifyNot checkRedirectToSame "mapfile -t foo (mapM_ (\x -> doAnalysis (checkOccurrences x) l) (getAllRedirs list))) list + where + note x = makeComment InfoC x 2094 + "Make sure not to read and write the same file in the same pipeline." + checkOccurrences t@(T_NormalWord exceptId x) u@(T_NormalWord newId y) | + exceptId /= newId + && x == y + && not (isInput t && isInput u) + && not (isOutput t && isOutput u) + && not (special t) + && not (any isHarmlessCommand [t,u]) + && not (any containsAssignment [u]) = do + addComment $ note newId + addComment $ note exceptId + checkOccurrences _ _ = return () + getAllRedirs = concatMap (\t -> + case t of + T_Redirecting _ ls _ -> concatMap getRedirs ls + _ -> []) + getRedirs (T_FdRedirect _ _ (T_IoFile _ op file)) = + case op of T_Greater _ -> [file] + T_Less _ -> [file] + T_DGREAT _ -> [file] + _ -> [] + getRedirs _ = [] + special x = "/dev/" `isPrefixOf` concat (oversimplify x) + isInput t = + case NE.tail $ getPath (parentMap params) t of + T_IoFile _ op _:_ -> + case op of + T_Less _ -> True + _ -> False + _ -> False + isOutput t = + case NE.tail $ getPath (parentMap params) t of + T_IoFile _ op _:_ -> + case op of + T_Greater _ -> True + T_DGREAT _ -> True + _ -> False + _ -> False + isHarmlessCommand arg = fromMaybe False $ do + cmd <- getClosestCommand (parentMap params) arg + name <- getCommandBasename cmd + return $ name `elem` ["echo", "mapfile", "printf", "sponge"] + containsAssignment arg = fromMaybe False $ do + cmd <- getClosestCommand (parentMap params) arg + return $ isAssignment cmd + +checkRedirectToSame _ _ = return () + + +prop_checkShorthandIf = verify checkShorthandIf "[[ ! -z file ]] && scp file host || rm file" +prop_checkShorthandIf2 = verifyNot checkShorthandIf "[[ ! -z file ]] && { scp file host || echo 'Eek'; }" +prop_checkShorthandIf3 = verifyNot checkShorthandIf "foo && bar || echo baz" +prop_checkShorthandIf4 = verifyNot checkShorthandIf "foo && a=b || a=c" +prop_checkShorthandIf5 = verifyNot checkShorthandIf "foo && rm || printf b" +prop_checkShorthandIf6 = verifyNot checkShorthandIf "if foo && bar || baz; then true; fi" +prop_checkShorthandIf7 = verifyNot checkShorthandIf "while foo && bar || baz; do true; done" +prop_checkShorthandIf8 = verify checkShorthandIf "if true; then foo && bar || baz; fi" +prop_checkShorthandIf9 = verifyNot checkShorthandIf "foo && [ -x /file ] || bar" +prop_checkShorthandIf10 = verifyNot checkShorthandIf "foo && bar || true" +prop_checkShorthandIf11 = verify checkShorthandIf "foo && bar || false" +checkShorthandIf params x@(T_OrIf _ (T_AndIf id _ b) (T_Pipeline _ _ t)) + | not (isOk t || inCondition) && not (isTestCommand b) = + info id 2015 "Note that A && B || C is not if-then-else. C may run when A is true." + where + isOk [t] = isAssignment t || fromMaybe False (do + name <- getCommandBasename t + return $ name `elem` ["echo", "exit", "return", "printf", "true", ":"]) + isOk _ = False + inCondition = isCondition $ getPath (parentMap params) x +checkShorthandIf _ _ = return () + + +prop_checkDollarStar = verify checkDollarStar "for f in $*; do ..; done" +prop_checkDollarStar2 = verifyNot checkDollarStar "a=$*" +prop_checkDollarStar3 = verifyNot checkDollarStar "[[ $* = 'a b' ]]" +prop_checkDollarStar4 = verify checkDollarStar "for f in ${var[*]}; do ..; done" +prop_checkDollarStar5 = verify checkDollarStar "ls ${*//foo/bar}" +prop_checkDollarStar6 = verify checkDollarStar "ls ${var[*]%%.*}" +prop_checkDollarStar7 = verify checkDollarStar "ls ${*}" +prop_checkDollarStar8 = verifyNot checkDollarStar "ls ${#*}" +prop_checkDollarStar9 = verify checkDollarStar "ls ${arr[*]}" +prop_checkDollarStar10 = verifyNot checkDollarStar "ls ${#arr[*]}" +checkDollarStar p t@(T_NormalWord _ [T_DollarBraced id _ l]) + | not (isStrictlyQuoteFree (shellType p) (parentMap p) t) = do + let str = concat (oversimplify l) + when ("*" `isPrefixOf` str) $ + warn id 2048 "Use \"$@\" (with quotes) to prevent whitespace problems." + when ("[*]" `isPrefixOf` (getBracedModifier str) && isVariableChar (headOrDefault '!' str)) $ + warn id 2048 "Use \"${array[@]}\" (with quotes) to prevent whitespace problems." + +checkDollarStar _ _ = return () + + +prop_checkUnquotedDollarAt = verify checkUnquotedDollarAt "ls $@" +prop_checkUnquotedDollarAt1 = verifyNot checkUnquotedDollarAt "ls ${#@}" +prop_checkUnquotedDollarAt2 = verify checkUnquotedDollarAt "ls ${foo[@]}" +prop_checkUnquotedDollarAt3 = verifyNot checkUnquotedDollarAt "ls ${#foo[@]}" +prop_checkUnquotedDollarAt4 = verifyNot checkUnquotedDollarAt "ls \"$@\"" +prop_checkUnquotedDollarAt5 = verifyNot checkUnquotedDollarAt "ls ${foo/@/ at }" +prop_checkUnquotedDollarAt6 = verifyNot checkUnquotedDollarAt "a=$@" +prop_checkUnquotedDollarAt7 = verify checkUnquotedDollarAt "for f in ${var[@]}; do true; done" +prop_checkUnquotedDollarAt8 = verifyNot checkUnquotedDollarAt "echo \"${args[@]:+${args[@]}}\"" +prop_checkUnquotedDollarAt9 = verifyNot checkUnquotedDollarAt "echo ${args[@]:+\"${args[@]}\"}" +prop_checkUnquotedDollarAt10 = verifyNot checkUnquotedDollarAt "echo ${@+\"$@\"}" +checkUnquotedDollarAt p word@(T_NormalWord _ parts) | not $ isStrictlyQuoteFree (shellType p) (parentMap p) word = + forM_ (find isArrayExpansion parts) $ \x -> + unless (isQuotedAlternativeReference x) $ + err (getId x) 2068 + "Double quote array expansions to avoid re-splitting elements." +checkUnquotedDollarAt _ _ = return () + +prop_checkConcatenatedDollarAt1 = verify checkConcatenatedDollarAt "echo \"foo$@\"" +prop_checkConcatenatedDollarAt2 = verify checkConcatenatedDollarAt "echo ${arr[@]}lol" +prop_checkConcatenatedDollarAt3 = verify checkConcatenatedDollarAt "echo $a$@" +prop_checkConcatenatedDollarAt4 = verifyNot checkConcatenatedDollarAt "echo $@" +prop_checkConcatenatedDollarAt5 = verifyNot checkConcatenatedDollarAt "echo \"${arr[@]}\"" +checkConcatenatedDollarAt p word@T_NormalWord {} + | not $ isQuoteFree (shellType p) (parentMap p) word + || null (drop 1 parts) = + mapM_ for array + where + parts = getWordParts word + array = find isArrayExpansion parts + for t = err (getId t) 2145 "Argument mixes string and array. Use * or separate argument." +checkConcatenatedDollarAt _ _ = return () + +prop_checkArrayAsString1 = verify checkArrayAsString "a=$@" +prop_checkArrayAsString2 = verify checkArrayAsString "a=\"${arr[@]}\"" +prop_checkArrayAsString3 = verify checkArrayAsString "a=*.png" +prop_checkArrayAsString4 = verify checkArrayAsString "a={1..10}" +prop_checkArrayAsString5 = verifyNot checkArrayAsString "a='*.gif'" +prop_checkArrayAsString6 = verifyNot checkArrayAsString "a=$*" +prop_checkArrayAsString7 = verifyNot checkArrayAsString "a=( $@ )" +checkArrayAsString _ (T_Assignment id _ _ _ word) = + if willConcatInAssignment word + then + warn (getId word) 2124 + "Assigning an array to a string! Assign as array, or use * instead of @ to concatenate." + else + when (willBecomeMultipleArgs word) $ + warn (getId word) 2125 + "Brace expansions and globs are literal in assignments. Quote it or use an array." +checkArrayAsString _ _ = return () + +prop_checkArrayWithoutIndex1 = verifyTree checkArrayWithoutIndex "foo=(a b); echo $foo" +prop_checkArrayWithoutIndex2 = verifyNotTree checkArrayWithoutIndex "foo='bar baz'; foo=($foo); echo ${foo[0]}" +prop_checkArrayWithoutIndex3 = verifyTree checkArrayWithoutIndex "coproc foo while true; do echo cow; done; echo $foo" +prop_checkArrayWithoutIndex4 = verifyTree checkArrayWithoutIndex "coproc tail -f log; echo $COPROC" +prop_checkArrayWithoutIndex5 = verifyTree checkArrayWithoutIndex "a[0]=foo; echo $a" +prop_checkArrayWithoutIndex6 = verifyTree checkArrayWithoutIndex "echo $PIPESTATUS" +prop_checkArrayWithoutIndex7 = verifyTree checkArrayWithoutIndex "a=(a b); a+=c" +prop_checkArrayWithoutIndex8 = verifyTree checkArrayWithoutIndex "declare -a foo; foo=bar;" +prop_checkArrayWithoutIndex9 = verifyTree checkArrayWithoutIndex "read -r -a arr <<< 'foo bar'; echo \"$arr\"" +prop_checkArrayWithoutIndex10 = verifyTree checkArrayWithoutIndex "read -ra arr <<< 'foo bar'; echo \"$arr\"" +prop_checkArrayWithoutIndex11 = verifyNotTree checkArrayWithoutIndex "read -rpfoobar r; r=42" +checkArrayWithoutIndex params _ = + doVariableFlowAnalysis readF writeF defaultSet (variableFlow params) + where + defaultSet = S.fromList arrayVariables + readF _ (T_DollarBraced id _ token) _ = do + s <- get + return . maybeToList $ do + name <- getLiteralString token + guard $ S.member name s + return $ makeComment WarningC id 2128 + "Expanding an array without an index only gives the first element." + readF _ _ _ = return [] + + writeF _ (T_Assignment id mode name [] _) _ (DataString _) = do + isArray <- gets (S.member name) + return $ if not isArray then [] else + case mode of + Assign -> [makeComment WarningC id 2178 "Variable was used as an array but is now assigned a string."] + Append -> [makeComment WarningC id 2179 "Use array+=(\"item\") to append items to an array."] + + writeF _ t name (DataArray _) = do + modify (S.insert name) + return [] + writeF _ expr name _ = do + if isIndexed expr + then modify (S.insert name) + else modify (S.delete name) + return [] + + isIndexed expr = + case expr of + T_Assignment _ _ _ (_:_) _ -> True + _ -> False + +prop_checkStderrRedirect = verify checkStderrRedirect "test 2>&1 > cow" +prop_checkStderrRedirect2 = verifyNot checkStderrRedirect "test > cow 2>&1" +prop_checkStderrRedirect3 = verifyNot checkStderrRedirect "test 2>&1 > file | grep stderr" +prop_checkStderrRedirect4 = verifyNot checkStderrRedirect "errors=$(test 2>&1 > file)" +prop_checkStderrRedirect5 = verifyNot checkStderrRedirect "read < <(test 2>&1 > file)" +prop_checkStderrRedirect6 = verify checkStderrRedirect "foo | bar 2>&1 > /dev/null" +prop_checkStderrRedirect7 = verifyNot checkStderrRedirect "{ cmd > file; } 2>&1" +checkStderrRedirect params redir@(T_Redirecting _ [ + T_FdRedirect id "2" (T_IoDuplicate _ (T_GREATAND _) "1"), + T_FdRedirect _ _ (T_IoFile _ op _) + ] _) = case op of + T_Greater _ -> error + T_DGREAT _ -> error + _ -> return () + where + usesOutput t = + case t of + (T_Pipeline _ _ list) -> length list > 1 && not (isParentOf (parentMap params) (last list) redir) + T_ProcSub {} -> True + T_DollarExpansion {} -> True + T_Backticked {} -> True + _ -> False + isCaptured = any usesOutput $ getPath (parentMap params) redir + + error = unless isCaptured $ + warn id 2069 "To redirect stdout+stderr, 2>&1 must be last (or use '{ cmd > file; } 2>&1' to clarify)." + +checkStderrRedirect _ _ = return () + +lt x = trace ("Tracing " ++ show x) x -- STRIP +ltt t = trace ("Tracing " ++ show t) -- STRIP + + +prop_checkSingleQuotedVariables = verify checkSingleQuotedVariables "echo '$foo'" +prop_checkSingleQuotedVariables2 = verify checkSingleQuotedVariables "echo 'lol$1.jpg'" +prop_checkSingleQuotedVariables3 = verifyNot checkSingleQuotedVariables "sed 's/foo$/bar/'" +prop_checkSingleQuotedVariables3a = verify checkSingleQuotedVariables "sed 's/${foo}/bar/'" +prop_checkSingleQuotedVariables3b = verify checkSingleQuotedVariables "sed 's/$(echo cow)/bar/'" +prop_checkSingleQuotedVariables3c = verify checkSingleQuotedVariables "sed 's/$((1+foo))/bar/'" +prop_checkSingleQuotedVariables4 = verifyNot checkSingleQuotedVariables "awk '{print $1}'" +prop_checkSingleQuotedVariables5 = verifyNot checkSingleQuotedVariables "trap 'echo $SECONDS' EXIT" +prop_checkSingleQuotedVariables6 = verifyNot checkSingleQuotedVariables "sed -n '$p'" +prop_checkSingleQuotedVariables6a = verify checkSingleQuotedVariables "sed -n '$pattern'" +prop_checkSingleQuotedVariables7 = verifyNot checkSingleQuotedVariables "PS1='$PWD \\$ '" +prop_checkSingleQuotedVariables8 = verify checkSingleQuotedVariables "find . -exec echo '$1' {} +" +prop_checkSingleQuotedVariables9 = verifyNot checkSingleQuotedVariables "find . -exec awk '{print $1}' {} \\;" +prop_checkSingleQuotedVariables10 = verify checkSingleQuotedVariables "echo '`pwd`'" +prop_checkSingleQuotedVariables11 = verifyNot checkSingleQuotedVariables "sed '${/lol/d}'" +prop_checkSingleQuotedVariables12 = verifyNot checkSingleQuotedVariables "eval 'echo $1'" +prop_checkSingleQuotedVariables13 = verifyNot checkSingleQuotedVariables "busybox awk '{print $1}'" +prop_checkSingleQuotedVariables14 = verifyNot checkSingleQuotedVariables "[ -v 'bar[$foo]' ]" +prop_checkSingleQuotedVariables15 = verifyNot checkSingleQuotedVariables "git filter-branch 'test $GIT_COMMIT'" +prop_checkSingleQuotedVariables16 = verify checkSingleQuotedVariables "git '$a'" +prop_checkSingleQuotedVariables17 = verifyNot checkSingleQuotedVariables "rename 's/(.)a/$1/g' *" +prop_checkSingleQuotedVariables18 = verifyNot checkSingleQuotedVariables "echo '``'" +prop_checkSingleQuotedVariables19 = verifyNot checkSingleQuotedVariables "echo '```'" +prop_checkSingleQuotedVariables20 = verifyNot checkSingleQuotedVariables "mumps -run %XCMD 'W $O(^GLOBAL(5))'" +prop_checkSingleQuotedVariables21 = verifyNot checkSingleQuotedVariables "mumps -run LOOP%XCMD --xec 'W $O(^GLOBAL(6))'" +prop_checkSingleQuotedVariables22 = verifyNot checkSingleQuotedVariables "jq '$__loc__'" +prop_checkSingleQuotedVariables23 = verifyNot checkSingleQuotedVariables "command jq '$__loc__'" +prop_checkSingleQuotedVariables24 = verifyNot checkSingleQuotedVariables "exec jq '$__loc__'" +prop_checkSingleQuotedVariables25 = verifyNot checkSingleQuotedVariables "exec -c -a foo jq '$__loc__'" + + +checkSingleQuotedVariables params t@(T_SingleQuoted id s) = + when (s `matches` re) $ + if "sed" == commandName + then unless (s `matches` sedContra) showMessage + else unless isProbablyOk showMessage + where + parents = parentMap params + showMessage = info id 2016 + "Expressions don't expand in single quotes, use double quotes for that." + commandName = fromMaybe "" $ do + cmd <- getClosestCommand parents t + name <- getCommandBasename cmd + return $ if name == "find" then getFindCommand cmd else if name == "git" then getGitCommand cmd else if name == "mumps" then getMumpsCommand cmd else name + + isProbablyOk = + any isOkAssignment (NE.take 3 $ getPath parents t) + || commandName `elem` [ + "trap" + ,"sh" + ,"bash" + ,"ksh" + ,"zsh" + ,"ssh" + ,"eval" + ,"xprop" + ,"alias" + ,"sudo" -- covering "sudo sh" and such + ,"doas" -- same as sudo + ,"run0" -- same as sudo + ,"docker" -- like above + ,"podman" + ,"oc" + ,"dpkg-query" + ,"jq" -- could also check that user provides --arg + ,"rename" + ,"rg" + ,"unset" + ,"git filter-branch" + ,"mumps -run %XCMD" + ,"mumps -run LOOP%XCMD" + ] + || "awk" `isSuffixOf` commandName + || "perl" `isPrefixOf` commandName + + commonlyQuoted = ["PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND"] + isOkAssignment t = + case t of + T_Assignment _ _ name _ _ -> name `elem` commonlyQuoted + TC_Unary _ _ "-v" _ -> True + _ -> False + + re = mkRegex "\\$[{(0-9a-zA-Z_]|`[^`]+`" + sedContra = mkRegex "\\$[{dpsaic]($|[^a-zA-Z])" + + getFindCommand (T_SimpleCommand _ _ words) = + let list = map getLiteralString words + cmd = dropWhile (\x -> x /= Just "-exec" && x /= Just "-execdir") list + in + case cmd of + (flag:cmd:rest) -> fromMaybe "find" cmd + _ -> "find" + getFindCommand (T_Redirecting _ _ cmd) = getFindCommand cmd + getFindCommand _ = "find" + getGitCommand (T_SimpleCommand _ _ words) = + case map getLiteralString words of + Just "git":Just "filter-branch":_ -> "git filter-branch" + _ -> "git" + getGitCommand (T_Redirecting _ _ cmd) = getGitCommand cmd + getGitCommand _ = "git" + getMumpsCommand (T_SimpleCommand _ _ words) = + case map getLiteralString words of + Just "mumps":Just "-run":Just "%XCMD":_ -> "mumps -run %XCMD" + Just "mumps":Just "-run":Just "LOOP%XCMD":_ -> "mumps -run LOOP%XCMD" + _ -> "mumps" + getMumpsCommand (T_Redirecting _ _ cmd) = getMumpsCommand cmd + getMumpsCommand _ = "mumps" +checkSingleQuotedVariables _ _ = return () + + +prop_checkUnquotedN = verify checkUnquotedN "if [ -n $foo ]; then echo cow; fi" +prop_checkUnquotedN2 = verify checkUnquotedN "[ -n $cow ]" +prop_checkUnquotedN3 = verifyNot checkUnquotedN "[[ -n $foo ]] && echo cow" +prop_checkUnquotedN4 = verify checkUnquotedN "[ -n $cow -o -t 1 ]" +prop_checkUnquotedN5 = verifyNot checkUnquotedN "[ -n \"$@\" ]" +checkUnquotedN _ (TC_Unary _ SingleBracket "-n" t) | willSplit t = + unless (any isArrayExpansion $ getWordParts t) $ -- There's SC2198 for these + err (getId t) 2070 "-n doesn't work with unquoted arguments. Quote or use [[ ]]." +checkUnquotedN _ _ = return () + +prop_checkNumberComparisons1 = verify checkNumberComparisons "[[ $foo < 3 ]]" +prop_checkNumberComparisons2 = verify checkNumberComparisons "[[ 0 >= $(cmd) ]]" +prop_checkNumberComparisons3 = verifyNot checkNumberComparisons "[[ $foo ]] > 3" +prop_checkNumberComparisons4 = verify checkNumberComparisons "[[ $foo > 2.72 ]]" +prop_checkNumberComparisons5 = verify checkNumberComparisons "[[ $foo -le 2.72 ]]" +prop_checkNumberComparisons6 = verify checkNumberComparisons "[[ 3.14 -eq $foo ]]" +prop_checkNumberComparisons7 = verifyNot checkNumberComparisons "[[ 3.14 == $foo ]]" +prop_checkNumberComparisons8 = verify checkNumberComparisons "[ foo <= bar ]" +prop_checkNumberComparisons9 = verify checkNumberComparisons "[ foo \\>= bar ]" +prop_checkNumberComparisons11 = verify checkNumberComparisons "[ $foo -eq 'N' ]" +prop_checkNumberComparisons12 = verify checkNumberComparisons "[ x$foo -gt x${N} ]" +prop_checkNumberComparisons13 = verify checkNumberComparisons "[ $foo > $bar ]" +prop_checkNumberComparisons14 = verifyNot checkNumberComparisons "[[ foo < bar ]]" +prop_checkNumberComparisons15 = verifyNot checkNumberComparisons "[ $foo '>' $bar ]" +prop_checkNumberComparisons16 = verify checkNumberComparisons "[ foo -eq 'y' ]" +prop_checkNumberComparisons17 = verify checkNumberComparisons "[[ 'foo' -eq 2 ]]" +prop_checkNumberComparisons18 = verify checkNumberComparisons "[[ foo -eq 2 ]]" +prop_checkNumberComparisons19 = verifyNot checkNumberComparisons "foo=1; [[ foo -eq 2 ]]" +prop_checkNumberComparisons20 = verify checkNumberComparisons "[[ 2 -eq / ]]" +prop_checkNumberComparisons21 = verify checkNumberComparisons "[[ foo -eq foo ]]" +prop_checkNumberComparisons22 = verify checkNumberComparisons "x=10; [[ $x > $z ]]" +prop_checkNumberComparisons23 = verify checkNumberComparisons "x=0; if [[ -n $def ]]; then x=$def; fi; while [ $x > $z ]; do lol; done" +prop_checkNumberComparisons24 = verify checkNumberComparisons "x=$RANDOM; [ $x > $z ]" +prop_checkNumberComparisons25 = verify checkNumberComparisons "[[ $((n++)) > $x ]]" + +checkNumberComparisons params (TC_Binary id typ op lhs rhs) = do + if isNum lhs || isNum rhs + then do + when (isLtGt op) $ + err id 2071 $ + op ++ " is for string comparisons. Use " ++ eqv op ++ " instead." + when (isLeGe op && hasStringComparison) $ + err id 2071 $ op ++ " is not a valid operator. " ++ + "Use " ++ eqv op ++ " ." + else do + when (isLeGe op || isLtGt op) $ + mapM_ checkDecimals [lhs, rhs] + + when (isLeGe op && hasStringComparison) $ + err id 2122 $ op ++ " is not a valid operator. " ++ + "Use '! a " ++ esc ++ invert op ++ " b' instead." + + when (typ == SingleBracket && op `elem` ["<", ">"]) $ + case shellType params of + Sh -> return () -- These are unsupported and will be caught by bashism checks. + Dash -> err id 2073 $ "Escape \\" ++ op ++ " to prevent it redirecting." + BusyboxSh -> err id 2073 $ "Escape \\" ++ op ++ " to prevent it redirecting." + _ -> err id 2073 $ "Escape \\" ++ op ++ " to prevent it redirecting (or switch to [[ .. ]])." + + when (op `elem` arithmeticBinaryTestOps) $ do + mapM_ checkDecimals [lhs, rhs] + mapM_ checkString [lhs, rhs] + + + where + hasStringComparison = shellType params /= Sh + isLtGt = flip elem ["<", "\\<", ">", "\\>"] + isLeGe = flip elem ["<=", "\\<=", ">=", "\\>="] + + checkDecimals hs = + when (isFraction hs && not (hasFloatingPoint params)) $ + err (getId hs) 2072 decimalError + decimalError = "Decimals are not supported. " ++ + "Either use integers only, or use bc or awk to compare." + + checkString t = + let + asString = getLiteralStringDef "\0" t + isVar = isVariableName asString + kind = if isVar then "a variable" else "an arithmetic expression" + fix = if isVar then "$var" else "$((expr))" + in + when (isNonNum t) $ + if typ == SingleBracket + then + err (getId t) 2170 $ + "Invalid number for " ++ op ++ ". Use " ++ seqv op ++ + " to compare as string (or use " ++ fix ++ + " to expand as " ++ kind ++ ")." + else + -- We should warn if any of the following holds: + -- The string is not a variable name + -- Any part of it is quoted + -- It's not a recognized variable name + when (not isVar || any isQuotes (getWordParts t) || asString `notElem` assignedVariables) $ + warn (getId t) 2309 $ + op ++ " treats this as " ++ kind ++ ". " ++ + "Use " ++ seqv op ++ " to compare as string (or expand explicitly with " ++ fix ++ ")." + + assignedVariables :: [String] + assignedVariables = mapMaybe f (variableFlow params) + where + f t = do + Assignment (_, _, name, _) <- return t + return name + + isNonNum t = not . all numChar $ onlyLiteralString t + numChar x = isDigit x || x `elem` "+-. " + + isNum t = + case getWordParts t of + [T_DollarArithmetic {}] -> True + [b@(T_DollarBraced id _ c)] -> + let + str = concat $ oversimplify c + var = getBracedReference str + in fromMaybe False $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga id + value <- Map.lookup var $ CF.variablesInScope state + return $ CF.numericalStatus (CF.variableValue value) >= CF.NumericalStatusMaybe + _ -> + case oversimplify t of + [v] -> all isDigit v + _ -> False + + isFraction t = + case oversimplify t of + [v] -> isJust $ matchRegex floatRegex v + _ -> False + + eqv ('\\':s) = eqv s + eqv "<" = "-lt" + eqv ">" = "-gt" + eqv "<=" = "-le" + eqv ">=" = "-ge" + eqv _ = "the numerical equivalent" + + esc = if typ == SingleBracket then "\\" else "" + seqv "-ge" = "! a " ++ esc ++ "< b" + seqv "-gt" = esc ++ ">" + seqv "-le" = "! a " ++ esc ++ "> b" + seqv "-lt" = esc ++ "<" + seqv "-eq" = "=" + seqv "-ne" = "!=" + seqv _ = "the string equivalent" + + invert ('\\':s) = invert s + invert "<=" = ">" + invert ">=" = "<" + + floatRegex = mkRegex "^[-+]?[0-9]+\\.[0-9]+$" +checkNumberComparisons _ _ = return () + +prop_checkSingleBracketOperators1 = verify checkSingleBracketOperators "[ test =~ foo ]" +checkSingleBracketOperators params (TC_Binary id SingleBracket "=~" lhs rhs) + | shellType params `elem` [Bash, Ksh] = + err id 2074 $ "Can't use =~ in [ ]. Use [[..]] instead." +checkSingleBracketOperators _ _ = return () + +prop_checkDoubleBracketOperators1 = verify checkDoubleBracketOperators "[[ 3 \\< 4 ]]" +prop_checkDoubleBracketOperators3 = verifyNot checkDoubleBracketOperators "[[ foo < bar ]]" +checkDoubleBracketOperators _ x@(TC_Binary id typ op lhs rhs) + | typ == DoubleBracket && op `elem` ["\\<", "\\>"] = + err id 2075 $ "Escaping " ++ op ++" is required in [..], but invalid in [[..]]" +checkDoubleBracketOperators _ _ = return () + +prop_checkConditionalAndOrs1 = verify checkConditionalAndOrs "[ foo && bar ]" +prop_checkConditionalAndOrs2 = verify checkConditionalAndOrs "[[ foo -o bar ]]" +prop_checkConditionalAndOrs3 = verifyNot checkConditionalAndOrs "[[ foo || bar ]]" +prop_checkConditionalAndOrs4 = verify checkConditionalAndOrs "[ foo -a bar ]" +prop_checkConditionalAndOrs5 = verify checkConditionalAndOrs "[ -z 3 -o a = b ]" +checkConditionalAndOrs _ t = + case t of + (TC_And id SingleBracket "&&" _ _) -> + err id 2107 "Instead of [ a && b ], use [ a ] && [ b ]." + (TC_And id DoubleBracket "-a" _ _) -> + err id 2108 "In [[..]], use && instead of -a." + (TC_Or id SingleBracket "||" _ _) -> + err id 2109 "Instead of [ a || b ], use [ a ] || [ b ]." + (TC_Or id DoubleBracket "-o" _ _) -> + err id 2110 "In [[..]], use || instead of -o." + + (TC_And id SingleBracket "-a" _ _) -> + warn id 2166 "Prefer [ p ] && [ q ] as [ p -a q ] is not well defined." + (TC_Or id SingleBracket "-o" _ _) -> + warn id 2166 "Prefer [ p ] || [ q ] as [ p -o q ] is not well defined." + + _ -> return () + +prop_checkQuotedCondRegex1 = verify checkQuotedCondRegex "[[ $foo =~ \"bar.*\" ]]" +prop_checkQuotedCondRegex2 = verify checkQuotedCondRegex "[[ $foo =~ '(cow|bar)' ]]" +prop_checkQuotedCondRegex3 = verifyNot checkQuotedCondRegex "[[ $foo =~ $foo ]]" +prop_checkQuotedCondRegex4 = verifyNot checkQuotedCondRegex "[[ $foo =~ \"bar\" ]]" +prop_checkQuotedCondRegex5 = verifyNot checkQuotedCondRegex "[[ $foo =~ 'cow bar' ]]" +prop_checkQuotedCondRegex6 = verify checkQuotedCondRegex "[[ $foo =~ 'cow|bar' ]]" +checkQuotedCondRegex _ (TC_Binary _ _ "=~" _ rhs) = + case rhs of + T_NormalWord id [T_DoubleQuoted _ _] -> error rhs + T_NormalWord id [T_SingleQuoted _ _] -> error rhs + _ -> return () + where + error t = + unless (isConstantNonRe t) $ + warn (getId t) 2076 + "Remove quotes from right-hand side of =~ to match as a regex rather than literally." + re = mkRegex "[][*.+()|]" + hasMetachars s = s `matches` re + isConstantNonRe t = fromMaybe False $ do + s <- getLiteralString t + return . not $ hasMetachars s +checkQuotedCondRegex _ _ = return () + +prop_checkGlobbedRegex1 = verify checkGlobbedRegex "[[ $foo =~ *foo* ]]" +prop_checkGlobbedRegex2 = verify checkGlobbedRegex "[[ $foo =~ f* ]]" +prop_checkGlobbedRegex3 = verifyNot checkGlobbedRegex "[[ $foo =~ $foo ]]" +prop_checkGlobbedRegex4 = verifyNot checkGlobbedRegex "[[ $foo =~ ^c.* ]]" +prop_checkGlobbedRegex5 = verifyNot checkGlobbedRegex "[[ $foo =~ \\* ]]" +prop_checkGlobbedRegex6 = verifyNot checkGlobbedRegex "[[ $foo =~ (o*) ]]" +prop_checkGlobbedRegex7 = verifyNot checkGlobbedRegex "[[ $foo =~ \\*foo ]]" +prop_checkGlobbedRegex8 = verifyNot checkGlobbedRegex "[[ $foo =~ x\\* ]]" +checkGlobbedRegex _ (TC_Binary _ DoubleBracket "=~" _ rhs) + | isConfusedGlobRegex s = + warn (getId rhs) 2049 "=~ is for regex, but this looks like a glob. Use = instead." + where s = concat $ oversimplify rhs +checkGlobbedRegex _ _ = return () + + +prop_checkConstantIfs1 = verify checkConstantIfs "[[ foo != bar ]]" +prop_checkConstantIfs2a = verify checkConstantIfs "[ n -le 4 ]" +prop_checkConstantIfs2b = verifyNot checkConstantIfs "[[ n -le 4 ]]" +prop_checkConstantIfs3 = verify checkConstantIfs "[[ $n -le 4 && n != 2 ]]" +prop_checkConstantIfs4 = verifyNot checkConstantIfs "[[ $n -le 3 ]]" +prop_checkConstantIfs5 = verifyNot checkConstantIfs "[[ $n -le $n ]]" +prop_checkConstantIfs6 = verifyNot checkConstantIfs "[[ a -ot b ]]" +prop_checkConstantIfs7 = verifyNot checkConstantIfs "[ a -nt b ]" +prop_checkConstantIfs8 = verifyNot checkConstantIfs "[[ ~foo == '~foo' ]]" +prop_checkConstantIfs9 = verify checkConstantIfs "[[ *.png == [a-z] ]]" +prop_checkConstantIfs10 = verifyNot checkConstantIfs "[[ ~me == ~+ ]]" +prop_checkConstantIfs11 = verifyNot checkConstantIfs "[[ ~ == ~+ ]]" +prop_checkConstantIfs12 = verify checkConstantIfs "[[ '~' == x ]]" +checkConstantIfs _ (TC_Binary id typ op lhs rhs) | not isDynamic = + if isConstant lhs && isConstant rhs + then warn id 2050 "This expression is constant. Did you forget the $ on a variable?" + else checkUnmatchable id op lhs rhs + where + isDynamic = + op `elem` arithmeticBinaryTestOps + && typ == DoubleBracket + || op `elem` [ "-nt", "-ot", "-ef"] + + checkUnmatchable id op lhs rhs = + when (op `elem` ["=", "==", "!="] && not (wordsCanBeEqual lhs rhs)) $ + warn id 2193 "The arguments to this comparison can never be equal. Make sure your syntax is correct." +checkConstantIfs _ _ = return () + +prop_checkLiteralBreakingTest = verify checkLiteralBreakingTest "[[ a==$foo ]]" +prop_checkLiteralBreakingTest2 = verify checkLiteralBreakingTest "[ $foo=3 ]" +prop_checkLiteralBreakingTest3 = verify checkLiteralBreakingTest "[ $foo!=3 ]" +prop_checkLiteralBreakingTest4 = verify checkLiteralBreakingTest "[ \"$(ls) \" ]" +prop_checkLiteralBreakingTest5 = verify checkLiteralBreakingTest "[ -n \"$(true) \" ]" +prop_checkLiteralBreakingTest6 = verify checkLiteralBreakingTest "[ -z $(true)z ]" +prop_checkLiteralBreakingTest7 = verifyNot checkLiteralBreakingTest "[ -z $(true) ]" +prop_checkLiteralBreakingTest8 = verifyNot checkLiteralBreakingTest "[ $(true)$(true) ]" +prop_checkLiteralBreakingTest10 = verify checkLiteralBreakingTest "[ -z foo ]" +checkLiteralBreakingTest _ t = sequence_ $ + case t of + (TC_Nullary _ _ w@(T_NormalWord _ l)) -> do + guard . not $ isConstant w -- Covered by SC2078 + comparisonWarning l `mplus` tautologyWarning w "Argument to implicit -n is always true due to literal strings." + (TC_Unary _ _ op w@(T_NormalWord _ l)) -> + case op of + "-n" -> tautologyWarning w "Argument to -n is always true due to literal strings." + "-z" -> tautologyWarning w "Argument to -z is always false due to literal strings." + _ -> fail "not relevant" + _ -> fail "not my problem" + where + hasEquals = matchToken ('=' `elem`) + isNonEmpty = matchToken (not . null) + matchToken m t = maybe False m (getLiteralString t) + + comparisonWarning list = do + token <- find hasEquals list + return $ err (getId token) 2077 "You need spaces around the comparison operator." + tautologyWarning t s = do + token <- find isNonEmpty $ getWordParts t + return $ err (getId token) 2157 s + +prop_checkConstantNullary = verify checkConstantNullary "[[ '$(foo)' ]]" +prop_checkConstantNullary2 = verify checkConstantNullary "[ \"-f lol\" ]" +prop_checkConstantNullary3 = verify checkConstantNullary "[[ cmd ]]" +prop_checkConstantNullary4 = verify checkConstantNullary "[[ ! cmd ]]" +prop_checkConstantNullary5 = verify checkConstantNullary "[[ true ]]" +prop_checkConstantNullary6 = verify checkConstantNullary "[ 1 ]" +prop_checkConstantNullary7 = verify checkConstantNullary "[ false ]" +checkConstantNullary _ (TC_Nullary _ _ t) | isConstant t = + case onlyLiteralString t of + "false" -> err (getId t) 2158 "[ false ] is true. Remove the brackets." + "0" -> err (getId t) 2159 "[ 0 ] is true. Use 'false' instead." + "true" -> style (getId t) 2160 "Instead of '[ true ]', just use 'true'." + "1" -> style (getId t) 2161 "Instead of '[ 1 ]', use 'true'." + _ -> err (getId t) 2078 "This expression is constant. Did you forget a $ somewhere?" + where + string = onlyLiteralString t + +checkConstantNullary _ _ = return () + +prop_checkForDecimals1 = verify checkForDecimals "((3.14*c))" +prop_checkForDecimals2 = verify checkForDecimals "foo[1.2]=bar" +prop_checkForDecimals3 = verifyNot checkForDecimals "declare -A foo; foo[1.2]=bar" +checkForDecimals params t@(TA_Expansion id _) = sequence_ $ do + guard $ not (hasFloatingPoint params) + first:rest <- getLiteralString t + guard $ isDigit first && '.' `elem` rest + return $ err id 2079 "(( )) doesn't support decimals. Use bc or awk." +checkForDecimals _ _ = return () + +prop_checkDivBeforeMult = verify checkDivBeforeMult "echo $((c/n*100))" +prop_checkDivBeforeMult2 = verifyNot checkDivBeforeMult "echo $((c*100/n))" +prop_checkDivBeforeMult3 = verifyNot checkDivBeforeMult "echo $((c/10*10))" +checkDivBeforeMult params (TA_Binary _ "*" (TA_Binary id "/" _ x) y) + | not (hasFloatingPoint params) && x /= y = + info id 2017 "Increase precision by replacing a/b*c with a*c/b." +checkDivBeforeMult _ _ = return () + +prop_checkArithmeticDeref = verify checkArithmeticDeref "echo $((3+$foo))" +prop_checkArithmeticDeref2 = verify checkArithmeticDeref "cow=14; (( s+= $cow ))" +prop_checkArithmeticDeref3 = verifyNot checkArithmeticDeref "cow=1/40; (( s+= ${cow%%/*} ))" +prop_checkArithmeticDeref4 = verifyNot checkArithmeticDeref "(( ! $? ))" +prop_checkArithmeticDeref5 = verifyNot checkArithmeticDeref "(($1))" +prop_checkArithmeticDeref6 = verify checkArithmeticDeref "(( a[$i] ))" +prop_checkArithmeticDeref7 = verifyNot checkArithmeticDeref "(( 10#$n ))" +prop_checkArithmeticDeref8 = verifyNot checkArithmeticDeref "let i=$i+1" +prop_checkArithmeticDeref9 = verifyNot checkArithmeticDeref "(( a[foo] ))" +prop_checkArithmeticDeref10 = verifyNot checkArithmeticDeref "(( a[\\$foo] ))" +prop_checkArithmeticDeref11 = verify checkArithmeticDeref "a[$foo]=wee" +prop_checkArithmeticDeref11b = verifyNot checkArithmeticDeref "declare -A a; a[$foo]=wee" +prop_checkArithmeticDeref12 = verify checkArithmeticDeref "for ((i=0; $i < 3; i)); do true; done" +prop_checkArithmeticDeref13 = verifyNot checkArithmeticDeref "(( $$ ))" +prop_checkArithmeticDeref14 = verifyNot checkArithmeticDeref "(( $! ))" +prop_checkArithmeticDeref15 = verifyNot checkArithmeticDeref "(( ${!var} ))" +prop_checkArithmeticDeref16 = verifyNot checkArithmeticDeref "(( ${x+1} + ${x=42} ))" +checkArithmeticDeref params t@(TA_Expansion _ [T_DollarBraced id _ l]) = + unless (isException $ concat $ oversimplify l) getWarning + where + isException [] = True + isException s@(h:_) = any (`elem` "/.:#%?*@$-!+=^,") s || isDigit h + getWarning = fromMaybe noWarning . msum . NE.map warningFor $ parents params t + warningFor t = + case t of + T_Arithmetic {} -> return normalWarning + T_DollarArithmetic {} -> return normalWarning + T_ForArithmetic {} -> return normalWarning + T_Assignment {} -> return normalWarning + T_SimpleCommand {} -> return noWarning + _ -> Nothing + + normalWarning = style id 2004 "$/${} is unnecessary on arithmetic variables." + noWarning = return () +checkArithmeticDeref _ _ = return () + +prop_checkArithmeticBadOctal1 = verify checkArithmeticBadOctal "(( 0192 ))" +prop_checkArithmeticBadOctal2 = verifyNot checkArithmeticBadOctal "(( 0x192 ))" +prop_checkArithmeticBadOctal3 = verifyNot checkArithmeticBadOctal "(( 1 ^ 0777 ))" +checkArithmeticBadOctal _ t@(TA_Expansion id _) = sequence_ $ do + str <- getLiteralString t + guard $ str `matches` octalRE + return $ err id 2080 "Numbers with leading 0 are considered octal." + where + octalRE = mkRegex "^0[0-7]*[8-9]" +checkArithmeticBadOctal _ _ = return () + +prop_checkComparisonAgainstGlob = verify checkComparisonAgainstGlob "[[ $cow == $bar ]]" +prop_checkComparisonAgainstGlob2 = verifyNot checkComparisonAgainstGlob "[[ $cow == \"$bar\" ]]" +prop_checkComparisonAgainstGlob3 = verify checkComparisonAgainstGlob "[ $cow = *foo* ]" +prop_checkComparisonAgainstGlob4 = verifyNot checkComparisonAgainstGlob "[ $cow = foo ]" +prop_checkComparisonAgainstGlob5 = verify checkComparisonAgainstGlob "[[ $cow != $bar ]]" +prop_checkComparisonAgainstGlob6 = verify checkComparisonAgainstGlob "[ $f != /* ]" +prop_checkComparisonAgainstGlob7 = verify checkComparisonAgainstGlob "#!/bin/busybox sh\n[[ $f == *foo* ]]" +checkComparisonAgainstGlob _ (TC_Binary _ DoubleBracket op _ (T_NormalWord id [T_DollarBraced _ _ _])) + | op `elem` ["=", "==", "!="] = + warn id 2053 $ "Quote the right-hand side of " ++ op ++ " in [[ ]] to prevent glob matching." +checkComparisonAgainstGlob params (TC_Binary _ SingleBracket op _ word) + | op `elem` ["=", "==", "!="] && isGlob word = + err (getId word) 2081 msg + where + msg = if (shellType params) `elem` [Bash, Ksh] -- Busybox does not support glob matching + then "[ .. ] can't match globs. Use [[ .. ]] or case statement." + else "[ .. ] can't match globs. Use a case statement." + +checkComparisonAgainstGlob params (TC_Binary _ DoubleBracket op _ word) + | shellType params == BusyboxSh && op `elem` ["=", "==", "!="] && isGlob word = + err (getId word) 2330 "BusyBox [[ .. ]] does not support glob matching. Use a case statement." + +checkComparisonAgainstGlob _ _ = return () + +prop_checkCaseAgainstGlob1 = verify checkCaseAgainstGlob "case foo in lol$n) foo;; esac" +prop_checkCaseAgainstGlob2 = verify checkCaseAgainstGlob "case foo in $(foo)) foo;; esac" +prop_checkCaseAgainstGlob3 = verifyNot checkCaseAgainstGlob "case foo in *$bar*) foo;; esac" +checkCaseAgainstGlob _ t = + case t of + (T_CaseExpression _ _ cases) -> mapM_ check cases + _ -> return () + where + check (_, list, _) = mapM_ check' list + check' expr@(T_NormalWord _ list) + -- If it's already a glob, assume that's what the user wanted + | not (isGlob expr) && any isQuoteableExpansion list = + warn (getId expr) 2254 "Quote expansions in case patterns to match literally rather than as a glob." + check' _ = return () + +prop_checkCommarrays1 = verify checkCommarrays "a=(1, 2)" +prop_checkCommarrays2 = verify checkCommarrays "a+=(1,2,3)" +prop_checkCommarrays3 = verifyNot checkCommarrays "cow=(1 \"foo,bar\" 3)" +prop_checkCommarrays4 = verifyNot checkCommarrays "cow=('one,' 'two')" +prop_checkCommarrays5 = verify checkCommarrays "a=([a]=b, [c]=d)" +prop_checkCommarrays6 = verify checkCommarrays "a=([a]=b,[c]=d,[e]=f)" +prop_checkCommarrays7 = verify checkCommarrays "a=(1,2)" +checkCommarrays _ (T_Array id l) = + when (any (isCommaSeparated . literal) l) $ + warn id 2054 "Use spaces, not commas, to separate array elements." + where + literal (T_IndexedElement _ _ l) = literal l + literal (T_NormalWord _ l) = concatMap literal l + literal (T_Literal _ str) = str + literal _ = "" + + isCommaSeparated = elem ',' +checkCommarrays _ _ = return () + +prop_checkOrNeq1 = verify checkOrNeq "if [[ $lol -ne cow || $lol -ne foo ]]; then echo foo; fi" +prop_checkOrNeq2 = verify checkOrNeq "(( a!=lol || a!=foo ))" +prop_checkOrNeq3 = verify checkOrNeq "[ \"$a\" != lol || \"$a\" != foo ]" +prop_checkOrNeq4 = verifyNot checkOrNeq "[ a != $cow || b != $foo ]" +prop_checkOrNeq5 = verifyNot checkOrNeq "[[ $a != /home || $a != */public_html/* ]]" +prop_checkOrNeq6 = verify checkOrNeq "[ $a != a ] || [ $a != b ]" +prop_checkOrNeq7 = verify checkOrNeq "[ $a != a ] || [ $a != b ] || true" +prop_checkOrNeq8 = verifyNot checkOrNeq "[[ $a != x || $a != x ]]" +prop_checkOrNeq9 = verifyNot checkOrNeq "[ 0 -ne $FOO ] || [ 0 -ne $BAR ]" +-- This only catches the most idiomatic cases. Fixme? + +-- For test-level "or": [ x != y -o x != z ] +checkOrNeq _ (TC_Or id typ op (TC_Binary _ _ op1 lhs1 rhs1 ) (TC_Binary _ _ op2 lhs2 rhs2)) + | (op1 == op2 && (op1 == "-ne" || op1 == "!=")) && lhs1 == lhs2 && rhs1 /= rhs2 && not (any isGlob [rhs1,rhs2]) = + warn id 2055 $ "You probably wanted " ++ (if typ == SingleBracket then "-a" else "&&") ++ " here, otherwise it's always true." + +-- For arithmetic context "or" +checkOrNeq _ (TA_Binary id "||" (TA_Binary _ "!=" word1 _) (TA_Binary _ "!=" word2 _)) + | word1 == word2 = + warn id 2056 "You probably wanted && here, otherwise it's always true." + +-- For command level "or": [ x != y ] || [ x != z ] +checkOrNeq _ (T_OrIf id lhs rhs) = sequence_ $ do + (lhs1, op1, rhs1) <- getExpr lhs + (lhs2, op2, rhs2) <- getExpr rhs + guard $ op1 == op2 && op1 `elem` ["-ne", "!="] + guard $ lhs1 == lhs2 && rhs1 /= rhs2 + guard . not $ any isGlob [rhs1, rhs2] + return $ warn id 2252 "You probably wanted && here, otherwise it's always true." + where + getExpr x = + case x of + T_OrIf _ lhs _ -> getExpr lhs -- Fetches x and y in `T_OrIf x (T_OrIf y z)` + T_Pipeline _ _ [x] -> getExpr x + T_Redirecting _ _ c -> getExpr c + T_Condition _ _ c -> getExpr c + TC_Binary _ _ op lhs rhs -> orient (lhs, op, rhs) + _ -> Nothing + + -- Swap items so that the constant side is rhs (or Nothing if both/neither is constant) + orient (lhs, op, rhs) = + case (isConstant lhs, isConstant rhs) of + (True, False) -> return (rhs, op, lhs) + (False, True) -> return (lhs, op, rhs) + _ -> Nothing + + +checkOrNeq _ _ = return () + + +prop_checkAndEq1 = verifyNot checkAndEq "cow=0; foo=0; if [[ $lol -eq cow && $lol -eq foo ]]; then echo foo; fi" +prop_checkAndEq2 = verifyNot checkAndEq "lol=0 foo=0; (( a==lol && a==foo ))" +prop_checkAndEq3 = verify checkAndEq "[ \"$a\" = lol && \"$a\" = foo ]" +prop_checkAndEq4 = verifyNot checkAndEq "[ a = $cow && b = $foo ]" +prop_checkAndEq5 = verifyNot checkAndEq "[[ $a = /home && $a = */public_html/* ]]" +prop_checkAndEq6 = verify checkAndEq "[ $a = a ] && [ $a = b ]" +prop_checkAndEq7 = verify checkAndEq "[ $a = a ] && [ $a = b ] || true" +prop_checkAndEq8 = verifyNot checkAndEq "[[ $a == x && $a == x ]]" +prop_checkAndEq9 = verifyNot checkAndEq "[ 0 -eq $FOO ] && [ 0 -eq $BAR ]" +prop_checkAndEq10 = verify checkAndEq "(( a == 1 && a == 2 ))" +prop_checkAndEq11 = verify checkAndEq "[ $x -eq 1 ] && [ $x -eq 2 ]" +prop_checkAndEq12 = verify checkAndEq "[ 1 -eq $x ] && [ $x -eq 2 ]" +prop_checkAndEq13 = verifyNot checkAndEq "[ 1 -eq $x ] && [ $x -eq 1 ]" +prop_checkAndEq14 = verifyNot checkAndEq "[ $a = $b ] && [ $a = $c ]" + +checkAndEqOperands "-eq" rhs1 rhs2 = isLiteralNumber rhs1 && isLiteralNumber rhs2 +checkAndEqOperands op rhs1 rhs2 | op == "=" || op == "==" = isLiteral rhs1 && isLiteral rhs2 +checkAndEqOperands _ _ _ = False + +-- For test-level "and": [ x = y -a x = z ] +checkAndEq _ (TC_And id typ op (TC_Binary _ _ op1 lhs1 rhs1 ) (TC_Binary _ _ op2 lhs2 rhs2)) + | op1 == op2 && lhs1 == lhs2 && rhs1 /= rhs2 && checkAndEqOperands op1 rhs1 rhs2 = + warn id 2333 $ "You probably wanted " ++ (if typ == SingleBracket then "-o" else "||") ++ " here, otherwise it's always false." + +-- For arithmetic context "and" +checkAndEq _ (TA_Binary id "&&" (TA_Binary _ "==" lhs1 rhs1) (TA_Binary _ "==" lhs2 rhs2)) + | lhs1 == lhs2 && isLiteralNumber rhs1 && isLiteralNumber rhs2 = + warn id 2334 "You probably wanted || here, otherwise it's always false." + +-- For command level "and": [ x = y ] && [ x = z ] +checkAndEq _ (T_AndIf id lhs rhs) = sequence_ $ do + (lhs1, op1, rhs1) <- getExpr lhs + (lhs2, op2, rhs2) <- getExpr rhs + guard $ op1 == op2 + guard $ lhs1 == lhs2 && rhs1 /= rhs2 + guard $ checkAndEqOperands op1 rhs1 rhs2 + return $ warn id 2333 "You probably wanted || here, otherwise it's always false." + where + getExpr x = + case x of + T_AndIf _ lhs _ -> getExpr lhs -- Fetches x and y in `T_AndIf x (T_AndIf y z)` + T_Pipeline _ _ [x] -> getExpr x + T_Redirecting _ _ c -> getExpr c + T_Condition _ _ c -> getExpr c + TC_Binary _ _ op lhs rhs -> orient (lhs, op, rhs) + _ -> Nothing + + -- Swap items so that the constant side is rhs (or Nothing if both/neither is constant) + orient (lhs, op, rhs) = + case (isConstant lhs, isConstant rhs) of + (True, False) -> return (rhs, op, lhs) + (False, True) -> return (lhs, op, rhs) + _ -> Nothing + + +checkAndEq _ _ = return () + + +prop_checkValidCondOps1 = verify checkValidCondOps "[[ a -xz b ]]" +prop_checkValidCondOps2 = verify checkValidCondOps "[ -M a ]" +prop_checkValidCondOps2a = verifyNot checkValidCondOps "[ 3 \\> 2 ]" +prop_checkValidCondOps3 = verifyNot checkValidCondOps "[ 1 = 2 -a 3 -ge 4 ]" +prop_checkValidCondOps4 = verifyNot checkValidCondOps "[[ ! -v foo ]]" +checkValidCondOps _ (TC_Binary id _ s _ _) + | s `notElem` binaryTestOps = + warn id 2057 "Unknown binary operator." +checkValidCondOps _ (TC_Unary id _ s _) + | s `notElem` unaryTestOps = + warn id 2058 "Unknown unary operator." +checkValidCondOps _ _ = return () + +prop_checkUuoeVar1 = verify checkUuoeVar "for f in $(echo $tmp); do echo lol; done" +prop_checkUuoeVar2 = verify checkUuoeVar "date +`echo \"$format\"`" +prop_checkUuoeVar3 = verifyNot checkUuoeVar "foo \"$(echo -e '\r')\"" +prop_checkUuoeVar4 = verifyNot checkUuoeVar "echo $tmp" +prop_checkUuoeVar5 = verify checkUuoeVar "foo \"$(echo \"$(date) value:\" $value)\"" +prop_checkUuoeVar6 = verifyNot checkUuoeVar "foo \"$(echo files: *.png)\"" +prop_checkUuoeVar7 = verifyNot checkUuoeVar "foo $(echo $(bar))" -- covered by 2005 +prop_checkUuoeVar8 = verifyNot checkUuoeVar "#!/bin/sh\nz=$(echo)" +prop_checkUuoeVar9 = verify checkUuoeVar "foo $(echo $( check id cmd + T_DollarExpansion id [cmd] -> check id cmd + _ -> return () + where + couldBeOptimized f = case f of + T_Glob {} -> False + T_Extglob {} -> False + T_BraceExpansion {} -> False + T_NormalWord _ l -> all couldBeOptimized l + T_DoubleQuoted _ l -> all couldBeOptimized l + _ -> True + + check id (T_Pipeline _ _ [T_Redirecting _ _ c]) = warnForEcho id c + check _ _ = return () + isCovered first rest = null rest && tokenIsJustCommandOutput first + warnForEcho id = checkUnqualifiedCommand "echo" $ \_ vars -> + case vars of + (first:rest) -> + unless (isCovered first rest || "-" `isPrefixOf` onlyLiteralString first) $ + when (all couldBeOptimized vars) $ style id 2116 + "Useless echo? Instead of 'cmd $(echo foo)', just use 'cmd foo'." + _ -> return () + + +prop_checkTestRedirects1 = verify checkTestRedirects "test 3 > 1" +prop_checkTestRedirects2 = verifyNot checkTestRedirects "test 3 \\> 1" +prop_checkTestRedirects3 = verify checkTestRedirects "/usr/bin/test $var > $foo" +prop_checkTestRedirects4 = verifyNot checkTestRedirects "test 1 -eq 2 2> file" +checkTestRedirects _ (T_Redirecting id redirs cmd) | cmd `isCommand` "test" = + mapM_ check redirs + where + check t = + when (suspicious t) $ + warn (getId t) 2065 "This is interpreted as a shell file redirection, not a comparison." + suspicious t = -- Ignore redirections of stderr because these are valid for squashing e.g. int errors, + case t of -- and >> and similar redirections because these are probably not comparisons. + T_FdRedirect _ fd (T_IoFile _ op _) -> fd /= "2" && isComparison op + _ -> False + isComparison t = + case t of + T_Greater _ -> True + T_Less _ -> True + _ -> False +checkTestRedirects _ _ = return () + +prop_checkPS11 = verify checkPS1Assignments "PS1='\\033[1;35m\\$ '" +prop_checkPS11a = verify checkPS1Assignments "export PS1='\\033[1;35m\\$ '" +prop_checkPSf2 = verify checkPS1Assignments "PS1='\\h \\e[0m\\$ '" +prop_checkPS13 = verify checkPS1Assignments "PS1=$'\\x1b[c '" +prop_checkPS14 = verify checkPS1Assignments "PS1=$'\\e[3m; '" +prop_checkPS14a = verify checkPS1Assignments "export PS1=$'\\e[3m; '" +prop_checkPS15 = verifyNot checkPS1Assignments "PS1='\\[\\033[1;35m\\]\\$ '" +prop_checkPS16 = verifyNot checkPS1Assignments "PS1='\\[\\e1m\\e[1m\\]\\$ '" +prop_checkPS17 = verifyNot checkPS1Assignments "PS1='e033x1B'" +prop_checkPS18 = verifyNot checkPS1Assignments "PS1='\\[\\e\\]'" +checkPS1Assignments _ (T_Assignment _ _ "PS1" _ word) = warnFor word + where + warnFor word = + let contents = concat $ oversimplify word in + when (containsUnescaped contents) $ + info (getId word) 2025 "Make sure all escape sequences are enclosed in \\[..\\] to prevent line wrapping issues" + containsUnescaped s = + let unenclosed = subRegex enclosedRegex s "" in + isJust $ matchRegex escapeRegex unenclosed + enclosedRegex = mkRegex "\\\\\\[.*\\\\\\]" -- FIXME: shouldn't be eager + escapeRegex = mkRegex "\\\\x1[Bb]|\\\\e|\x1B|\\\\033" +checkPS1Assignments _ _ = return () + +prop_checkBackticks1 = verify checkBackticks "echo `foo`" +prop_checkBackticks2 = verifyNot checkBackticks "echo $(foo)" +prop_checkBackticks3 = verifyNot checkBackticks "echo `#inlined comment` foo" +checkBackticks params (T_Backticked id list) | not (null list) = + addComment $ + makeCommentWithFix StyleC id 2006 "Use $(...) notation instead of legacy backticks `...`." + (fixWith [replaceStart id params 1 "$(", replaceEnd id params 1 ")"]) +checkBackticks _ _ = return () + + +prop_checkBadParameterSubstitution1 = verify checkBadParameterSubstitution "${foo$n}" +prop_checkBadParameterSubstitution2 = verifyNot checkBadParameterSubstitution "${foo//$n/lol}" +prop_checkBadParameterSubstitution3 = verify checkBadParameterSubstitution "${$#}" +prop_checkBadParameterSubstitution4 = verify checkBadParameterSubstitution "${var${n}_$((i%2))}" +prop_checkBadParameterSubstitution5 = verifyNot checkBadParameterSubstitution "${bar}" +prop_checkBadParameterSubstitution6 = verify checkBadParameterSubstitution "${\"bar\"}" +prop_checkBadParameterSubstitution7 = verify checkBadParameterSubstitution "${{var}" +prop_checkBadParameterSubstitution8 = verify checkBadParameterSubstitution "${$(x)//x/y}" +prop_checkBadParameterSubstitution9 = verifyNot checkBadParameterSubstitution "$# ${#} $! ${!} ${!#} ${#!}" +prop_checkBadParameterSubstitution10 = verify checkBadParameterSubstitution "${'foo'}" +prop_checkBadParameterSubstitution11 = verify checkBadParameterSubstitution "${${x%.*}##*/}" + +checkBadParameterSubstitution _ t = + case t of + (T_DollarBraced i _ (T_NormalWord _ contents@(first:_))) -> + if isIndirection contents + then err i 2082 "To expand via indirection, use arrays, ${!name} or (for sh only) eval." + else checkFirst first + _ -> return () + + where + + isIndirection vars = + let list = mapMaybe isIndirectionPart vars in + not (null list) && and list + + isIndirectionPart t = + case t of T_DollarExpansion {} -> Just True + T_Backticked {} -> Just True + T_DollarBraced {} -> Just True + T_DollarArithmetic {} -> Just True + T_Literal _ s -> if all isVariableChar s + then Nothing + else Just False + _ -> Just False + + checkFirst t = + case t of + T_Literal id (c:_) -> + if isVariableChar c || isSpecialVariableChar c + then return () + else err id 2296 $ "Parameter expansions can't start with " ++ e4m [c] ++ ". Double check syntax." + + T_ParamSubSpecialChar {} -> return () + + T_DoubleQuoted id [T_Literal _ s] | isVariable s -> + err id 2297 "Double quotes must be outside ${}: ${\"invalid\"} vs \"${valid}\"." + + T_DollarBraced id braces _ | isUnmodifiedParameterExpansion t -> + err id 2298 $ + (if braces then "${${x}}" else "${$x}") + ++ " is invalid. For expansion, use ${x}. For indirection, use arrays, ${!x} or (for sh) eval." + + T_DollarBraced {} -> + err (getId t) 2299 "Parameter expansions can't be nested. Use temporary variables." + + _ | isCommandSubstitution t -> + err (getId t) 2300 "Parameter expansion can't be applied to command substitutions. Use temporary variables." + + _ -> err (getId t) 2301 $ "Parameter expansion starts with unexpected " ++ name t ++ ". Double check syntax." + + isVariable str = + case str of + [c] -> isVariableStartChar c || isSpecialVariableChar c || isDigit c + x -> isVariableName x + + name t = + case t of + T_SingleQuoted {} -> "quotes" + T_DoubleQuoted {} -> "quotes" + _ -> "syntax" + + +prop_checkInexplicablyUnquoted1 = verify checkInexplicablyUnquoted "echo 'var='value';'" +prop_checkInexplicablyUnquoted2 = verifyNot checkInexplicablyUnquoted "'foo'*" +prop_checkInexplicablyUnquoted3 = verifyNot checkInexplicablyUnquoted "wget --user-agent='something'" +prop_checkInexplicablyUnquoted4 = verify checkInexplicablyUnquoted "echo \"VALUES (\"id\")\"" +prop_checkInexplicablyUnquoted5 = verifyNot checkInexplicablyUnquoted "\"$dir\"/\"$file\"" +prop_checkInexplicablyUnquoted6 = verifyNot checkInexplicablyUnquoted "\"$dir\"some_stuff\"$file\"" +prop_checkInexplicablyUnquoted7 = verifyNot checkInexplicablyUnquoted "${dir/\"foo\"/\"bar\"}" +prop_checkInexplicablyUnquoted8 = verifyNot checkInexplicablyUnquoted " 'foo'\\\n 'bar'" +prop_checkInexplicablyUnquoted9 = verifyNot checkInexplicablyUnquoted "[[ $x =~ \"foo\"(\"bar\"|\"baz\") ]]" +prop_checkInexplicablyUnquoted10 = verifyNot checkInexplicablyUnquoted "cmd ${x+--name=\"$x\" --output=\"$x.out\"}" +prop_checkInexplicablyUnquoted11 = verifyNot checkInexplicablyUnquoted "echo \"foo\"/\"bar\"" +prop_checkInexplicablyUnquoted12 = verifyNot checkInexplicablyUnquoted "declare \"foo\"=\"bar\"" +checkInexplicablyUnquoted params (T_NormalWord id tokens) = mapM_ check (tails tokens) + where + check (T_SingleQuoted _ _:T_Literal id str:_) + | not (null str) && all isAlphaNum str = + info id 2026 "This word is outside of quotes. Did you intend to 'nest '\"'single quotes'\"' instead'? " + + check (T_DoubleQuoted _ a:trapped:T_DoubleQuoted _ b:_) = + case trapped of + T_DollarExpansion id _ -> warnAboutExpansion id + T_DollarBraced id _ _ -> warnAboutExpansion id + T_Literal id s + | not (quotesSingleThing a && quotesSingleThing b + || s `elem` ["=", ":", "/"] + || isSpecial (NE.toList $ getPath (parentMap params) trapped) + ) -> + warnAboutLiteral id + _ -> return () + + check _ = return () + + -- Regexes for [[ .. =~ re ]] are parsed with metacharacters like ()| as unquoted + -- literals. The same is true for ${x+"foo" "bar"}. Avoid overtriggering on these. + isSpecial t = + case t of + (T_Redirecting {} : _) -> False + T_DollarBraced {} : _ -> True + (a:(TC_Binary _ _ "=~" lhs rhs):rest) -> getId a == getId rhs + _:rest -> isSpecial rest + _ -> False + + -- If the surrounding quotes quote single things, like "$foo"_and_then_some_"$stuff", + -- the quotes were probably intentional and harmless. + quotesSingleThing x = case x of + [T_DollarExpansion _ _] -> True + [T_DollarBraced _ _ _] -> True + [T_Backticked _ _] -> True + _ -> False + + warnAboutExpansion id = + warn id 2027 "The surrounding quotes actually unquote this. Remove or escape them." + warnAboutLiteral id = + warn id 2140 "Word is of the form \"A\"B\"C\" (B indicated). Did you mean \"ABC\" or \"A\\\"B\\\"C\"?" +checkInexplicablyUnquoted _ _ = return () + +prop_checkTildeInQuotes1 = verify checkTildeInQuotes "var=\"~/out.txt\"" +prop_checkTildeInQuotes2 = verify checkTildeInQuotes "foo > '~/dir'" +prop_checkTildeInQuotes4 = verifyNot checkTildeInQuotes "~/file" +prop_checkTildeInQuotes5 = verifyNot checkTildeInQuotes "echo '/~foo/cow'" +prop_checkTildeInQuotes6 = verifyNot checkTildeInQuotes "awk '$0 ~ /foo/'" +checkTildeInQuotes _ = check + where + verify id ('~':'/':_) = warn id 2088 "Tilde does not expand in quotes. Use $HOME." + verify _ _ = return () + check (T_NormalWord _ (T_SingleQuoted id str:_)) = + verify id str + check (T_NormalWord _ (T_DoubleQuoted _ (T_Literal id str:_):_)) = + verify id str + check _ = return () + +prop_checkLonelyDotDash1 = verify checkLonelyDotDash "./ file" +prop_checkLonelyDotDash2 = verifyNot checkLonelyDotDash "./file" +checkLonelyDotDash _ t@(T_Redirecting id _ _) + | isUnqualifiedCommand t "./" = + err id 2083 "Don't add spaces after the slash in './file'." +checkLonelyDotDash _ _ = return () + + +prop_checkSpuriousExec1 = verify checkSpuriousExec "exec foo; true" +prop_checkSpuriousExec2 = verify checkSpuriousExec "if a; then exec b; exec c; fi" +prop_checkSpuriousExec3 = verifyNot checkSpuriousExec "echo cow; exec foo" +prop_checkSpuriousExec4 = verifyNot checkSpuriousExec "if a; then exec b; fi" +prop_checkSpuriousExec5 = verifyNot checkSpuriousExec "exec > file; cmd" +prop_checkSpuriousExec6 = verify checkSpuriousExec "exec foo > file; cmd" +prop_checkSpuriousExec7 = verifyNot checkSpuriousExec "exec file; echo failed; exit 3" +prop_checkSpuriousExec8 = verifyNot checkSpuriousExec "exec {origout}>&1- >tmp.log 2>&1; bar" +prop_checkSpuriousExec9 = verify checkSpuriousExec "for file in rc.d/*; do exec \"$file\"; done" +prop_checkSpuriousExec10 = verifyNot checkSpuriousExec "exec file; r=$?; printf >&2 'failed\n'; return $r" +prop_checkSpuriousExec11 = verifyNot checkSpuriousExec "exec file; :" +prop_checkSpuriousExec12 = verifyNot checkSpuriousExec "#!/bin/bash\nshopt -s execfail; exec foo; exec bar; echo 'Error'; exit 1;" +prop_checkSpuriousExec13 = verify checkSpuriousExec "#!/bin/dash\nshopt -s execfail; exec foo; exec bar; echo 'Error'; exit 1;" +checkSpuriousExec params t = when (not $ hasExecfail params) $ doLists t + where + doLists (T_Script _ _ cmds) = doList cmds False + doLists (T_BraceGroup _ cmds) = doList cmds False + doLists (T_WhileExpression _ _ cmds) = doList cmds True + doLists (T_UntilExpression _ _ cmds) = doList cmds True + doLists (T_ForIn _ _ _ cmds) = doList cmds True + doLists (T_ForArithmetic _ _ _ _ cmds) = doList cmds True + doLists (T_IfExpression _ thens elses) = do + mapM_ (\(_, l) -> doList l False) thens + doList elses False + doLists _ = return () + + stripCleanup = reverse . dropWhile cleanup . reverse + cleanup (T_Pipeline _ _ [cmd]) = + isCommandMatch cmd (`elem` [":", "echo", "exit", "printf", "return"]) + || isAssignment cmd + cleanup _ = False + + doList = doList' . stripCleanup + -- The second parameter is True if we are in a loop + -- In that case we should emit the warning also if `exec' is the last statement + doList' (current:t@(following:_)) False = do + commentIfExec current + doList t False + doList' (current:tail) True = do + commentIfExec current + doList tail True + doList' _ _ = return () + + commentIfExec (T_Pipeline id _ [c]) = commentIfExec c + commentIfExec (T_Redirecting _ _ (T_SimpleCommand id _ (cmd:additionalArg:_))) | + getLiteralString cmd == Just "exec" = + warn id 2093 "Remove \"exec \" if script should continue after this command." + commentIfExec _ = return () + + +prop_checkSpuriousExpansion1 = verify checkSpuriousExpansion "if $(true); then true; fi" +prop_checkSpuriousExpansion3 = verifyNot checkSpuriousExpansion "$(cmd) --flag1 --flag2" +prop_checkSpuriousExpansion4 = verify checkSpuriousExpansion "$((i++))" +checkSpuriousExpansion _ (T_SimpleCommand _ _ [T_NormalWord _ [word]]) = check word + where + check word = case word of + T_DollarExpansion id _ -> + warn id 2091 "Remove surrounding $() to avoid executing output (or use eval if intentional)." + T_Backticked id _ -> + warn id 2092 "Remove backticks to avoid executing output (or use eval if intentional)." + T_DollarArithmetic id _ -> + err id 2084 "Remove '$' or use '_=$((expr))' to avoid executing output." + _ -> return () +checkSpuriousExpansion _ _ = return () + + +prop_checkDollarBrackets1 = verify checkDollarBrackets "echo $[1+2]" +prop_checkDollarBrackets2 = verifyNot checkDollarBrackets "echo $((1+2))" +checkDollarBrackets _ (T_DollarBracket id _) = + style id 2007 "Use $((..)) instead of deprecated $[..]" +checkDollarBrackets _ _ = return () + +prop_checkSshHereDoc1 = verify checkSshHereDoc "ssh host << foo\necho $PATH\nfoo" +prop_checkSshHereDoc2 = verifyNot checkSshHereDoc "ssh host << 'foo'\necho $PATH\nfoo" +checkSshHereDoc _ (T_Redirecting _ redirs cmd) + | cmd `isCommand` "ssh" = + mapM_ checkHereDoc redirs + where + hasVariables = mkRegex "[`$]" + checkHereDoc (T_FdRedirect _ _ (T_HereDoc id _ Unquoted token tokens)) + | not (all isConstant tokens) = + warn id 2087 $ "Quote '" ++ (e4m token) ++ "' to make here document expansions happen on the server side rather than on the client." + checkHereDoc _ = return () +checkSshHereDoc _ _ = return () + +--- Subshell detection +prop_subshellAssignmentCheck = verifyTree subshellAssignmentCheck "cat foo | while read bar; do a=$bar; done; echo \"$a\"" +prop_subshellAssignmentCheck2 = verifyNotTree subshellAssignmentCheck "while read bar; do a=$bar; done < file; echo \"$a\"" +prop_subshellAssignmentCheck3 = verifyTree subshellAssignmentCheck "( A=foo; ); rm $A" +prop_subshellAssignmentCheck4 = verifyNotTree subshellAssignmentCheck "( A=foo; rm $A; )" +prop_subshellAssignmentCheck5 = verifyTree subshellAssignmentCheck "cat foo | while read cow; do true; done; echo $cow;" +prop_subshellAssignmentCheck6 = verifyTree subshellAssignmentCheck "( export lol=$(ls); ); echo $lol;" +prop_subshellAssignmentCheck6a = verifyTree subshellAssignmentCheck "( typeset -a lol=a; ); echo $lol;" +prop_subshellAssignmentCheck7 = verifyTree subshellAssignmentCheck "cmd | while read foo; do (( n++ )); done; echo \"$n lines\"" +prop_subshellAssignmentCheck8 = verifyTree subshellAssignmentCheck "n=3 & echo $((n++))" +prop_subshellAssignmentCheck9 = verifyTree subshellAssignmentCheck "read n & n=foo$n" +prop_subshellAssignmentCheck10 = verifyTree subshellAssignmentCheck "(( n <<= 3 )) & (( n |= 4 )) &" +prop_subshellAssignmentCheck11 = verifyTree subshellAssignmentCheck "cat /etc/passwd | while read line; do let n=n+1; done\necho $n" +prop_subshellAssignmentCheck12 = verifyTree subshellAssignmentCheck "cat /etc/passwd | while read line; do let ++n; done\necho $n" +prop_subshellAssignmentCheck13 = verifyTree subshellAssignmentCheck "#!/bin/bash\necho foo | read bar; echo $bar" +prop_subshellAssignmentCheck14 = verifyNotTree subshellAssignmentCheck "#!/bin/ksh93\necho foo | read bar; echo $bar" +prop_subshellAssignmentCheck15 = verifyNotTree subshellAssignmentCheck "#!/bin/ksh\ncat foo | while read bar; do a=$bar; done\necho \"$a\"" +prop_subshellAssignmentCheck16 = verifyNotTree subshellAssignmentCheck "(set -e); echo $@" +prop_subshellAssignmentCheck17 = verifyNotTree subshellAssignmentCheck "foo=${ { bar=$(baz); } 2>&1; }; echo $foo $bar" +prop_subshellAssignmentCheck18 = verifyTree subshellAssignmentCheck "( exec {n}>&2; ); echo $n" +prop_subshellAssignmentCheck19 = verifyNotTree subshellAssignmentCheck "#!/bin/bash\nshopt -s lastpipe; echo a | read -r b; echo \"$b\"" +prop_subshellAssignmentCheck20 = verifyTree subshellAssignmentCheck "@test 'foo' { a=1; }\n@test 'bar' { echo $a; }\n" +prop_subshellAssignmentCheck21 = verifyNotTree subshellAssignmentCheck "test1() { echo foo | if [[ $var ]]; then echo $var; fi; }; test2() { echo $var; }" +prop_subshellAssignmentCheck22 = verifyNotTree subshellAssignmentCheck "( [[ -n $foo || -z $bar ]] ); echo $foo $bar" +prop_subshellAssignmentCheck23 = verifyNotTree subshellAssignmentCheck "( export foo ); echo $foo" +subshellAssignmentCheck params t = + let flow = variableFlow params + check = findSubshelled flow [("oops",[])] Map.empty + in execWriter check + + +findSubshelled [] _ _ = return () +findSubshelled (Assignment x@(_, _, str, data_):rest) scopes@((reason,scope):restscope) deadVars = + if isTrueAssignmentSource data_ + then findSubshelled rest ((reason, x:scope):restscope) $ Map.insert str Alive deadVars + else findSubshelled rest scopes deadVars + +findSubshelled (Reference (_, readToken, str):rest) scopes deadVars = do + unless (shouldIgnore str) $ case Map.findWithDefault Alive str deadVars of + Alive -> return () + Dead writeToken reason -> do + info (getId writeToken) 2030 $ "Modification of " ++ str ++ " is local (to subshell caused by "++ reason ++")." + info (getId readToken) 2031 $ str ++ " was modified in a subshell. That change might be lost." + findSubshelled rest scopes deadVars + where + shouldIgnore str = + str `elem` ["@", "*", "IFS"] + +findSubshelled (StackScope (SubshellScope reason):rest) scopes deadVars = + findSubshelled rest ((reason,[]):scopes) deadVars + +findSubshelled (StackScopeEnd:rest) ((reason, scope):oldScopes) deadVars = + findSubshelled rest oldScopes $ + foldl (\m (_, token, var, _) -> + Map.insert var (Dead token reason) m) deadVars scope + + +-- FIXME: This is a very strange way of doing it. +-- For each variable read/write, run a stateful function that emits +-- comments. The comments are collected and returned. +doVariableFlowAnalysis :: + (Token -> Token -> String -> State t [v]) + -> (Token -> Token -> String -> DataType -> State t [v]) + -> t + -> [StackData] + -> [v] + +doVariableFlowAnalysis readFunc writeFunc empty flow = evalState ( + foldM (\list x -> do { l <- doFlow x; return $ l ++ list; }) [] flow + ) empty + where + doFlow (Reference (base, token, name)) = + readFunc base token name + doFlow (Assignment (base, token, name, values)) = + writeFunc base token name values + doFlow _ = return [] + +-- Don't suggest quotes if this will instead be autocorrected +-- from $foo=bar to foo=bar. This is not pretty but ok. +quotesMayConflictWithSC2281 params t = + case getPath (parentMap params) t of + _ NE.:| T_NormalWord parentId (me:T_Literal _ ('=':_):_) : T_SimpleCommand _ _ (cmd:_) : _ -> + (getId t) == (getId me) && (parentId == getId cmd) + _ -> False + +addDoubleQuotesAround params token = (surroundWith (getId token) params "\"") + +prop_checkSpacefulnessCfg1 = verify checkSpacefulnessCfg "a='cow moo'; echo $a" +prop_checkSpacefulnessCfg2 = verifyNot checkSpacefulnessCfg "a='cow moo'; [[ $a ]]" +prop_checkSpacefulnessCfg3 = verifyNot checkSpacefulnessCfg "a='cow*.mp3'; echo \"$a\"" +prop_checkSpacefulnessCfg4 = verify checkSpacefulnessCfg "for f in *.mp3; do echo $f; done" +prop_checkSpacefulnessCfg4a = verifyNot checkSpacefulnessCfg "foo=3; foo=$(echo $foo)" +prop_checkSpacefulnessCfg5 = verify checkSpacefulnessCfg "a='*'; b=$a; c=lol${b//foo/bar}; echo $c" +prop_checkSpacefulnessCfg6 = verify checkSpacefulnessCfg "a=foo$(lol); echo $a" +prop_checkSpacefulnessCfg7 = verify checkSpacefulnessCfg "a=foo\\ bar; rm $a" +prop_checkSpacefulnessCfg8 = verifyNot checkSpacefulnessCfg "a=foo\\ bar; a=foo; rm $a" +prop_checkSpacefulnessCfg10 = verify checkSpacefulnessCfg "rm $1" +prop_checkSpacefulnessCfg11 = verify checkSpacefulnessCfg "rm ${10//foo/bar}" +prop_checkSpacefulnessCfg12 = verifyNot checkSpacefulnessCfg "(( $1 + 3 ))" +prop_checkSpacefulnessCfg13 = verifyNot checkSpacefulnessCfg "if [[ $2 -gt 14 ]]; then true; fi" +prop_checkSpacefulnessCfg14 = verifyNot checkSpacefulnessCfg "foo=$3 env" +prop_checkSpacefulnessCfg15 = verifyNot checkSpacefulnessCfg "local foo=$1" +prop_checkSpacefulnessCfg16 = verifyNot checkSpacefulnessCfg "declare foo=$1" +prop_checkSpacefulnessCfg17 = verify checkSpacefulnessCfg "echo foo=$1" +prop_checkSpacefulnessCfg18 = verifyNot checkSpacefulnessCfg "$1 --flags" +prop_checkSpacefulnessCfg19 = verify checkSpacefulnessCfg "echo $PWD" +prop_checkSpacefulnessCfg20 = verifyNot checkSpacefulnessCfg "n+='foo bar'" +prop_checkSpacefulnessCfg21 = verifyNot checkSpacefulnessCfg "select foo in $bar; do true; done" +prop_checkSpacefulnessCfg22 = verifyNot checkSpacefulnessCfg "echo $\"$1\"" +prop_checkSpacefulnessCfg23 = verifyNot checkSpacefulnessCfg "a=(1); echo ${a[@]}" +prop_checkSpacefulnessCfg24 = verify checkSpacefulnessCfg "a='a b'; cat <<< $a" +prop_checkSpacefulnessCfg25 = verify checkSpacefulnessCfg "a='s/[0-9]//g'; sed $a" +prop_checkSpacefulnessCfg26 = verify checkSpacefulnessCfg "a='foo bar'; echo {1,2,$a}" +prop_checkSpacefulnessCfg27 = verifyNot checkSpacefulnessCfg "echo ${a:+'foo'}" +prop_checkSpacefulnessCfg28 = verifyNot checkSpacefulnessCfg "exec {n}>&1; echo $n" +prop_checkSpacefulnessCfg29 = verifyNot checkSpacefulnessCfg "n=$(stuff); exec {n}>&-;" +prop_checkSpacefulnessCfg30 = verify checkSpacefulnessCfg "file='foo bar'; echo foo > $file;" +prop_checkSpacefulnessCfg31 = verifyNot checkSpacefulnessCfg "echo \"`echo \\\"$1\\\"`\"" +prop_checkSpacefulnessCfg32 = verifyNot checkSpacefulnessCfg "var=$1; [ -v var ]" +prop_checkSpacefulnessCfg33 = verify checkSpacefulnessCfg "for file; do echo $file; done" +prop_checkSpacefulnessCfg34 = verify checkSpacefulnessCfg "declare foo$n=$1" +prop_checkSpacefulnessCfg35 = verifyNot checkSpacefulnessCfg "echo ${1+\"$1\"}" +prop_checkSpacefulnessCfg36 = verifyNot checkSpacefulnessCfg "arg=$#; echo $arg" +prop_checkSpacefulnessCfg37 = verifyNot checkSpacefulnessCfg "@test 'status' {\n [ $status -eq 0 ]\n}" +prop_checkSpacefulnessCfg37v = verify checkVerboseSpacefulnessCfg "@test 'status' {\n [ $status -eq 0 ]\n}" +prop_checkSpacefulnessCfg38 = verify checkSpacefulnessCfg "a=; echo $a" +prop_checkSpacefulnessCfg39 = verifyNot checkSpacefulnessCfg "a=''\"\"''; b=x$a; echo $b" +prop_checkSpacefulnessCfg40 = verifyNot checkSpacefulnessCfg "a=$((x+1)); echo $a" +prop_checkSpacefulnessCfg41 = verifyNot checkSpacefulnessCfg "exec $1 --flags" +prop_checkSpacefulnessCfg42 = verifyNot checkSpacefulnessCfg "run $1 --flags" +prop_checkSpacefulnessCfg43 = verifyNot checkSpacefulnessCfg "$foo=42" +prop_checkSpacefulnessCfg44 = verify checkSpacefulnessCfg "#!/bin/sh\nexport var=$value" +prop_checkSpacefulnessCfg45 = verifyNot checkSpacefulnessCfg "wait -zzx -p foo; echo $foo" +prop_checkSpacefulnessCfg46 = verifyNot checkSpacefulnessCfg "x=0; (( x += 1 )); echo $x" +prop_checkSpacefulnessCfg47 = verifyNot checkSpacefulnessCfg "x=0; (( x-- )); echo $x" +prop_checkSpacefulnessCfg48 = verifyNot checkSpacefulnessCfg "x=0; (( ++x )); echo $x" +prop_checkSpacefulnessCfg49 = verifyNot checkSpacefulnessCfg "for i in 1 2 3; do echo $i; done" +prop_checkSpacefulnessCfg50 = verify checkSpacefulnessCfg "for i in 1 2 *; do echo $i; done" +prop_checkSpacefulnessCfg51 = verify checkSpacefulnessCfg "x='foo bar'; x && x=1; echo $x" +prop_checkSpacefulnessCfg52 = verifyNot checkSpacefulnessCfg "x=1; if f; then x='foo bar'; exit; fi; echo $x" +prop_checkSpacefulnessCfg53 = verifyNot checkSpacefulnessCfg "s=1; f() { local s='a b'; }; f; echo $s" +prop_checkSpacefulnessCfg54 = verifyNot checkSpacefulnessCfg "s='a b'; f() { s=1; }; f; echo $s" +prop_checkSpacefulnessCfg55 = verify checkSpacefulnessCfg "s='a b'; x && f() { s=1; }; f; echo $s" +prop_checkSpacefulnessCfg56 = verifyNot checkSpacefulnessCfg "s=1; cat <(s='a b'); echo $s" +prop_checkSpacefulnessCfg57 = verifyNot checkSpacefulnessCfg "declare -i s=0; s=$(f); echo $s" +prop_checkSpacefulnessCfg58 = verify checkSpacefulnessCfg "f() { declare -i s; }; f; s=$(var); echo $s" +prop_checkSpacefulnessCfg59 = verifyNot checkSpacefulnessCfg "f() { declare -gi s; }; f; s=$(var); echo $s" +prop_checkSpacefulnessCfg60 = verify checkSpacefulnessCfg "declare -i s; declare +i s; s=$(foo); echo $s" +prop_checkSpacefulnessCfg61 = verify checkSpacefulnessCfg "declare -x X; y=foo$X; echo $y;" +prop_checkSpacefulnessCfg62 = verifyNot checkSpacefulnessCfg "f() { declare -x X; y=foo$X; echo $y; }" +prop_checkSpacefulnessCfg63 = verify checkSpacefulnessCfg "f && declare -i s; s='x + y'; echo $s" +prop_checkSpacefulnessCfg64 = verifyNot checkSpacefulnessCfg "declare -i s; s='x + y'; x=$s; echo $x" +prop_checkSpacefulnessCfg65 = verifyNot checkSpacefulnessCfg "f() { s=$?; echo $s; }; f" +prop_checkSpacefulnessCfg66 = verifyNot checkSpacefulnessCfg "f() { s=$?; echo $s; }" + +checkSpacefulnessCfg = checkSpacefulnessCfg' True +checkVerboseSpacefulnessCfg = checkSpacefulnessCfg' False + +checkSpacefulnessCfg' :: Bool -> (Parameters -> Token -> Writer [TokenComment] ()) +checkSpacefulnessCfg' dirtyPass params token@(T_DollarBraced id _ list) = + when (needsQuoting && (dirtyPass == not isClean)) $ + unless (name `elem` specialVariablesWithoutSpaces || quotesMayConflictWithSC2281 params token) $ + if dirtyPass + then + if isDefaultAssignment (parentMap params) token + then + info (getId token) 2223 + "This default assignment may cause DoS due to globbing. Quote it." + else + infoWithFix id 2086 "Double quote to prevent globbing and word splitting." $ + addDoubleQuotesAround params token + else + styleWithFix id 2248 "Prefer double quoting even when variables don't contain special characters." $ + addDoubleQuotesAround params token + + where + bracedString = concat $ oversimplify list + name = getBracedReference bracedString + parents = parentMap params + needsQuoting = + not (isArrayExpansion token) -- There's another warning for this + && not (isCountingReference token) + && not (isQuoteFree (shellType params) parents token) + && not (isQuotedAlternativeReference token) + && not (usedAsCommandName parents token) + + isClean = fromMaybe False $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga id + value <- Map.lookup name $ CF.variablesInScope state + return $ isCleanState value + + isCleanState state = + (all (S.member CFVPInteger) $ CF.variableProperties state) + || CF.spaceStatus (CF.variableValue state) == CF.SpaceStatusClean + + isDefaultAssignment parents token = + let modifier = getBracedModifier bracedString in + any (`isPrefixOf` modifier) ["=", ":="] + && isParamTo parents ":" token + +checkSpacefulnessCfg' _ _ _ = return () + + +prop_CheckVariableBraces1 = verify checkVariableBraces "a='123'; echo $a" +prop_CheckVariableBraces2 = verifyNot checkVariableBraces "a='123'; echo ${a}" +prop_CheckVariableBraces3 = verifyNot checkVariableBraces "#shellcheck disable=SC2016\necho '$a'" +prop_CheckVariableBraces4 = verifyNot checkVariableBraces "echo $* $1" +prop_CheckVariableBraces5 = verifyNot checkVariableBraces "$foo=42" +checkVariableBraces params t@(T_DollarBraced id False l) + | name `notElem` unbracedVariables && not (quotesMayConflictWithSC2281 params t) = + styleWithFix id 2250 + "Prefer putting braces around variable references even when not strictly required." + (fixFor t) + where + name = getBracedReference $ concat $ oversimplify l + fixFor token = fixWith [replaceStart (getId token) params 1 "${" + ,replaceEnd (getId token) params 0 "}"] +checkVariableBraces _ _ = return () + +prop_checkQuotesInLiterals1 = verifyTree checkQuotesInLiterals "param='--foo=\"bar\"'; app $param" +prop_checkQuotesInLiterals1a = verifyTree checkQuotesInLiterals "param=\"--foo='lolbar'\"; app $param" +prop_checkQuotesInLiterals2 = verifyNotTree checkQuotesInLiterals "param='--foo=\"bar\"'; app \"$param\"" +prop_checkQuotesInLiterals3 =verifyNotTree checkQuotesInLiterals "param=('--foo='); app \"${param[@]}\"" +prop_checkQuotesInLiterals4 = verifyNotTree checkQuotesInLiterals "param=\"don't bother with this one\"; app $param" +prop_checkQuotesInLiterals5 = verifyNotTree checkQuotesInLiterals "param=\"--foo='lolbar'\"; eval app $param" +prop_checkQuotesInLiterals6 = verifyTree checkQuotesInLiterals "param='my\\ file'; cmd=\"rm $param\"; $cmd" +prop_checkQuotesInLiterals6a = verifyNotTree checkQuotesInLiterals "param='my\\ file'; cmd=\"rm ${#param}\"; $cmd" +prop_checkQuotesInLiterals7 = verifyTree checkQuotesInLiterals "param='my\\ file'; rm $param" +prop_checkQuotesInLiterals8 = verifyTree checkQuotesInLiterals "param=\"/foo/'bar baz'/etc\"; rm $param" +prop_checkQuotesInLiterals9 = verifyNotTree checkQuotesInLiterals "param=\"/foo/'bar baz'/etc\"; rm ${#param}" +checkQuotesInLiterals params t = + doVariableFlowAnalysis readF writeF Map.empty (variableFlow params) + where + getQuotes name = gets (Map.lookup name) + setQuotes name ref = modify $ Map.insert name ref + deleteQuotes = modify . Map.delete + parents = parentMap params + quoteRegex = mkRegex "\"|([/= ]|^)'|'( |$)|\\\\ " + containsQuotes s = s `matches` quoteRegex + + writeF _ _ name (DataString (SourceFrom values)) = do + quoteMap <- get + let quotedVars = msum $ map (forToken quoteMap) values + case quotedVars of + Nothing -> deleteQuotes name + Just x -> setQuotes name x + return [] + writeF _ _ _ _ = return [] + + forToken map (T_DollarBraced id _ t) = + -- skip getBracedReference here to avoid false positives on PE + Map.lookup (concat . oversimplify $ t) map + forToken quoteMap (T_DoubleQuoted id tokens) = + msum $ map (forToken quoteMap) tokens + forToken quoteMap (T_NormalWord id tokens) = + msum $ map (forToken quoteMap) tokens + forToken _ t = + if containsQuotes (concat $ oversimplify t) + then return $ getId t + else Nothing + + squashesQuotes t = + case t of + T_DollarBraced id _ l -> "#" `isPrefixOf` concat (oversimplify l) + _ -> False + + readF _ expr name = do + assignment <- getQuotes name + return $ case assignment of + Just j + | not (isParamTo parents "eval" expr) + && not (isQuoteFree (shellType params) parents expr) + && not (squashesQuotes expr) + -> [ + makeComment WarningC j 2089 $ + "Quotes/backslashes will be treated literally. " ++ suggestion, + makeComment WarningC (getId expr) 2090 + "Quotes/backslashes in this variable will not be respected." + ] + _ -> [] + suggestion = + if supportsArrays (shellType params) + then "Use an array." + else "Rewrite using set/\"$@\" or functions." + + +prop_checkFunctionsUsedExternally1 = + verifyTree checkFunctionsUsedExternally "foo() { :; }; sudo foo" +prop_checkFunctionsUsedExternally2 = + verifyTree checkFunctionsUsedExternally "alias f='a'; xargs -0 f" +prop_checkFunctionsUsedExternally2b = + verifyNotTree checkFunctionsUsedExternally "alias f='a'; find . -type f" +prop_checkFunctionsUsedExternally2c = + verifyTree checkFunctionsUsedExternally "alias f='a'; find . -type f -exec f +" +prop_checkFunctionsUsedExternally3 = + verifyNotTree checkFunctionsUsedExternally "f() { :; }; echo f" +prop_checkFunctionsUsedExternally4 = + verifyNotTree checkFunctionsUsedExternally "foo() { :; }; run0 \"foo\"" +prop_checkFunctionsUsedExternally5 = + verifyTree checkFunctionsUsedExternally "foo() { :; }; ssh host foo" +prop_checkFunctionsUsedExternally6 = + verifyNotTree checkFunctionsUsedExternally "foo() { :; }; ssh host echo foo" +prop_checkFunctionsUsedExternally7 = + verifyNotTree checkFunctionsUsedExternally "install() { :; }; sudo apt-get install foo" +prop_checkFunctionsUsedExternally8 = + verifyTree checkFunctionsUsedExternally "foo() { :; }; command sudo foo" +prop_checkFunctionsUsedExternally9 = + verifyTree checkFunctionsUsedExternally "foo() { :; }; exec -c doas foo" +checkFunctionsUsedExternally params t = + runNodeAnalysis checkCommand params t + where + checkCommand _ t@(T_SimpleCommand _ _ argv) = + case getCommandNameAndToken False t of + (Just str, t) -> do + let name = basename str + let args = skipOver t argv + let argStrings = map (\x -> (onlyLiteralString x, x)) args + let candidates = getPotentialCommands name argStrings + mapM_ (checkArg name (getId t)) candidates + _ -> return () + checkCommand _ _ = return () + + skipOver t list = drop 1 $ dropWhile (\c -> getId c /= id) $ list + where id = getId t + + -- Try to pick out the argument[s] that may be commands + getPotentialCommands name argAndString = + case name of + "chroot" -> firstNonFlag + "screen" -> firstNonFlag + "sudo" -> firstNonFlag + "doas" -> firstNonFlag + "run0" -> firstNonFlag + "xargs" -> firstNonFlag + "tmux" -> firstNonFlag + "ssh" -> take 1 $ drop 1 $ dropFlags argAndString + "find" -> take 1 $ drop 1 $ + dropWhile (\x -> fst x `notElem` findExecFlags) argAndString + _ -> [] + where + firstNonFlag = take 1 $ dropFlags argAndString + findExecFlags = ["-exec", "-execdir", "-ok"] + dropFlags = dropWhile (\x -> "-" `isPrefixOf` fst x) + + functionsAndAliases = Map.union (functions t) (aliases t) + + patternContext id = + case posLine . fst <$> Map.lookup id (tokenPositions params) of + Just l -> " on line " <> show l <> "." + _ -> "." + + checkArg cmd cmdId (_, arg) = sequence_ $ do + literalArg <- getUnquotedLiteral arg -- only consider unquoted literals + definitionId <- Map.lookup literalArg functionsAndAliases + return $ do + warn (getId arg) 2033 + "Shell functions can't be passed to external commands. Use separate script or sh -c." + info definitionId 2032 $ + "This function can't be invoked via " ++ cmd ++ patternContext cmdId + +prop_checkUnused0 = verifyNotTree checkUnusedAssignments "var=foo; echo $var" +prop_checkUnused1 = verifyTree checkUnusedAssignments "var=foo; echo $bar" +prop_checkUnused2 = verifyNotTree checkUnusedAssignments "var=foo; export var;" +prop_checkUnused3 = verifyTree checkUnusedAssignments "for f in *; do echo '$f'; done" +prop_checkUnused4 = verifyTree checkUnusedAssignments "local i=0" +prop_checkUnused5 = verifyNotTree checkUnusedAssignments "read lol; echo $lol" +prop_checkUnused6 = verifyNotTree checkUnusedAssignments "var=4; (( var++ ))" +prop_checkUnused7 = verifyNotTree checkUnusedAssignments "var=2; $((var))" +prop_checkUnused8 = verifyTree checkUnusedAssignments "var=2; var=3;" +prop_checkUnused9 = verifyNotTree checkUnusedAssignments "read ''" +prop_checkUnused10 = verifyNotTree checkUnusedAssignments "read -p 'test: '" +prop_checkUnused11 = verifyNotTree checkUnusedAssignments "bar=5; export foo[$bar]=3" +prop_checkUnused12 = verifyNotTree checkUnusedAssignments "read foo; echo ${!foo}" +prop_checkUnused13 = verifyNotTree checkUnusedAssignments "x=(1); (( x[0] ))" +prop_checkUnused14 = verifyNotTree checkUnusedAssignments "x=(1); n=0; echo ${x[n]}" +prop_checkUnused15 = verifyNotTree checkUnusedAssignments "x=(1); n=0; (( x[n] ))" +prop_checkUnused16 = verifyNotTree checkUnusedAssignments "foo=5; declare -x foo" +prop_checkUnused16b = verifyNotTree checkUnusedAssignments "f() { local -x foo; foo=42; bar; }; f" +prop_checkUnused17 = verifyNotTree checkUnusedAssignments "read -i 'foo' -e -p 'Input: ' bar; $bar;" +prop_checkUnused18 = verifyNotTree checkUnusedAssignments "a=1; arr=( [$a]=42 ); echo \"${arr[@]}\"" +prop_checkUnused19 = verifyNotTree checkUnusedAssignments "a=1; let b=a+1; echo $b" +prop_checkUnused20 = verifyNotTree checkUnusedAssignments "a=1; PS1='$a'" +prop_checkUnused21 = verifyNotTree checkUnusedAssignments "a=1; trap 'echo $a' INT" +prop_checkUnused22 = verifyNotTree checkUnusedAssignments "a=1; [ -v a ]" +prop_checkUnused23 = verifyNotTree checkUnusedAssignments "a=1; [ -R a ]" +prop_checkUnused24 = verifyNotTree checkUnusedAssignments "mapfile -C a b; echo ${b[@]}" +prop_checkUnused25 = verifyNotTree checkUnusedAssignments "readarray foo; echo ${foo[@]}" +prop_checkUnused26 = verifyNotTree checkUnusedAssignments "declare -F foo" +prop_checkUnused27 = verifyTree checkUnusedAssignments "var=3; [ var -eq 3 ]" +prop_checkUnused28 = verifyNotTree checkUnusedAssignments "var=3; [[ var -eq 3 ]]" +prop_checkUnused29 = verifyNotTree checkUnusedAssignments "var=(a b); declare -p var" +prop_checkUnused30 = verifyTree checkUnusedAssignments "let a=1" +prop_checkUnused31 = verifyTree checkUnusedAssignments "let 'a=1'" +prop_checkUnused32 = verifyTree checkUnusedAssignments "let a=b=c; echo $a" +prop_checkUnused33 = verifyNotTree checkUnusedAssignments "a=foo; [[ foo =~ ^{$a}$ ]]" +prop_checkUnused34 = verifyNotTree checkUnusedAssignments "foo=1; (( t = foo )); echo $t" +prop_checkUnused35 = verifyNotTree checkUnusedAssignments "a=foo; b=2; echo ${a:b}" +prop_checkUnused36 = verifyNotTree checkUnusedAssignments "if [[ -v foo ]]; then true; fi" +prop_checkUnused37 = verifyNotTree checkUnusedAssignments "fd=2; exec {fd}>&-" +prop_checkUnused38 = verifyTree checkUnusedAssignments "(( a=42 ))" +prop_checkUnused39 = verifyNotTree checkUnusedAssignments "declare -x -f foo" +prop_checkUnused40 = verifyNotTree checkUnusedAssignments "arr=(1 2); num=2; echo \"${arr[@]:num}\"" +prop_checkUnused41 = verifyNotTree checkUnusedAssignments "@test 'foo' {\ntrue\n}\n" +prop_checkUnused42 = verifyNotTree checkUnusedAssignments "DEFINE_string foo '' ''; echo \"${FLAGS_foo}\"" +prop_checkUnused43 = verifyTree checkUnusedAssignments "DEFINE_string foo '' ''" +prop_checkUnused44 = verifyNotTree checkUnusedAssignments "DEFINE_string \"foo$ibar\" x y" +prop_checkUnused45 = verifyTree checkUnusedAssignments "readonly foo=bar" +prop_checkUnused46 = verifyTree checkUnusedAssignments "readonly foo=(bar)" +prop_checkUnused47 = verifyNotTree checkUnusedAssignments "a=1; alias hello='echo $a'" +prop_checkUnused48 = verifyNotTree checkUnusedAssignments "_a=1" +prop_checkUnused49 = verifyNotTree checkUnusedAssignments "declare -A array; key=a; [[ -v array[$key] ]]" +prop_checkUnused50 = verifyNotTree checkUnusedAssignments "foofunc() { :; }; typeset -fx foofunc" +prop_checkUnused51 = verifyTree checkUnusedAssignments "x[y[z=1]]=1; echo ${x[@]}" + +checkUnusedAssignments params t = execWriter (mapM_ warnFor unused) + where + flow = variableFlow params + references = Map.union (Map.fromList [(stripSuffix name, ()) | Reference (base, token, name) <- flow]) defaultMap + + assignments = Map.fromList [(name, token) | Assignment (_, token, name, _) <- flow, isVariableName name] + + unused = Map.assocs $ Map.difference assignments references + + warnFor (name, token) = + unless ("_" `isPrefixOf` name) $ + warn (getId token) 2034 $ + name ++ " appears unused. Verify use (or export if used externally)." + + stripSuffix = takeWhile isVariableChar + defaultMap = Map.fromList $ zip internalVariables $ repeat () + +prop_checkUnassignedReferences1 = verifyTree checkUnassignedReferences "echo $foo" +prop_checkUnassignedReferences2 = verifyNotTree checkUnassignedReferences "foo=hello; echo $foo" +prop_checkUnassignedReferences3 = verifyTree checkUnassignedReferences "MY_VALUE=3; echo $MYVALUE" +prop_checkUnassignedReferences4 = verifyNotTree checkUnassignedReferences "RANDOM2=foo; echo $RANDOM" +prop_checkUnassignedReferences5 = verifyNotTree checkUnassignedReferences "declare -A foo=([bar]=baz); echo ${foo[bar]}" +prop_checkUnassignedReferences6 = verifyNotTree checkUnassignedReferences "foo=..; echo ${foo-bar}" +prop_checkUnassignedReferences7 = verifyNotTree checkUnassignedReferences "getopts ':h' foo; echo $foo" +prop_checkUnassignedReferences8 = verifyNotTree checkUnassignedReferences "let 'foo = 1'; echo $foo" +prop_checkUnassignedReferences9 = verifyNotTree checkUnassignedReferences "echo ${foo-bar}" +prop_checkUnassignedReferences10 = verifyNotTree checkUnassignedReferences "echo ${foo:?}" +prop_checkUnassignedReferences11 = verifyNotTree checkUnassignedReferences "declare -A foo; echo \"${foo[@]}\"" +prop_checkUnassignedReferences12 = verifyNotTree checkUnassignedReferences "typeset -a foo; echo \"${foo[@]}\"" +prop_checkUnassignedReferences13 = verifyNotTree checkUnassignedReferences "f() { local foo; echo $foo; }" +prop_checkUnassignedReferences14 = verifyNotTree checkUnassignedReferences "foo=; echo $foo" +prop_checkUnassignedReferences15 = verifyNotTree checkUnassignedReferences "f() { true; }; export -f f" +prop_checkUnassignedReferences16 = verifyNotTree checkUnassignedReferences "declare -A foo=( [a b]=bar ); echo ${foo[a b]}" +prop_checkUnassignedReferences17 = verifyNotTree checkUnassignedReferences "USERS=foo; echo $USER" +prop_checkUnassignedReferences18 = verifyNotTree checkUnassignedReferences "FOOBAR=42; export FOOBAR=" +prop_checkUnassignedReferences19 = verifyNotTree checkUnassignedReferences "readonly foo=bar; echo $foo" +prop_checkUnassignedReferences20 = verifyNotTree checkUnassignedReferences "printf -v foo bar; echo $foo" +prop_checkUnassignedReferences21 = verifyTree checkUnassignedReferences "echo ${#foo}" +prop_checkUnassignedReferences22 = verifyNotTree checkUnassignedReferences "echo ${!os*}" +prop_checkUnassignedReferences23 = verifyTree checkUnassignedReferences "declare -a foo; foo[bar]=42;" +prop_checkUnassignedReferences24 = verifyNotTree checkUnassignedReferences "declare -A foo; foo[bar]=42;" +prop_checkUnassignedReferences25 = verifyNotTree checkUnassignedReferences "declare -A foo=(); foo[bar]=42;" +prop_checkUnassignedReferences26 = verifyNotTree checkUnassignedReferences "a::b() { foo; }; readonly -f a::b" +prop_checkUnassignedReferences27 = verifyNotTree checkUnassignedReferences ": ${foo:=bar}" +prop_checkUnassignedReferences28 = verifyNotTree checkUnassignedReferences "#!/bin/ksh\necho \"${.sh.version}\"\n" +prop_checkUnassignedReferences29 = verifyNotTree checkUnassignedReferences "if [[ -v foo ]]; then echo $foo; fi" +prop_checkUnassignedReferences30 = verifyNotTree checkUnassignedReferences "if [[ -v foo[3] ]]; then echo ${foo[3]}; fi" +prop_checkUnassignedReferences31 = verifyNotTree checkUnassignedReferences "X=1; if [[ -v foo[$X+42] ]]; then echo ${foo[$X+42]}; fi" +prop_checkUnassignedReferences32 = verifyNotTree checkUnassignedReferences "if [[ -v \"foo[1]\" ]]; then echo ${foo[@]}; fi" +prop_checkUnassignedReferences33 = verifyNotTree checkUnassignedReferences "f() { local -A foo; echo \"${foo[@]}\"; }" +prop_checkUnassignedReferences34 = verifyNotTree checkUnassignedReferences "declare -A foo; (( foo[bar] ))" +prop_checkUnassignedReferences35 = verifyNotTree checkUnassignedReferences "echo ${arr[foo-bar]:?fail}" +prop_checkUnassignedReferences36 = verifyNotTree checkUnassignedReferences "read -a foo -r <<<\"foo bar\"; echo \"$foo\"" +prop_checkUnassignedReferences37 = verifyNotTree checkUnassignedReferences "var=howdy; printf -v 'array[0]' %s \"$var\"; printf %s \"${array[0]}\";" +prop_checkUnassignedReferences38 = verifyTree (checkUnassignedReferences' True) "echo $VAR" +prop_checkUnassignedReferences39 = verifyNotTree checkUnassignedReferences "builtin export var=4; echo $var" +prop_checkUnassignedReferences40 = verifyNotTree checkUnassignedReferences ": ${foo=bar}" +prop_checkUnassignedReferences41 = verifyNotTree checkUnassignedReferences "mapfile -t files 123; echo \"${files[@]}\"" +prop_checkUnassignedReferences42 = verifyNotTree checkUnassignedReferences "mapfile files -t; echo \"${files[@]}\"" +prop_checkUnassignedReferences43 = verifyNotTree checkUnassignedReferences "mapfile --future files; echo \"${files[@]}\"" +prop_checkUnassignedReferences_minusNPlain = verifyNotTree checkUnassignedReferences "if [ -n \"$x\" ]; then echo $x; fi" +prop_checkUnassignedReferences_minusZPlain = verifyNotTree checkUnassignedReferences "if [ -z \"$x\" ]; then echo \"\"; fi" +prop_checkUnassignedReferences_minusNBraced = verifyNotTree checkUnassignedReferences "if [ -n \"${x}\" ]; then echo $x; fi" +prop_checkUnassignedReferences_minusZBraced = verifyNotTree checkUnassignedReferences "if [ -z \"${x}\" ]; then echo \"\"; fi" +prop_checkUnassignedReferences_minusNDefault = verifyNotTree checkUnassignedReferences "if [ -n \"${x:-}\" ]; then echo $x; fi" +prop_checkUnassignedReferences_minusZDefault = verifyNotTree checkUnassignedReferences "if [ -z \"${x:-}\" ]; then echo \"\"; fi" +prop_checkUnassignedReferences50 = verifyNotTree checkUnassignedReferences "echo ${foo:+bar}" +prop_checkUnassignedReferences51 = verifyNotTree checkUnassignedReferences "echo ${foo:+$foo}" +prop_checkUnassignedReferences52 = verifyNotTree checkUnassignedReferences "wait -p pid; echo $pid" +prop_checkUnassignedReferences53 = verifyTree checkUnassignedReferences "x=($foo)" + +checkUnassignedReferences = checkUnassignedReferences' False +checkUnassignedReferences' includeGlobals params t = warnings + where + (readMap, writeMap) = execState (mapM tally $ variableFlow params) (Map.empty, Map.empty) + defaultAssigned = Map.fromList $ map (\a -> (a, ())) $ filter (not . null) internalVariables + + tally (Assignment (_, _, name, _)) = + modify (\(read, written) -> (read, Map.insert name () written)) + tally (Reference (_, place, name)) = + modify (\(read, written) -> (Map.insertWith (const id) name place read, written)) + tally _ = return () + + unassigned = Map.toList $ Map.difference (Map.difference readMap writeMap) defaultAssigned + writtenVars = filter isVariableName $ Map.keys writeMap + + getBestMatch var = do + (match, score) <- listToMaybe best + guard $ goodMatch var match score + return match + where + matches = map (\x -> (x, match var x)) writtenVars + best = sortBy (comparing snd) matches + goodMatch var match score = + let l = length match in + l > 3 && score <= 1 + || l > 7 && score <= 2 + + isLocal = any isLower + + warningForGlobals var place = do + match <- getBestMatch var + return $ info (getId place) 2153 $ + "Possible misspelling: " ++ var ++ " may not be assigned. Did you mean " ++ match ++ "?" + + warningForLocals var place = + return $ warn (getId place) 2154 $ + var ++ " is referenced but not assigned" ++ optionalTip ++ "." + where + optionalTip = + if var `elem` commonCommands + then " (for output from commands, use \"$(" ++ var ++ " ..." ++ ")\" )" + else fromMaybe "" $ do + match <- getBestMatch var + return $ " (did you mean '" ++ match ++ "'?)" + + warningFor (var, place) = do + guard $ isVariableName var + guard . not $ isException var place || isGuarded place + (if includeGlobals || isLocal var + then warningForLocals + else warningForGlobals) var place + + warnings = execWriter . sequence $ mapMaybe warningFor unassigned + + -- ${foo[bar baz]} may not be referencing bar/baz. Just skip these. + -- We can also have ${foo:+$foo} should be treated like [[ -n $foo ]] && echo $foo + isException var t = any shouldExclude $ getPath (parentMap params) t + where + shouldExclude t = + case t of + (T_DollarBraced _ _ l) -> + let str = concat $ oversimplify l + ref = getBracedReference str + mod = getBracedModifier str + in + -- Either we're used as an array index like ${arr[here]} + ref /= var || + -- or the reference is guarded by a parent, ${here:+foo$here} + "+" `isPrefixOf` mod || ":+" `isPrefixOf` mod + _ -> False + + isGuarded (T_DollarBraced _ _ v) = + rest `matches` guardRegex + where + name = concat $ oversimplify v + rest = dropWhile isVariableChar $ dropWhile (`elem` "#!") name + isGuarded _ = False + -- :? or :- with optional array index and colon + guardRegex = mkRegex "^(\\[.*\\])?:?[-?]" + + match var candidate = + if var /= candidate && map toLower var == map toLower candidate + then 1 + else dist var candidate + + +prop_checkGlobsAsOptions1 = verify checkGlobsAsOptions "rm *.txt" +prop_checkGlobsAsOptions2 = verify checkGlobsAsOptions "ls ??.*" +prop_checkGlobsAsOptions3 = verifyNot checkGlobsAsOptions "rm -- *.txt" +prop_checkGlobsAsOptions4 = verifyNot checkGlobsAsOptions "*.txt" +prop_checkGlobsAsOptions5 = verifyNot checkGlobsAsOptions "echo 'Files:' *.txt" +prop_checkGlobsAsOptions6 = verifyNot checkGlobsAsOptions "printf '%s\\n' *" +checkGlobsAsOptions _ cmd@(T_SimpleCommand _ _ args) = + unless ((fromMaybe "" $ getCommandBasename cmd) `elem` ["echo", "printf"]) $ + mapM_ check $ takeWhile (not . isEndOfArgs) (drop 1 args) + where + check v@(T_NormalWord _ (T_Glob id s:_)) | s == "*" || s == "?" = + info id 2035 "Use ./*glob* or -- *glob* so names with dashes won't become options." + check _ = return () + + isEndOfArgs t = + case concat $ oversimplify t of + "--" -> True + ":::" -> True + "::::" -> True + _ -> False + +checkGlobsAsOptions _ _ = return () + + +prop_checkWhileReadPitfalls1 = verify checkWhileReadPitfalls "while read foo; do ssh $foo uptime; done < file" +prop_checkWhileReadPitfalls2 = verifyNot checkWhileReadPitfalls "while read -u 3 foo; do ssh $foo uptime; done 3< file" +prop_checkWhileReadPitfalls3 = verifyNot checkWhileReadPitfalls "while true; do ssh host uptime; done" +prop_checkWhileReadPitfalls4 = verifyNot checkWhileReadPitfalls "while read foo; do ssh $foo hostname < /dev/null; done" +prop_checkWhileReadPitfalls5 = verifyNot checkWhileReadPitfalls "while read foo; do echo ls | ssh $foo; done" +prop_checkWhileReadPitfalls6 = verifyNot checkWhileReadPitfalls "while read foo <&3; do ssh $foo; done 3< foo" +prop_checkWhileReadPitfalls7 = verify checkWhileReadPitfalls "while read foo; do if true; then ssh $foo uptime; fi; done < file" +prop_checkWhileReadPitfalls8 = verifyNot checkWhileReadPitfalls "while read foo; do ssh -n $foo uptime; done < file" +prop_checkWhileReadPitfalls9 = verify checkWhileReadPitfalls "while read foo; do ffmpeg -i foo.mkv bar.mkv -an; done" +prop_checkWhileReadPitfalls10 = verify checkWhileReadPitfalls "while read foo; do mplayer foo.ogv > file; done" +prop_checkWhileReadPitfalls11 = verifyNot checkWhileReadPitfalls "while read foo; do mplayer foo.ogv <<< q; done" +prop_checkWhileReadPitfalls12 = verifyNot checkWhileReadPitfalls "while read foo\ndo\nmplayer foo.ogv << EOF\nq\nEOF\ndone" +prop_checkWhileReadPitfalls13 = verify checkWhileReadPitfalls "while read foo; do x=$(ssh host cmd); done" +prop_checkWhileReadPitfalls14 = verify checkWhileReadPitfalls "while read foo; do echo $(ssh host cmd) < /dev/null; done" +prop_checkWhileReadPitfalls15 = verify checkWhileReadPitfalls "while read foo; do ssh $foo cmd & done" + +checkWhileReadPitfalls params (T_WhileExpression id [command] contents) + | isStdinReadCommand command = + mapM_ checkMuncher contents + where + -- Map of munching commands to a function that checks if the flags should exclude it + munchers = Map.fromList [ + ("ssh", (hasFlag, addFlag, "-n")), + ("ffmpeg", (hasArgument, addFlag, "-nostdin")), + ("mplayer", (hasArgument, addFlag, "-noconsolecontrols")), + ("HandBrakeCLI", (\_ _ -> False, addRedirect, "< /dev/null")) + ] + -- Use flag parsing, e.g. "-an" -> "a", "n" + hasFlag ('-':flag) = elem flag . map snd . getAllFlags + -- Simple string match, e.g. "-an" -> "-an" + hasArgument arg = elem arg . mapMaybe getLiteralString . fromJust . getCommandArgv + addFlag string cmd = fixWith [replaceEnd (getId $ getCommandTokenOrThis cmd) params 0 (' ':string)] + addRedirect string cmd = fixWith [replaceEnd (getId cmd) params 0 (' ':string)] + + isStdinReadCommand (T_Pipeline _ _ [T_Redirecting id redirs cmd]) = + let plaintext = oversimplify cmd + in headOrDefault "" plaintext == "read" + && ("-u" `notElem` plaintext) + && not (any stdinRedirect redirs) + isStdinReadCommand _ = False + + checkMuncher :: Token -> Writer [TokenComment] () + checkMuncher (T_Pipeline _ _ (T_Redirecting _ redirs cmd:_)) = do + -- Check command substitutions regardless of the command + case cmd of + T_SimpleCommand _ vars args -> + mapM_ checkMuncher $ concat $ concatMap getCommandSequences $ concatMap getWords $ vars ++ args + _ -> return () + + unless (any stdinRedirect redirs) $ do + -- Recurse into ifs/loops/groups/etc if this doesn't redirect + mapM_ checkMuncher $ concat $ getCommandSequences cmd + + -- Check the actual command + sequence_ $ do + name <- getCommandBasename cmd + (check, fix, flag) <- Map.lookup name munchers + guard $ not (check flag cmd) + + return $ do + info id 2095 $ + name ++ " may swallow stdin, preventing this loop from working properly." + warnWithFix (getId cmd) 2095 + ("Use " ++ name ++ " " ++ flag ++ " to prevent " ++ name ++ " from swallowing stdin.") + (fix flag cmd) + checkMuncher (T_Backgrounded _ t) = checkMuncher t + checkMuncher _ = return () + + stdinRedirect (T_FdRedirect _ fd op) + | fd == "0" = True + | fd == "" = + case op of + T_IoFile _ (T_Less _) _ -> True + T_IoDuplicate _ (T_LESSAND _) _ -> True + T_HereString _ _ -> True + T_HereDoc {} -> True + _ -> False + stdinRedirect _ = False + + getWords t = + case t of + T_Assignment _ _ _ _ x -> getWordParts x + _ -> getWordParts t +checkWhileReadPitfalls _ _ = return () + + +prop_checkPrefixAssign1 = verify checkPrefixAssignmentReference "var=foo echo $var" +prop_checkPrefixAssign2 = verifyNot checkPrefixAssignmentReference "var=$(echo $var) cmd" +checkPrefixAssignmentReference params t@(T_DollarBraced id _ value) = + check path + where + name = getBracedReference $ concat $ oversimplify value + path = NE.toList $ getPath (parentMap params) t + idPath = map getId path + + check [] = return () + check (t:rest) = + case t of + T_SimpleCommand _ vars (_:_) -> mapM_ checkVar vars + _ -> check rest + checkVar (T_Assignment aId mode aName [] value) | + aName == name && (aId `notElem` idPath) = do + warn aId 2097 "This assignment is only seen by the forked process." + warn id 2098 "This expansion will not see the mentioned assignment." + checkVar _ = return () + +checkPrefixAssignmentReference _ _ = return () + +prop_checkCharRangeGlob1 = verify checkCharRangeGlob "ls *[:digit:].jpg" +prop_checkCharRangeGlob2 = verifyNot checkCharRangeGlob "ls *[[:digit:]].jpg" +prop_checkCharRangeGlob3 = verify checkCharRangeGlob "ls [10-15]" +prop_checkCharRangeGlob4 = verifyNot checkCharRangeGlob "ls [a-zA-Z]" +prop_checkCharRangeGlob5 = verifyNot checkCharRangeGlob "tr -d [aa]" -- tr has 2060 +prop_checkCharRangeGlob6 = verifyNot checkCharRangeGlob "[[ $x == [!!]* ]]" +prop_checkCharRangeGlob7 = verifyNot checkCharRangeGlob "[[ -v arr[keykey] ]]" +prop_checkCharRangeGlob8 = verifyNot checkCharRangeGlob "[[ arr[keykey] -gt 1 ]]" +prop_checkCharRangeGlob9 = verifyNot checkCharRangeGlob "read arr[keykey]" -- tr has 2313 +checkCharRangeGlob p t@(T_Glob id str) | + isCharClass str && not isIgnoredCommand && not (isDereferenced t) = + if ":" `isPrefixOf` contents + && ":" `isSuffixOf` contents + && contents /= ":" + then warn id 2101 "Named class needs outer [], e.g. [[:digit:]]." + else + when ('[' `notElem` contents && hasDupes) $ + info id 2102 "Ranges can only match single chars (mentioned due to duplicates)." + where + isCharClass str = "[" `isPrefixOf` str && "]" `isSuffixOf` str + contents = dropNegation . drop 1 . take (length str - 1) $ str + hasDupes = any ((>1) . length) . group . sort . filter (/= '-') $ contents + dropNegation s = + case s of + '!':rest -> rest + '^':rest -> rest + x -> x + + isIgnoredCommand = fromMaybe False $ do + cmd <- getClosestCommand (parentMap p) t + return $ isCommandMatch cmd (`elem` ["tr", "read"]) + + -- Check if this is a dereferencing context like [[ -v array[operandhere] ]] + isDereferenced = fromMaybe False . msum . NE.map isDereferencingOp . getPath (parentMap p) + isDereferencingOp t = + case t of + TC_Binary _ DoubleBracket str _ _ -> return $ isDereferencingBinaryOp str + TC_Unary _ _ str _ -> return $ str == "-v" + T_SimpleCommand {} -> return False + _ -> Nothing +checkCharRangeGlob _ _ = return () + + + +prop_checkCdAndBack1 = verify checkCdAndBack "for f in *; do cd $f; git pull; cd ..; done" +prop_checkCdAndBack2 = verifyNot checkCdAndBack "for f in *; do cd $f || continue; git pull; cd ..; done" +prop_checkCdAndBack3 = verifyNot checkCdAndBack "while [[ $PWD != / ]]; do cd ..; done" +prop_checkCdAndBack4 = verify checkCdAndBack "cd $tmp; foo; cd -" +prop_checkCdAndBack5 = verifyNot checkCdAndBack "cd ..; foo; cd .." +prop_checkCdAndBack6 = verify checkCdAndBack "for dir in */; do cd \"$dir\"; some_cmd; cd ..; done" +prop_checkCdAndBack7 = verifyNot checkCdAndBack "set -e; for dir in */; do cd \"$dir\"; some_cmd; cd ..; done" +prop_checkCdAndBack8 = verifyNot checkCdAndBack "cd tmp\nfoo\n# shellcheck disable=SC2103\ncd ..\n" +checkCdAndBack params t = + unless (hasSetE params) $ mapM_ doList $ getCommandSequences t + where + isCdRevert t = + case oversimplify t of + [_, p] -> p `elem` ["..", "-"] + _ -> False + + getCandidate (T_Annotation _ _ x) = getCandidate x + getCandidate (T_Pipeline id _ [x]) | x `isCommand` "cd" = return x + getCandidate _ = Nothing + + findCdPair list = + case list of + (a:b:rest) -> + if isCdRevert b && not (isCdRevert a) + then return $ getId b + else findCdPair (b:rest) + _ -> Nothing + + doList list = sequence_ $ do + cd <- findCdPair $ mapMaybe getCandidate list + return $ info cd 2103 "Use a ( subshell ) to avoid having to cd back." + +prop_checkLoopKeywordScope1 = verify checkLoopKeywordScope "continue 2" +prop_checkLoopKeywordScope2 = verify checkLoopKeywordScope "for f; do ( break; ); done" +prop_checkLoopKeywordScope3 = verify checkLoopKeywordScope "if true; then continue; fi" +prop_checkLoopKeywordScope4 = verifyNot checkLoopKeywordScope "while true; do break; done" +prop_checkLoopKeywordScope5 = verify checkLoopKeywordScope "if true; then break; fi" +prop_checkLoopKeywordScope6 = verify checkLoopKeywordScope "while true; do true | { break; }; done" +prop_checkLoopKeywordScope7 = verifyNot checkLoopKeywordScope "#!/bin/ksh\nwhile true; do true | { break; }; done" +checkLoopKeywordScope params t | + Just name <- getCommandName t, name `elem` ["continue", "break"] = + if any isLoop path + then case map subshellType $ filter (not . isFunction) path of + Just str:_ -> warn (getId t) 2106 $ + "This only exits the subshell caused by the " ++ str ++ "." + _ -> return () + else case path of + -- breaking at a source/function invocation is an abomination. Let's ignore it. + h:_ | isFunction h -> err (getId t) 2104 $ "In functions, use return instead of " ++ name ++ "." + _ -> err (getId t) 2105 $ name ++ " is only valid in loops." + where + path = let p = getPath (parentMap params) t in NE.filter relevant p + subshellType t = case leadType params t of + NoneScope -> Nothing + SubshellScope str -> return str + relevant t = isLoop t || isFunction t || isJust (subshellType t) +checkLoopKeywordScope _ _ = return () + + +prop_checkFunctionDeclarations1 = verify checkFunctionDeclarations "#!/bin/ksh\nfunction foo() { command foo --lol \"$@\"; }" +prop_checkFunctionDeclarations2 = verify checkFunctionDeclarations "#!/bin/dash\nfunction foo { lol; }" +prop_checkFunctionDeclarations3 = verifyNot checkFunctionDeclarations "foo() { echo bar; }" +checkFunctionDeclarations params + (T_Function id (FunctionKeyword hasKeyword) (FunctionParentheses hasParens) _ _) = + case shellType params of + Bash -> return () + Ksh -> + when (hasKeyword && hasParens) $ + err id 2111 "ksh does not allow 'function' keyword and '()' at the same time." + Dash -> forSh + BusyboxSh -> forSh + Sh -> forSh + + where + forSh = do + when (hasKeyword && hasParens) $ + warn id 2112 "'function' keyword is non-standard. Delete it." + when (hasKeyword && not hasParens) $ + warn id 2113 "'function' keyword is non-standard. Use 'foo()' instead of 'function foo'." +checkFunctionDeclarations _ _ = return () + + + +prop_checkStderrPipe1 = verify checkStderrPipe "#!/bin/ksh\nfoo |& bar" +prop_checkStderrPipe2 = verifyNot checkStderrPipe "#!/bin/bash\nfoo |& bar" +checkStderrPipe params = + case shellType params of + Ksh -> match + _ -> const $ return () + where + match (T_Pipe id "|&") = + err id 2118 "Ksh does not support |&. Use 2>&1 |." + match _ = return () + +prop_checkUnpassedInFunctions1 = verifyTree checkUnpassedInFunctions "foo() { echo $1; }; foo" +prop_checkUnpassedInFunctions2 = verifyNotTree checkUnpassedInFunctions "foo() { echo $1; };" +prop_checkUnpassedInFunctions3 = verifyNotTree checkUnpassedInFunctions "foo() { echo $lol; }; foo" +prop_checkUnpassedInFunctions4 = verifyNotTree checkUnpassedInFunctions "foo() { echo $0; }; foo" +prop_checkUnpassedInFunctions5 = verifyNotTree checkUnpassedInFunctions "foo() { echo $1; }; foo 'lol'; foo" +prop_checkUnpassedInFunctions6 = verifyNotTree checkUnpassedInFunctions "foo() { set -- *; echo $1; }; foo" +prop_checkUnpassedInFunctions7 = verifyTree checkUnpassedInFunctions "foo() { echo $1; }; foo; foo;" +prop_checkUnpassedInFunctions8 = verifyNotTree checkUnpassedInFunctions "foo() { echo $((1)); }; foo;" +prop_checkUnpassedInFunctions9 = verifyNotTree checkUnpassedInFunctions "foo() { echo $(($b)); }; foo;" +prop_checkUnpassedInFunctions10 = verifyNotTree checkUnpassedInFunctions "foo() { echo $!; }; foo;" +prop_checkUnpassedInFunctions11 = verifyNotTree checkUnpassedInFunctions "foo() { bar() { echo $1; }; bar baz; }; foo;" +prop_checkUnpassedInFunctions12 = verifyNotTree checkUnpassedInFunctions "foo() { echo ${!var*}; }; foo;" +prop_checkUnpassedInFunctions13 = verifyNotTree checkUnpassedInFunctions "# shellcheck disable=SC2120\nfoo() { echo $1; }\nfoo\n" +prop_checkUnpassedInFunctions14 = verifyTree checkUnpassedInFunctions "foo() { echo $#; }; foo" +prop_checkUnpassedInFunctions15 = verifyNotTree checkUnpassedInFunctions "foo() { echo ${1-x}; }; foo" +prop_checkUnpassedInFunctions16 = verifyNotTree checkUnpassedInFunctions "foo() { echo ${1:-x}; }; foo" +prop_checkUnpassedInFunctions17 = verifyNotTree checkUnpassedInFunctions "foo() { mycommand ${1+--verbose}; }; foo" +prop_checkUnpassedInFunctions18 = verifyNotTree checkUnpassedInFunctions "foo() { if mycheck; then foo ${1?Missing}; fi; }; foo" +checkUnpassedInFunctions params root = + execWriter $ mapM_ warnForGroup referenceGroups + where + functionMap :: Map.Map String Token + functionMap = Map.fromList $ execWriter $ doAnalysis (tell . maybeToList . findFunction) root + + findFunction t@(T_Function id _ _ name body) + | any (isPositionalReference t) flow && not (any isPositionalAssignment flow) + = return (name,t) + where flow = getVariableFlow params body + findFunction _ = Nothing + + isPositionalAssignment x = + case x of + Assignment (_, _, str, _) -> isPositional str + _ -> False + + isPositionalReference function x = + case x of + Reference (_, t, str) -> isPositional str && t `isDirectChildOf` function && not (hasDefaultValue t) + _ -> False + + isDirectChildOf child parent = fromMaybe False $ do + function <- find (\x -> case x of + T_Function {} -> True + T_Script {} -> True -- for sourced files + _ -> False) $ + getPath (parentMap params) child + return $ getId parent == getId function + + referenceList :: [(String, Bool, Token)] + referenceList = execWriter $ + doAnalysis (sequence_ . checkCommand) root + + checkCommand :: Token -> Maybe (Writer [(String, Bool, Token)] ()) + checkCommand t@(T_SimpleCommand _ _ (cmd:args)) = do + str <- getLiteralString cmd + guard $ Map.member str functionMap + return $ tell [(str, null args, t)] + checkCommand _ = Nothing + + isPositional str = str == "*" || str == "@" || str == "#" + || (all isDigit str && str /= "0" && str /= "") + + -- True if t is a variable that specifies a default value, + -- such as ${1-x} or ${1:-x}. + hasDefaultValue t = + case t of + T_DollarBraced _ True l -> + let str = concat $ oversimplify l + in isDefaultValueModifier $ getBracedModifier str + _ -> False + + isDefaultValueModifier str = + case str of + ':':c:_ -> c `elem` handlesDefault + c:_ -> c `elem` handlesDefault + _ -> False + where handlesDefault = "-+?" + + isArgumentless (_, b, _) = b + referenceGroups = Map.elems $ foldr updateWith Map.empty referenceList + updateWith x@(name, _, _) = Map.insertWith (++) name [x] + + warnForGroup group = + -- Allow ignoring SC2120 on the function to ignore all calls + when (all isArgumentless group && not ignoring) $ do + mapM_ suggestParams group + warnForDeclaration func name + where (name, func) = getFunction group + ignoring = shouldIgnoreCode params 2120 func + + suggestParams (name, _, thing) = + info (getId thing) 2119 $ + "Use " ++ (e4m name) ++ " \"$@\" if function's $1 should mean script's $1." + warnForDeclaration func name = + warn (getId func) 2120 $ + name ++ " references arguments, but none are ever passed." + + getFunction ((name, _, _):_) = + (name, functionMap Map.! name) + + +prop_checkOverridingPath1 = verify checkOverridingPath "PATH=\"$var/$foo\"" +prop_checkOverridingPath2 = verify checkOverridingPath "PATH=\"mydir\"" +prop_checkOverridingPath3 = verify checkOverridingPath "PATH=/cow/foo" +prop_checkOverridingPath4 = verifyNot checkOverridingPath "PATH=/cow/foo/bin" +prop_checkOverridingPath5 = verifyNot checkOverridingPath "PATH='/bin:/sbin'" +prop_checkOverridingPath6 = verifyNot checkOverridingPath "PATH=\"$var/$foo\" cmd" +prop_checkOverridingPath7 = verifyNot checkOverridingPath "PATH=$OLDPATH" +prop_checkOverridingPath8 = verifyNot checkOverridingPath "PATH=$PATH:/stuff" +checkOverridingPath _ (T_SimpleCommand _ vars []) = + mapM_ checkVar vars + where + checkVar (T_Assignment id Assign "PATH" [] word) + | not $ any (`isInfixOf` string) ["/bin", "/sbin" ] = do + when ('/' `elem` string && ':' `notElem` string) $ notify id + when (isLiteral word && ':' `notElem` string && '/' `notElem` string) $ notify id + where string = concat $ oversimplify word + checkVar _ = return () + notify id = warn id 2123 "PATH is the shell search path. Use another name." +checkOverridingPath _ _ = return () + +prop_checkTildeInPath1 = verify checkTildeInPath "PATH=\"$PATH:~/bin\"" +prop_checkTildeInPath2 = verify checkTildeInPath "PATH='~foo/bin'" +prop_checkTildeInPath3 = verifyNot checkTildeInPath "PATH=~/bin" +checkTildeInPath _ (T_SimpleCommand _ vars _) = + mapM_ checkVar vars + where + checkVar (T_Assignment id Assign "PATH" [] (T_NormalWord _ parts)) + | any (\x -> isQuoted x && hasTilde x) parts = + warn id 2147 "Literal tilde in PATH works poorly across programs." + checkVar _ = return () + + hasTilde t = '~' `elem` onlyLiteralString t + isQuoted T_DoubleQuoted {} = True + isQuoted T_SingleQuoted {} = True + isQuoted _ = False +checkTildeInPath _ _ = return () + +prop_checkUnsupported3 = verify checkUnsupported "#!/bin/sh\ncase foo in bar) baz ;& esac" +prop_checkUnsupported4 = verify checkUnsupported "#!/bin/ksh\ncase foo in bar) baz ;;& esac" +prop_checkUnsupported5 = verifyNot checkUnsupported "#!/bin/bash\necho \"${ ls; }\"" +prop_checkUnsupported6 = verify checkUnsupported "#!/bin/ash\necho \"${ ls; }\"" +checkUnsupported params t = + unless (null support || (shellType params `elem` support)) $ + report name + where + (name, support) = shellSupport t + report s = err (getId t) 2127 $ + "To use " ++ s ++ ", specify #!/usr/bin/env " ++ + (intercalate " or " . map (map toLower . show) $ support) + +-- TODO: Move more of these checks here +shellSupport t = + case t of + T_CaseExpression _ _ list -> forCase (map (\(a,_,_) -> a) list) + T_DollarBraceCommandExpansion {} -> ("${ ..; } command expansion", [Bash, Ksh]) + _ -> ("", []) + where + forCase seps | CaseContinue `elem` seps = ("cases with ;;&", [Bash]) + forCase seps | CaseFallThrough `elem` seps = ("cases with ;&", [Bash, Ksh]) + forCase _ = ("", []) + + +groupWith f = groupBy ((==) `on` f) + +prop_checkMultipleAppends1 = verify checkMultipleAppends "foo >> file; bar >> file; baz >> file;" +prop_checkMultipleAppends2 = verify checkMultipleAppends "foo >> file; bar | grep f >> file; baz >> file;" +prop_checkMultipleAppends3 = verifyNot checkMultipleAppends "foo < file; bar < file; baz < file;" +checkMultipleAppends params t = + mapM_ checkList $ getCommandSequences t + where + checkList list = + mapM_ checkGroup (groupWith (fmap fst) $ map getTarget list) + checkGroup (Just (_,id):_:_:_) = + style id 2129 + "Consider using { cmd1; cmd2; } >> file instead of individual redirects." + checkGroup _ = return () + getTarget (T_Annotation _ _ t) = getTarget t + getTarget (T_Pipeline _ _ args@(_:_)) = getTarget (last args) + getTarget (T_Redirecting id list _) = do + file <- mapMaybe getAppend list !!! 0 + return (file, id) + getTarget _ = Nothing + getAppend (T_FdRedirect _ _ (T_IoFile _ T_DGREAT {} f)) = return f + getAppend _ = Nothing + + +prop_checkSuspiciousIFS1 = verify checkSuspiciousIFS "IFS=\"\\n\"" +prop_checkSuspiciousIFS2 = verifyNot checkSuspiciousIFS "IFS=$'\\t'" +prop_checkSuspiciousIFS3 = verify checkSuspiciousIFS "IFS=' \\t\\n'" +checkSuspiciousIFS params (T_Assignment _ _ "IFS" [] value) = + mapM_ check $ getLiteralString value + where + hasDollarSingle = shellType params == Bash || shellType params == Ksh + n = if hasDollarSingle then "$'\\n'" else "''" + t = if hasDollarSingle then "$'\\t'" else "\"$(printf '\\t')\"" + check value = + case value of + "\\n" -> suggest n + "\\t" -> suggest t + x | '\\' `elem` x -> suggest2 "a literal backslash" + x | 'n' `elem` x -> suggest2 "the literal letter 'n'" + x | 't' `elem` x -> suggest2 "the literal letter 't'" + _ -> return () + suggest r = warn (getId value) 2141 $ "This backslash is literal. Did you mean IFS=" ++ r ++ " ?" + suggest2 desc = warn (getId value) 2141 $ "This IFS value contains " ++ desc ++ ". For tabs/linefeeds/escapes, use $'..', literal, or printf." +checkSuspiciousIFS _ _ = return () + + +prop_checkGrepQ1 = verify checkShouldUseGrepQ "[[ $(foo | grep bar) ]]" +prop_checkGrepQ2 = verify checkShouldUseGrepQ "[ -z $(fgrep lol) ]" +prop_checkGrepQ3 = verify checkShouldUseGrepQ "[ -n \"$(foo | zgrep lol)\" ]" +prop_checkGrepQ4 = verifyNot checkShouldUseGrepQ "[ -z $(grep bar | cmd) ]" +prop_checkGrepQ5 = verifyNot checkShouldUseGrepQ "rm $(ls | grep file)" +prop_checkGrepQ6 = verifyNot checkShouldUseGrepQ "[[ -n $(pgrep foo) ]]" +checkShouldUseGrepQ params t = + sequence_ $ case t of + TC_Nullary id _ token -> check id True token + TC_Unary id _ "-n" token -> check id True token + TC_Unary id _ "-z" token -> check id False token + _ -> fail "not check" + where + check id bool token = do + name <- getFinalGrep token + let op = if bool then "-n" else "-z" + let flip = if bool then "" else "! " + return . style id 2143 $ + "Use " ++ flip ++ name ++ " -q instead of " ++ + "comparing output with [ " ++ op ++ " .. ]." + + getFinalGrep t = do + cmds <- getPipeline t + guard . not . null $ cmds + name <- getCommandBasename $ last cmds + guard . isGrep $ name + return name + getPipeline t = + case t of + T_NormalWord _ [x] -> getPipeline x + T_DoubleQuoted _ [x] -> getPipeline x + T_DollarExpansion _ [x] -> getPipeline x + T_Pipeline _ _ cmds -> return cmds + _ -> fail "unknown" + isGrep = (`elem` ["grep", "egrep", "fgrep", "bz3grep", "bzgrep", "xzgrep", "zgrep", "zipgrep", "zstdgrep"]) + +prop_checkTestArgumentSplitting1 = verify checkTestArgumentSplitting "[ -e *.mp3 ]" +prop_checkTestArgumentSplitting2 = verifyNot checkTestArgumentSplitting "[[ $a == *b* ]]" +prop_checkTestArgumentSplitting3 = verify checkTestArgumentSplitting "[[ *.png == '' ]]" +prop_checkTestArgumentSplitting4 = verify checkTestArgumentSplitting "[[ foo == f{o,oo,ooo} ]]" +prop_checkTestArgumentSplitting5 = verify checkTestArgumentSplitting "[[ $@ ]]" +prop_checkTestArgumentSplitting6 = verify checkTestArgumentSplitting "[ -e $@ ]" +prop_checkTestArgumentSplitting7 = verify checkTestArgumentSplitting "[ $@ == $@ ]" +prop_checkTestArgumentSplitting8 = verify checkTestArgumentSplitting "[[ $@ = $@ ]]" +prop_checkTestArgumentSplitting9 = verifyNot checkTestArgumentSplitting "[[ foo =~ bar{1,2} ]]" +prop_checkTestArgumentSplitting10 = verifyNot checkTestArgumentSplitting "[ \"$@\" ]" +prop_checkTestArgumentSplitting11 = verify checkTestArgumentSplitting "[[ \"$@\" ]]" +prop_checkTestArgumentSplitting12 = verify checkTestArgumentSplitting "[ *.png ]" +prop_checkTestArgumentSplitting13 = verify checkTestArgumentSplitting "[ \"$@\" == \"\" ]" +prop_checkTestArgumentSplitting14 = verify checkTestArgumentSplitting "[[ \"$@\" == \"\" ]]" +prop_checkTestArgumentSplitting15 = verifyNot checkTestArgumentSplitting "[[ \"$*\" == \"\" ]]" +prop_checkTestArgumentSplitting16 = verifyNot checkTestArgumentSplitting "[[ -v foo[123] ]]" +prop_checkTestArgumentSplitting17 = verifyNot checkTestArgumentSplitting "#!/bin/ksh\n[ -e foo* ]" +prop_checkTestArgumentSplitting18 = verify checkTestArgumentSplitting "#!/bin/ksh\n[ -d foo* ]" +prop_checkTestArgumentSplitting19 = verifyNot checkTestArgumentSplitting "[[ var[x] -eq 2*3 ]]" +prop_checkTestArgumentSplitting20 = verify checkTestArgumentSplitting "[ var[x] -eq 2 ]" +prop_checkTestArgumentSplitting21 = verify checkTestArgumentSplitting "[ 6 -eq 2*3 ]" +checkTestArgumentSplitting :: Parameters -> Token -> Writer [TokenComment] () +checkTestArgumentSplitting params t = + case t of + (TC_Unary _ typ op token) | isGlob token -> + if op == "-v" + then + when (typ == SingleBracket) $ + err (getId token) 2208 $ + "Use [[ ]] or quote arguments to -v to avoid glob expansion." + else + if (typ == SingleBracket && shellType params == Ksh) + then + -- Ksh appears to stop processing after unrecognized tokens, so operators + -- will effectively work with globs, but only the first match. + when (op `elem` [['-', c] | c <- "bcdfgkprsuwxLhNOGRS" ]) $ + warn (getId token) 2245 $ + op ++ " only applies to the first expansion of this glob. Use a loop to check any/all." + else + err (getId token) 2144 $ + op ++ " doesn't work with globs. Use a for loop." + + (TC_Nullary _ typ token) -> do + checkBraces typ token + checkGlobs typ token + when (typ == DoubleBracket) $ + checkArrays typ token + + (TC_Unary _ typ op token) -> checkAll typ token + + (TC_Binary _ typ op lhs rhs) | op `elem` arithmeticBinaryTestOps -> + if typ == DoubleBracket + then + mapM_ (\c -> do + checkArrays typ c + checkBraces typ c) [lhs, rhs] + else + mapM_ (\c -> do + checkNumericalGlob typ c + checkArrays typ c + checkBraces typ c) [lhs, rhs] + + (TC_Binary _ typ op lhs rhs) -> + if op `elem` ["=", "==", "!=", "=~"] + then do + checkAll typ lhs + checkArrays typ rhs + checkBraces typ rhs + else mapM_ (checkAll typ) [lhs, rhs] + _ -> return () + where + checkAll typ token = do + checkArrays typ token + checkBraces typ token + checkGlobs typ token + + checkArrays typ token = + when (any isArrayExpansion $ getWordParts token) $ + if typ == SingleBracket + then warn (getId token) 2198 "Arrays don't work as operands in [ ]. Use a loop (or concatenate with * instead of @)." + else err (getId token) 2199 "Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @)." + + checkBraces typ token = + when (any isBraceExpansion $ getWordParts token) $ + if typ == SingleBracket + then warn (getId token) 2200 "Brace expansions don't work as operands in [ ]. Use a loop." + else err (getId token) 2201 "Brace expansion doesn't happen in [[ ]]. Use a loop." + + checkGlobs typ token = + when (isGlob token) $ + if typ == SingleBracket + then warn (getId token) 2202 "Globs don't work as operands in [ ]. Use a loop." + else err (getId token) 2203 "Globs are ignored in [[ ]] except right of =/!=. Use a loop." + + checkNumericalGlob SingleBracket token = + -- var[x] and x*2 look like globs + when (shellType params /= Ksh && isGlob token) $ + err (getId token) 2255 "[ ] does not apply arithmetic evaluation. Evaluate with $((..)) for numbers, or use string comparator for strings." + + +prop_checkReadWithoutR1 = verify checkReadWithoutR "read -a foo" +prop_checkReadWithoutR2 = verifyNot checkReadWithoutR "read -ar foo" +prop_checkReadWithoutR3 = verifyNot checkReadWithoutR "read -t 0" +prop_checkReadWithoutR4 = verifyNot checkReadWithoutR "read -t 0 && read --d '' -r bar" +prop_checkReadWithoutR5 = verifyNot checkReadWithoutR "read -t 0 foo < file.txt" +prop_checkReadWithoutR6 = verifyNot checkReadWithoutR "read -u 3 -t 0" +checkReadWithoutR _ t@T_SimpleCommand {} | t `isUnqualifiedCommand` "read" + && "r" `notElem` map snd flags && not has_t0 = + info (getId $ getCommandTokenOrThis t) 2162 "read without -r will mangle backslashes." + where + flags = getAllFlags t + has_t0 = Just "0" == do + parsed <- getGnuOpts flagsForRead $ arguments t + (_, t) <- lookup "t" parsed + getLiteralString t + +checkReadWithoutR _ _ = return () + +prop_checkUncheckedCd1 = verifyTree checkUncheckedCdPushdPopd "cd ~/src; rm -r foo" +prop_checkUncheckedCd2 = verifyNotTree checkUncheckedCdPushdPopd "cd ~/src || exit; rm -r foo" +prop_checkUncheckedCd3 = verifyNotTree checkUncheckedCdPushdPopd "set -e; cd ~/src; rm -r foo" +prop_checkUncheckedCd4 = verifyNotTree checkUncheckedCdPushdPopd "if cd foo; then rm foo; fi" +prop_checkUncheckedCd5 = verifyTree checkUncheckedCdPushdPopd "if true; then cd foo; fi" +prop_checkUncheckedCd6 = verifyNotTree checkUncheckedCdPushdPopd "cd .." +prop_checkUncheckedCd7 = verifyNotTree checkUncheckedCdPushdPopd "#!/bin/bash -e\ncd foo\nrm bar" +prop_checkUncheckedCd8 = verifyNotTree checkUncheckedCdPushdPopd "set -o errexit; cd foo; rm bar" +prop_checkUncheckedCd9 = verifyTree checkUncheckedCdPushdPopd "builtin cd ~/src; rm -r foo" +prop_checkUncheckedPushd1 = verifyTree checkUncheckedCdPushdPopd "pushd ~/src; rm -r foo" +prop_checkUncheckedPushd2 = verifyNotTree checkUncheckedCdPushdPopd "pushd ~/src || exit; rm -r foo" +prop_checkUncheckedPushd3 = verifyNotTree checkUncheckedCdPushdPopd "set -e; pushd ~/src; rm -r foo" +prop_checkUncheckedPushd4 = verifyNotTree checkUncheckedCdPushdPopd "if pushd foo; then rm foo; fi" +prop_checkUncheckedPushd5 = verifyTree checkUncheckedCdPushdPopd "if true; then pushd foo; fi" +prop_checkUncheckedPushd6 = verifyNotTree checkUncheckedCdPushdPopd "pushd .." +prop_checkUncheckedPushd7 = verifyNotTree checkUncheckedCdPushdPopd "#!/bin/bash -e\npushd foo\nrm bar" +prop_checkUncheckedPushd8 = verifyNotTree checkUncheckedCdPushdPopd "set -o errexit; pushd foo; rm bar" +prop_checkUncheckedPushd9 = verifyNotTree checkUncheckedCdPushdPopd "pushd -n foo" +prop_checkUncheckedPopd1 = verifyTree checkUncheckedCdPushdPopd "popd; rm -r foo" +prop_checkUncheckedPopd2 = verifyNotTree checkUncheckedCdPushdPopd "popd || exit; rm -r foo" +prop_checkUncheckedPopd3 = verifyNotTree checkUncheckedCdPushdPopd "set -e; popd; rm -r foo" +prop_checkUncheckedPopd4 = verifyNotTree checkUncheckedCdPushdPopd "if popd; then rm foo; fi" +prop_checkUncheckedPopd5 = verifyTree checkUncheckedCdPushdPopd "if true; then popd; fi" +prop_checkUncheckedPopd6 = verifyTree checkUncheckedCdPushdPopd "popd" +prop_checkUncheckedPopd7 = verifyNotTree checkUncheckedCdPushdPopd "#!/bin/bash -e\npopd\nrm bar" +prop_checkUncheckedPopd8 = verifyNotTree checkUncheckedCdPushdPopd "set -o errexit; popd; rm bar" +prop_checkUncheckedPopd9 = verifyNotTree checkUncheckedCdPushdPopd "popd -n foo" +prop_checkUncheckedPopd10 = verifyNotTree checkUncheckedCdPushdPopd "cd ../.." +prop_checkUncheckedPopd11 = verifyNotTree checkUncheckedCdPushdPopd "cd ../.././.." +prop_checkUncheckedPopd12 = verifyNotTree checkUncheckedCdPushdPopd "cd /" +prop_checkUncheckedPopd13 = verifyTree checkUncheckedCdPushdPopd "cd ../../.../.." + +checkUncheckedCdPushdPopd params root = + if hasSetE params then + [] + else execWriter $ doAnalysis checkElement root + where + checkElement t@T_SimpleCommand {} + | name `elem` ["cd", "pushd", "popd"] + && not (isSafeDir t) + && not (name `elem` ["pushd", "popd"] && ("n" `elem` map snd (getAllFlags t))) + && not (isCondition $ getPath (parentMap params) t) = + warnWithFix (getId t) 2164 + ("Use '" ++ name ++ " ... || exit' or '" ++ name ++ " ... || return' in case " ++ name ++ " fails.") + (fixWith [replaceEnd (getId t) params 0 " || exit"]) + where name = getName t + checkElement _ = return () + getName t = fromMaybe "" $ getCommandName t + isSafeDir t = case oversimplify t of + [_, str] -> str `matches` regex + _ -> False + regex = mkRegex "^/*((\\.|\\.\\.)/+)*(\\.|\\.\\.)?$" + +prop_checkLoopVariableReassignment1 = verify checkLoopVariableReassignment "for i in *; do for i in *.bar; do true; done; done" +prop_checkLoopVariableReassignment2 = verify checkLoopVariableReassignment "for i in *; do for((i=0; i<3; i++)); do true; done; done" +prop_checkLoopVariableReassignment3 = verifyNot checkLoopVariableReassignment "for i in *; do for j in *.bar; do true; done; done" +prop_checkLoopVariableReassignment4 = verifyNot checkLoopVariableReassignment "for _ in *; do for _ in *.bar; do true; done; done" +checkLoopVariableReassignment params token = + sequence_ $ case token of + T_ForIn {} -> check + T_ForArithmetic {} -> check + _ -> Nothing + where + check = do + str <- loopVariable token + guard $ str /= "_" + next <- find (\x -> loopVariable x == Just str) path + return $ do + warn (getId token) 2165 "This nested loop overrides the index variable of its parent." + warn (getId next) 2167 "This parent loop has its index variable overridden." + path = NE.tail $ getPath (parentMap params) token + loopVariable :: Token -> Maybe String + loopVariable t = + case t of + T_ForIn _ s _ _ -> return s + T_ForArithmetic _ + (TA_Sequence _ + [TA_Assignment _ "=" + (TA_Variable _ var _ ) _]) + _ _ _ -> return var + _ -> fail "not loop" + +prop_checkTrailingBracket1 = verify checkTrailingBracket "if -z n ]]; then true; fi " +prop_checkTrailingBracket2 = verifyNot checkTrailingBracket "if [[ -z n ]]; then true; fi " +prop_checkTrailingBracket3 = verify checkTrailingBracket "a || b ] && thing" +prop_checkTrailingBracket4 = verifyNot checkTrailingBracket "run [ foo ]" +prop_checkTrailingBracket5 = verifyNot checkTrailingBracket "run bar ']'" +checkTrailingBracket _ token = + case token of + T_SimpleCommand _ _ tokens@(_:_) -> check (last tokens) token + _ -> return () + where + check (T_NormalWord id [T_Literal _ str]) command + | str `elem` [ "]]", "]" ] + && opposite `notElem` parameters + = warn id 2171 $ + "Found trailing " ++ str ++ " outside test. Add missing " ++ opposite ++ " or quote if intentional." + where + opposite = invert str + parameters = oversimplify command + check _ _ = return () + invert s = + case s of + "]]" -> "[[" + "]" -> "[" + x -> x + +prop_checkReturnAgainstZero1 = verify checkReturnAgainstZero "[ $? -eq 0 ]" +prop_checkReturnAgainstZero2 = verify checkReturnAgainstZero "[[ \"$?\" -gt 0 ]]" +prop_checkReturnAgainstZero3 = verify checkReturnAgainstZero "[[ 0 -ne $? ]]" +prop_checkReturnAgainstZero4 = verifyNot checkReturnAgainstZero "[[ $? -eq 4 ]]" +prop_checkReturnAgainstZero5 = verify checkReturnAgainstZero "[[ 0 -eq $? ]]" +prop_checkReturnAgainstZero6 = verifyNot checkReturnAgainstZero "[[ $R -eq 0 ]]" +prop_checkReturnAgainstZero7 = verify checkReturnAgainstZero "(( $? == 0 ))" +prop_checkReturnAgainstZero8 = verify checkReturnAgainstZero "(( $? ))" +prop_checkReturnAgainstZero9 = verify checkReturnAgainstZero "(( ! $? ))" +prop_checkReturnAgainstZero10 = verifyNot checkReturnAgainstZero "x=$(( $? > 0 ))" +prop_checkReturnAgainstZero11 = verify checkReturnAgainstZero "(( ! ! ! $? ))" +prop_checkReturnAgainstZero12 = verify checkReturnAgainstZero "[ ! $? -eq 0 ]" +prop_checkReturnAgainstZero13 = verifyNot checkReturnAgainstZero "(( ! $? && $? > 42))" +prop_checkReturnAgainstZero14 = verifyNot checkReturnAgainstZero "[[ -e foo || $? -eq 0 ]]" +prop_checkReturnAgainstZero15 = verifyNot checkReturnAgainstZero "(( $?, n=1 ))" +prop_checkReturnAgainstZero16 = verifyNot checkReturnAgainstZero "(( $? || $? == 4 ))" +prop_checkReturnAgainstZero17 = verifyNot checkReturnAgainstZero "(( $? + 0 ))" +prop_checkReturnAgainstZero18 = verifyNot checkReturnAgainstZero "f() { if [ $? -eq 0 ]; then :; fi; }" +prop_checkReturnAgainstZero19 = verifyNot checkReturnAgainstZero "f() ( [ $? -eq 0 ] || exit 42; )" +prop_checkReturnAgainstZero20 = verify checkReturnAgainstZero "f() { if :; then x; [ $? -eq 0 ] && exit; fi; }" +prop_checkReturnAgainstZero21 = verify checkReturnAgainstZero "(( ( $? ) ))" +prop_checkReturnAgainstZero22 = verify checkReturnAgainstZero "[[ ( $? -eq 0 ) ]]" +checkReturnAgainstZero params token = + case token of + TC_Binary id _ op lhs rhs -> check op lhs rhs + TA_Binary id op lhs rhs + | op `elem` [">", "<", ">=", "<=", "==", "!="] -> check op lhs rhs + TA_Unary id op@"!" exp + | isExitCode exp -> message (checksSuccessLhs op) (getId exp) + TA_Sequence _ [exp] + | isExitCode exp -> message False (getId exp) + _ -> return () + where + -- We don't want to warn about composite expressions like + -- [[ $? -eq 0 || $? -eq 4 ]] since these can be annoying to rewrite. + isOnlyTestInCommand t = + case NE.tail $ getPath (parentMap params) t of + (T_Condition {}):_ -> True + (T_Arithmetic {}):_ -> True + (TA_Sequence _ [_]):(T_Arithmetic {}):_ -> True + + -- Some negations and groupings are also fine + next@(TC_Unary _ _ "!" _):_ -> isOnlyTestInCommand next + next@(TA_Unary _ "!" _):_ -> isOnlyTestInCommand next + next@(TC_Group {}):_ -> isOnlyTestInCommand next + next@(TA_Sequence _ [_]):_ -> isOnlyTestInCommand next + next@(TA_Parenthesis _ _):_ -> isOnlyTestInCommand next + _ -> False + + -- TODO: Do better $? tracking and filter on whether + -- the target command is in the same function + getFirstCommandInFunction = f + where + f t = case t of + T_Function _ _ _ _ x -> f x + T_BraceGroup _ (x:_) -> f x + T_Subshell _ (x:_) -> f x + T_Annotation _ _ x -> f x + T_AndIf _ x _ -> f x + T_OrIf _ x _ -> f x + T_Pipeline _ _ (x:_) -> f x + T_Redirecting _ _ (T_IfExpression _ (((x:_),_):_) _) -> f x + x -> x + + isFirstCommandInFunction = fromMaybe False $ do + let path = getPath (parentMap params) token + func <- find isFunction path + cmd <- getClosestCommand (parentMap params) token + return $ getId cmd == getId (getFirstCommandInFunction func) + + -- Is "$? op 0" trying to check if the command succeeded? + checksSuccessLhs op = not $ op `elem` ["-gt", "-ne", "!=", "!"] + -- Is "0 op $?" trying to check if the command succeeded? + checksSuccessRhs op = op `notElem` ["-ne", "!="] + + check op lhs rhs = + if isZero rhs && isExitCode lhs + then message (checksSuccessLhs op) (getId lhs) + else when (isZero lhs && isExitCode rhs) $ message (checksSuccessRhs op) (getId rhs) + isZero t = getLiteralString t == Just "0" + isExitCode t = + case getWordParts t of + [T_DollarBraced _ _ l] -> concat (oversimplify l) == "?" + _ -> False + + message forSuccess id = when (isOnlyTestInCommand token && not isFirstCommandInFunction) $ style id 2181 $ + "Check exit code directly with e.g. 'if " ++ (if forSuccess then "" else "! ") ++ "mycmd;', not indirectly with $?." + + +prop_checkRedirectedNowhere1 = verify checkRedirectedNowhere "> file" +prop_checkRedirectedNowhere2 = verify checkRedirectedNowhere "> file | grep foo" +prop_checkRedirectedNowhere3 = verify checkRedirectedNowhere "grep foo | > bar" +prop_checkRedirectedNowhere4 = verifyNot checkRedirectedNowhere "grep foo > bar" +prop_checkRedirectedNowhere5 = verifyNot checkRedirectedNowhere "foo | grep bar > baz" +prop_checkRedirectedNowhere6 = verifyNot checkRedirectedNowhere "var=$(value) 2> /dev/null" +prop_checkRedirectedNowhere7 = verifyNot checkRedirectedNowhere "var=$(< file)" +prop_checkRedirectedNowhere8 = verifyNot checkRedirectedNowhere "var=`< file`" +checkRedirectedNowhere params token = + case token of + T_Pipeline _ _ [single] -> sequence_ $ do + redir <- getDanglingRedirect single + guard . not $ isInExpansion token + return $ warn (getId redir) 2188 "This redirection doesn't have a command. Move to its command (or use 'true' as no-op)." + + T_Pipeline _ _ list -> forM_ list $ \x -> sequence_ $ do + redir <- getDanglingRedirect x + return $ err (getId redir) 2189 "You can't have | between this redirection and the command it should apply to." + + _ -> return () + where + isInExpansion t = + case NE.tail $ getPath (parentMap params) t of + T_DollarExpansion _ [_] : _ -> True + T_Backticked _ [_] : _ -> True + t@T_Annotation {} : _ -> isInExpansion t + _ -> False + getDanglingRedirect token = + case token of + T_Redirecting _ (first:_) (T_SimpleCommand _ [] []) -> return first + _ -> Nothing + + +prop_checkArrayAssignmentIndices1 = verifyTree checkArrayAssignmentIndices "declare -A foo; foo=(bar)" +prop_checkArrayAssignmentIndices2 = verifyNotTree checkArrayAssignmentIndices "declare -a foo; foo=(bar)" +prop_checkArrayAssignmentIndices3 = verifyNotTree checkArrayAssignmentIndices "declare -A foo; foo=([i]=bar)" +prop_checkArrayAssignmentIndices4 = verifyTree checkArrayAssignmentIndices "typeset -A foo; foo+=(bar)" +prop_checkArrayAssignmentIndices5 = verifyTree checkArrayAssignmentIndices "arr=( [foo]= bar )" +prop_checkArrayAssignmentIndices6 = verifyTree checkArrayAssignmentIndices "arr=( [foo] = bar )" +prop_checkArrayAssignmentIndices7 = verifyNotTree checkArrayAssignmentIndices "arr=( var=value )" +prop_checkArrayAssignmentIndices8 = verifyNotTree checkArrayAssignmentIndices "arr=( [foo]=bar )" +prop_checkArrayAssignmentIndices9 = verifyNotTree checkArrayAssignmentIndices "arr=( [foo]=\"\" )" +prop_checkArrayAssignmentIndices10 = verifyTree checkArrayAssignmentIndices "declare -A arr; arr=( var=value )" +prop_checkArrayAssignmentIndices11 = verifyTree checkArrayAssignmentIndices "arr=( 1=value )" +prop_checkArrayAssignmentIndices12 = verifyTree checkArrayAssignmentIndices "arr=( $a=value )" +prop_checkArrayAssignmentIndices13 = verifyTree checkArrayAssignmentIndices "arr=( $((1+1))=value )" +checkArrayAssignmentIndices params root = + runNodeAnalysis check params root + where + assocs = getAssociativeArrays root + check _ t = + case t of + T_Assignment _ _ name [] (T_Array _ list) -> + let isAssoc = name `elem` assocs in + mapM_ (checkElement isAssoc) list + _ -> return () + + checkElement isAssociative t = + case t of + T_IndexedElement _ _ (T_Literal id "") -> + warn id 2192 "This array element has no value. Remove spaces after = or use \"\" for empty string." + T_IndexedElement {} -> + return () + + T_NormalWord _ parts -> + let literalEquals = do + T_Literal id str <- parts + let (before, after) = break ('=' ==) str + guard $ all isDigit before && not (null after) + return $ warnWithFix id 2191 "The = here is literal. To assign by index, use ( [index]=value ) with no spaces. To keep as literal, quote it." (surroundWith id params "\"") + in + if null literalEquals && isAssociative + then warn (getId t) 2190 "Elements in associative arrays need index, e.g. array=( [index]=value ) ." + else sequence_ literalEquals + + _ -> return () + + +prop_checkUnmatchableCases1 = verify checkUnmatchableCases "case foo in bar) true; esac" +prop_checkUnmatchableCases2 = verify checkUnmatchableCases "case foo-$bar in ??|*) true; esac" +prop_checkUnmatchableCases3 = verify checkUnmatchableCases "case foo in foo) true; esac" +prop_checkUnmatchableCases4 = verifyNot checkUnmatchableCases "case foo-$bar in foo*|*bar|*baz*) true; esac" +prop_checkUnmatchableCases5 = verify checkUnmatchableCases "case $f in *.txt) true;; f??.txt) false;; esac" +prop_checkUnmatchableCases6 = verifyNot checkUnmatchableCases "case $f in ?*) true;; *) false;; esac" +prop_checkUnmatchableCases7 = verifyNot checkUnmatchableCases "case $f in $(x)) true;; asdf) false;; esac" +prop_checkUnmatchableCases8 = verify checkUnmatchableCases "case $f in cow) true;; bar|cow) false;; esac" +prop_checkUnmatchableCases9 = verifyNot checkUnmatchableCases "case $f in x) true;;& x) false;; esac" +checkUnmatchableCases params t = + case t of + T_CaseExpression _ word list -> do + -- Check all patterns for whether they can ever match + let allpatterns = concatMap snd3 list + -- Check only the non-fallthrough branches for shadowing + let breakpatterns = concatMap snd3 $ filter (\x -> fst3 x == CaseBreak) list + + if isConstant word + then warn (getId word) 2194 + "This word is constant. Did you forget the $ on a variable?" + else mapM_ (check $ wordToPseudoGlob word) allpatterns + + let exactGlobs = tupMap wordToExactPseudoGlob breakpatterns + let fuzzyGlobs = tupMap wordToPseudoGlob breakpatterns + let dominators = zip exactGlobs (tails $ drop 1 fuzzyGlobs) + + mapM_ checkDoms dominators + + _ -> return () + where + fst3 (x,_,_) = x + snd3 (_,x,_) = x + tp = tokenPositions params + check target candidate = unless (pseudoGlobsCanOverlap target $ wordToPseudoGlob candidate) $ + warn (getId candidate) 2195 + "This pattern will never match the case statement's word. Double check them." + + tupMap f l = map (\x -> (x, f x)) l + checkDoms ((glob, Just x), rest) = + forM_ (find (\(_, p) -> x `pseudoGlobIsSuperSetof` p) rest) $ + \(first,_) -> do + warn (getId glob) 2221 $ "This pattern always overrides a later one" <> patternContext (getId first) + warn (getId first) 2222 $ "This pattern never matches because of a previous pattern" <> patternContext (getId glob) + where + patternContext :: Id -> String + patternContext id = + case posLine . fst <$> Map.lookup id tp of + Just l -> " on line " <> show l <> "." + _ -> "." + checkDoms _ = return () + + +prop_checkSubshellAsTest1 = verify checkSubshellAsTest "( -e file )" +prop_checkSubshellAsTest2 = verify checkSubshellAsTest "( 1 -gt 2 )" +prop_checkSubshellAsTest3 = verifyNot checkSubshellAsTest "( grep -c foo bar )" +prop_checkSubshellAsTest4 = verifyNot checkSubshellAsTest "[ 1 -gt 2 ]" +prop_checkSubshellAsTest5 = verify checkSubshellAsTest "( -e file && -x file )" +prop_checkSubshellAsTest6 = verify checkSubshellAsTest "( -e file || -x file && -t 1 )" +prop_checkSubshellAsTest7 = verify checkSubshellAsTest "( ! -d file )" +checkSubshellAsTest _ t = + case t of + T_Subshell id [w] -> check id w + _ -> return () + where + check id t = case t of + (T_Banged _ w) -> check id w + (T_AndIf _ w _) -> check id w + (T_OrIf _ w _) -> check id w + (T_Pipeline _ _ [T_Redirecting _ _ (T_SimpleCommand _ [] (first:second:_))]) -> + checkParams id first second + _ -> return () + + + checkParams id first second = do + when (maybe False (`elem` unaryTestOps) $ getLiteralString first) $ + err id 2204 "(..) is a subshell. Did you mean [ .. ], a test expression?" + when (maybe False (`elem` binaryTestOps) $ getLiteralString second) $ + warn id 2205 "(..) is a subshell. Did you mean [ .. ], a test expression?" + + +prop_checkSplittingInArrays1 = verify checkSplittingInArrays "a=( $var )" +prop_checkSplittingInArrays2 = verify checkSplittingInArrays "a=( $(cmd) )" +prop_checkSplittingInArrays3 = verifyNot checkSplittingInArrays "a=( \"$var\" )" +prop_checkSplittingInArrays4 = verifyNot checkSplittingInArrays "a=( \"$(cmd)\" )" +prop_checkSplittingInArrays5 = verifyNot checkSplittingInArrays "a=( $! $$ $# )" +prop_checkSplittingInArrays6 = verifyNot checkSplittingInArrays "a=( ${#arr[@]} )" +prop_checkSplittingInArrays7 = verifyNot checkSplittingInArrays "a=( foo{1,2} )" +prop_checkSplittingInArrays8 = verifyNot checkSplittingInArrays "a=( * )" +checkSplittingInArrays params t = + case t of + T_Array _ elements -> mapM_ check elements + _ -> return () + where + check word = case word of + T_NormalWord _ parts -> mapM_ checkPart parts + _ -> return () + checkPart part = case part of + T_DollarExpansion id _ -> forCommand id + T_DollarBraceCommandExpansion id _ _ -> forCommand id + T_Backticked id _ -> forCommand id + T_DollarBraced id _ str | + not (isCountingReference part) + && not (isQuotedAlternativeReference part) + && getBracedReference (concat $ oversimplify str) `notElem` variablesWithoutSpaces + -> warn id 2206 $ + if shellType params == Ksh + then "Quote to prevent word splitting/globbing, or split robustly with read -A or while read." + else "Quote to prevent word splitting/globbing, or split robustly with mapfile or read -a." + _ -> return () + + forCommand id = + warn id 2207 $ + if shellType params == Ksh + then "Prefer read -A or while read to split command output (or quote to avoid splitting)." + else "Prefer mapfile or read -a to split command output (or quote to avoid splitting)." + + +prop_checkRedirectionToNumber1 = verify checkRedirectionToNumber "( 1 > 2 )" +prop_checkRedirectionToNumber2 = verify checkRedirectionToNumber "foo 1>2" +prop_checkRedirectionToNumber3 = verifyNot checkRedirectionToNumber "echo foo > '2'" +prop_checkRedirectionToNumber4 = verifyNot checkRedirectionToNumber "foo 1>&2" +checkRedirectionToNumber _ t = case t of + T_IoFile id _ word -> sequence_ $ do + file <- getUnquotedLiteral word + guard $ all isDigit file + return $ warn id 2210 "This is a file redirection. Was it supposed to be a comparison or fd operation?" + _ -> return () + +prop_checkGlobAsCommand1 = verify checkGlobAsCommand "foo*" +prop_checkGlobAsCommand2 = verify checkGlobAsCommand "$(var[i])" +prop_checkGlobAsCommand3 = verifyNot checkGlobAsCommand "echo foo*" +checkGlobAsCommand _ t = case t of + T_SimpleCommand _ _ (first:_) + | isGlob first -> + warn (getId first) 2211 "This is a glob used as a command name. Was it supposed to be in ${..}, array, or is it missing quoting?" + _ -> return () + + +prop_checkFlagAsCommand1 = verify checkFlagAsCommand "-e file" +prop_checkFlagAsCommand2 = verify checkFlagAsCommand "foo\n --bar=baz" +prop_checkFlagAsCommand3 = verifyNot checkFlagAsCommand "'--myexec--' args" +prop_checkFlagAsCommand4 = verifyNot checkFlagAsCommand "var=cmd --arg" -- Handled by SC2037 +checkFlagAsCommand _ t = case t of + T_SimpleCommand _ [] (first:_) + | isUnquotedFlag first -> + warn (getId first) 2215 "This flag is used as a command name. Bad line break or missing [ .. ]?" + _ -> return () + + +prop_checkEmptyCondition1 = verify checkEmptyCondition "if [ ]; then ..; fi" +prop_checkEmptyCondition2 = verifyNot checkEmptyCondition "[ foo -o bar ]" +checkEmptyCondition _ t = case t of + TC_Empty id _ -> style id 2212 "Use 'false' instead of empty [/[[ conditionals." + _ -> return () + +prop_checkPipeToNowhere1 = verify checkPipeToNowhere "foo | echo bar" +prop_checkPipeToNowhere2 = verify checkPipeToNowhere "basename < file.txt" +prop_checkPipeToNowhere3 = verify checkPipeToNowhere "printf 'Lol' <<< str" +prop_checkPipeToNowhere4 = verify checkPipeToNowhere "printf 'Lol' << eof\nlol\neof\n" +prop_checkPipeToNowhere5 = verifyNot checkPipeToNowhere "echo foo | xargs du" +prop_checkPipeToNowhere6 = verifyNot checkPipeToNowhere "ls | echo $(cat)" +prop_checkPipeToNowhere7 = verifyNot checkPipeToNowhere "echo foo | var=$(cat) ls" +prop_checkPipeToNowhere9 = verifyNot checkPipeToNowhere "mv -i f . < /dev/stdin" +prop_checkPipeToNowhere10 = verify checkPipeToNowhere "ls > file | grep foo" +prop_checkPipeToNowhere11 = verify checkPipeToNowhere "ls | grep foo < file" +prop_checkPipeToNowhere12 = verify checkPipeToNowhere "ls > foo > bar" +prop_checkPipeToNowhere13 = verify checkPipeToNowhere "ls > foo 2> bar > baz" +prop_checkPipeToNowhere14 = verify checkPipeToNowhere "ls > foo &> bar" +prop_checkPipeToNowhere15 = verifyNot checkPipeToNowhere "ls > foo 2> bar |& grep 'No space left'" +prop_checkPipeToNowhere16 = verifyNot checkPipeToNowhere "echo World | cat << EOF\nhello $(cat)\nEOF\n" +prop_checkPipeToNowhere17 = verify checkPipeToNowhere "echo World | cat << 'EOF'\nhello $(cat)\nEOF\n" +prop_checkPipeToNowhere18 = verifyNot checkPipeToNowhere "ls 1>&3 3>&1 3>&- | wc -l" +prop_checkPipeToNowhere19 = verifyNot checkPipeToNowhere "find . -print0 | du --files0-from=/dev/stdin" +prop_checkPipeToNowhere20 = verifyNot checkPipeToNowhere "find . | du --exclude-from=/dev/fd/0" +prop_checkPipeToNowhere21 = verifyNot checkPipeToNowhere "yes | cp -ri foo/* bar" +prop_checkPipeToNowhere22 = verifyNot checkPipeToNowhere "yes | rm --interactive *" + +data PipeType = StdoutPipe | StdoutStderrPipe | NoPipe deriving (Eq) +checkPipeToNowhere :: Parameters -> Token -> WriterT [TokenComment] Identity () +checkPipeToNowhere params t = + case t of + T_Pipeline _ pipes cmds -> + mapM_ checkPipe $ commandsWithContext pipes cmds + T_Redirecting _ redirects cmd | any redirectsStdin redirects -> checkRedir cmd + _ -> return () + where + checkPipe (input, stage, output) = do + let hasConsumers = hasAdditionalConsumers stage + let hasProducers = hasAdditionalProducers stage + + sequence_ $ do + cmd <- getCommand stage + name <- getCommandBasename cmd + guard $ name `elem` nonReadingCommands + guard $ not hasConsumers && input /= NoPipe + guard . not $ commandSpecificException name cmd + + -- Confusing echo for cat is so common that it's worth a special case + let suggestion = + if name == "echo" + then "Did you want 'cat' instead?" + else "Wrong command or missing xargs?" + return $ warn (getId cmd) 2216 $ + "Piping to '" ++ name ++ "', a command that doesn't read stdin. " ++ suggestion + + sequence_ $ do + T_Redirecting _ redirs cmd <- return stage + fds <- mapM getRedirectionFds redirs + + let fdAndToken :: [(Integer, Token)] + fdAndToken = + concatMap (\(list, redir) -> map (\n -> (n, redir)) list) $ + zip fds redirs + + let fdMap = + Map.fromListWith (++) $ + map (\(a,b) -> (a,[b])) fdAndToken + + let inputWarning = sequence_ $ do + guard $ input /= NoPipe && not hasConsumers + (override:_) <- Map.lookup 0 fdMap + return $ err (getOpId override) 2259 $ + "This redirection overrides piped input. To use both, merge or pass filenames." + + -- Only produce output warnings for regular pipes, since these are + -- way more common, and `foo > out 2> err |& foo` can still write + -- to stderr if the files fail to open + let outputWarning = sequence_ $ do + guard $ output == StdoutPipe && not hasProducers + (override:_) <- Map.lookup 1 fdMap + return $ err (getOpId override) 2260 $ + "This redirection overrides the output pipe. Use 'tee' to output to both." + + return $ do + inputWarning + outputWarning + mapM_ warnAboutDupes $ Map.assocs fdMap + + commandSpecificException name cmd = + case name of + "du" -> any ((`elem` ["exclude-from", "files0-from"]) . snd) $ getAllFlags cmd + _ | name `elem` interactiveFlagCmds -> hasInteractiveFlag cmd + _ -> False + + warnAboutDupes (n, list@(_:_:_)) = + forM_ list $ \c -> err (getOpId c) 2261 $ + "Multiple redirections compete for " ++ str n ++ ". Use cat, tee, or pass filenames instead." + warnAboutDupes _ = return () + + alternative = + if shellType params `elem` [Bash, Ksh] + then "process substitutions or temp files" + else "temporary files" + + str n = + case n of + 0 -> "stdin" + 1 -> "stdout" + 2 -> "stderr" + _ -> "FD " ++ show n + + checkRedir cmd = sequence_ $ do + name <- getCommandBasename cmd + guard $ name `elem` nonReadingCommands + guard . not $ hasAdditionalConsumers cmd + guard . not $ name `elem` interactiveFlagCmds && hasInteractiveFlag cmd + let suggestion = + if name == "echo" + then "Did you want 'cat' instead?" + else "Bad quoting, wrong command or missing xargs?" + return $ warn (getId cmd) 2217 $ + "Redirecting to '" ++ name ++ "', a command that doesn't read stdin. " ++ suggestion + + -- Could any words in a SimpleCommand consume stdin (e.g. echo "$(cat)")? + hasAdditionalConsumers = treeContains mayConsume + -- Could any words in a SimpleCommand produce stdout? E.g. >(tee foo) + hasAdditionalProducers = treeContains mayProduce + treeContains pred t = isNothing $ + doAnalysis (guard . not . pred) t + + interactiveFlagCmds = [ "cp", "mv", "rm" ] + hasInteractiveFlag cmd = cmd `hasFlag` "i" || cmd `hasFlag` "interactive" + + mayConsume t = + case t of + T_ProcSub _ "<" _ -> True + T_Backticked {} -> True + T_DollarExpansion {} -> True + _ -> False + + mayProduce t = + case t of + T_ProcSub _ ">" _ -> True + _ -> False + + getOpId t = + case t of + T_FdRedirect _ _ x -> getOpId x + T_IoFile _ op _ -> getId op + _ -> getId t + + getRedirectionFds t = + case t of + T_FdRedirect _ "" x -> getDefaultFds x + T_FdRedirect _ "&" _ -> return [1, 2] + T_FdRedirect _ num x | all isDigit num -> + -- Don't report the number unless we know what it is. + -- This avoids triggering on 3>&1 1>&3 + getDefaultFds x *> return [read num] + -- Don't bother with {fd}>42 and such + _ -> Nothing + + getDefaultFds redir = + case redir of + T_HereDoc {} -> return [0] + T_HereString {} -> return [0] + T_IoFile _ op _ -> + case op of + T_Less {} -> return [0] + T_Greater {} -> return [1] + T_DGREAT {} -> return [1] + T_GREATAND {} -> return [1, 2] + T_CLOBBER {} -> return [1] + T_IoDuplicate _ op "-" -> getDefaultFds op + _ -> Nothing + _ -> Nothing + + redirectsStdin t = + fromMaybe False $ do + fds <- getRedirectionFds t + return $ 0 `elem` fds + + pipeType t = + case t of + T_Pipe _ "|" -> StdoutPipe + T_Pipe _ "|&" -> StdoutStderrPipe + _ -> NoPipe + + commandsWithContext pipes cmds = + let pipeTypes = map pipeType pipes + inputs = NoPipe : pipeTypes + outputs = pipeTypes ++ [NoPipe] + in + zip3 inputs cmds outputs + +prop_checkUseBeforeDefinition1 = verifyTree checkUseBeforeDefinition "f; f() { true; }" +prop_checkUseBeforeDefinition2 = verifyNotTree checkUseBeforeDefinition "f() { true; }; f" +prop_checkUseBeforeDefinition3 = verifyNotTree checkUseBeforeDefinition "if ! mycmd --version; then mycmd() { true; }; fi" +prop_checkUseBeforeDefinition4 = verifyNotTree checkUseBeforeDefinition "mycmd || mycmd() { f; }" +prop_checkUseBeforeDefinition5 = verifyTree checkUseBeforeDefinition "false || mycmd; mycmd() { f; }" +prop_checkUseBeforeDefinition6 = verifyNotTree checkUseBeforeDefinition "f() { one; }; f; f() { two; }; f" +checkUseBeforeDefinition :: Parameters -> Token -> [TokenComment] +checkUseBeforeDefinition params t = fromMaybe [] $ do + cfga <- cfgAnalysis params + let funcs = execState (doAnalysis findFunction t) Map.empty + -- Green cut: no point enumerating commands if there are no functions. + guard . not $ Map.null funcs + return $ execWriter $ doAnalysis (findInvocation cfga funcs) t + where + findFunction t = + case t of + T_Function id _ _ name _ -> modify (Map.insertWith (++) name [id]) + _ -> return () + + findInvocation cfga funcs t = + case t of + T_SimpleCommand id _ (cmd:_) -> sequence_ $ do + name <- getLiteralString cmd + invocations <- Map.lookup name funcs + -- Is the function definitely being defined later? + guard $ any (\c -> CF.doesPostDominate cfga c id) invocations + -- Was one already defined, so it's actually a re-definition? + guard . not $ any (\c -> CF.doesPostDominate cfga id c) invocations + return $ err id 2218 "This function is only defined later. Move the definition up." + _ -> return () + +prop_checkForLoopGlobVariables1 = verify checkForLoopGlobVariables "for i in $var/*.txt; do true; done" +prop_checkForLoopGlobVariables2 = verifyNot checkForLoopGlobVariables "for i in \"$var\"/*.txt; do true; done" +prop_checkForLoopGlobVariables3 = verifyNot checkForLoopGlobVariables "for i in $var; do true; done" +checkForLoopGlobVariables _ t = + case t of + T_ForIn _ _ words _ -> mapM_ check words + _ -> return () + where + check (T_NormalWord _ parts) = + when (any isGlob parts) $ + mapM_ suggest $ filter isQuoteableExpansion parts + suggest t = info (getId t) 2231 + "Quote expansions in this for loop glob to prevent wordsplitting, e.g. \"$dir\"/*.txt ." + + +prop_checkSubshelledTests1 = verify checkSubshelledTests "a && ( [ b ] || ! [ c ] )" +prop_checkSubshelledTests2 = verify checkSubshelledTests "( [ a ] )" +prop_checkSubshelledTests3 = verify checkSubshelledTests "( [ a ] && [ b ] || test c )" +prop_checkSubshelledTests4 = verify checkSubshelledTests "( [ a ] && { [ b ] && [ c ]; } )" +prop_checkSubshelledTests5 = verifyNot checkSubshelledTests "( [[ ${var:=x} = y ]] )" +prop_checkSubshelledTests6 = verifyNot checkSubshelledTests "( [[ $((i++)) = 10 ]] )" +prop_checkSubshelledTests7 = verifyNot checkSubshelledTests "( [[ $((i+=1)) = 10 ]] )" +prop_checkSubshelledTests8 = verify checkSubshelledTests "# shellcheck disable=SC2234\nf() ( [[ x ]] )" + +checkSubshelledTests params t = + case t of + T_Subshell id list | all isTestStructure list && (not (hasAssignment t)) -> + case () of + -- Special case for if (test) and while (test) + _ | isCompoundCondition (getPath (parentMap params) t) -> + style id 2233 "Remove superfluous (..) around condition to avoid subshell overhead." + + -- Special case for ([ x ]), except for func() ( [ x ] ) + _ | isSingleTest list && (not $ isFunctionBody (getPath (parentMap params) t)) -> + style id 2234 "Remove superfluous (..) around test command to avoid subshell overhead." + + -- General case for ([ x ] || [ y ] && etc) + _ -> style id 2235 "Use { ..; } instead of (..) to avoid subshell overhead." + _ -> return () + where + + isSingleTest cmds = + case cmds of + [c] | isTestCommand c -> True + _ -> False + + isFunctionBody path = + case path of + (_ NE.:| f:_) -> isFunction f + _ -> False + + isTestStructure t = + case t of + T_Banged _ t -> isTestStructure t + T_AndIf _ a b -> isTestStructure a && isTestStructure b + T_OrIf _ a b -> isTestStructure a && isTestStructure b + T_Pipeline _ [] [T_Redirecting _ _ cmd] -> + case cmd of + T_BraceGroup _ ts -> all isTestStructure ts + T_Subshell _ ts -> all isTestStructure ts + _ -> isTestCommand t + _ -> isTestCommand t + + isTestCommand t = + case t of + T_Pipeline _ [] [T_Redirecting _ _ cmd] -> + case cmd of + T_Condition {} -> True + _ -> cmd `isCommand` "test" + _ -> False + + -- Check if a T_Subshell is used as a condition, e.g. if ( test ) + -- This technically also triggers for `if true; then ( test ); fi` + -- but it's still a valid suggestion. + isCompoundCondition chain = + case dropWhile skippable (NE.tail chain) of + T_IfExpression {} : _ -> True + T_WhileExpression {} : _ -> True + T_UntilExpression {} : _ -> True + _ -> False + + hasAssignment t = isNothing $ doAnalysis guardNotAssignment t + guardNotAssignment t = + case t of + TA_Assignment {} -> Nothing + TA_Unary _ s _ -> guard . not $ "++" `isInfixOf` s || "--" `isInfixOf` s + T_DollarBraced _ _ l -> + let str = concat $ oversimplify l + modifier = getBracedModifier str + in + guard . not $ "=" `isPrefixOf` modifier || ":=" `isPrefixOf` modifier + T_DollarBraceCommandExpansion {} -> Nothing + _ -> Just () + + -- Skip any parent of a T_Subshell until we reach something interesting + skippable t = + case t of + T_Redirecting _ [] _ -> True + T_Pipeline _ [] _ -> True + T_Annotation {} -> True + _ -> False + +prop_checkUnnecessarilyInvertedTest1 = verify checkUnnecessarilyInvertedTest "[ ! -z $var ]" +prop_checkUnnecessarilyInvertedTest2 = verify checkUnnecessarilyInvertedTest "! [[ -n $var ]]" +prop_checkUnnecessarilyInvertedTest3 = verifyNot checkUnnecessarilyInvertedTest "! [ -x $var ]" +prop_checkUnnecessarilyInvertedTest4 = verifyNot checkUnnecessarilyInvertedTest "[[ ! -w $var ]]" +prop_checkUnnecessarilyInvertedTest5 = verifyNot checkUnnecessarilyInvertedTest "[ -z $var ]" +prop_checkUnnecessarilyInvertedTest6 = verify checkUnnecessarilyInvertedTest "! [ $var != foo ]" +prop_checkUnnecessarilyInvertedTest7 = verify checkUnnecessarilyInvertedTest "[[ ! $var == foo ]]" +prop_checkUnnecessarilyInvertedTest8 = verifyNot checkUnnecessarilyInvertedTest "! [[ $var =~ .* ]]" +prop_checkUnnecessarilyInvertedTest9 = verify checkUnnecessarilyInvertedTest "[ ! $var -eq 0 ]" +prop_checkUnnecessarilyInvertedTest10 = verify checkUnnecessarilyInvertedTest "! [[ $var -gt 3 ]]" +checkUnnecessarilyInvertedTest _ t = + case t of + TC_Unary _ _ "!" (TC_Unary _ _ op _) -> + case op of + "-n" -> style (getId t) 2236 "Use -z instead of ! -n." + "-z" -> style (getId t) 2236 "Use -n instead of ! -z." + _ -> return () + T_Banged _ (T_Pipeline _ _ + [T_Redirecting _ _ (T_Condition _ _ (TC_Unary _ _ op _))]) -> + case op of + "-n" -> style (getId t) 2237 "Use [ -z .. ] instead of ! [ -n .. ]." + "-z" -> style (getId t) 2237 "Use [ -n .. ] instead of ! [ -z .. ]." + _ -> return () + TC_Unary _ _ "!" (TC_Binary _ bracketStyle op _ _) -> + maybeSuggestRewrite True bracketStyle (getId t) op + T_Banged _ (T_Pipeline _ _ + [T_Redirecting _ _ (T_Condition _ _ (TC_Binary _ bracketStyle op _ _))]) -> + maybeSuggestRewrite False bracketStyle (getId t) op + _ -> return () + where + inversionMap = Map.fromList [ + ("=", "!="), + ("==", "!="), + ("!=", "="), + ("-eq", "-ne"), + ("-ne", "-eq"), + ("-le", "-gt"), + ("-gt", "-le"), + ("-ge", "-lt"), + ("-lt", "-ge") + ] + maybeSuggestRewrite bangInside bracketStyle id op = sequence_ $ do + newOp <- Map.lookup op inversionMap + let oldExpr = "a " ++ op ++ " b" + let newExpr = "a " ++ newOp ++ " b" + let bracket s = if bracketStyle == SingleBracket then "[ " ++ s ++ " ]" else "[[ " ++ s ++ " ]]" + return $ + if bangInside + then style id 2335 $ "Use " ++ newExpr ++ " instead of ! " ++ oldExpr ++ "." + else style id 2335 $ "Use " ++ (bracket newExpr) ++ " instead of ! " ++ (bracket oldExpr) ++ "." + + +prop_checkRedirectionToCommand1 = verify checkRedirectionToCommand "ls > rm" +prop_checkRedirectionToCommand2 = verifyNot checkRedirectionToCommand "ls > 'rm'" +prop_checkRedirectionToCommand3 = verifyNot checkRedirectionToCommand "ls > myfile" +checkRedirectionToCommand _ t = + case t of + T_IoFile _ _ (T_NormalWord id [T_Literal _ str]) | str `elem` commonCommands + && str /= "file" -> -- This would be confusing + warn id 2238 "Redirecting to/from command name instead of file. Did you want pipes/xargs (or quote to ignore)?" + _ -> return () + +prop_checkNullaryExpansionTest1 = verify checkNullaryExpansionTest "[[ $(a) ]]" +prop_checkNullaryExpansionTest2 = verify checkNullaryExpansionTest "[[ $a ]]" +prop_checkNullaryExpansionTest3 = verifyNot checkNullaryExpansionTest "[[ $a=1 ]]" +prop_checkNullaryExpansionTest4 = verifyNot checkNullaryExpansionTest "[[ -n $(a) ]]" +prop_checkNullaryExpansionTest5 = verify checkNullaryExpansionTest "[[ \"$a$b\" ]]" +prop_checkNullaryExpansionTest6 = verify checkNullaryExpansionTest "[[ `x` ]]" +checkNullaryExpansionTest params t = + case t of + TC_Nullary _ _ word -> + case getWordParts word of + [t] | isCommandSubstitution t -> + styleWithFix id 2243 "Prefer explicit -n to check for output (or run command without [/[[ to check for success)." fix + + -- If they're constant, you get SC2157 &co + x | all (not . isConstant) x -> + styleWithFix id 2244 "Prefer explicit -n to check non-empty string (or use =/-ne to check boolean/integer)." fix + _ -> return () + where + id = getId word + fix = fixWith [replaceStart id params 0 "-n "] + _ -> return () + + +prop_checkDollarQuoteParen1 = verify checkDollarQuoteParen "$\"(foo)\"" +prop_checkDollarQuoteParen2 = verify checkDollarQuoteParen "$\"{foo}\"" +prop_checkDollarQuoteParen3 = verifyNot checkDollarQuoteParen "\"$(foo)\"" +prop_checkDollarQuoteParen4 = verifyNot checkDollarQuoteParen "$\"..\"" +checkDollarQuoteParen params t = + case t of + T_DollarDoubleQuoted id ((T_Literal _ (c:_)):_) | c `elem` "({" -> + warnWithFix id 2247 "Flip leading $ and \" if this should be a quoted substitution." (fix id) + _ -> return () + where + fix id = fixWith [replaceStart id params 2 "\"$"] + +prop_checkTranslatedStringVariable1 = verify checkTranslatedStringVariable "foo_bar2=val; $\"foo_bar2\"" +prop_checkTranslatedStringVariable2 = verifyNot checkTranslatedStringVariable "$\"foo_bar2\"" +prop_checkTranslatedStringVariable3 = verifyNot checkTranslatedStringVariable "$\"..\"" +prop_checkTranslatedStringVariable4 = verifyNot checkTranslatedStringVariable "var=val; $\"$var\"" +prop_checkTranslatedStringVariable5 = verifyNot checkTranslatedStringVariable "foo=var; bar=val2; $\"foo bar\"" +checkTranslatedStringVariable params (T_DollarDoubleQuoted id [T_Literal _ s]) + | all isVariableChar s + && S.member s assignments + = warnWithFix id 2256 "This translated string is the name of a variable. Flip leading $ and \" if this should be a quoted substitution." (fix id) + where + assignments = S.fromList [name | Assignment (_, _, name, _) <- variableFlow params, isVariableName name] + fix id = fixWith [replaceStart id params 2 "\"$"] +checkTranslatedStringVariable _ _ = return () + +prop_checkDefaultCase1 = verify checkDefaultCase "case $1 in a) true ;; esac" +prop_checkDefaultCase2 = verify checkDefaultCase "case $1 in ?*?) true ;; *? ) true ;; esac" +prop_checkDefaultCase3 = verifyNot checkDefaultCase "case $1 in x|*) true ;; esac" +prop_checkDefaultCase4 = verifyNot checkDefaultCase "case $1 in **) true ;; esac" +checkDefaultCase _ t = + case t of + T_CaseExpression id _ list -> + unless (any canMatchAny list) $ + info id 2249 "Consider adding a default *) case, even if it just exits with error." + _ -> return () + where + canMatchAny (_, list, _) = any canMatchAny' list + -- hlint objects to 'pattern' as a variable name + canMatchAny' pat = fromMaybe False $ do + pg <- wordToExactPseudoGlob pat + return $ pseudoGlobIsSuperSetof pg [PGMany] + +prop_checkUselessBang1 = verify checkUselessBang "set -e; ! true; rest" +prop_checkUselessBang2 = verifyNot checkUselessBang "! true; rest" +prop_checkUselessBang3 = verify checkUselessBang "set -e; while true; do ! true; done" +prop_checkUselessBang4 = verifyNot checkUselessBang "set -e; if ! true; then true; fi" +prop_checkUselessBang5 = verifyNot checkUselessBang "set -e; ( ! true )" +prop_checkUselessBang6 = verify checkUselessBang "set -e; { ! true; }" +prop_checkUselessBang7 = verifyNot checkUselessBang "set -e; x() { ! [ x ]; }" +prop_checkUselessBang8 = verifyNot checkUselessBang "set -e; if { ! true; }; then true; fi" +prop_checkUselessBang9 = verifyNot checkUselessBang "set -e; while ! true; do true; done" +prop_checkUselessBang10 = verify checkUselessBang "set -e\nshellcheck disable=SC0000\n! true\nrest" +checkUselessBang params t = when (hasSetE params) $ mapM_ check (getNonReturningCommands t) + where + check t = + case t of + T_Banged id cmd | not $ isCondition (getPath (parentMap params) t) -> + addComment $ makeCommentWithFix InfoC id 2251 + "This ! is not on a condition and skips errexit. Use `&& exit 1` instead, or make sure $? is checked." + (fixWith [replaceStart id params 1 "", replaceEnd (getId cmd) params 0 " && exit 1"]) + T_Annotation _ _ t -> check t + _ -> return () + + -- Get all the subcommands that aren't likely to be the return value + getNonReturningCommands :: Token -> [Token] + getNonReturningCommands t = + case t of + T_Script _ _ list -> dropLast list + T_BraceGroup _ list -> if isFunctionBody t then dropLast list else list + T_Subshell _ list -> dropLast list + T_WhileExpression _ conds cmds -> dropLast conds ++ cmds + T_UntilExpression _ conds cmds -> dropLast conds ++ cmds + T_ForIn _ _ _ list -> list + T_ForArithmetic _ _ _ _ list -> list + T_Annotation _ _ t -> getNonReturningCommands t + T_IfExpression _ conds elses -> + concatMap (dropLast . fst) conds ++ concatMap snd conds ++ elses + _ -> [] + + isFunctionBody t = + case getPath (parentMap params) t of + _ NE.:| T_Function {}:_-> True + _ -> False + + dropLast t = + case t of + [_] -> [] + x:rest -> x : dropLast rest + _ -> [] + +prop_checkModifiedArithmeticInRedirection1 = verify checkModifiedArithmeticInRedirection "ls > $((i++))" +prop_checkModifiedArithmeticInRedirection2 = verify checkModifiedArithmeticInRedirection "cat < \"foo$((i++)).txt\"" +prop_checkModifiedArithmeticInRedirection3 = verifyNot checkModifiedArithmeticInRedirection "while true; do true; done > $((i++))" +prop_checkModifiedArithmeticInRedirection4 = verify checkModifiedArithmeticInRedirection "cat <<< $((i++))" +prop_checkModifiedArithmeticInRedirection5 = verify checkModifiedArithmeticInRedirection "cat << foo\n$((i++))\nfoo\n" +prop_checkModifiedArithmeticInRedirection6 = verifyNot checkModifiedArithmeticInRedirection "#!/bin/dash\nls > $((i=i+1))" +prop_checkModifiedArithmeticInRedirection7 = verifyNot checkModifiedArithmeticInRedirection "#!/bin/busybox sh\ncat << foo\n$((i++))\nfoo\n" +checkModifiedArithmeticInRedirection params t = unless (shellType params == Dash || shellType params == BusyboxSh) $ + case t of + T_Redirecting _ redirs (T_SimpleCommand _ _ (_:_)) -> mapM_ checkRedirs redirs + _ -> return () + where + checkRedirs t = + case t of + T_FdRedirect _ _ (T_IoFile _ _ word) -> + mapM_ checkArithmetic $ getWordParts word + T_FdRedirect _ _ (T_HereString _ word) -> + mapM_ checkArithmetic $ getWordParts word + T_FdRedirect _ _ (T_HereDoc _ _ _ _ list) -> + mapM_ checkArithmetic list + _ -> return () + checkArithmetic t = + case t of + T_DollarArithmetic _ x -> checkModifying x + _ -> return () + checkModifying t = + case t of + TA_Sequence _ list -> mapM_ checkModifying list + TA_Unary id s _ | s `elem` ["|++", "++|", "|--", "--|"] -> warnFor id + TA_Assignment id _ _ _ -> warnFor id + TA_Binary _ _ x y -> mapM_ checkModifying [x ,y] + TA_Trinary _ x y z -> mapM_ checkModifying [x, y, z] + _ -> return () + warnFor id = + warn id 2257 "Arithmetic modifications in command redirections may be discarded. Do them separately." + +prop_checkAliasUsedInSameParsingUnit1 = verifyTree checkAliasUsedInSameParsingUnit "alias x=y; x" +prop_checkAliasUsedInSameParsingUnit2 = verifyNotTree checkAliasUsedInSameParsingUnit "alias x=y\nx" +prop_checkAliasUsedInSameParsingUnit3 = verifyTree checkAliasUsedInSameParsingUnit "{ alias x=y\nx\n}" +prop_checkAliasUsedInSameParsingUnit4 = verifyNotTree checkAliasUsedInSameParsingUnit "alias x=y; 'x';" +prop_checkAliasUsedInSameParsingUnit5 = verifyNotTree checkAliasUsedInSameParsingUnit ":\n{\n#shellcheck disable=SC2262\nalias x=y\nx\n}" +prop_checkAliasUsedInSameParsingUnit6 = verifyNotTree checkAliasUsedInSameParsingUnit ":\n{\n#shellcheck disable=SC2262\nalias x=y\nalias x=z\nx\n}" -- Only consider the first instance +checkAliasUsedInSameParsingUnit :: Parameters -> Token -> [TokenComment] +checkAliasUsedInSameParsingUnit params root = + let + -- Get all root commands + commands = concat $ getCommandSequences root + -- Group them by whether they start on the same line where the previous one ended + units = groupByLink followsOnLine commands + in + execWriter $ mapM_ checkUnit units + where + lineSpan t = + let m = tokenPositions params in do + (start, end) <- Map.lookup t m + return $ (posLine start, posLine end) + + followsOnLine a b = fromMaybe False $ do + (_, end) <- lineSpan (getId a) + (start, _) <- lineSpan (getId b) + return $ end == start + + checkUnit :: [Token] -> Writer [TokenComment] () + checkUnit unit = evalStateT (mapM_ (doAnalysis findCommands) unit) (Map.empty) + + findCommands :: Token -> StateT (Map.Map String Token) (Writer [TokenComment]) () + findCommands t = case t of + T_SimpleCommand _ _ (cmd:args) -> + case getUnquotedLiteral cmd of + Just "alias" -> + mapM_ addAlias args + Just name | '/' `notElem` name -> do + cmd <- gets (Map.lookup name) + case cmd of + Just alias -> + unless (isSourced params t || shouldIgnoreCode params 2262 alias) $ do + warn (getId alias) 2262 "This alias can't be defined and used in the same parsing unit. Use a function instead." + info (getId t) 2263 "Since they're in the same parsing unit, this command will not refer to the previously mentioned alias." + _ -> return () + _ -> return () + _ -> return () + addAlias arg = do + let (name, value) = break (== '=') $ getLiteralStringDef "-" arg + when (isVariableName name && not (null value)) $ + modify (Map.insertWith (\new old -> old) name arg) + +isSourced params t = + let + f (T_SourceCommand {}) = True + f _ = False + in + any f $ getPath (parentMap params) t + + +-- Like groupBy, but compares pairs of adjacent elements, rather than against the first of the span +prop_groupByLink1 = groupByLink (\a b -> a+1 == b) [1,2,3,2,3,7,8,9] == [[1,2,3], [2,3], [7,8,9]] +prop_groupByLink2 = groupByLink (==) ([] :: [()]) == [] +groupByLink :: (a -> a -> Bool) -> [a] -> [[a]] +groupByLink f list = + case list of + [] -> [] + (x:xs) -> foldr c n xs x [] + where + c next rest current span = + if f current next + then rest next (current:span) + else (reverse $ current:span) : rest next [] + n current span = [reverse (current:span)] + + +prop_checkBlatantRecursion1 = verify checkBlatantRecursion ":(){ :|:& };:" +prop_checkBlatantRecursion2 = verify checkBlatantRecursion "f() { f; }" +prop_checkBlatantRecursion3 = verifyNot checkBlatantRecursion "f() { command f; }" +prop_checkBlatantRecursion4 = verify checkBlatantRecursion "cd() { cd \"$lol/$1\" || exit; }" +prop_checkBlatantRecursion5 = verifyNot checkBlatantRecursion "cd() { [ -z \"$1\" ] || cd \"$1\"; }" +prop_checkBlatantRecursion6 = verifyNot checkBlatantRecursion "cd() { something; cd $1; }" +prop_checkBlatantRecursion7 = verifyNot checkBlatantRecursion "cd() { builtin cd $1; }" +checkBlatantRecursion :: Parameters -> Token -> Writer [TokenComment] () +checkBlatantRecursion params t = + case t of + T_Function _ _ _ name body -> + case getCommandSequences body of + [first : _] -> checkList name first + _ -> return () + _ -> return () + where + checkList :: String -> Token -> Writer [TokenComment] () + checkList name t = + case t of + T_Backgrounded _ t -> checkList name t + T_AndIf _ lhs _ -> checkList name lhs + T_OrIf _ lhs _ -> checkList name lhs + T_Pipeline _ _ cmds -> mapM_ (checkCommand name) cmds + _ -> return () + + checkCommand :: String -> Token -> Writer [TokenComment] () + checkCommand name cmd = sequence_ $ do + let (invokedM, t) = getCommandNameAndToken True cmd + invoked <- invokedM + guard $ name == invoked + return $ + errWithFix (getId t) 2264 + ("This function unconditionally re-invokes itself. Missing 'command'?") + (fixWith [replaceStart (getId t) params 0 $ "command "]) + + +prop_checkBadTestAndOr1 = verify checkBadTestAndOr "[ x ] & [ y ]" +prop_checkBadTestAndOr2 = verify checkBadTestAndOr "test -e foo & [ y ]" +prop_checkBadTestAndOr3 = verify checkBadTestAndOr "[ x ] | [ y ]" +checkBadTestAndOr params t = + case t of + T_Pipeline _ seps cmds@(_:_:_) -> checkOrs seps cmds + T_Backgrounded id cmd -> checkAnds id cmd + _ -> return () + where + checkOrs seps cmds = + let maybeSeps = map Just seps + commandWithSeps = zip3 (Nothing:maybeSeps) cmds (maybeSeps ++ [Nothing]) + in + mapM_ checkTest commandWithSeps + checkTest (before, cmd, after) = + when (isTestCommand cmd) $ do + checkPipe before + checkPipe after + + checkPipe t = + case t of + Just (T_Pipe id "|") -> + warnWithFix id 2266 "Use || for logical OR. Single | will pipe." $ + fixWith [replaceEnd id params 0 "|"] + _ -> return () + + checkAnds id t = + case t of + T_AndIf _ _ rhs -> checkAnds id rhs + T_OrIf _ _ rhs -> checkAnds id rhs + T_Pipeline _ _ list | not (null list) -> checkAnds id (last list) + cmd -> when (isTestCommand cmd) $ + errWithFix id 2265 "Use && for logical AND. Single & will background and return true." $ + (fixWith [replaceEnd id params 0 "&"]) + + +prop_checkComparisonWithLeadingX1 = verify checkComparisonWithLeadingX "[ x$foo = xlol ]" +prop_checkComparisonWithLeadingX2 = verify checkComparisonWithLeadingX "test x$foo = xlol" +prop_checkComparisonWithLeadingX3 = verifyNot checkComparisonWithLeadingX "[ $foo = xbar ]" +prop_checkComparisonWithLeadingX4 = verifyNot checkComparisonWithLeadingX "test $foo = xbar" +prop_checkComparisonWithLeadingX5 = verify checkComparisonWithLeadingX "[ \"x$foo\" = 'xlol' ]" +prop_checkComparisonWithLeadingX6 = verify checkComparisonWithLeadingX "[ x\"$foo\" = x'lol' ]" +prop_checkComparisonWithLeadingX7 = verify checkComparisonWithLeadingX "[ X$foo != Xbar ]" +checkComparisonWithLeadingX params t = + case t of + TC_Binary id typ op lhs rhs + | op `elem` ["=", "==", "!="] -> + check lhs rhs + T_SimpleCommand _ _ [cmd, lhs, op, rhs] + | getLiteralString cmd == Just "test" && + getLiteralString op `elem` [Just "=", Just "==", Just "!="] -> + check lhs rhs + _ -> return () + where + msg = "Avoid x-prefix in comparisons as it no longer serves a purpose." + check lhs rhs = sequence_ $ do + l <- fixLeadingX lhs + r <- fixLeadingX rhs + return $ styleWithFix (getId lhs) 2268 msg $ fixWith [l, r] + + fixLeadingX token = + case getWordParts token of + T_Literal id (c:_):_ | toLower c == 'x' -> + case token of + -- The side is a single, unquoted x or X, so we have to quote + T_NormalWord _ [T_Literal id [c]] -> + return $ replaceStart id params 1 "\"\"" + -- Otherwise we can just delete it + _ -> return $ replaceStart id params 1 "" + T_SingleQuoted id (c:rest):_ | toLower c == 'x' -> + -- Replace the single quote and the character x or X + return $ replaceStart id params 2 "'" + _ -> Nothing + + +prop_checkAssignToSelf1 = verify checkAssignToSelf "x=$x" +prop_checkAssignToSelf2 = verify checkAssignToSelf "x=${x}" +prop_checkAssignToSelf3 = verify checkAssignToSelf "x=\"$x\"" +prop_checkAssignToSelf4 = verifyNot checkAssignToSelf "x=$x mycmd" +checkAssignToSelf _ t = + case t of + T_SimpleCommand _ vars [] -> mapM_ check vars + _ -> return () + where + check t = + case t of + T_Assignment id Assign name [] t -> + case getWordParts t of + [T_DollarBraced _ _ b] -> do + when (Just name == getLiteralString b) $ + msg id + _ -> return () + _ -> return () + msg id = info id 2269 "This variable is assigned to itself, so the assignment does nothing." + + +prop_checkEqualsInCommand1a = verifyCodes checkEqualsInCommand [2277] "#!/bin/bash\n0='foo'" +prop_checkEqualsInCommand2a = verifyCodes checkEqualsInCommand [2278] "#!/bin/ksh \n$0='foo'" +prop_checkEqualsInCommand3a = verifyCodes checkEqualsInCommand [2279] "#!/bin/dash\n${0}='foo'" +prop_checkEqualsInCommand4a = verifyCodes checkEqualsInCommand [2280] "#!/bin/sh \n0='foo'" + +prop_checkEqualsInCommand1b = verifyCodes checkEqualsInCommand [2270] "1='foo'" +prop_checkEqualsInCommand2b = verifyCodes checkEqualsInCommand [2270] "${2}='foo'" + +prop_checkEqualsInCommand1c = verifyCodes checkEqualsInCommand [2271] "var$((n+1))=value" +prop_checkEqualsInCommand2c = verifyCodes checkEqualsInCommand [2271] "var${x}=value" +prop_checkEqualsInCommand3c = verifyCodes checkEqualsInCommand [2271] "var$((cmd))x='foo'" +prop_checkEqualsInCommand4c = verifyCodes checkEqualsInCommand [2271] "$(cmd)='foo'" + +prop_checkEqualsInCommand1d = verifyCodes checkEqualsInCommand [2273] "=======" +prop_checkEqualsInCommand2d = verifyCodes checkEqualsInCommand [2274] "======= Here =======" +prop_checkEqualsInCommand3d = verifyCodes checkEqualsInCommand [2275] "foo\n=42" + +prop_checkEqualsInCommand1e = verifyCodes checkEqualsInCommand [] "--foo=bar" +prop_checkEqualsInCommand2e = verifyCodes checkEqualsInCommand [] "$(cmd)'=foo'" +prop_checkEqualsInCommand3e = verifyCodes checkEqualsInCommand [2276] "var${x}/=value" +prop_checkEqualsInCommand4e = verifyCodes checkEqualsInCommand [2276] "${}=value" +prop_checkEqualsInCommand5e = verifyCodes checkEqualsInCommand [2276] "${#x}=value" + +prop_checkEqualsInCommand1f = verifyCodes checkEqualsInCommand [2281] "$var=foo" +prop_checkEqualsInCommand2f = verifyCodes checkEqualsInCommand [2281] "$a=$b" +prop_checkEqualsInCommand3f = verifyCodes checkEqualsInCommand [2281] "${var}=foo" +prop_checkEqualsInCommand4f = verifyCodes checkEqualsInCommand [2281] "${var[42]}=foo" +prop_checkEqualsInCommand5f = verifyCodes checkEqualsInCommand [2281] "$var+=foo" + +prop_checkEqualsInCommand1g = verifyCodes checkEqualsInCommand [2282] "411toppm=true" + +checkEqualsInCommand params originalToken = + case originalToken of + T_SimpleCommand _ _ (word:_) -> check word + _ -> return () + where + hasEquals t = + case t of + T_Literal _ s -> '=' `elem` s + _ -> False + + check t@(T_NormalWord _ list) | any hasEquals list = + case break hasEquals list of + (leading, (eq:_)) -> msg t (stripSinglePlus leading) eq + _ -> return () + check _ = return () + + -- This is a workaround for the parser adding + and = as separate literals + stripSinglePlus l = + case reverse l of + (T_Literal _ "+"):rest -> reverse rest + _ -> l + + positionalAssignmentRe = mkRegex "^[0-9][0-9]?=" + positionalMsg id = + err id 2270 "To assign positional parameters, use 'set -- first second ..' (or use [ ] to compare)." + indirectionMsg id = + err id 2271 "For indirection, use arrays, declare \"var$n=value\", or (for sh) read/eval." + badComparisonMsg id = + err id 2272 "Command name contains ==. For comparison, use [ \"$var\" = value ]." + conflictMarkerMsg id = + err id 2273 "Sequence of ===s found. Merge conflict or intended as a commented border?" + borderMsg id = + err id 2274 "Command name starts with ===. Intended as a commented border?" + prefixMsg id = + err id 2275 "Command name starts with =. Bad line break?" + genericMsg id = + err id 2276 "This is interpreted as a command name containing '='. Bad assignment or comparison?" + assign0Msg id bashfix = + case shellType params of + Bash -> errWithFix id 2277 "Use BASH_ARGV0 to assign to $0 in bash (or use [ ] to compare)." bashfix + Ksh -> err id 2278 "$0 can't be assigned in Ksh (but it does reflect the current function)." + Dash -> err id 2279 "$0 can't be assigned in Dash. This becomes a command name." + BusyboxSh -> err id 2279 "$0 can't be assigned in Busybox Ash. This becomes a command name." + _ -> err id 2280 "$0 can't be assigned this way, and there is no portable alternative." + leadingNumberMsg id = + err id 2282 "Variable names can't start with numbers, so this is interpreted as a command." + + isExpansion t = + case t of + T_Arithmetic {} -> True + _ -> isQuoteableExpansion t + + isConflictMarker cmd = fromMaybe False $ do + str <- getUnquotedLiteral cmd + guard $ all (== '=') str + guard $ length str >= 4 && length str <= 12 -- Git uses 7 but who knows + return True + + mayBeVariableName l = fromMaybe False $ do + guard . not $ any isQuotes l + guard . not $ any willBecomeMultipleArgs l + str <- getLiteralStringExt (\_ -> Just "x") (T_NormalWord (Id 0) l) + return $ isVariableName str + + isLeadingNumberVar s = + case takeWhile (/= '=') s of + lead@(x:_) -> isDigit x && all isVariableChar lead && not (all isDigit lead) + [] -> False + + msg cmd leading (T_Literal litId s) = do + -- There are many different cases, and the order of the branches matter. + case leading of + -- --foo=42 + [] | "-" `isPrefixOf` s -> -- There's SC2215 for these + return () + + -- ======Hello====== + [] | "=" `isPrefixOf` s -> + case originalToken of + T_SimpleCommand _ [] [word] | isConflictMarker word -> + conflictMarkerMsg (getId originalToken) + _ | "===" `isPrefixOf` s -> borderMsg (getId originalToken) + _ -> prefixMsg (getId cmd) + + -- '$var==42' + _ | "==" `isInfixOf` s -> + badComparisonMsg (getId cmd) + + -- '${foo[x]}=42' and '$foo=42' + [T_DollarBraced id braced l] | "=" `isPrefixOf` s -> do + let variableStr = concat $ oversimplify l + let variableReference = getBracedReference variableStr + let variableModifier = getBracedModifier variableStr + let isPlain = isVariableName variableStr + let isPositional = all isDigit variableStr + + let isArray = variableReference /= "" + && "[" `isPrefixOf` variableModifier + && "]" `isSuffixOf` variableModifier + + case () of + -- '$foo=bar' should already have caused a parse-time SC1066 + -- _ | not braced && isPlain -> + -- return () + + _ | variableStr == "" -> -- Don't try to fix ${}=foo + genericMsg (getId cmd) + + -- '$#=42' or '${#var}=42' + _ | "#" `isPrefixOf` variableStr -> + genericMsg (getId cmd) + + -- '${0}=42' + _ | variableStr == "0" -> + assign0Msg id $ fixWith [replaceToken id params "BASH_ARGV0"] + + -- '$2=2' + _ | isPositional -> + positionalMsg id + + _ | isArray || isPlain -> + errWithFix id 2281 + ("Don't use " ++ (if braced then "${}" else "$") ++ " on the left side of assignments.") $ + fixWith $ + if braced + then [ replaceStart id params 2 "", replaceEnd id params 1 "" ] + else [ replaceStart id params 1 "" ] + + _ -> indirectionMsg id + + -- 2=42 + [] | s `matches` positionalAssignmentRe -> + if "0=" `isPrefixOf` s + then + assign0Msg litId $ fixWith [replaceStart litId params 1 "BASH_ARGV0"] + else + positionalMsg litId + + -- 9foo=42 + [] | isLeadingNumberVar s -> + leadingNumberMsg (getId cmd) + + -- var${foo}x=42 + (_:_) | mayBeVariableName leading && (all isVariableChar $ takeWhile (/= '=') s) -> + indirectionMsg (getId cmd) + + _ -> genericMsg (getId cmd) + + +prop_checkSecondArgIsComparison1 = verify checkSecondArgIsComparison "foo = $bar" +prop_checkSecondArgIsComparison2 = verify checkSecondArgIsComparison "$foo = $bar" +prop_checkSecondArgIsComparison3 = verify checkSecondArgIsComparison "2f == $bar" +prop_checkSecondArgIsComparison4 = verify checkSecondArgIsComparison "'var' =$bar" +prop_checkSecondArgIsComparison5 = verify checkSecondArgIsComparison "foo ='$bar'" +prop_checkSecondArgIsComparison6 = verify checkSecondArgIsComparison "$foo =$bar" +prop_checkSecondArgIsComparison7 = verify checkSecondArgIsComparison "2f ==$bar" +prop_checkSecondArgIsComparison8 = verify checkSecondArgIsComparison "'var' =$bar" +prop_checkSecondArgIsComparison9 = verify checkSecondArgIsComparison "var += $(foo)" +prop_checkSecondArgIsComparison10 = verify checkSecondArgIsComparison "var +=$(foo)" +checkSecondArgIsComparison _ t = + case t of + T_SimpleCommand _ _ (lhs:arg:_) -> sequence_ $ do + argString <- getLeadingUnquotedString arg + case argString of + '=':'=':'=':'=':_ -> Nothing -- Don't warn about `echo ======` and such + '+':'=':_ -> + return $ err (headId t) 2285 $ + "Remove spaces around += to assign (or quote '+=' if literal)." + '=':'=':_ -> + return $ err (getId t) 2284 $ + "Use [ x = y ] to compare values (or quote '==' if literal)." + '=':_ -> + return $ err (headId arg) 2283 $ + "Remove spaces around = to assign (or use [ ] to compare, or quote '=' if literal)." + _ -> Nothing + _ -> return () + where + -- We don't pinpoint exactly, but this helps cases like foo =$bar + headId t = + case t of + T_NormalWord _ (x:_) -> getId x + _ -> getId t + + +prop_checkCommandWithTrailingSymbol1 = verify checkCommandWithTrailingSymbol "/" +prop_checkCommandWithTrailingSymbol2 = verify checkCommandWithTrailingSymbol "/foo/ bar/baz" +prop_checkCommandWithTrailingSymbol3 = verify checkCommandWithTrailingSymbol "/" +prop_checkCommandWithTrailingSymbol4 = verifyNot checkCommandWithTrailingSymbol "/*" +prop_checkCommandWithTrailingSymbol5 = verifyNot checkCommandWithTrailingSymbol "$foo/$bar" +prop_checkCommandWithTrailingSymbol6 = verify checkCommandWithTrailingSymbol "foo, bar" +prop_checkCommandWithTrailingSymbol7 = verifyNot checkCommandWithTrailingSymbol ". foo.sh" +prop_checkCommandWithTrailingSymbol8 = verifyNot checkCommandWithTrailingSymbol ": foo" +prop_checkCommandWithTrailingSymbol9 = verifyNot checkCommandWithTrailingSymbol "/usr/bin/python[23] file.py" + +checkCommandWithTrailingSymbol _ t = + case t of + T_SimpleCommand _ _ (cmd:_) -> + let str = getLiteralStringDef "x" cmd + last = lastOrDefault 'x' str + in + case str of + "." -> return () -- The . command + ":" -> return () -- The : command + " " -> return () -- Probably caught by SC1101 + "//" -> return () -- Probably caught by SC1127 + "" -> err (getId cmd) 2286 "This empty string is interpreted as a command name. Double check syntax (or use 'true' as a no-op)." + _ | last == '/' -> err (getId cmd) 2287 "This is interpreted as a command name ending with '/'. Double check syntax." + _ | last `elem` "\\.,([{<>}])#\"\'% " -> warn (getId cmd) 2288 ("This is interpreted as a command name ending with " ++ (format last) ++ ". Double check syntax.") + _ | '\t' `elem` str -> err (getId cmd) 2289 "This is interpreted as a command name containing a tab. Double check syntax." + _ | '\n' `elem` str -> err (getId cmd) 2289 "This is interpreted as a command name containing a linefeed. Double check syntax." + _ -> return () + _ -> return () + where + format x = + case x of + ' ' -> "space" + '\'' -> "apostrophe" + '\"' -> "doublequote" + x -> '\'' : x : "\'" + + +prop_checkRequireDoubleBracket1 = verifyTree checkRequireDoubleBracket "[ -x foo ]" +prop_checkRequireDoubleBracket2 = verifyTree checkRequireDoubleBracket "[ foo -o bar ]" +prop_checkRequireDoubleBracket3 = verifyNotTree checkRequireDoubleBracket "#!/bin/sh\n[ -x foo ]" +prop_checkRequireDoubleBracket4 = verifyNotTree checkRequireDoubleBracket "[[ -x foo ]]" +checkRequireDoubleBracket params = + if (shellType params) `elem` [Bash, Ksh, BusyboxSh] + then nodeChecksToTreeCheck [check] params + else const [] + where + check _ t = case t of + T_Condition id SingleBracket _ -> + styleWithFix id 2292 "Prefer [[ ]] over [ ] for tests in Bash/Ksh/Busybox." (fixFor t) + _ -> return () + + fixFor t = fixWith $ + if isSimple t + then + [ + replaceStart (getId t) params 0 "[", + replaceEnd (getId t) params 0 "]" + ] + else [] + + -- We don't tag operators like < and -o well enough to replace them, + -- so just handle the simple cases. + isSimple t = case t of + T_Condition _ _ s -> isSimple s + TC_Binary _ _ op _ _ -> not $ any (\x -> x `elem` op) "<>" + TC_Unary {} -> True + TC_Nullary {} -> True + _ -> False + + +prop_checkUnquotedParameterExpansionPattern1 = verify checkUnquotedParameterExpansionPattern "echo \"${var#$x}\"" +prop_checkUnquotedParameterExpansionPattern2 = verify checkUnquotedParameterExpansionPattern "echo \"${var%%$(x)}\"" +prop_checkUnquotedParameterExpansionPattern3 = verifyNot checkUnquotedParameterExpansionPattern "echo \"${var[#$x]}\"" +prop_checkUnquotedParameterExpansionPattern4 = verifyNot checkUnquotedParameterExpansionPattern "echo \"${var%\"$x\"}\"" + +checkUnquotedParameterExpansionPattern params x = + case x of + T_DollarBraced _ True word@(T_NormalWord _ (T_Literal _ s : rest@(_:_))) -> do + let modifier = getBracedModifier $ concat $ oversimplify word + when ("%" `isPrefixOf` modifier || "#" `isPrefixOf` modifier) $ + mapM_ check rest + _ -> return () + where + check t = + case t of + T_DollarBraced {} -> inform t + T_DollarExpansion {} -> inform t + T_Backticked {} -> inform t + _ -> return () + + inform t = + infoWithFix (getId t) 2295 + "Expansions inside ${..} need to be quoted separately, otherwise they match as patterns." $ + surroundWith (getId t) params "\"" + + +prop_checkArrayValueUsedAsIndex1 = verifyTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do echo ${arr[i]}; done" +prop_checkArrayValueUsedAsIndex2 = verifyTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do echo ${arr[$i]}; done" +prop_checkArrayValueUsedAsIndex3 = verifyTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do echo $((arr[i])); done" +prop_checkArrayValueUsedAsIndex4 = verifyTree checkArrayValueUsedAsIndex "for i in ${arr1[@]} ${arr2[@]}; do echo ${arr1[$i]}; done" +prop_checkArrayValueUsedAsIndex5 = verifyTree checkArrayValueUsedAsIndex "for i in ${arr1[@]} ${arr2[@]}; do echo ${arr2[$i]}; done" +prop_checkArrayValueUsedAsIndex7 = verifyNotTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do echo ${arr[K]}; done" +prop_checkArrayValueUsedAsIndex8 = verifyNotTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do i=42; echo ${arr[i]}; done" +prop_checkArrayValueUsedAsIndex9 = verifyNotTree checkArrayValueUsedAsIndex "for i in ${arr[@]}; do echo ${arr2[i]}; done" + +checkArrayValueUsedAsIndex params _ = + doVariableFlowAnalysis read write Map.empty (variableFlow params) + where + write loop@T_ForIn {} _ name (DataString (SourceFrom words)) = do + modify $ Map.insert name (loop, mapMaybe f words) + return [] + where + f x = do + name <- getArrayName x + return (x, name) + + write _ _ name _ = do + modify $ Map.delete name + return [] + + read _ t name = do + varMap <- get + return $ fromMaybe [] $ do + (loop, arrays) <- Map.lookup name varMap + (arrayRef, arrayName) <- getArrayIfUsedAsIndex name t + -- Is this one of the 'for' arrays? + (loopWord, _) <- find ((==arrayName) . snd) arrays + -- Are we still in this loop? + let loopId = getId loop + guard $ any (\t -> loopId == getId t) (getPath parents t) + return [ + makeComment WarningC (getId loopWord) 2302 "This loops over values. To loop over keys, use \"${!array[@]}\".", + makeComment WarningC (getId arrayRef) 2303 $ (e4m name) ++ " is an array value, not a key. Use directly or loop over keys instead." + ] + + parents = parentMap params + + getArrayName :: Token -> Maybe String + getArrayName t = do + [T_DollarBraced _ _ l] <- return $ getWordParts t + let str = concat $ oversimplify l + guard $ getBracedModifier str == "[@]" && not ("!" `isPrefixOf` str) + return $ getBracedReference str + + -- This is much uglier than it should be + getArrayIfUsedAsIndex :: String -> Token -> Maybe (Token, String) + getArrayIfUsedAsIndex name t = + case t of + T_DollarBraced _ _ list -> do + let ref = getBracedReference $ concat $ oversimplify list + guard $ ref == name + -- We found a $name. Look up the chain to see if it's ${arr[$name]} + list@T_NormalWord {} <- Map.lookup (getId t) parents + (T_DollarBraced _ _ parentList) <- Map.lookup (getId list) parents + (T_Literal _ head : index : T_Literal _ tail : _) <- return $ getWordParts parentList + let str = concat $ oversimplify list + let modifier = getBracedModifier str + guard $ getId index == getId t + guard $ "[${VAR}]" `isPrefixOf` modifier + return (t, getBracedReference str) + + T_NormalWord wordId list -> do + -- We found just name. Check if it's part of ${something[name]} + parent@(T_DollarBraced _ _ parentList) <- Map.lookup wordId parents + let str = concat $ oversimplify t + let modifier = getBracedModifier str + guard $ ("[" ++ name ++ "]") `isPrefixOf` modifier + return (parent, getBracedReference str) + + TA_Variable indexId ref [] -> do + -- We found arithmetic name. See if it's part of arithmetic arr[name] + guard $ ref == name + (TA_Sequence seqId [element]) <- Map.lookup indexId parents + guard $ getId element == indexId + parent@(TA_Variable arrayId arrayName [element]) <- Map.lookup seqId parents + guard $ getId element == seqId + return (parent, arrayName) + + _ -> Nothing + +prop_checkSetESuppressed1 = verifyTree checkSetESuppressed "set -e; f(){ :; }; x=$(f)" +prop_checkSetESuppressed2 = verifyNotTree checkSetESuppressed "f(){ :; }; x=$(f)" +prop_checkSetESuppressed3 = verifyNotTree checkSetESuppressed "set -e; f(){ :; }; x=$(set -e; f)" +prop_checkSetESuppressed4 = verifyTree checkSetESuppressed "set -e; f(){ :; }; baz=$(set -e; f) || :" +prop_checkSetESuppressed5 = verifyNotTree checkSetESuppressed "set -e; f(){ :; }; baz=$(echo \"\") || :" +prop_checkSetESuppressed6 = verifyTree checkSetESuppressed "set -e; f(){ :; }; f && echo" +prop_checkSetESuppressed7 = verifyTree checkSetESuppressed "set -e; f(){ :; }; f || echo" +prop_checkSetESuppressed8 = verifyNotTree checkSetESuppressed "set -e; f(){ :; }; echo && f" +prop_checkSetESuppressed9 = verifyNotTree checkSetESuppressed "set -e; f(){ :; }; echo || f" +prop_checkSetESuppressed10 = verifyTree checkSetESuppressed "set -e; f(){ :; }; ! f" +prop_checkSetESuppressed11 = verifyTree checkSetESuppressed "set -e; f(){ :; }; if f; then :; fi" +prop_checkSetESuppressed12 = verifyTree checkSetESuppressed "set -e; f(){ :; }; if set -e; f; then :; fi" +prop_checkSetESuppressed13 = verifyTree checkSetESuppressed "set -e; f(){ :; }; while f; do :; done" +prop_checkSetESuppressed14 = verifyTree checkSetESuppressed "set -e; f(){ :; }; while set -e; f; do :; done" +prop_checkSetESuppressed15 = verifyTree checkSetESuppressed "set -e; f(){ :; }; until f; do :; done" +prop_checkSetESuppressed16 = verifyTree checkSetESuppressed "set -e; f(){ :; }; until set -e; f; do :; done" +prop_checkSetESuppressed17 = verifyNotTree checkSetESuppressed "set -e; f(){ :; }; g(){ :; }; g f" +prop_checkSetESuppressed18 = verifyNotTree checkSetESuppressed "set -e; shopt -s inherit_errexit; f(){ :; }; x=$(f)" +prop_checkSetESuppressed19 = verifyNotTree checkSetESuppressed "set -e; set -o posix; f(){ :; }; x=$(f)" +checkSetESuppressed params t = + if hasSetE params then runNodeAnalysis checkNode params t else [] + where + checkNode _ (T_SimpleCommand _ _ (cmd:_)) = when (isFunction cmd) (checkCmd cmd) + checkNode _ _ = return () + + functions_ = functions t + + isFunction cmd = isJust $ do + literalArg <- getUnquotedLiteral cmd + Map.lookup literalArg functions_ + + checkCmd cmd = go $ NE.toList $ getPath (parentMap params) cmd + where + go (child:parent:rest) = do + case parent of + T_Banged _ condition | child `isIn` [condition] -> informConditional "a ! condition" cmd + T_AndIf _ condition _ | child `isIn` [condition] -> informConditional "an && condition" cmd + T_OrIf _ condition _ | child `isIn` [condition] -> informConditional "an || condition" cmd + T_IfExpression _ condition _ | child `isIn` concatMap fst condition -> informConditional "an 'if' condition" cmd + T_UntilExpression _ condition _ | child `isIn` condition -> informConditional "an 'until' condition" cmd + T_WhileExpression _ condition _ | child `isIn` condition -> informConditional "a 'while' condition" cmd + T_DollarExpansion {} | not $ errExitEnabled parent -> informUninherited cmd + T_Backticked {} | not $ errExitEnabled parent -> informUninherited cmd + _ -> return () + go (parent:rest) + go _ = return () + + informConditional condType t = + info (getId t) 2310 ( + "This function is invoked in " ++ condType ++ " so set -e " ++ + "will be disabled. Invoke separately if failures should " ++ + "cause the script to exit.") + informUninherited t = + info (getId t) 2311 ( + "Bash implicitly disabled set -e for this function " ++ + "invocation because it's inside a command substitution. " ++ + "Add set -e; before it or enable inherit_errexit.") + errExitEnabled t = hasInheritErrexit params || containsSetE t + isIn t cmds = getId t `elem` map getId cmds + + +prop_checkExtraMaskedReturns1 = verifyTree checkExtraMaskedReturns "cat < <(ls)" +prop_checkExtraMaskedReturns2 = verifyTree checkExtraMaskedReturns "read -ra arr <(ls)" +prop_checkExtraMaskedReturns3 = verifyTree checkExtraMaskedReturns "ls >(cat)" +prop_checkExtraMaskedReturns4 = verifyTree checkExtraMaskedReturns "false | true" +prop_checkExtraMaskedReturns5 = verifyNotTree checkExtraMaskedReturns "set -o pipefail; false | true" +prop_checkExtraMaskedReturns6 = verifyNotTree checkExtraMaskedReturns "false | true || true" +prop_checkExtraMaskedReturns7 = verifyTree checkExtraMaskedReturns "true $(false)" +prop_checkExtraMaskedReturns8 = verifyTree checkExtraMaskedReturns "x=$(false)$(true)" +prop_checkExtraMaskedReturns9 = verifyNotTree checkExtraMaskedReturns "x=$(false)true" +prop_checkExtraMaskedReturns10 = verifyTree checkExtraMaskedReturns "x=`false``false`" +prop_checkExtraMaskedReturns11 = verifyTree checkExtraMaskedReturns "x=\"$(false)$(true)\"" +prop_checkExtraMaskedReturns12 = verifyTree checkExtraMaskedReturns "x=\"$(false)\"\"$(true)\"" +prop_checkExtraMaskedReturns13 = verifyTree checkExtraMaskedReturns "true <<<$(false)" +prop_checkExtraMaskedReturns14 = verifyNotTree checkExtraMaskedReturns "echo asdf | false" +prop_checkExtraMaskedReturns15 = verifyNotTree checkExtraMaskedReturns "readonly x=$(false)" +prop_checkExtraMaskedReturns16 = verifyTree checkExtraMaskedReturns "readarray -t files < <(ls)" +prop_checkExtraMaskedReturns17 = verifyNotTree checkExtraMaskedReturns "x=( $(false) false )" +prop_checkExtraMaskedReturns18 = verifyTree checkExtraMaskedReturns "x=( $(false) $(false) )" +prop_checkExtraMaskedReturns19 = verifyNotTree checkExtraMaskedReturns "x=( $(false) [4]=false )" +prop_checkExtraMaskedReturns20 = verifyTree checkExtraMaskedReturns "x=( $(false) [4]=$(false) )" +prop_checkExtraMaskedReturns21 = verifyTree checkExtraMaskedReturns "cat << foo\n $(false)\nfoo" +prop_checkExtraMaskedReturns22 = verifyTree checkExtraMaskedReturns "[[ $(false) ]]" +prop_checkExtraMaskedReturns23 = verifyNotTree checkExtraMaskedReturns "x=$(false) y=z" +prop_checkExtraMaskedReturns24 = verifyNotTree checkExtraMaskedReturns "x=$(( $(date +%s) ))" +prop_checkExtraMaskedReturns25 = verifyTree checkExtraMaskedReturns "echo $(( $(date +%s) ))" +prop_checkExtraMaskedReturns26 = verifyNotTree checkExtraMaskedReturns "x=( $(false) )" +prop_checkExtraMaskedReturns27 = verifyTree checkExtraMaskedReturns "x=$(false) false" +prop_checkExtraMaskedReturns28 = verifyTree checkExtraMaskedReturns "x=$(false) y=$(false)" +prop_checkExtraMaskedReturns29 = verifyNotTree checkExtraMaskedReturns "false < <(set -e)" +prop_checkExtraMaskedReturns30 = verifyNotTree checkExtraMaskedReturns "false < <(shopt -s cdspell)" +prop_checkExtraMaskedReturns31 = verifyNotTree checkExtraMaskedReturns "false < <(dirname \"${BASH_SOURCE[0]}\")" +prop_checkExtraMaskedReturns32 = verifyNotTree checkExtraMaskedReturns "false < <(basename \"${BASH_SOURCE[0]}\")" +prop_checkExtraMaskedReturns33 = verifyNotTree checkExtraMaskedReturns "{ false || true; } | true" +prop_checkExtraMaskedReturns34 = verifyNotTree checkExtraMaskedReturns "{ false || :; } | true" +prop_checkExtraMaskedReturns35 = verifyTree checkExtraMaskedReturns "f() { local -r x=$(false); }" +prop_checkExtraMaskedReturns36 = verifyNotTree checkExtraMaskedReturns "time false" +prop_checkExtraMaskedReturns37 = verifyNotTree checkExtraMaskedReturns "time $(time false)" +prop_checkExtraMaskedReturns38 = verifyTree checkExtraMaskedReturns "x=$(time time time false) time $(time false)" + +checkExtraMaskedReturns params t = + runNodeAnalysis findMaskingNodes params (removeTransparentCommands t) + where + findMaskingNodes _ (T_Arithmetic _ list) = findMaskedNodesInList [list] + findMaskingNodes _ (T_Array _ list) = findMaskedNodesInList $ allButLastSimpleCommands list + findMaskingNodes _ (T_Condition _ _ condition) = findMaskedNodesInList [condition] + findMaskingNodes _ (T_DoubleQuoted _ list) = findMaskedNodesInList $ allButLastSimpleCommands list + findMaskingNodes _ (T_HereDoc _ _ _ _ list) = findMaskedNodesInList list + findMaskingNodes _ (T_HereString _ word) = findMaskedNodesInList [word] + findMaskingNodes _ (T_NormalWord _ parts) = findMaskedNodesInList $ allButLastSimpleCommands parts + findMaskingNodes _ (T_Pipeline _ _ cmds) | not (hasPipefail params) = findMaskedNodesInList $ allButLastSimpleCommands cmds + findMaskingNodes _ (T_ProcSub _ _ list) = findMaskedNodesInList list + findMaskingNodes _ (T_SimpleCommand _ assigns (_:args)) = findMaskedNodesInList $ assigns ++ args + findMaskingNodes _ (T_SimpleCommand _ assigns []) = findMaskedNodesInList $ allButLastSimpleCommands assigns + findMaskingNodes _ _ = return () + + findMaskedNodesInList = mapM_ (doAnalysis findMaskedNodes) + + isMaskedNode t = not (isHarmlessCommand t || isCheckedElsewhere t || isMaskDeliberate t) + findMaskedNodes t@(T_SimpleCommand _ _ (_:_)) = when (isMaskedNode t) $ inform t + findMaskedNodes t@T_Condition {} = when (isMaskedNode t) $ inform t + findMaskedNodes _ = return () + + containsSimpleCommand t = isNothing $ doAnalysis go t + where + go t = case t of + T_SimpleCommand {} -> fail "" + _ -> return () + + allButLastSimpleCommands cmds = + if null simpleCommands then [] else init simpleCommands + where + simpleCommands = filter containsSimpleCommand cmds + + removeTransparentCommands t = + doTransform go t + where + go cmd@(T_SimpleCommand id assigns (_:args)) | isTransparentCommand cmd + = T_SimpleCommand id assigns args + go t = t + + inform t = info (getId t) 2312 ("Consider invoking this command " + ++ "separately to avoid masking its return value (or use '|| true' " + ++ "to ignore).") + + isMaskDeliberate t = any isOrIf $ NE.init $ parents params t + where + isOrIf (T_OrIf _ _ (T_Pipeline _ _ [T_Redirecting _ _ cmd])) + = getCommandBasename cmd `elem` [Just "true", Just ":"] + isOrIf _ = False + + isCheckedElsewhere t = any isDeclaringCommand $ NE.tail $ parents params t + where + isDeclaringCommand t = fromMaybe False $ do + cmd <- getCommand t + basename <- getCommandBasename cmd + return $ + case basename of + -- local -r x=$(false) is intentionally ignored for SC2155 + "local" | "r" `elem` (map snd $ getAllFlags cmd) -> False + _ -> basename `elem` declaringCommands + + isHarmlessCommand t = fromMaybe False $ do + basename <- getCommandBasename t + return $ basename `elem` [ + "echo" + ,"basename" + ,"dirname" + ,"printf" + ,"set" + ,"shopt" + ] + + isTransparentCommand t = getCommandBasename t == Just "time" + + +-- hard error on negated command that is not last +prop_checkBatsTestDoesNotUseNegation1 = verify checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { ! true; false; }" +prop_checkBatsTestDoesNotUseNegation2 = verify checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { ! [[ -e test ]]; false; }" +prop_checkBatsTestDoesNotUseNegation3 = verify checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { ! [ -e test ]; false; }" +-- acceptable formats: +-- using run +prop_checkBatsTestDoesNotUseNegation4 = verifyNot checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { run ! true; }" +-- using || false +prop_checkBatsTestDoesNotUseNegation5 = verifyNot checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { ! [[ -e test ]] || false; }" +prop_checkBatsTestDoesNotUseNegation6 = verifyNot checkBatsTestDoesNotUseNegation "#!/usr/bin/env/bats\n@test \"name\" { ! [ -e test ] || false; }" +-- only style warning when last command +prop_checkBatsTestDoesNotUseNegation7 = verifyCodes checkBatsTestDoesNotUseNegation [2314] "#!/usr/bin/env/bats\n@test \"name\" { ! true; }" +prop_checkBatsTestDoesNotUseNegation8 = verifyCodes checkBatsTestDoesNotUseNegation [2315] "#!/usr/bin/env/bats\n@test \"name\" { ! [[ -e test ]]; }" +prop_checkBatsTestDoesNotUseNegation9 = verifyCodes checkBatsTestDoesNotUseNegation [2315] "#!/usr/bin/env/bats\n@test \"name\" { ! [ -e test ]; }" + +checkBatsTestDoesNotUseNegation params t = + case t of + T_BatsTest _ _ (T_BraceGroup _ commands) -> mapM_ (check commands) commands + _ -> return () + where + check commands t = + case t of + T_Banged id (T_Pipeline _ _ [T_Redirecting _ _ (T_Condition idCondition _ _)]) -> + if t `isLastOf` commands + then style id 2315 "In Bats, ! will not fail the test if it is not the last command anymore. Fold the `!` into the conditional!" + else err id 2315 "In Bats, ! does not cause a test failure. Fold the `!` into the conditional!" + + T_Banged id cmd -> if t `isLastOf` commands + then styleWithFix id 2314 "In Bats, ! will not fail the test if it is not the last command anymore. Use `run ! ` (on Bats >= 1.5.0) instead." + (fixWith [replaceStart id params 0 "run "]) + else errWithFix id 2314 "In Bats, ! does not cause a test failure. Use 'run ! ' (on Bats >= 1.5.0) instead." + (fixWith [replaceStart id params 0 "run "]) + _ -> return () + isLastOf t commands = + case commands of + [x] -> x == t + x:rest -> isLastOf t rest + [] -> False + + +prop_checkCommandIsUnreachable1 = verify checkCommandIsUnreachable "foo; bar; exit; baz" +prop_checkCommandIsUnreachable2 = verify checkCommandIsUnreachable "die() { exit; }; foo; bar; die; baz" +prop_checkCommandIsUnreachable3 = verifyNot checkCommandIsUnreachable "foo; bar || exit; baz" +prop_checkCommandIsUnreachable4 = verifyNot checkCommandIsUnreachable "f() { foo; }; # Maybe sourced" +prop_checkCommandIsUnreachable5 = verify checkCommandIsUnreachable "f() { foo; }; exit # Not sourced" +checkCommandIsUnreachable params t = + case t of + T_Pipeline {} -> sequence_ $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga (getId t) + guard . not $ CF.stateIsReachable state + guard . not $ isSourced params t + guard . not $ any (\t -> isUnreachable t || isUnreachableFunction t) $ NE.drop 1 $ getPath (parentMap params) t + return $ info (getId t) 2317 "Command appears to be unreachable. Check usage (or ignore if invoked indirectly)." + T_Function id _ _ _ _ -> + when (isUnreachableFunction t + && (not . any isUnreachableFunction . NE.drop 1 $ getPath (parentMap params) t) + && (not $ isSourced params t)) $ + info id 2329 "This function is never invoked. Check usage (or ignored if invoked indirectly)." + _ -> return () + where + isUnreachableFunction :: Token -> Bool + isUnreachableFunction f = + case f of + T_Function id _ _ _ t -> isUnreachable t + _ -> False + isUnreachable t = fromMaybe False $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga (getId t) + return . not $ CF.stateIsReachable state + + +prop_checkOverwrittenExitCode1 = verify checkOverwrittenExitCode "x; [ $? -eq 1 ] || [ $? -eq 2 ]" +prop_checkOverwrittenExitCode2 = verifyNot checkOverwrittenExitCode "x; [ $? -eq 1 ]" +prop_checkOverwrittenExitCode3 = verify checkOverwrittenExitCode "x; echo \"Exit is $?\"; [ $? -eq 0 ]" +prop_checkOverwrittenExitCode4 = verifyNot checkOverwrittenExitCode "x; [ $? -eq 0 ] && echo Success" +prop_checkOverwrittenExitCode5 = verify checkOverwrittenExitCode "x; if [ $? -eq 0 ]; then var=$?; fi" +prop_checkOverwrittenExitCode6 = verify checkOverwrittenExitCode "x; [ $? -gt 0 ] && fail=$?" +prop_checkOverwrittenExitCode7 = verifyNot checkOverwrittenExitCode "[ 1 -eq 2 ]; status=$?" +prop_checkOverwrittenExitCode8 = verifyNot checkOverwrittenExitCode "[ 1 -eq 2 ]; exit $?" +checkOverwrittenExitCode params t = + case t of + T_DollarBraced id _ val | getLiteralString val == Just "?" -> check id + _ -> return () + where + check id = sequence_ $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga id + let exitCodeIds = CF.exitCodes state + guard . not $ S.null exitCodeIds + + let idToToken = idMap params + exitCodeTokens <- traverse (\k -> Map.lookup k idToToken) $ S.toList exitCodeIds + return $ do + when (all isCondition exitCodeTokens && not (usedUnconditionally cfga t exitCodeIds)) $ + warn id 2319 "This $? refers to a condition, not a command. Assign to a variable to avoid it being overwritten." + when (all isPrinting exitCodeTokens) $ + warn id 2320 "This $? refers to echo/printf, not a previous command. Assign to variable to avoid it being overwritten." + + isCondition t = + case t of + T_Condition {} -> True + T_SimpleCommand {} -> getCommandName t == Just "test" + _ -> False + + -- If we don't do anything based on the condition, assume we wanted the condition itself + -- This helps differentiate `x; [ $? -gt 0 ] && exit $?` vs `[ cond ]; exit $?` + usedUnconditionally cfga t testIds = + all (\c -> CF.doesPostDominate cfga (getId t) c) testIds + + isPrinting t = + case getCommandBasename t of + Just "echo" -> True + Just "printf" -> True + _ -> False + + +prop_checkUnnecessaryArithmeticExpansionIndex1 = verify checkUnnecessaryArithmeticExpansionIndex "a[$((1+1))]=n" +prop_checkUnnecessaryArithmeticExpansionIndex2 = verifyNot checkUnnecessaryArithmeticExpansionIndex "a[1+1]=n" +prop_checkUnnecessaryArithmeticExpansionIndex3 = verifyNot checkUnnecessaryArithmeticExpansionIndex "a[$(echo $((1+1)))]=n" +prop_checkUnnecessaryArithmeticExpansionIndex4 = verifyNot checkUnnecessaryArithmeticExpansionIndex "declare -A a; a[$((1+1))]=val" +checkUnnecessaryArithmeticExpansionIndex params t = + case t of + T_Assignment _ mode var [TA_Sequence _ [ TA_Expansion _ [expansion@(T_DollarArithmetic id _)]]] val -> + styleWithFix id 2321 "Array indices are already arithmetic contexts. Prefer removing the $(( and ))." $ fix id + _ -> return () + + where + fix id = + fixWith [ + replaceStart id params 3 "", -- Remove "$((" + replaceEnd id params 2 "" -- Remove "))" + ] + + +prop_checkUnnecessaryParens1 = verify checkUnnecessaryParens "echo $(( ((1+1)) ))" +prop_checkUnnecessaryParens2 = verify checkUnnecessaryParens "x[((1+1))+1]=1" +prop_checkUnnecessaryParens3 = verify checkUnnecessaryParens "x[(1+1)]=1" +prop_checkUnnecessaryParens4 = verify checkUnnecessaryParens "$(( (x) ))" +prop_checkUnnecessaryParens5 = verify checkUnnecessaryParens "(( (x) ))" +prop_checkUnnecessaryParens6 = verifyNot checkUnnecessaryParens "x[(1+1)+1]=1" +prop_checkUnnecessaryParens7 = verifyNot checkUnnecessaryParens "(( (1*1)+1 ))" +prop_checkUnnecessaryParens8 = verifyNot checkUnnecessaryParens "(( (1)+1 ))" +checkUnnecessaryParens params t = + case t of + T_DollarArithmetic _ t -> checkLeading "$(( (x) )) is the same as $(( x ))" t + T_ForArithmetic _ x y z _ -> mapM_ (checkLeading "for (((x); (y); (z))) is the same as for ((x; y; z))") [x,y,z] + T_Assignment _ _ _ [t] _ -> checkLeading "a[(x)] is the same as a[x]" t + T_Arithmetic _ t -> checkLeading "(( (x) )) is the same as (( x ))" t + TA_Parenthesis _ (TA_Sequence _ [ TA_Parenthesis id _ ]) -> + styleWithFix id 2322 "In arithmetic contexts, ((x)) is the same as (x). Prefer only one layer of parentheses." $ fix id + _ -> return () + where + + checkLeading str t = + case t of + TA_Sequence _ [TA_Parenthesis id _ ] -> styleWithFix id 2323 (str ++ ". Prefer not wrapping in additional parentheses.") $ fix id + _ -> return () + + fix id = + fixWith [ + replaceStart id params 1 "", -- Remove "(" + replaceEnd id params 1 "" -- Remove ")" + ] + + +prop_checkPlusEqualsNumber1 = verify checkPlusEqualsNumber "x+=1" +prop_checkPlusEqualsNumber2 = verify checkPlusEqualsNumber "x+=42" +prop_checkPlusEqualsNumber3 = verifyNot checkPlusEqualsNumber "(( x += 1 ))" +prop_checkPlusEqualsNumber4 = verifyNot checkPlusEqualsNumber "declare -i x=0; x+=1" +prop_checkPlusEqualsNumber5 = verifyNot checkPlusEqualsNumber "x+='1'" +prop_checkPlusEqualsNumber6 = verifyNot checkPlusEqualsNumber "n=foo; x+=n" +prop_checkPlusEqualsNumber7 = verify checkPlusEqualsNumber "n=4; x+=n" +prop_checkPlusEqualsNumber8 = verify checkPlusEqualsNumber "n=4; x+=$n" +prop_checkPlusEqualsNumber9 = verifyNot checkPlusEqualsNumber "declare -ia var; var[x]+=1" +checkPlusEqualsNumber params t = + case t of + T_Assignment id Append var _ word -> sequence_ $ do + cfga <- cfgAnalysis params + state <- CF.getIncomingState cfga id + guard $ isNumber state word + guard . not $ fromMaybe False $ CF.variableMayBeDeclaredInteger state var + -- Recommend "typeset" because ksh does not have "declare". + return $ warn id 2324 "var+=1 will append, not increment. Use (( var += 1 )), typeset -i var, or quote number to silence." + _ -> return () + + where + isNumber state word = + let + unquotedLiteral = getUnquotedLiteral word + isEmpty = unquotedLiteral == Just "" + isUnquotedNumber = not isEmpty && maybe False (all isDigit) unquotedLiteral + isNumericalVariableName = fromMaybe False $ do + str <- unquotedLiteral + CF.variableMayBeAssignedInteger state str + isNumericalVariableExpansion = + case word of + T_NormalWord _ [part] -> fromMaybe False $ do + str <- getUnmodifiedParameterExpansion part + CF.variableMayBeAssignedInteger state str + _ -> False + in + isUnquotedNumber || isNumericalVariableName || isNumericalVariableExpansion + + + +prop_checkExpansionWithRedirection1 = verify checkExpansionWithRedirection "var=$(foo > bar)" +prop_checkExpansionWithRedirection2 = verify checkExpansionWithRedirection "var=`foo 1> bar`" +prop_checkExpansionWithRedirection3 = verify checkExpansionWithRedirection "var=${ foo >> bar; }" +prop_checkExpansionWithRedirection4 = verify checkExpansionWithRedirection "var=$(foo | bar > baz)" +prop_checkExpansionWithRedirection5 = verifyNot checkExpansionWithRedirection "stderr=$(foo 2>&1 > /dev/null)" +prop_checkExpansionWithRedirection6 = verifyNot checkExpansionWithRedirection "var=$(foo; bar > baz)" +prop_checkExpansionWithRedirection7 = verifyNot checkExpansionWithRedirection "var=$(foo > bar; baz)" +prop_checkExpansionWithRedirection8 = verifyNot checkExpansionWithRedirection "var=$(cat <&3)" +checkExpansionWithRedirection params t = + case t of + T_DollarExpansion id [cmd] -> check id cmd + T_Backticked id [cmd] -> check id cmd + T_DollarBraceCommandExpansion id _ [cmd] -> check id cmd + _ -> return () + where + check id pipe = + case pipe of + (T_Pipeline _ _ t@(_:_)) -> checkCmd id (last t) + _ -> return () + + checkCmd captureId (T_Redirecting _ redirs _) = foldr (walk captureId) (return ()) redirs + + walk captureId t acc = + case t of + T_FdRedirect _ _ (T_IoDuplicate _ _ "1") -> return () + T_FdRedirect id "1" (T_IoDuplicate _ _ _) -> return () + T_FdRedirect id "" (T_IoDuplicate _ op _) | op `elem` [T_GREATAND (Id 0), T_Greater (Id 0)] -> emit id captureId True + T_FdRedirect id str (T_IoFile _ op file) | str `elem` ["", "1"] && op `elem` [ T_DGREAT (Id 0), T_Greater (Id 0) ] -> + emit id captureId $ getLiteralString file /= Just "/dev/null" + _ -> acc + + emit redirectId captureId suggestTee = do + warn captureId 2327 "This command substitution will be empty because the command's output gets redirected away." + err redirectId 2328 $ "This redirection takes output away from the command substitution" ++ if suggestTee then " (use tee to duplicate)." else "." + + +prop_checkUnaryTestA1 = verify checkUnaryTestA "[ -a foo ]" +prop_checkUnaryTestA2 = verify checkUnaryTestA "[ ! -a foo ]" +prop_checkUnaryTestA3 = verifyNot checkUnaryTestA "[ foo -a bar ]" +checkUnaryTestA params t = + case t of + TC_Unary id _ "-a" _ -> + styleWithFix id 2331 "For file existence, prefer standard -e over legacy -a." $ + fixWith [replaceStart id params 2 "-e"] + _ -> return () + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/Analyzer.hs b/src/ShellCheck/Analyzer.hs new file mode 100644 index 0000000..53717ed --- /dev/null +++ b/src/ShellCheck/Analyzer.hs @@ -0,0 +1,55 @@ +{- + Copyright 2012-2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Analyzer (analyzeScript, ShellCheck.Analyzer.optionalChecks) where + +import ShellCheck.Analytics +import ShellCheck.AnalyzerLib +import ShellCheck.Interface +import Data.List +import Data.Monoid +import qualified ShellCheck.Checks.Commands +import qualified ShellCheck.Checks.ControlFlow +import qualified ShellCheck.Checks.Custom +import qualified ShellCheck.Checks.ShellSupport + + +-- TODO: Clean up the cruft this is layered on +analyzeScript :: AnalysisSpec -> AnalysisResult +analyzeScript spec = newAnalysisResult { + arComments = + filterByAnnotation spec params . nub $ + runChecker params (checkers spec params) +} + where + params = makeParameters spec + +checkers spec params = mconcat $ map ($ params) [ + ShellCheck.Analytics.checker spec, + ShellCheck.Checks.Commands.checker spec, + ShellCheck.Checks.ControlFlow.checker spec, + ShellCheck.Checks.Custom.checker, + ShellCheck.Checks.ShellSupport.checker + ] + +optionalChecks = mconcat $ [ + ShellCheck.Analytics.optionalChecks, + ShellCheck.Checks.Commands.optionalChecks, + ShellCheck.Checks.ControlFlow.optionalChecks + ] diff --git a/src/ShellCheck/AnalyzerLib.hs b/src/ShellCheck/AnalyzerLib.hs new file mode 100644 index 0000000..da528a4 --- /dev/null +++ b/src/ShellCheck/AnalyzerLib.hs @@ -0,0 +1,944 @@ +{- + Copyright 2012-2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.AnalyzerLib where + +import ShellCheck.AST +import ShellCheck.ASTLib +import qualified ShellCheck.CFGAnalysis as CF +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Parser +import ShellCheck.Prelude +import ShellCheck.Regex + +import Control.Arrow (first) +import Control.DeepSeq +import Control.Monad +import Control.Monad.Identity +import Control.Monad.RWS +import Control.Monad.State +import Control.Monad.Writer +import Data.Char +import Data.List +import Data.Maybe +import Data.Semigroup +import qualified Data.List.NonEmpty as NE +import qualified Data.Map as Map + +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (maxSuccess, quickCheckWithResult, stdArgs) + +type Analysis = AnalyzerM () +type AnalyzerM a = RWS Parameters [TokenComment] Cache a +nullCheck = const $ return () + + +data Checker = Checker { + perScript :: Root -> Analysis, + perToken :: Token -> Analysis +} + +runChecker :: Parameters -> Checker -> [TokenComment] +runChecker params checker = notes + where + root = rootNode params + check = perScript checker `composeAnalyzers` (\(Root x) -> void $ doAnalysis (perToken checker) x) + notes = snd $ evalRWS (check $ Root root) params Cache + +instance Semigroup Checker where + (<>) x y = Checker { + perScript = perScript x `composeAnalyzers` perScript y, + perToken = perToken x `composeAnalyzers` perToken y + } + +instance Monoid Checker where + mempty = Checker { + perScript = nullCheck, + perToken = nullCheck + } + mappend = (Data.Semigroup.<>) + +composeAnalyzers :: (a -> Analysis) -> (a -> Analysis) -> a -> Analysis +composeAnalyzers f g x = f x >> g x + +data Parameters = Parameters { + -- Whether this script has the 'lastpipe' option set/default. + hasLastpipe :: Bool, + -- Whether this script has the 'inherit_errexit' option set/default. + hasInheritErrexit :: Bool, + -- Whether this script has 'set -e' anywhere. + hasSetE :: Bool, + -- Whether this script has 'set -o pipefail' anywhere. + hasPipefail :: Bool, + -- Whether this script has 'shopt -s execfail' anywhere. + hasExecfail :: Bool, + -- A linear (bad) analysis of data flow + variableFlow :: [StackData], + -- A map from Id to Token + idMap :: Map.Map Id Token, + -- A map from Id to parent Token + parentMap :: Map.Map Id Token, + -- The shell type, such as Bash or Ksh + shellType :: Shell, + -- True if shell type was forced via flags + shellTypeSpecified :: Bool, + -- The root node of the AST + rootNode :: Token, + -- map from token id to start and end position + tokenPositions :: Map.Map Id (Position, Position), + -- Result from Control Flow Graph analysis (including data flow analysis) + cfgAnalysis :: Maybe CF.CFGAnalysis + } deriving (Show) + +-- TODO: Cache results of common AST ops here +data Cache = Cache {} + +data Scope = SubshellScope String | NoneScope deriving (Show, Eq) +data StackData = + StackScope Scope + | StackScopeEnd + -- (Base expression, specific position, var name, assigned values) + | Assignment (Token, Token, String, DataType) + | Reference (Token, Token, String) + deriving (Show) + +data DataType = DataString DataSource | DataArray DataSource + deriving (Show) + +data DataSource = + SourceFrom [Token] + | SourceExternal + | SourceDeclaration + | SourceInteger + | SourceChecked + deriving (Show) + +data VariableState = Dead Token String | Alive deriving (Show) + +defaultSpec pr = spec { + asShellType = Nothing, + asCheckSourced = False, + asExecutionMode = Executed, + asTokenPositions = prTokenPositions pr +} where spec = newAnalysisSpec (fromJust $ prRoot pr) + +pScript s = + let + pSpec = newParseSpec { + psFilename = "script", + psScript = s + } + in runIdentity $ parseScript (mockedSystemInterface []) pSpec + +-- For testing. If parsed, returns whether there are any comments +producesComments :: Checker -> String -> Maybe Bool +producesComments c s = do + let pr = pScript s + prRoot pr + let spec = defaultSpec pr + let params = makeParameters spec + return . not . null $ filterByAnnotation spec params $ runChecker params c + +makeComment :: Severity -> Id -> Code -> String -> TokenComment +makeComment severity id code note = + newTokenComment { + tcId = id, + tcComment = newComment { + cSeverity = severity, + cCode = code, + cMessage = note + } + } + +addComment note = note `deepseq` tell [note] + +warn :: MonadWriter [TokenComment] m => Id -> Code -> String -> m () +warn id code str = addComment $ makeComment WarningC id code str +err id code str = addComment $ makeComment ErrorC id code str +info id code str = addComment $ makeComment InfoC id code str +style id code str = addComment $ makeComment StyleC id code str + +errWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m () +errWithFix = addCommentWithFix ErrorC +warnWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m () +warnWithFix = addCommentWithFix WarningC +infoWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m () +infoWithFix = addCommentWithFix InfoC +styleWithFix :: MonadWriter [TokenComment] m => Id -> Code -> String -> Fix -> m () +styleWithFix = addCommentWithFix StyleC + +addCommentWithFix :: MonadWriter [TokenComment] m => Severity -> Id -> Code -> String -> Fix -> m () +addCommentWithFix severity id code str fix = + addComment $ makeCommentWithFix severity id code str fix + +makeCommentWithFix :: Severity -> Id -> Code -> String -> Fix -> TokenComment +makeCommentWithFix severity id code str fix = + let comment = makeComment severity id code str + withFix = comment { + -- If fix is empty, pretend it wasn't there. + tcFix = if null (fixReplacements fix) then Nothing else Just fix + } + in force withFix + +-- makeParameters :: CheckSpec -> Parameters +makeParameters spec = params + where + extendedAnalysis = fromMaybe True $ msum [asExtendedAnalysis spec, getExtendedAnalysisDirective root] + params = Parameters { + rootNode = root, + shellType = fromMaybe (determineShell (asFallbackShell spec) root) $ asShellType spec, + hasSetE = containsSetE root, + hasLastpipe = + case shellType params of + Bash -> isOptionSet "lastpipe" root + Dash -> False + BusyboxSh -> False + Sh -> False + Ksh -> True, + hasInheritErrexit = + case shellType params of + Bash -> isOptionSet "inherit_errexit" root + Dash -> True + BusyboxSh -> True + Sh -> True + Ksh -> False, + hasPipefail = + case shellType params of + Bash -> isOptionSet "pipefail" root + Dash -> True + BusyboxSh -> isOptionSet "pipefail" root + Sh -> True + Ksh -> isOptionSet "pipefail" root, + hasExecfail = + case shellType params of + Bash -> isOptionSet "execfail" root + _ -> False, + shellTypeSpecified = isJust (asShellType spec) || isJust (asFallbackShell spec), + idMap = getTokenMap root, + parentMap = getParentTree root, + variableFlow = getVariableFlow params root, + tokenPositions = asTokenPositions spec, + cfgAnalysis = do + guard extendedAnalysis + return $ CF.analyzeControlFlow cfParams root + } + cfParams = CF.CFGParameters { + CF.cfLastpipe = hasLastpipe params, + CF.cfPipefail = hasPipefail params + } + root = asScript spec + + +-- Does this script mention 'set -e' anywhere? +-- Used as a hack to disable certain warnings. +containsSetE root = isNothing $ doAnalysis (guard . not . isSetE) root + where + isSetE t = + case t of + T_Script _ (T_Literal _ str) _ -> str `matches` re + T_SimpleCommand {} -> + t `isUnqualifiedCommand` "set" && + ("errexit" `elem` oversimplify t || + "e" `elem` map snd (getAllFlags t)) + _ -> False + re = mkRegex "[[:space:]]-[^-]*e" + + +containsSetOption opt root = isNothing $ doAnalysis (guard . not . isPipefail) root + where + isPipefail t = + case t of + T_SimpleCommand {} -> + t `isUnqualifiedCommand` "set" && + (opt `elem` oversimplify t || + "o" `elem` map snd (getAllFlags t)) + _ -> False + +containsShopt shopt root = + isNothing $ doAnalysis (guard . not . isShoptLastPipe) root + where + isShoptLastPipe t = + case t of + T_SimpleCommand {} -> + t `isUnqualifiedCommand` "shopt" && + (shopt `elem` oversimplify t) + _ -> False + +-- Does this script mention 'shopt -s $opt' or 'set -o $opt' anywhere? +isOptionSet opt root = containsShopt opt root || containsSetOption opt root + + +prop_determineShell0 = determineShellTest "#!/bin/sh" == Sh +prop_determineShell1 = determineShellTest "#!/usr/bin/env ksh" == Ksh +prop_determineShell2 = determineShellTest "" == Bash +prop_determineShell3 = determineShellTest "#!/bin/sh -e" == Sh +prop_determineShell4 = determineShellTest "#!/bin/ksh\n#shellcheck shell=sh\nfoo" == Sh +prop_determineShell5 = determineShellTest "#shellcheck shell=sh\nfoo" == Sh +prop_determineShell6 = determineShellTest "#! /bin/sh" == Sh +prop_determineShell7 = determineShellTest "#! /bin/ash" == Dash +prop_determineShell8 = determineShellTest' (Just Ksh) "#!/bin/sh" == Sh +prop_determineShell9 = determineShellTest "#!/bin/env -S dash -x" == Dash +prop_determineShell10 = determineShellTest "#!/bin/env --split-string= dash -x" == Dash +prop_determineShell11 = determineShellTest "#!/bin/busybox sh" == BusyboxSh -- busybox sh is a specific shell, not posix sh +prop_determineShell12 = determineShellTest "#!/bin/busybox ash" == BusyboxSh + +determineShellTest = determineShellTest' Nothing +determineShellTest' fallbackShell = determineShell fallbackShell . fromJust . prRoot . pScript +determineShell fallbackShell t = fromMaybe Bash $ + shellForExecutable shellString `mplus` fallbackShell + where + shellString = getCandidate t + getCandidate :: Token -> String + getCandidate t@T_Script {} = fromShebang t + getCandidate (T_Annotation _ annotations s) = + headOrDefault (fromShebang s) [s | ShellOverride s <- annotations] + fromShebang (T_Script _ (T_Literal _ s) _) = executableFromShebang s + +-- Given a root node, make a map from Id to parent Token. +-- This is used to populate parentMap in Parameters +getParentTree :: Token -> Map.Map Id Token +getParentTree t = + snd $ execState (doStackAnalysis pre post t) ([], Map.empty) + where + pre t = modify (first ((:) t)) + post t = do + (x, map) <- get + case x of + _:rest -> case rest of [] -> put (rest, map) + (x:_) -> put (rest, Map.insert (getId t) x map) + +-- Given a root node, make a map from Id to Token +getTokenMap :: Token -> Map.Map Id Token +getTokenMap t = + execState (doAnalysis f t) Map.empty + where + f t = modify (Map.insert (getId t) t) + + +-- Is this token in a quoting free context? (i.e. would variable expansion split) +-- True: Assignments, [[ .. ]], here docs, already in double quotes +-- False: Regular words +isStrictlyQuoteFree = isQuoteFreeNode True + +-- Like above, but also allow some cases where splitting may be desired. +-- True: Like above + for loops +-- False: Like above +isQuoteFree = isQuoteFreeNode False + + +isQuoteFreeNode strict shell tree t = + isQuoteFreeElement t || + (fromMaybe False $ msum $ map isQuoteFreeContext $ NE.tail $ getPath tree t) + where + -- Is this node self-quoting in itself? + isQuoteFreeElement t = + case t of + T_Assignment id _ _ _ _ -> assignmentIsQuoting id + T_FdRedirect {} -> True + _ -> False + + -- Are any subnodes inherently self-quoting? + isQuoteFreeContext t = + case t of + TC_Nullary _ DoubleBracket _ -> return True + TC_Unary _ DoubleBracket _ _ -> return True + TC_Binary _ DoubleBracket _ _ _ -> return True + TA_Sequence {} -> return True + T_Arithmetic {} -> return True + T_Assignment id _ _ _ _ -> return $ assignmentIsQuoting id + T_Redirecting {} -> return False + T_DoubleQuoted _ _ -> return True + T_DollarDoubleQuoted _ _ -> return True + T_CaseExpression {} -> return True + T_HereDoc {} -> return True + T_DollarBraced {} -> return True + -- When non-strict, pragmatically assume it's desirable to split here + T_ForIn {} -> return (not strict) + T_SelectIn {} -> return (not strict) + _ -> Nothing + + -- Check whether this assignment is self-quoting due to being a recognized + -- assignment passed to a Declaration Utility. This will soon be required + -- by POSIX: https://austingroupbugs.net/view.php?id=351 + assignmentIsQuoting id = shellParsesParamsAsAssignments || not (isAssignmentParamToCommand id) + shellParsesParamsAsAssignments = shell /= Sh + + -- Is this assignment a parameter to a command like export/typeset/etc? + isAssignmentParamToCommand id = + case Map.lookup id tree of + Just (T_SimpleCommand _ _ (_:args)) -> id `elem` (map getId args) + _ -> False + +-- Check if a token is a parameter to a certain command by name: +-- Example: isParamTo (parentMap params) "sed" t +isParamTo :: Map.Map Id Token -> String -> Token -> Bool +isParamTo tree cmd = + go + where + go x = case Map.lookup (getId x) tree of + Nothing -> False + Just parent -> check parent + check t = + case t of + T_SingleQuoted _ _ -> go t + T_DoubleQuoted _ _ -> go t + T_NormalWord _ _ -> go t + T_SimpleCommand {} -> isCommand t cmd + T_Redirecting {} -> isCommand t cmd + _ -> False + +-- Get the parent command (T_Redirecting) of a Token, if any. +getClosestCommand :: Map.Map Id Token -> Token -> Maybe Token +getClosestCommand tree t = + findFirst findCommand $ NE.toList $ getPath tree t + where + findCommand t = + case t of + T_Redirecting {} -> return True + T_Script {} -> return False + _ -> Nothing + +-- Like above, if koala_man knew Haskell when starting this project. +getClosestCommandM t = do + params <- ask + return $ getClosestCommand (parentMap params) t + +-- Is the token used as a command name (the first word in a T_SimpleCommand)? +usedAsCommandName tree token = go (getId token) (NE.tail $ getPath tree token) + where + go currentId (T_NormalWord id [word]:rest) + | currentId == getId word = go id rest + go currentId (T_DoubleQuoted id [word]:rest) + | currentId == getId word = go id rest + go currentId (t@(T_SimpleCommand _ _ (word:_)):_) = + getId word == currentId || getId (getCommandTokenOrThis t) == currentId + go _ _ = False + +-- Version of the above taking the map from the current context +-- Todo: give this the name "getPath" +getPathM t = do + params <- ask + return $ getPath (parentMap params) t + +isParentOf tree parent child = + any (\t -> parentId == getId t) (getPath tree child) + where + parentId = getId parent + +parents params = getPath (parentMap params) + +-- Find the first match in a list where the predicate is Just True. +-- Stops if it's Just False and ignores Nothing. +findFirst :: (a -> Maybe Bool) -> [a] -> Maybe a +findFirst p = foldr go Nothing + where + go x acc = + case p x of + Just True -> return x + Just False -> Nothing + Nothing -> acc + +-- Check whether a word is entirely output from a single command +tokenIsJustCommandOutput t = case t of + T_NormalWord id [T_DollarExpansion _ cmds] -> check cmds + T_NormalWord id [T_DoubleQuoted _ [T_DollarExpansion _ cmds]] -> check cmds + T_NormalWord id [T_Backticked _ cmds] -> check cmds + T_NormalWord id [T_DoubleQuoted _ [T_Backticked _ cmds]] -> check cmds + _ -> False + where + check [x] = not $ isOnlyRedirection x + check _ = False + +-- TODO: Replace this with a proper Control Flow Graph +getVariableFlow params t = + reverse $ execState (doStackAnalysis startScope endScope t) [] + where + startScope t = + let scopeType = leadType params t + in do + when (scopeType /= NoneScope) $ modify (StackScope scopeType:) + when (assignFirst t) $ setWritten t + + endScope t = + let scopeType = leadType params t + in do + setRead t + unless (assignFirst t) $ setWritten t + when (scopeType /= NoneScope) $ modify (StackScopeEnd:) + + assignFirst T_ForIn {} = True + assignFirst T_SelectIn {} = True + assignFirst (T_BatsTest {}) = True + assignFirst _ = False + + setRead t = + let read = getReferencedVariables (parentMap params) t + in mapM_ (\v -> modify (Reference v:)) read + + setWritten t = + let written = getModifiedVariables t + in mapM_ (\v -> modify (Assignment v:)) written + + +leadType params t = + case t of + T_DollarExpansion _ _ -> SubshellScope "$(..) expansion" + T_Backticked _ _ -> SubshellScope "`..` expansion" + T_Backgrounded _ _ -> SubshellScope "backgrounding &" + T_Subshell _ _ -> SubshellScope "(..) group" + T_BatsTest {} -> SubshellScope "@bats test" + T_CoProcBody _ _ -> SubshellScope "coproc" + T_Redirecting {} -> + if causesSubshell == Just True + then SubshellScope "pipeline" + else NoneScope + _ -> NoneScope + where + parentPipeline = do + parent <- Map.lookup (getId t) (parentMap params) + case parent of + T_Pipeline {} -> return parent + _ -> Nothing + + causesSubshell = do + (T_Pipeline _ _ list) <- parentPipeline + return $ case list of + _:_:_ -> not (hasLastpipe params) || getId (last list) /= getId t + _ -> False + +getModifiedVariables t = + case t of + T_SimpleCommand _ vars [] -> + [(x, x, name, dataTypeFrom DataString w) | x@(T_Assignment id _ name _ w) <- vars] + T_SimpleCommand {} -> + getModifiedVariableCommand t + + TA_Unary _ op v@(TA_Variable _ name _) | "--" `isInfixOf` op || "++" `isInfixOf` op -> + [(t, v, name, DataString SourceInteger)] + TA_Assignment _ op (TA_Variable _ name _) rhs -> do + guard $ op `elem` ["=", "*=", "/=", "%=", "+=", "-=", "<<=", ">>=", "&=", "^=", "|="] + return (t, t, name, DataString SourceInteger) + + T_BatsTest {} -> [ + (t, t, "lines", DataArray SourceExternal), + (t, t, "status", DataString SourceInteger), + (t, t, "output", DataString SourceExternal), + (t, t, "stderr", DataString SourceExternal), + (t, t, "stderr_lines", DataArray SourceExternal) + ] + + -- Count [[ -v foo ]] as an "assignment". + -- This is to prevent [ -v foo ] being unassigned or unused. + TC_Unary id _ "-v" token -> maybeToList $ do + str <- getVariableForTestDashV token + return (t, token, str, DataString SourceChecked) + + TC_Unary _ _ "-n" token -> markAsChecked t token + TC_Unary _ _ "-z" token -> markAsChecked t token + TC_Nullary _ _ token -> markAsChecked t token + + T_DollarBraced _ _ l -> maybeToList $ do + let string = concat $ oversimplify l + let modifier = getBracedModifier string + guard $ any (`isPrefixOf` modifier) ["=", ":="] + return (t, t, getBracedReference string, DataString $ SourceFrom [l]) + + T_FdRedirect _ ('{':var) op -> -- {foo}>&2 modifies foo + [(t, t, takeWhile (/= '}') var, DataString SourceInteger) | not $ isClosingFileOp op] + + T_CoProc _ Nothing _ -> + [(t, t, "COPROC", DataArray SourceInteger)] + + T_CoProc _ (Just token) _ -> do + name <- maybeToList $ getLiteralString token + [(t, t, name, DataArray SourceInteger)] + + --Points to 'for' rather than variable + T_ForIn id str [] _ -> [(t, t, str, DataString SourceExternal)] + T_ForIn id str words _ -> [(t, t, str, DataString $ SourceFrom words)] + T_SelectIn id str words _ -> [(t, t, str, DataString $ SourceFrom words)] + _ -> [] + where + markAsChecked place token = mapMaybe (f place) $ getWordParts token + f place t = case t of + T_DollarBraced _ _ l -> + let str = getBracedReference $ concat $ oversimplify l in do + guard $ isVariableName str + return (place, t, str, DataString SourceChecked) + _ -> Nothing + + +-- Consider 'export/declare -x' a reference, since it makes the var available +getReferencedVariableCommand base@(T_SimpleCommand _ _ (T_NormalWord _ (T_Literal _ x:_):rest)) = + case x of + "declare" -> forDeclare + "typeset" -> forDeclare + + "export" -> if "f" `elem` flags + then [] + else concatMap getReference rest + "local" -> if "x" `elem` flags + then concatMap getReference rest + else [] + "trap" -> + case rest of + head:_ -> map (\x -> (base, head, x)) $ getVariablesFromLiteralToken head + _ -> [] + "alias" -> [(base, token, name) | token <- rest, name <- getVariablesFromLiteralToken token] + _ -> [] + where + forDeclare = + if + any (`elem` flags) ["x", "p"] && + (not $ any (`elem` flags) ["f", "F"]) + then concatMap getReference rest + else [] + + getReference t@(T_Assignment _ _ name _ value) = [(t, t, name)] + getReference t@(T_NormalWord _ [T_Literal _ name]) | not ("-" `isPrefixOf` name) = [(t, t, name)] + getReference _ = [] + flags = map snd $ getAllFlags base + +getReferencedVariableCommand _ = [] + +-- The function returns a tuple consisting of four items describing an assignment. +-- Given e.g. declare foo=bar +-- ( +-- BaseCommand :: Token, -- The command/structure assigning the variable, i.e. declare foo=bar +-- AssignmentToken :: Token, -- The specific part that assigns this variable, i.e. foo=bar +-- VariableName :: String, -- The variable name, i.e. foo +-- VariableValue :: DataType -- A description of the value being assigned, i.e. "Literal string with value foo" +-- ) +getModifiedVariableCommand base@(T_SimpleCommand id cmdPrefix (T_NormalWord _ (T_Literal _ x:_):rest)) = + filter (\(_,_,s,_) -> not ("-" `isPrefixOf` s)) $ + case x of + "builtin" -> + getModifiedVariableCommand $ T_SimpleCommand id cmdPrefix rest + "read" -> + let fallback = catMaybes $ takeWhile isJust (reverse $ map getLiteral rest) + in fromMaybe fallback $ do + parsed <- getGnuOpts flagsForRead rest + case lookup "a" parsed of + Just (_, var) -> (:[]) <$> getLiteralArray var + Nothing -> return $ catMaybes $ + map (getLiteral . snd . snd) $ filter (null . fst) parsed + + "getopts" -> + case rest of + opts:var:_ -> maybeToList $ getLiteral var + _ -> [] + + "let" -> concatMap letParamToLiteral rest + + "export" -> + if "f" `elem` flags then [] else concatMap getModifierParamString rest + + "declare" -> forDeclare + "typeset" -> forDeclare + + "local" -> concatMap getModifierParamString rest + "readonly" -> + if any (`elem` flags) ["f", "p"] + then [] + else concatMap getModifierParamString rest + "set" -> maybeToList $ do + params <- getSetParams rest + return (base, base, "@", DataString $ SourceFrom params) + + "printf" -> maybeToList $ getPrintfVariable rest + "wait" -> maybeToList $ getWaitVariable rest + + "mapfile" -> maybeToList $ getMapfileArray base rest + "readarray" -> maybeToList $ getMapfileArray base rest + + "DEFINE_boolean" -> maybeToList $ getFlagVariable rest + "DEFINE_float" -> maybeToList $ getFlagVariable rest + "DEFINE_integer" -> maybeToList $ getFlagVariable rest + "DEFINE_string" -> maybeToList $ getFlagVariable rest + + _ -> [] + where + flags = map snd $ getAllFlags base + stripEquals s = drop 1 $ dropWhile (/= '=') s + stripEqualsFrom (T_NormalWord id1 (T_Literal id2 s:rs)) = + T_NormalWord id1 (T_Literal id2 (stripEquals s):rs) + stripEqualsFrom (T_NormalWord id1 [T_DoubleQuoted id2 [T_Literal id3 s]]) = + T_NormalWord id1 [T_DoubleQuoted id2 [T_Literal id3 (stripEquals s)]] + stripEqualsFrom t = t + + forDeclare = if any (`elem` flags) ["F", "f", "p"] then [] else declaredVars + + declaredVars = concatMap (getModifierParam defaultType) rest + where + defaultType = if any (`elem` flags) ["a", "A"] then DataArray else DataString + + getLiteralOfDataType t d = do + s <- getLiteralString t + when ("-" `isPrefixOf` s) $ fail "argument" + return (base, t, s, d) + + getLiteral t = getLiteralOfDataType t (DataString SourceExternal) + + getLiteralArray t = getLiteralOfDataType t (DataArray SourceExternal) + + getModifierParamString = getModifierParam DataString + + getModifierParam def t@(T_Assignment _ _ name _ value) = + [(base, t, name, dataTypeFrom def value)] + getModifierParam def t@T_NormalWord {} = maybeToList $ do + name <- getLiteralString t + guard $ isVariableName name + return (base, t, name, def SourceDeclaration) + getModifierParam _ _ = [] + + letParamToLiteral token = + if null var + then [] + else [(base, token, var, DataString $ SourceFrom [stripEqualsFrom token])] + where var = takeWhile isVariableChar $ dropWhile (`elem` "+-") $ concat $ oversimplify token + + getSetParams (t:_:rest) | getLiteralString t == Just "-o" = getSetParams rest + getSetParams (t:rest) = + let s = getLiteralString t in + case s of + Just "--" -> return rest + Just ('-':_) -> getSetParams rest + _ -> return (t:fromMaybe [] (getSetParams rest)) + getSetParams [] = Nothing + + getPrintfVariable list = getFlagAssignedVariable "v" (SourceFrom list) $ getBsdOpts "v:" list + getWaitVariable list = getFlagAssignedVariable "p" SourceInteger $ return $ getGenericOpts list + + getFlagAssignedVariable str dataSource maybeFlags = do + flags <- maybeFlags + (_, (flag, value)) <- find ((== str) . fst) flags + variableName <- getLiteralStringExt (const $ return "!") value + let (baseName, index) = span (/= '[') variableName + return (base, value, baseName, (if null index then DataString else DataArray) dataSource) + + -- mapfile has some curious syntax allowing flags plus 0..n variable names + -- where only the first non-option one is used if any. + getMapfileArray base rest = parseArgs `mplus` fallback + where + parseArgs :: Maybe (Token, Token, String, DataType) + parseArgs = do + args <- getGnuOpts "d:n:O:s:u:C:c:t" rest + case [y | ("",(_,y)) <- args] of + [] -> + return (base, base, "MAPFILE", DataArray SourceExternal) + first:_ -> do + name <- getLiteralString first + guard $ isVariableName name + return (base, first, name, DataArray SourceExternal) + -- If arg parsing fails (due to bad or new flags), get the last variable name + fallback :: Maybe (Token, Token, String, DataType) + fallback = do + (name, token) <- listToMaybe . mapMaybe f $ reverse rest + return (base, token, name, DataArray SourceExternal) + f arg = do + name <- getLiteralString arg + guard $ isVariableName name + return (name, arg) + + -- get the FLAGS_ variable created by a shflags DEFINE_ call + getFlagVariable (n:v:_) = do + name <- getLiteralString n + return (base, n, "FLAGS_" ++ name, DataString $ SourceExternal) + getFlagVariable _ = Nothing + +getModifiedVariableCommand _ = [] + +-- Given a NormalWord like foo or foo[$bar], get foo. +-- Primarily used to get references for [[ -v foo[bar] ]] +getVariableForTestDashV :: Token -> Maybe String +getVariableForTestDashV t = do + str <- takeWhile ('[' /=) <$> getLiteralStringExt toStr t + guard $ isVariableName str + return str + where + -- foo[bar] gets parsed with [bar] as a glob, so undo that + toStr (T_Glob _ s) = return s + -- Turn foo[$x] into foo[\0] so that we can get the constant array name + -- in a non-constant expression (while filtering out foo$x[$y]) + toStr _ = return "\0" + +getReferencedVariables parents t = + case t of + T_DollarBraced id _ l -> let str = concat $ oversimplify l in + (t, t, getBracedReference str) : + map (\x -> (l, l, x)) ( + getIndexReferences str + ++ getOffsetReferences (getBracedModifier str)) + TA_Variable id name _ -> + if isArithmeticAssignment t + then [] + else [(t, t, name)] + T_Assignment id mode str _ word -> + [(t, t, str) | mode == Append] ++ specialReferences str t word + + TC_Unary id _ "-v" token -> getIfReference t token + TC_Unary id _ "-R" token -> getIfReference t token + TC_Binary id DoubleBracket op lhs rhs -> + if isDereferencingBinaryOp op + then concatMap (getIfReference t) [lhs, rhs] + else [] + + T_BatsTest {} -> [ -- pretend @test references vars to avoid warnings + (t, t, "lines"), + (t, t, "status"), + (t, t, "output") + ] + + T_FdRedirect _ ('{':var) op -> -- {foo}>&- references and closes foo + [(t, t, takeWhile (/= '}') var) | isClosingFileOp op] + x -> getReferencedVariableCommand x + where + -- Try to reduce false positives for unused vars only referenced from evaluated vars + specialReferences name base word = + if name `elem` [ + "PS1", "PS2", "PS3", "PS4", + "PROMPT_COMMAND" + ] + then + map (\x -> (base, base, x)) $ + getVariablesFromLiteralToken word + else [] + + literalizer t = case t of + T_Glob _ s -> return s -- Also when parsed as globs + _ -> [] + + getIfReference context token = maybeToList $ do + str <- getVariableForTestDashV token + return (context, token, getBracedReference str) + + isArithmeticAssignment t = case getPath parents t of + this NE.:| TA_Assignment _ "=" lhs _ :_ -> lhs == t + _ -> False + +isDereferencingBinaryOp = (`elem` ["-eq", "-ne", "-lt", "-le", "-gt", "-ge"]) + +dataTypeFrom defaultType v = (case v of T_Array {} -> DataArray; _ -> defaultType) $ SourceFrom [v] + + +--- Command specific checks + +-- Compare a command to a string: t `isCommand` "sed" (also matches /usr/bin/sed) +isCommand token str = isCommandMatch token (\cmd -> cmd == str || ('/' : str) `isSuffixOf` cmd) + +-- Compare a command to a literal. Like above, but checks full path. +isUnqualifiedCommand token str = isCommandMatch token (== str) + +isCommandMatch token matcher = maybe False + matcher (getCommandName token) + +-- Does this regex look like it was intended as a glob? +-- True: *foo* +-- False: .*foo.* +isConfusedGlobRegex :: String -> Bool +isConfusedGlobRegex ('*':_) = True +isConfusedGlobRegex [x,'*'] | x `notElem` "\\." = True +isConfusedGlobRegex _ = False + +getVariablesFromLiteralToken token = + getVariablesFromLiteral (getLiteralStringDef " " token) + +-- Try to get referenced variables from a literal string like "$foo" +-- Ignores tons of cases like arithmetic evaluation and array indices. +prop_getVariablesFromLiteral1 = + getVariablesFromLiteral "$foo${bar//a/b}$BAZ" == ["foo", "bar", "BAZ"] +getVariablesFromLiteral string = + map head $ matchAllSubgroups variableRegex string + where + variableRegex = mkRegex "\\$\\{?([A-Za-z0-9_]+)" + + +-- Run a command if the shell is in the given list +whenShell l c = do + params <- ask + when (shellType params `elem` l ) c + + +filterByAnnotation asSpec params = + filter (not . shouldIgnore) + where + token = asScript asSpec + shouldIgnore note = + any (shouldIgnoreFor (getCode note)) $ + getPath parents (T_Bang $ tcId note) + shouldIgnoreFor _ T_Include {} = not $ asCheckSourced asSpec + shouldIgnoreFor code t = isAnnotationIgnoringCode code t + parents = parentMap params + getCode = cCode . tcComment + +shouldIgnoreCode params code t = + any (isAnnotationIgnoringCode code) $ + getPath (parentMap params) t + +-- Is this a ${#anything}, to get string length or array count? +isCountingReference (T_DollarBraced id _ token) = + case concat $ oversimplify token of + '#':_ -> True + _ -> False +isCountingReference _ = False + +-- FIXME: doesn't handle ${a:+$var} vs ${a:+"$var"} +isQuotedAlternativeReference t = + case t of + T_DollarBraced _ _ l -> + getBracedModifier (concat $ oversimplify l) `matches` re + _ -> False + where + re = mkRegex "(^|\\]):?\\+" + +supportsArrays Bash = True +supportsArrays Ksh = True +supportsArrays _ = False + +isTrueAssignmentSource c = + case c of + DataString SourceChecked -> False + DataString SourceDeclaration -> False + DataArray SourceChecked -> False + DataArray SourceDeclaration -> False + _ -> True + +modifiesVariable params token name = + or $ map check flow + where + flow = getVariableFlow params token + check t = + case t of + Assignment (_, _, n, source) -> isTrueAssignmentSource source && n == name + _ -> False + +isTestCommand t = + case t of + T_Condition {} -> True + T_SimpleCommand {} -> t `isCommand` "test" + T_Redirecting _ _ t -> isTestCommand t + T_Annotation _ _ t -> isTestCommand t + T_Pipeline _ _ [t] -> isTestCommand t + _ -> False + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/CFG.hs b/src/ShellCheck/CFG.hs new file mode 100644 index 0000000..c235cb7 --- /dev/null +++ b/src/ShellCheck/CFG.hs @@ -0,0 +1,1319 @@ +{- + Copyright 2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE DeriveAnyClass, DeriveGeneric #-} + +-- Constructs a Control Flow Graph from an AST +module ShellCheck.CFG ( + CFNode (..), + CFEdge (..), + CFEffect (..), + CFStringPart (..), + CFVariableProp (..), + CFGResult (..), + CFValue (..), + CFGraph, + CFGParameters (..), + IdTagged (..), + Scope (..), + buildGraph + , ShellCheck.CFG.runTests -- STRIP + ) + where + +import GHC.Generics (Generic) +import ShellCheck.AST +import ShellCheck.ASTLib +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Prelude +import ShellCheck.Regex +import Control.DeepSeq +import Control.Monad +import Control.Monad.Identity +import Data.Array.Unboxed +import Data.Array.ST +import Data.List hiding (map) +import qualified Data.List.NonEmpty as NE +import Data.Maybe +import qualified Data.Map as M +import qualified Data.Set as S +import Control.Monad.RWS.Lazy +import Data.Graph.Inductive.Graph +import Data.Graph.Inductive.Query.DFS +import Data.Graph.Inductive.Basic +import Data.Graph.Inductive.Query.Dominators +import Data.Graph.Inductive.PatriciaTree as G +import Debug.Trace -- STRIP + +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (quickCheckWithResult, stdArgs, maxSuccess) + + +-- Our basic Graph type +type CFGraph = G.Gr CFNode CFEdge + +-- Node labels in a Control Flow Graph +data CFNode = + -- A no-op node for structural purposes + CFStructuralNode + -- A no-op for graph inspection purposes + | CFEntryPoint String + -- Drop current prefix assignments + | CFDropPrefixAssignments + -- A node with a certain effect on program state + | CFApplyEffects [IdTagged CFEffect] + -- The execution of a command or function by literal string if possible + | CFExecuteCommand (Maybe String) + -- Execute a subshell. These are represented by disjoint graphs just like + -- functions, but they don't require any form of name resolution + | CFExecuteSubshell String Node Node + -- Assignment of $? + | CFSetExitCode Id + -- The virtual 'exit' at the natural end of a subshell + | CFImpliedExit + -- An exit statement resolvable at CFG build time + | CFResolvedExit + -- An exit statement only resolvable at DFA time + | CFUnresolvedExit + -- An unreachable node, serving as the unconnected end point of a range + | CFUnreachable + -- Assignment of $! + | CFSetBackgroundPid Id + deriving (Eq, Ord, Show, Generic, NFData) + +-- Edge labels in a Control Flow Graph +data CFEdge = + CFEErrExit + -- Regular control flow edge + | CFEFlow + -- An edge that a human might think exists (e.g. from a backgrounded process to its parent) + | CFEFalseFlow + -- An edge followed on exit + | CFEExit + deriving (Eq, Ord, Show, Generic, NFData) + +-- Actions we track +data CFEffect = + CFSetProps (Maybe Scope) String (S.Set CFVariableProp) + | CFUnsetProps (Maybe Scope) String (S.Set CFVariableProp) + | CFReadVariable String + | CFWriteVariable String CFValue + | CFWriteGlobal String CFValue + | CFWriteLocal String CFValue + | CFWritePrefix String CFValue + | CFDefineFunction String Id Node Node + | CFUndefine String + | CFUndefineVariable String + | CFUndefineFunction String + | CFUndefineNameref String + -- Usage implies that this is an array (e.g. it's expanded with index) + | CFHintArray String + -- Operation implies that the variable will be defined (e.g. [ -z "$var" ]) + | CFHintDefined String + deriving (Eq, Ord, Show, Generic, NFData) + +data IdTagged a = IdTagged Id a + deriving (Eq, Ord, Show, Generic, NFData) + +-- Where a variable's value comes from +data CFValue = + -- The special 'uninitialized' value + CFValueUninitialized + -- An arbitrary array value + | CFValueArray + -- An arbitrary string value + | CFValueString + -- An arbitrary integer + | CFValueInteger + -- Token 'Id' concatenates and assigns the given parts + | CFValueComputed Id [CFStringPart] + deriving (Eq, Ord, Show, Generic, NFData) + +-- Simplified computed strings +data CFStringPart = + -- A known literal string value, like 'foo' + CFStringLiteral String + -- The contents of a variable, like $foo (may not be a string) + | CFStringVariable String + -- An value that is unknown but an integer + | CFStringInteger + -- An unknown string value, for things we can't handle + | CFStringUnknown + deriving (Eq, Ord, Show, Generic, NFData) + +-- The properties of a variable +data CFVariableProp = CFVPExport | CFVPArray | CFVPAssociative | CFVPInteger + deriving (Eq, Ord, Show, Generic, NFData) + +-- Options when generating CFG +data CFGParameters = CFGParameters { + -- Whether the last element in a pipeline runs in the current shell + cfLastpipe :: Bool, + -- Whether all elements in a pipeline count towards the exit status + cfPipefail :: Bool +} + +data CFGResult = CFGResult { + -- The graph itself + cfGraph :: CFGraph, + -- Map from Id to nominal start&end node (i.e. assuming normal execution without exits) + cfIdToRange :: M.Map Id (Node, Node), + -- A set of all nodes belonging to an Id, recursively + cfIdToNodes :: M.Map Id (S.Set Node), + -- An array (from,to) saying whether 'from' postdominates 'to' + cfPostDominators :: Array Node [Node] +} + deriving (Show) + +buildGraph :: CFGParameters -> Token -> CFGResult +buildGraph params root = + let + (nextNode, base) = execRWS (buildRoot root) (newCFContext params) 0 + (nodes, edges, mapping, association) = +-- renumberTopologically $ + removeUnnecessaryStructuralNodes + base + + idToRange = M.fromList mapping + isRealEdge (from, to, edge) = case edge of CFEFlow -> True; CFEExit -> True; _ -> False + onlyRealEdges = filter isRealEdge edges + (_, mainExit) = fromJust $ M.lookup (getId root) idToRange + + result = CFGResult { + cfGraph = mkGraph nodes edges, + cfIdToRange = idToRange, + cfIdToNodes = M.fromListWith S.union $ map (\(id, n) -> (id, S.singleton n)) association, + cfPostDominators = findPostDominators mainExit $ mkGraph nodes onlyRealEdges + } + in + result + +remapGraph :: M.Map Node Node -> CFW -> CFW +remapGraph remap (nodes, edges, mapping, assoc) = + ( + map (remapNode remap) nodes, + map (remapEdge remap) edges, + map (\(id, (a,b)) -> (id, (remapHelper remap a, remapHelper remap b))) mapping, + map (\(id, n) -> (id, remapHelper remap n)) assoc + ) + +prop_testRenumbering = + let + s = CFStructuralNode + before = ( + [(1,s), (3,s), (4, s), (8,s)], + [(1,3,CFEFlow), (3,4, CFEFlow), (4,8,CFEFlow)], + [(Id 0, (3,4))], + [(Id 1, 3), (Id 2, 4)] + ) + after = ( + [(0,s), (1,s), (2,s), (3,s)], + [(0,1,CFEFlow), (1,2, CFEFlow), (2,3,CFEFlow)], + [(Id 0, (1,2))], + [(Id 1, 1), (Id 2, 2)] + ) + in after == renumberGraph before + +-- Renumber the graph for prettiness, so there are no gaps in node numbers +renumberGraph :: CFW -> CFW +renumberGraph g@(nodes, edges, mapping, assoc) = + let renumbering = M.fromList (flip zip [0..] $ sort $ map fst nodes) + in remapGraph renumbering g + +prop_testRenumberTopologically = + let + s = CFStructuralNode + before = ( + [(4,s), (2,s), (3, s)], + [(4,2,CFEFlow), (2,3, CFEFlow)], + [(Id 0, (4,2))], + [] + ) + after = ( + [(0,s), (1,s), (2,s)], + [(0,1,CFEFlow), (1,2, CFEFlow)], + [(Id 0, (0,1))], + [] + ) + in after == renumberTopologically before + +-- Renumber the graph in topological order +renumberTopologically g@(nodes, edges, mapping, assoc) = + let renumbering = M.fromList (flip zip [0..] $ topsort (mkGraph nodes edges :: CFGraph)) + in remapGraph renumbering g + +prop_testRemoveStructural = + let + s = CFStructuralNode + before = ( + [(1,s), (2,s), (3, s), (4,s)], + [(1,2,CFEFlow), (2,3, CFEFlow), (3,4,CFEFlow)], + [(Id 0, (2,3))], + [(Id 0, 3)] + ) + after = ( + [(1,s), (2,s), (4,s)], + [(1,2,CFEFlow), (2,4,CFEFlow)], + [(Id 0, (2,2))], + [(Id 0, 2)] + ) + in after == removeUnnecessaryStructuralNodes before + +-- Collapse structural nodes that just form long chains like x->x->x. +-- This way we can generate them with abandon, without making DFA slower. +-- +-- Note in particular that we can't remove a structural node x in +-- foo -> x -> bar , because then the pre/post-condition for tokens +-- previously pointing to x would be wrong. +removeUnnecessaryStructuralNodes (nodes, edges, mapping, association) = + remapGraph recursiveRemapping + ( + filter (\(n, _) -> n `M.notMember` recursiveRemapping) nodes, + filter (`S.notMember` edgesToCollapse) edges, + mapping, + association + ) + where + regularEdges = filter isRegularEdge edges + inDegree = counter $ map (\(from,to,_) -> from) regularEdges + outDegree = counter $ map (\(from,to,_) -> to) regularEdges + structuralNodes = S.fromList [node | (node, CFStructuralNode) <- nodes] + candidateNodes = S.filter isLinear structuralNodes + edgesToCollapse = S.fromList $ filter filterEdges regularEdges + + remapping :: M.Map Node Node + remapping = M.fromList $ map orderEdge $ S.toList edgesToCollapse + recursiveRemapping = M.mapWithKey (\c _ -> recursiveLookup remapping c) remapping + + filterEdges (a,b,_) = + a `S.member` candidateNodes && b `S.member` candidateNodes + + orderEdge (a,b,_) = if a < b then (b,a) else (a,b) + counter = M.fromListWith (+) . map (\key -> (key, 1)) + isRegularEdge (_, _, CFEFlow) = True + isRegularEdge _ = False + + recursiveLookup :: M.Map Node Node -> Node -> Node + recursiveLookup map node = + case M.lookup node map of + Nothing -> node + Just x -> recursiveLookup map x + + isLinear node = + M.findWithDefault 0 node inDegree == 1 + && M.findWithDefault 0 node outDegree == 1 + + +remapNode :: M.Map Node Node -> LNode CFNode -> LNode CFNode +remapNode m (node, label) = + (remapHelper m node, newLabel) + where + newLabel = case label of + CFApplyEffects effects -> CFApplyEffects (map (remapEffect m) effects) + CFExecuteSubshell s a b -> CFExecuteSubshell s (remapHelper m a) (remapHelper m b) + _ -> label + +remapEffect map old@(IdTagged id effect) = + case effect of + CFDefineFunction name id start end -> IdTagged id $ CFDefineFunction name id (remapHelper map start) (remapHelper map end) + _ -> old + +remapEdge :: M.Map Node Node -> LEdge CFEdge -> LEdge CFEdge +remapEdge map (from, to, label) = (remapHelper map from, remapHelper map to, label) +remapHelper map n = M.findWithDefault n n map + +data Range = Range Node Node + deriving (Eq, Show) + +data CFContext = CFContext { + cfIsCondition :: Bool, + cfIsFunction :: Bool, + cfLoopStack :: [(Node, Node)], + cfTokenStack :: [Id], + cfExitTarget :: Maybe Node, + cfReturnTarget :: Maybe Node, + cfParameters :: CFGParameters +} +newCFContext params = CFContext { + cfIsCondition = False, + cfIsFunction = False, + cfLoopStack = [], + cfTokenStack = [], + cfExitTarget = Nothing, + cfReturnTarget = Nothing, + cfParameters = params +} + +-- The monad we generate a graph in +type CFM a = RWS CFContext CFW Int a +type CFW = ([LNode CFNode], [LEdge CFEdge], [(Id, (Node, Node))], [(Id, Node)]) + +newNode :: CFNode -> CFM Node +newNode label = do + n <- get + stack <- asks cfTokenStack + put (n+1) + tell ([(n, label)], [], [], map (\c -> (c, n)) stack) + return n + +newNodeRange :: CFNode -> CFM Range +-- newNodeRange label = nodeToRange <$> newNode label +newNodeRange label = nodeToRange <$> newNode label + +-- Build a disjoint piece of the graph and return a CFExecuteSubshell. The Id is used purely for debug naming. +subshell :: Id -> String -> CFM Range -> CFM Range +subshell id reason p = do + start <- newNode $ CFEntryPoint $ "Subshell " ++ show id ++ ": " ++ reason + end <- newNode CFStructuralNode + middle <- local (\c -> c { cfExitTarget = Just end, cfReturnTarget = Just end}) p + linkRanges [nodeToRange start, middle, nodeToRange end] + newNodeRange $ CFExecuteSubshell reason start end + + +withFunctionScope p = do + end <- newNode CFStructuralNode + body <- local (\c -> c { cfReturnTarget = Just end, cfIsFunction = True }) p + linkRanges [body, nodeToRange end] + +-- Anything that happens recursively in f will be attributed to this id +under :: Id -> CFM a -> CFM a +under id f = local (\c -> c { cfTokenStack = id:(cfTokenStack c) }) f + +nodeToRange :: Node -> Range +nodeToRange n = Range n n + +link :: Node -> Node -> CFEdge -> CFM () +link from to label = do + tell ([], [(from, to, label)], [], []) + +registerNode :: Id -> Range -> CFM () +registerNode id (Range start end) = tell ([], [], [(id, (start, end))], []) + +linkRange :: Range -> Range -> CFM Range +linkRange = linkRangeAs CFEFlow + +linkRangeAs :: CFEdge -> Range -> Range -> CFM Range +linkRangeAs label (Range start mid1) (Range mid2 end) = do + link mid1 mid2 label + return (Range start end) + +-- Like linkRange but without actually linking +spanRange :: Range -> Range -> Range +spanRange (Range start mid1) (Range mid2 end) = Range start end + +linkRanges :: [Range] -> CFM Range +linkRanges [] = error "Empty range" +linkRanges (first:rest) = foldM linkRange first rest + +sequentially :: [Token] -> CFM Range +sequentially list = do + first <- newStructuralNode + rest <- mapM build list + linkRanges (first:rest) + +withContext :: (CFContext -> CFContext) -> CFM a -> CFM a +withContext = local + +withReturn :: Range -> CFM a -> CFM a +withReturn _ p = p + +asCondition :: CFM Range -> CFM Range +asCondition = withContext (\c -> c { cfIsCondition = True }) + +newStructuralNode = newNodeRange CFStructuralNode + +buildRoot :: Token -> CFM Range +buildRoot t = under (getId t) $ do + entry <- newNodeRange $ CFEntryPoint "MAIN" + impliedExit <- newNode CFImpliedExit + end <- newNode CFStructuralNode + start <- local (\c -> c { cfExitTarget = Just end, cfReturnTarget = Just impliedExit}) $ build t + range <- linkRanges [entry, start, nodeToRange impliedExit, nodeToRange end] + registerNode (getId t) range + return range + +applySingle e = CFApplyEffects [e] + +-- Build the CFG. +build :: Token -> CFM Range +build t = do + range <- under (getId t) $ build' t + registerNode (getId t) range + return range + where + build' t = case t of + T_Annotation _ _ list -> build list + T_Script _ _ list -> do + sequentially list + + TA_Assignment id op var@(TA_Variable _ name indices) rhs -> do + -- value first: (( var[x=1] = (x=2) )) runs x=1 last + value <- build rhs + subscript <- sequentially indices + read <- + if op == "=" + then none + -- This is += or something + else newNodeRange $ applySingle $ IdTagged id $ CFReadVariable name + + write <- newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable name $ + if null indices + then CFValueInteger + else CFValueArray + + linkRanges [value, subscript, read, write] + + TA_Assignment id op lhs rhs -> do + -- This is likely an invalid assignment like (( 1 = 2 )), but it + -- could be e.g. x=y; (( $x = 3 )); echo $y, so expand both sides + -- without updating anything + sequentially [lhs, rhs] + + TA_Binary _ _ a b -> sequentially [a,b] + TA_Expansion _ list -> sequentially list + TA_Sequence _ list -> sequentially list + TA_Parenthesis _ t -> build t + + TA_Trinary _ cond a b -> do + condition <- build cond + ifthen <- build a + elsethen <- build b + end <- newStructuralNode + linkRanges [condition, ifthen, end] + linkRanges [condition, elsethen, end] + + TA_Variable id name indices -> do + subscript <- sequentially indices + hint <- + if null indices + then none + else nodeToRange <$> newNode (applySingle $ IdTagged id $ CFHintArray name) + read <- nodeToRange <$> newNode (applySingle $ IdTagged id $ CFReadVariable name) + linkRanges [subscript, hint, read] + + TA_Unary id op (TA_Variable _ name indices) | "--" `isInfixOf` op || "++" `isInfixOf` op -> do + subscript <- sequentially indices + read <- newNodeRange $ applySingle $ IdTagged id $ CFReadVariable name + write <- newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable name $ + if null indices + then CFValueInteger + else CFValueArray + linkRanges [subscript, read, write] + TA_Unary _ _ arg -> build arg + + TC_And _ SingleBracket _ lhs rhs -> do + sequentially [lhs, rhs] + + TC_And _ DoubleBracket _ lhs rhs -> do + left <- build lhs + right <- build rhs + end <- newStructuralNode + -- complete + linkRanges [left, right, end] + -- short circuit + linkRange left end + + -- TODO: Handle integer ops + TC_Binary _ mode str lhs rhs -> do + left <- build lhs + right <- build rhs + linkRange left right + + TC_Empty {} -> newStructuralNode + + TC_Group _ _ t -> build t + + -- TODO: Mark as checked + TC_Nullary _ _ arg -> build arg + + TC_Or _ SingleBracket _ lhs rhs -> sequentially [lhs, rhs] + + TC_Or _ DoubleBracket _ lhs rhs -> do + left <- build lhs + right <- build rhs + end <- newStructuralNode + -- complete + linkRanges [left, right, end] + -- short circuit + linkRange left end + + -- TODO: Handle -v, -z, -n + TC_Unary _ _ op arg -> do + build arg + + T_Arithmetic id root -> do + exe <- build root + status <- newNodeRange (CFSetExitCode id) + linkRange exe status + + T_AndIf _ lhs rhs -> do + left <- build lhs + right <- build rhs + end <- newStructuralNode + linkRange left right + linkRange right end + linkRange left end + + T_Array _ list -> sequentially list + + T_Assignment {} -> buildAssignment Nothing t + + T_Backgrounded id body -> do + start <- newStructuralNode + fork <- subshell id "backgrounding '&'" $ build body + pid <- newNodeRange $ CFSetBackgroundPid id + status <- newNodeRange $ CFSetExitCode id + + linkRange start fork + -- Add a join from the fork to warn about variable changes + linkRangeAs CFEFalseFlow fork pid + linkRanges [start, pid, status] + + T_Backticked id body -> + subshell id "`..` expansion" $ sequentially body + + T_Banged id cmd -> do + main <- build cmd + status <- newNodeRange (CFSetExitCode id) + linkRange main status + + T_BatsTest id _ body -> do + -- These are technically set by the 'run' command, but we'll just define them + -- up front to avoid figuring out which commands named "run" belong to Bats. + status <- newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable "status" CFValueInteger + output <- newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable "output" CFValueString + main <- build body + linkRanges [status, output, main] + + T_BraceExpansion _ list -> sequentially list + + T_BraceGroup id body -> + sequentially body + + T_CaseExpression id t [] -> build t + + T_CaseExpression id t list@(hd:tl) -> do + start <- newStructuralNode + token <- build t + branches <- mapM buildBranch (hd NE.:| tl) + end <- newStructuralNode + + let neighbors = zip (NE.toList branches) $ NE.tail branches + let (_, firstCond, _) = NE.head branches + let (_, lastCond, lastBody) = NE.last branches + + linkRange start token + linkRange token firstCond + mapM_ (uncurry $ linkBranch end) neighbors + linkRange lastBody end + + unless (any hasCatchAll list) $ + -- There's no *) branch, so assume we can fall through + void $ linkRange token end + + return $ spanRange start end + + where + -- for a | b | c, evaluate each in turn and allow short circuiting + buildCond list = do + start <- newStructuralNode + conds <- mapM build list + end <- newStructuralNode + linkRanges (start:conds) + mapM_ (`linkRange` end) conds + return $ spanRange start end + + buildBranch (typ, cond, body) = do + c <- buildCond cond + b <- sequentially body + linkRange c b + return (typ, c, b) + + linkBranch end (typ, cond, body) (_, nextCond, nextBody) = do + -- Failure case + linkRange cond nextCond + -- After body + case typ of + CaseBreak -> linkRange body end + CaseFallThrough -> linkRange body nextBody + CaseContinue -> linkRange body nextCond + + -- Find a *) if any + + hasCatchAll (_,cond,_) = any isCatchAll cond + isCatchAll c = fromMaybe False $ do + pg <- wordToExactPseudoGlob c + return $ pg `pseudoGlobIsSuperSetof` [PGMany] + + T_Condition id _ op -> do + cond <- build op + status <- newNodeRange $ CFSetExitCode id + linkRange cond status + + T_CoProc id maybeNameToken t -> do + -- If unspecified, "COPROC". If not a constant string, Nothing. + let maybeName = case maybeNameToken of + Just x -> getLiteralString x + Nothing -> Just "COPROC" + + let parentNode = case maybeName of + Just str -> applySingle $ IdTagged id $ CFWriteVariable str CFValueArray + Nothing -> CFStructuralNode + + start <- newStructuralNode + parent <- newNodeRange parentNode + child <- subshell id "coproc" $ build t + end <- newNodeRange $ CFSetExitCode id + + linkRange start parent + linkRange start child + linkRange parent end + linkRangeAs CFEFalseFlow child end + + return $ spanRange start end + T_CoProcBody _ t -> build t + + T_DollarArithmetic _ arith -> build arith + T_DollarDoubleQuoted _ list -> sequentially list + T_DollarSingleQuoted _ _ -> none + T_DollarBracket _ t -> build t + + T_DollarBraced id _ t -> do + let str = concat $ oversimplify t + let modifier = getBracedModifier str + let reference = getBracedReference str + let indices = getIndexReferences str + let offsets = getOffsetReferences str + vals <- build t + others <- mapM (\x -> nodeToRange <$> newNode (applySingle $ IdTagged id $ CFReadVariable x)) (indices ++ offsets) + deps <- linkRanges (vals:others) + read <- nodeToRange <$> newNode (applySingle $ IdTagged id $ CFReadVariable reference) + totalRead <- linkRange deps read + + if any (`isPrefixOf` modifier) ["=", ":="] + then do + optionalAssign <- newNodeRange (applySingle $ IdTagged id $ CFWriteVariable reference CFValueString) + result <- newStructuralNode + linkRange optionalAssign result + linkRange totalRead result + else return totalRead + + T_DollarBraceCommandExpansion id _ body -> + sequentially body + + T_DoubleQuoted _ list -> sequentially list + + T_DollarExpansion id body -> + subshell id "$(..) expansion" $ sequentially body + + T_Extglob _ _ list -> sequentially list + + T_FdRedirect id ('{':identifier) op -> do + let name = takeWhile (/= '}') identifier + expression <- build op + rw <- newNodeRange $ + if isClosingFileOp op + then applySingle $ IdTagged id $ CFReadVariable name + else applySingle $ IdTagged id $ CFWriteVariable name CFValueInteger + + linkRange expression rw + + + T_FdRedirect _ name t -> do + build t + + T_ForArithmetic _ initT condT incT bodyT -> do + init <- build initT + cond <- build condT + body <- sequentially bodyT + inc <- build incT + end <- newStructuralNode + + -- Forward edges + linkRanges [init, cond, body, inc] + linkRange cond end + -- Backward edge + linkRange inc cond + return $ spanRange init end + + T_ForIn id name words body -> forInHelper id name words body + + -- For functions we generate an unlinked subgraph, and mention that in its definition node + T_Function id _ _ name body -> do + range <- local (\c -> c { cfExitTarget = Nothing }) $ do + entry <- newNodeRange $ CFEntryPoint $ "function " ++ name + f <- withFunctionScope $ build body + linkRange entry f + let (Range entry exit) = range + definition <- newNodeRange (applySingle $ IdTagged id $ CFDefineFunction name id entry exit) + exe <- newNodeRange (CFSetExitCode id) + linkRange definition exe + + T_Glob {} -> none + + T_HereString _ t -> build t + T_HereDoc _ _ _ _ list -> sequentially list + + T_IfExpression id ifs elses -> do + start <- newStructuralNode + branches <- doBranches start ifs elses [] + end <- newStructuralNode + mapM_ (`linkRange` end) branches + return $ spanRange start end + where + doBranches start ((conds, thens):rest) elses result = do + cond <- asCondition $ sequentially conds + action <- sequentially thens + linkRange start cond + linkRange cond action + doBranches cond rest elses (action:result) + doBranches start [] elses result = do + rest <- + if null elses + then newNodeRange (CFSetExitCode id) + else sequentially elses + linkRange start rest + return (rest:result) + + T_Include _ t -> build t + + T_IndexedElement _ indicesT valueT -> do + indices <- sequentially indicesT + value <- build valueT + linkRange indices value + + T_IoDuplicate _ op _ -> build op + + T_IoFile _ op t -> do + exp <- build t + doesntDoMuch <- build op + linkRange exp doesntDoMuch + + T_Literal {} -> none + + T_NormalWord _ list -> sequentially list + + T_OrIf _ lhs rhs -> do + left <- build lhs + right <- build rhs + end <- newStructuralNode + linkRange left right + linkRange right end + linkRange left end + + T_Pipeline _ _ [cmd] -> build cmd + T_Pipeline id _ cmds -> do + start <- newStructuralNode + hasLastpipe <- reader $ cfLastpipe . cfParameters + (leading, last) <- buildPipe hasLastpipe cmds + -- Ideally we'd let this exit code be that of the last command in the pipeline but ok + end <- newNodeRange $ CFSetExitCode id + + mapM_ (linkRange start) leading + mapM_ (\c -> linkRangeAs CFEFalseFlow c end) leading + linkRanges $ [start] ++ last ++ [end] + where + buildPipe True [x] = do + last <- build x + return ([], [last]) + buildPipe lp (first:rest) = do + this <- subshell id "pipeline" $ build first + (leading, last) <- buildPipe lp rest + return (this:leading, last) + buildPipe _ [] = return ([], []) + + T_ProcSub id op cmds -> do + start <- newStructuralNode + body <- subshell id (op ++ "() process substitution") $ sequentially cmds + end <- newStructuralNode + + linkRange start body + linkRangeAs CFEFalseFlow body end + linkRange start end + + T_Redirecting _ redirs cmd -> do + -- For simple commands, this is the other way around in bash + -- We do it in this order for comound commands like { x=name; } > "$x" + redir <- sequentially redirs + body <- build cmd + linkRange redir body + + T_SelectIn id name words body -> forInHelper id name words body + + T_SimpleCommand id vars [] -> do + -- Vars can also be empty, as in the command "> foo" + assignments <- sequentially vars + status <- newNodeRange (CFSetExitCode id) + linkRange assignments status + + T_SimpleCommand id vars (cmd:args) -> + handleCommand t vars (cmd NE.:| args) $ getUnquotedLiteral cmd + + T_SingleQuoted _ _ -> none + + T_SourceCommand _ originalCommand inlinedSource -> do + cmd <- build originalCommand + end <- newStructuralNode + inline <- withReturn end $ build inlinedSource + linkRange cmd inline + linkRange inline end + return $ spanRange cmd inline + + T_Subshell id body -> do + main <- subshell id "explicit (..) subshell" $ sequentially body + status <- newNodeRange (CFSetExitCode id) + linkRange main status + + T_UntilExpression id cond body -> whileHelper id cond body + T_WhileExpression id cond body -> whileHelper id cond body + + T_CLOBBER _ -> none + T_GREATAND _ -> none + T_LESSAND _ -> none + T_LESSGREAT _ -> none + T_DGREAT _ -> none + T_Greater _ -> none + T_Less _ -> none + T_ParamSubSpecialChar _ _ -> none + + x -> do + error ("Unimplemented: " ++ show x) -- STRIP + none + +-- Still in `where` clause + forInHelper id name words body = do + entry <- newStructuralNode + expansion <- sequentially words + assignmentChoice <- newStructuralNode + assignments <- + if null words || any willSplit words + then (:[]) <$> (newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable name CFValueString) + else mapM (\t -> newNodeRange $ applySingle $ IdTagged id $ CFWriteVariable name $ CFValueComputed (getId t) $ tokenToParts t) words + body <- sequentially body + exit <- newStructuralNode + -- Forward edges + linkRanges [entry, expansion, assignmentChoice] + mapM_ (\t -> linkRanges [assignmentChoice, t, body]) assignments + linkRange body exit + linkRange expansion exit + -- Backward edge + linkRange body assignmentChoice + return $ spanRange entry exit + + whileHelper id cond body = do + condRange <- asCondition $ sequentially cond + bodyRange <- sequentially body + end <- newNodeRange (CFSetExitCode id) + + linkRange condRange bodyRange + linkRange bodyRange condRange + linkRange condRange end + + +handleCommand cmd vars args literalCmd = do + -- TODO: Handle assignments in declaring commands + + case literalCmd of + Just "exit" -> regularExpansion vars (NE.toList args) $ handleExit + Just "return" -> regularExpansion vars (NE.toList args) $ handleReturn + Just "unset" -> regularExpansionWithStatus vars args $ handleUnset args + + Just "declare" -> handleDeclare args + Just "local" -> handleDeclare args + Just "typeset" -> handleDeclare args + + Just "printf" -> regularExpansionWithStatus vars args $ handlePrintf args + Just "wait" -> regularExpansionWithStatus vars args $ handleWait args + + Just "mapfile" -> regularExpansionWithStatus vars args $ handleMapfile args + Just "readarray" -> regularExpansionWithStatus vars args $ handleMapfile args + + Just "read" -> regularExpansionWithStatus vars args $ handleRead args + + Just "DEFINE_boolean" -> regularExpansionWithStatus vars args $ handleDEFINE args + Just "DEFINE_float" -> regularExpansionWithStatus vars args $ handleDEFINE args + Just "DEFINE_integer" -> regularExpansionWithStatus vars args $ handleDEFINE args + Just "DEFINE_string" -> regularExpansionWithStatus vars args $ handleDEFINE args + + -- This will mostly behave like 'command' but ok + Just "builtin" -> + case args of + _ NE.:| [] -> regular + (_ NE.:| newcmd:newargs) -> + handleCommand newcmd vars (newcmd NE.:| newargs) $ getLiteralString newcmd + Just "command" -> + case args of + _ NE.:| [] -> regular + (_ NE.:| newcmd:newargs) -> + handleOthers (getId newcmd) vars (newcmd NE.:| newargs) $ getLiteralString newcmd + _ -> regular + + where + regular = handleOthers (getId cmd) vars args literalCmd + handleExit = do + exitNode <- reader cfExitTarget + case exitNode of + Just target -> do + exit <- newNode CFResolvedExit + link exit target CFEExit + unreachable <- newNode CFUnreachable + return $ Range exit unreachable + Nothing -> do + exit <- newNode CFUnresolvedExit + unreachable <- newNode CFUnreachable + return $ Range exit unreachable + + handleReturn = do + returnTarget <- reader cfReturnTarget + case returnTarget of + Nothing -> error $ pleaseReport "missing return target" + Just target -> do + ret <- newNode CFStructuralNode + link ret target CFEFlow + unreachable <- newNode CFUnreachable + return $ Range ret unreachable + + handleUnset (cmd NE.:| args) = do + case () of + _ | "n" `elem` flagNames -> unsetWith CFUndefineNameref + _ | "v" `elem` flagNames -> unsetWith CFUndefineVariable + _ | "f" `elem` flagNames -> unsetWith CFUndefineFunction + _ -> unsetWith CFUndefine + where + pairs :: [(String, Token)] -- [(Flag string, token)] e.g. [("-f", t), ("", myfunc)] + pairs = map (\(str, (flag, val)) -> (str, flag)) $ fromMaybe (map (\c -> ("", (c,c))) args) $ getGnuOpts "vfn" args + (names, flags) = partition (null . fst) pairs + flagNames = map fst flags + literalNames :: [(Token, String)] -- Literal names to unset, e.g. [(myfuncToken, "myfunc")] + literalNames = mapMaybe (\(_, t) -> (,) t <$> getLiteralString t) names + -- Apply a constructor like CFUndefineVariable to each literalName, and tag with its id + unsetWith c = newNodeRange $ CFApplyEffects $ map (\(token, name) -> IdTagged (getId token) $ c name) literalNames + + + variableAssignRegex = mkRegex "^([_a-zA-Z][_a-zA-Z0-9]*)=" + + handleDeclare (cmd NE.:| args) = do + isFunc <- asks cfIsFunction + -- This is a bit of a kludge: we don't have great support for things like + -- 'declare -i x=$x' so do one round with declare x=$x, followed by declare -i x + let (evaluated, assignments, added, removed) = mconcat $ map (toEffects isFunc) args + before <- sequentially $ evaluated + assignments <- newNodeRange $ CFApplyEffects assignments + addedProps <- if null added then newStructuralNode else newNodeRange $ CFApplyEffects added + removedProps <- if null removed then newStructuralNode else newNodeRange $ CFApplyEffects removed + result <- newNodeRange $ CFSetExitCode (getId cmd) + linkRanges [before, assignments, addedProps, removedProps, result] + where + opts = map fst $ getGenericOpts args + array = "a" `elem` opts || associative + associative = "A" `elem` opts + integer = "i" `elem` opts + func = "f" `elem` opts || "F" `elem` opts + global = "g" `elem` opts + export = "x" `elem` opts + writer isFunc = + case () of + _ | global -> CFWriteGlobal + _ | isFunc -> CFWriteLocal + _ -> CFWriteVariable + + scope isFunc = + case () of + _ | global -> Just GlobalScope + _ | isFunc -> Just LocalScope + _ -> Nothing + + addedProps = S.fromList $ concat $ [ + [ CFVPArray | array ], + [ CFVPInteger | integer ], + [ CFVPExport | export ], + [ CFVPAssociative | associative ] + ] + + removedProps = S.fromList $ concat $ [ + -- Array property can't be unset + [ CFVPInteger | 'i' `elem` unsetOptions ], + [ CFVPExport | 'e' `elem` unsetOptions ] + ] + + toEffects isFunc (T_Assignment id mode var idx t) = + let + pre = idx ++ [t] + val = [ IdTagged id $ (writer isFunc) var $ CFValueComputed (getId t) $ [ CFStringVariable var | mode == Append ] ++ tokenToParts t ] + added = [ IdTagged id $ CFSetProps (scope isFunc) var addedProps | not $ S.null addedProps ] + removed = [ IdTagged id $ CFUnsetProps (scope isFunc) var addedProps | not $ S.null removedProps ] + in + (pre, val, added, removed) + + toEffects isFunc t = + let + id = getId t + pre = [t] + literal = getLiteralStringDef "\0" t + isKnown = '\0' `notElem` literal + match = fmap head $ variableAssignRegex `matchRegex` literal + name = fromMaybe literal match + + asLiteral = + IdTagged id $ (writer isFunc) name $ + CFValueComputed (getId t) [ CFStringLiteral $ drop 1 $ dropWhile (/= '=') $ literal ] + asUnknown = + IdTagged id $ (writer isFunc) name $ + CFValueString + + added = [ IdTagged id $ CFSetProps (scope isFunc) name addedProps ] + removed = [ IdTagged id $ CFUnsetProps (scope isFunc) name removedProps ] + + in + case () of + _ | not (isVariableName name) -> (pre, [], [], []) + _ | isJust match && isKnown -> (pre, [asLiteral], added, removed) + _ | isJust match -> (pre, [asUnknown], added, removed) + -- e.g. declare -i x + _ -> (pre, [], added, removed) + + -- find "ia" from `define +i +a` + unsetOptions :: String + unsetOptions = + let + strings = mapMaybe getLiteralString args + plusses = filter ("+" `isPrefixOf`) strings + in + concatMap (drop 1) plusses + + handlePrintf (cmd NE.:| args) = + newNodeRange $ CFApplyEffects $ maybeToList findVar + where + findVar = do + flags <- getBsdOpts "v:" args + (flag, arg) <- lookup "v" flags + name <- getLiteralString arg + return $ IdTagged (getId arg) $ CFWriteVariable name CFValueString + + handleWait (cmd NE.:| args) = + newNodeRange $ CFApplyEffects $ maybeToList findVar + where + findVar = do + let flags = getGenericOpts args + (flag, arg) <- lookup "p" flags + name <- getLiteralString arg + return $ IdTagged (getId arg) $ CFWriteVariable name CFValueInteger + + handleMapfile (cmd NE.:| args) = + newNodeRange $ CFApplyEffects [findVar] + where + findVar = + let (id, name) = fromMaybe (getId cmd, "MAPFILE") $ getFromArg `mplus` getFromFallback + in IdTagged id $ CFWriteVariable name CFValueArray + + getFromArg = do + flags <- getGnuOpts flagsForMapfile args + (_, arg) <- lookup "" flags + name <- getLiteralString arg + return (getId arg, name) + + getFromFallback = + listToMaybe $ mapMaybe getIfVar $ reverse args + getIfVar c = do + name <- getLiteralString c + guard $ isVariableName name + return (getId c, name) + + handleRead (cmd NE.:| args) = newNodeRange $ CFApplyEffects main + where + main = fromMaybe fallback $ do + flags <- getGnuOpts flagsForRead args + return $ fromMaybe (withFields flags) $ withArray flags + + withArray :: [(String, (Token, Token))] -> Maybe [IdTagged CFEffect] + withArray flags = do + (_, token) <- lookup "a" flags + return $ fromMaybe [] $ do + name <- getLiteralString token + return [ IdTagged (getId token) $ CFWriteVariable name CFValueArray ] + + withFields flags = mapMaybe getAssignment flags + + getAssignment :: (String, (Token, Token)) -> Maybe (IdTagged CFEffect) + getAssignment f = do + ("", (t, _)) <- return f + name <- getLiteralString t + return $ IdTagged (getId t) $ CFWriteVariable name CFValueString + + fallback = + let + names = reverse $ map fromJust $ takeWhile isJust $ map (\c -> sequence (getId c, getLiteralString c)) $ reverse args + namesOrDefault = if null names then [(getId cmd, "REPLY")] else names + hasDashA = any (== "a") $ map fst $ getGenericOpts args + value = if hasDashA then CFValueArray else CFValueString + in + map (\(id, name) -> IdTagged id $ CFWriteVariable name value) namesOrDefault + + handleDEFINE (cmd NE.:| args) = + newNodeRange $ CFApplyEffects $ maybeToList findVar + where + findVar = do + name <- listToMaybe $ drop 1 args + str <- getLiteralString name + guard $ isVariableName str + return $ IdTagged (getId name) $ CFWriteVariable str CFValueString + + handleOthers id vars args cmd = + regularExpansion vars (NE.toList args) $ do + exe <- newNodeRange $ CFExecuteCommand cmd + status <- newNodeRange $ CFSetExitCode id + linkRange exe status + + regularExpansion vars args p = do + args <- sequentially args + assignments <- mapM (buildAssignment (Just PrefixScope)) vars + exe <- p + dropAssignments <- + if null vars + then + return [] + else do + drop <- newNodeRange CFDropPrefixAssignments + return [drop] + + linkRanges $ [args] ++ assignments ++ [exe] ++ dropAssignments + + regularExpansionWithStatus vars args@(cmd NE.:| _) p = do + initial <- regularExpansion vars (NE.toList args) p + status <- newNodeRange $ CFSetExitCode (getId cmd) + linkRange initial status + + +none = newStructuralNode + +data Scope = GlobalScope | LocalScope | PrefixScope + deriving (Eq, Ord, Show, Generic, NFData) + +buildAssignment scope t = do + op <- case t of + T_Assignment id mode var indices value -> do + expand <- build value + index <- sequentially indices + read <- case mode of + Append -> newNodeRange (applySingle $ IdTagged id $ CFReadVariable var) + Assign -> none + let valueType = if null indices then f id value else CFValueArray + let scoper = + case scope of + Just PrefixScope -> CFWritePrefix + Just LocalScope -> CFWriteLocal + Just GlobalScope -> CFWriteGlobal + Nothing -> CFWriteVariable + write <- newNodeRange $ applySingle $ IdTagged id $ scoper var valueType + linkRanges [expand, index, read, write] + where + f :: Id -> Token -> CFValue + f id t@T_NormalWord {} = CFValueComputed id $ [CFStringVariable var | mode == Append] ++ tokenToParts t + f id t@(T_Literal _ str) = CFValueComputed id $ [CFStringVariable var | mode == Append] ++ tokenToParts t + f _ T_Array {} = CFValueArray + + registerNode (getId t) op + return op + + +tokenToParts t = + case t of + T_NormalWord _ list -> concatMap tokenToParts list + T_DoubleQuoted _ list -> concatMap tokenToParts list + T_SingleQuoted _ str -> [ CFStringLiteral str ] + T_Literal _ str -> [ CFStringLiteral str ] + T_DollarArithmetic {} -> [ CFStringInteger ] + T_DollarBracket {} -> [ CFStringInteger ] + T_DollarBraced _ _ list | isUnmodifiedParameterExpansion t -> [ CFStringVariable (getBracedReference $ concat $ oversimplify list) ] + -- Check if getLiteralString can handle it, if not it's unknown + _ -> [maybe CFStringUnknown CFStringLiteral $ getLiteralString t] + + +-- Like & but well defined when the node already exists +safeUpdate ctx@(_,node,_,_) graph = ctx & (delNode node graph) + +-- Change all subshell invocations to instead link directly to their contents. +-- This is used for producing dominator trees. +inlineSubshells :: CFGraph -> CFGraph +inlineSubshells graph = relinkedGraph + where + subshells = ufold find [] graph + find (incoming, node, label, outgoing) acc = + case label of + CFExecuteSubshell _ start end -> (node, label, start, end, incoming, outgoing):acc + _ -> acc + + relinkedGraph = foldl' relink graph subshells + relink graph (node, label, start, end, incoming, outgoing) = + let + -- Link CFExecuteSubshell to the CFEntryPoint + subshellToStart = (incoming, node, label, [(CFEFlow, start)]) + -- Link the subshell exit to the + endToNexts = (endIncoming, endNode, endLabel, outgoing) + (endIncoming, endNode, endLabel, _) = context graph end + in + subshellToStart `safeUpdate` (endToNexts `safeUpdate` graph) + +findEntryNodes :: CFGraph -> [Node] +findEntryNodes graph = ufold find [] graph + where + find (incoming, node, label, _) list = + case label of + CFEntryPoint {} | null incoming -> node:list + _ -> list + +findDominators main graph = asSetMap + where + inlined = inlineSubshells graph + entryNodes = main : findEntryNodes graph + asLists = concatMap (dom inlined) entryNodes + asSetMap = M.fromList $ map (\(node, list) -> (node, S.fromList list)) asLists + +findTerminalNodes :: CFGraph -> [Node] +findTerminalNodes graph = ufold find [] graph + where + find (_, node, label, _) list = + case label of + CFUnresolvedExit -> node:list + CFApplyEffects effects -> f effects list + _ -> list + + f [] list = list + f (IdTagged _ (CFDefineFunction _ id start end):rest) list = f rest (end:list) + f (_:rest) list = f rest list + +findPostDominators :: Node -> CFGraph -> Array Node [Node] +findPostDominators mainexit graph = asArray + where + inlined = inlineSubshells graph + terminals = findTerminalNodes inlined + (incoming, _, label, outgoing) = context graph mainexit + withExitEdges = (incoming ++ map (\c -> (CFEFlow, c)) terminals, mainexit, label, outgoing) `safeUpdate` inlined + reversed = grev withExitEdges + postDoms = dom reversed mainexit + (_, maxNode) = nodeRange graph + -- Holes in the array cause "Exception: (Array.!): undefined array element" while + -- inspecting/debugging, so fill the array first and then update. + initializedArray = listArray (0, maxNode) $ repeat [] + asArray = initializedArray // postDoms + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/CFGAnalysis.hs b/src/ShellCheck/CFGAnalysis.hs new file mode 100644 index 0000000..cf982e0 --- /dev/null +++ b/src/ShellCheck/CFGAnalysis.hs @@ -0,0 +1,1439 @@ +{- + Copyright 2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE DeriveAnyClass, DeriveGeneric #-} +{-# LANGUAGE CPP #-} + +{- + Data Flow Analysis on a Control Flow Graph. + + This module implements a pretty standard iterative Data Flow Analysis. + For an overview of the process, see Wikipedia. + + Since shell scripts rely heavily on global variables, this DFA includes + tracking the value of globals across calls. Each function invocation is + treated as a separate DFA problem, and a caching mechanism (hopefully) + avoids any exponential explosions. + + To do efficient DFA join operations (or merges, as the code calls them), + some of the data structures have an integer version attached. On update, + the version is changed. If two states have the same version number, + a merge is skipped on the grounds that they are identical. It is easy + to unintentionally forget to update/invalidate the version number, + and bugs will ensure. + + For performance reasons, the entire code runs in plain ST, with a manual + context object Ctx being passed around. It relies heavily on mutable + STRefs. However, this turned out to be literally thousands of times faster + than my several attempts using RWST, so it can't be helped. +-} + +module ShellCheck.CFGAnalysis ( + analyzeControlFlow + ,CFGParameters (..) + ,CFGAnalysis (..) + ,ProgramState (..) + ,VariableState (..) + ,VariableValue (..) + ,VariableProperties + ,SpaceStatus (..) + ,NumericalStatus (..) + ,getIncomingState + ,getOutgoingState + ,doesPostDominate + ,variableMayBeDeclaredInteger + ,variableMayBeAssignedInteger + ,ShellCheck.CFGAnalysis.runTests -- STRIP + ) where + +import Control.DeepSeq +import Control.Monad +import Control.Monad.ST +import Data.Array.Unboxed +import Data.Char +import Data.Graph.Inductive.Graph +import Data.Graph.Inductive.Query.DFS +import Data.List hiding (map) +import Data.Maybe +import Data.STRef +import Debug.Trace -- STRIP +import GHC.Generics (Generic) +import qualified Data.Map as M +import qualified Data.Set as S +import qualified ShellCheck.Data as Data +import ShellCheck.AST +import ShellCheck.CFG +import ShellCheck.Prelude + +import Test.QuickCheck + + +-- The number of iterations for DFA to stabilize +iterationCount = 1000000 +-- There have been multiple bugs where bad caching caused oscillations. +-- As a precaution, disable caching if there's this many iterations left. +fallbackThreshold = 10000 +-- The number of cache entries to keep per node +cacheEntries = 10 + +logVerbose log = do + -- traceShowM log + return () +logInfo log = do + -- traceShowM log + return () + +-- The result of the data flow analysis +data CFGAnalysis = CFGAnalysis { + graph :: CFGraph, + tokenToRange :: M.Map Id (Node, Node), + tokenToNodes :: M.Map Id (S.Set Node), + postDominators :: Array Node [Node], + nodeToData :: M.Map Node (ProgramState, ProgramState) +} deriving (Show) + +-- The program state we expose externally +data ProgramState = ProgramState { + -- internalState :: InternalState, -- For debugging + variablesInScope :: M.Map String VariableState, + exitCodes :: S.Set Id, + stateIsReachable :: Bool +} deriving (Show, Eq, Generic, NFData) + +internalToExternal :: InternalState -> ProgramState +internalToExternal s = + ProgramState { + -- Censor the literal value to avoid introducing dependencies on it. It's just for debugging. + variablesInScope = M.map censor flatVars, + -- internalState = s, -- For debugging + exitCodes = fromMaybe S.empty $ sExitCodes s, + stateIsReachable = fromMaybe True $ sIsReachable s + } + where + censor s = s { + variableValue = (variableValue s) { + literalValue = Nothing + } + } + flatVars = M.unions $ map mapStorage [sPrefixValues s, sLocalValues s, sGlobalValues s] + +-- Conveniently get the state before a token id +getIncomingState :: CFGAnalysis -> Id -> Maybe ProgramState +getIncomingState analysis id = do + (start,end) <- M.lookup id $ tokenToRange analysis + fst <$> M.lookup start (nodeToData analysis) + +-- Conveniently get the state after a token id +getOutgoingState :: CFGAnalysis -> Id -> Maybe ProgramState +getOutgoingState analysis id = do + (start,end) <- M.lookup id $ tokenToRange analysis + snd <$> M.lookup end (nodeToData analysis) + +-- Conveniently determine whether one node postdominates another, +-- i.e. whether 'target' always unconditionally runs after 'base'. +doesPostDominate :: CFGAnalysis -> Id -> Id -> Bool +doesPostDominate analysis target base = fromMaybe False $ do + (_, baseEnd) <- M.lookup base $ tokenToRange analysis + (targetStart, _) <- M.lookup target $ tokenToRange analysis + return $ targetStart `elem` (postDominators analysis ! baseEnd) + +-- See if any execution path results in the variable containing a state +variableMayHaveState :: ProgramState -> String -> CFVariableProp -> Maybe Bool +variableMayHaveState state var property = do + value <- M.lookup var $ variablesInScope state + return $ any (S.member property) $ variableProperties value + +-- See if any execution path declares the variable an integer (declare -i). +variableMayBeDeclaredInteger state var = variableMayHaveState state var CFVPInteger + +-- See if any execution path suggests the variable may contain an integer value +variableMayBeAssignedInteger state var = do + value <- M.lookup var $ variablesInScope state + return $ (numericalStatus $ variableValue value) >= NumericalStatusMaybe + +getDataForNode analysis node = M.lookup node $ nodeToData analysis + +-- The current state of data flow at a point in the program, potentially as a diff +data InternalState = InternalState { + sVersion :: Integer, + sGlobalValues :: VersionedMap String VariableState, + sLocalValues :: VersionedMap String VariableState, + sPrefixValues :: VersionedMap String VariableState, + sFunctionTargets :: VersionedMap String FunctionValue, + sExitCodes :: Maybe (S.Set Id), + sIsReachable :: Maybe Bool +} deriving (Show, Generic, NFData) + +newInternalState = InternalState { + sVersion = 0, + sGlobalValues = vmEmpty, + sLocalValues = vmEmpty, + sPrefixValues = vmEmpty, + sFunctionTargets = vmEmpty, + sExitCodes = Nothing, + sIsReachable = Nothing +} + +unreachableState = modified newInternalState { + sIsReachable = Just False +} + +-- The default state we assume we get from the environment +createEnvironmentState :: InternalState +createEnvironmentState = do + foldl' (flip ($)) newInternalState $ concat [ + addVars Data.internalVariables unknownVariableState, + addVars Data.variablesWithoutSpaces spacelessVariableState, + addVars Data.specialIntegerVariables integerVariableState + ] + where + addVars names val = map (\name -> insertGlobal name val) names + spacelessVariableState = unknownVariableState { + variableValue = VariableValue { + literalValue = Nothing, + spaceStatus = SpaceStatusClean, + numericalStatus = NumericalStatusUnknown + } + } + integerVariableState = unknownVariableState { + variableValue = unknownIntegerValue + } + + +modified s = s { sVersion = -1 } + +insertGlobal :: String -> VariableState -> InternalState -> InternalState +insertGlobal name value state = modified state { + sGlobalValues = vmInsert name value $ sGlobalValues state +} + +insertLocal :: String -> VariableState -> InternalState -> InternalState +insertLocal name value state = modified state { + sLocalValues = vmInsert name value $ sLocalValues state +} + +insertPrefix :: String -> VariableState -> InternalState -> InternalState +insertPrefix name value state = modified state { + sPrefixValues = vmInsert name value $ sPrefixValues state +} + +insertFunction :: String -> FunctionValue -> InternalState -> InternalState +insertFunction name value state = modified state { + sFunctionTargets = vmInsert name value $ sFunctionTargets state +} + +addProperties :: S.Set CFVariableProp -> VariableState -> VariableState +addProperties props state = state { + variableProperties = S.map (S.union props) $ variableProperties state +} + +removeProperties :: S.Set CFVariableProp -> VariableState -> VariableState +removeProperties props state = state { + variableProperties = S.map (\s -> S.difference s props) $ variableProperties state +} + +setExitCode id = setExitCodes (S.singleton id) +setExitCodes set state = modified state { + sExitCodes = Just $ set +} + +-- Dependencies on values, e.g. "if there is a global variable named 'foo' without spaces" +-- This is used to see if the DFA of a function would result in the same state, so anything +-- that affects DFA must be tracked. +data StateDependency = + -- Complete variable state + DepState Scope String VariableState + -- Only variable properties (we need properties but not values for x=1) + | DepProperties Scope String VariableProperties + -- Function definition + | DepFunction String (S.Set FunctionDefinition) + -- Whether invoking the node would result in recursion (i.e., is the function on the stack?) + | DepIsRecursive Node Bool + -- The set of commands that could have provided the exit code $? + | DepExitCodes (S.Set Id) + deriving (Show, Eq, Ord, Generic, NFData) + +-- A function definition, or lack thereof +data FunctionDefinition = FunctionUnknown | FunctionDefinition String Node Node + deriving (Show, Eq, Ord, Generic, NFData) + +-- The Set of places a command name can point (it's a Set to handle conditionally defined functions) +type FunctionValue = S.Set FunctionDefinition + +-- Create an InternalState that fulfills the given dependencies +depsToState :: S.Set StateDependency -> InternalState +depsToState set = foldl insert newInternalState $ S.toList set + where + insert :: InternalState -> StateDependency -> InternalState + insert state dep = + case dep of + DepFunction name val -> insertFunction name val state + DepState scope name val -> insertIn True scope name val state + -- State includes properties and more, so don't overwrite a state with properties + DepProperties scope name props -> insertIn False scope name unknownVariableState { variableProperties = props } state + DepIsRecursive _ _ -> state + DepExitCodes s -> setExitCodes s state + + insertIn overwrite scope name val state = + let + (mapToCheck, inserter) = + case scope of + PrefixScope -> (sPrefixValues, insertPrefix) + LocalScope -> (sLocalValues, insertLocal) + GlobalScope -> (sGlobalValues, insertGlobal) + + alreadyExists = isJust $ vmLookup name $ mapToCheck state + in + if overwrite || not alreadyExists + then inserter name val state + else state + +unknownFunctionValue = S.singleton FunctionUnknown + +-- The information about the value of a single variable +data VariableValue = VariableValue { + literalValue :: Maybe String, -- TODO: For debugging. Remove me. + spaceStatus :: SpaceStatus, + numericalStatus :: NumericalStatus +} + deriving (Show, Eq, Ord, Generic, NFData) + +data VariableState = VariableState { + variableValue :: VariableValue, + variableProperties :: VariableProperties +} + deriving (Show, Eq, Ord, Generic, NFData) + +-- Whether or not the value needs quoting (has spaces/globs), or we don't know +data SpaceStatus = SpaceStatusEmpty | SpaceStatusClean | SpaceStatusDirty deriving (Show, Eq, Ord, Generic, NFData) +-- +-- Whether or not the value needs quoting (has spaces/globs), or we don't know +data NumericalStatus = NumericalStatusUnknown | NumericalStatusEmpty | NumericalStatusMaybe | NumericalStatusDefinitely deriving (Show, Eq, Ord, Generic, NFData) + +-- The set of possible sets of properties for this variable +type VariableProperties = S.Set (S.Set CFVariableProp) + +defaultProperties = S.singleton S.empty + +unknownVariableState = VariableState { + variableValue = unknownVariableValue, + variableProperties = defaultProperties +} + +unknownVariableValue = VariableValue { + literalValue = Nothing, + spaceStatus = SpaceStatusDirty, + numericalStatus = NumericalStatusUnknown +} + +emptyVariableValue = unknownVariableValue { + literalValue = Just "", + spaceStatus = SpaceStatusEmpty, + numericalStatus = NumericalStatusEmpty +} + +unsetVariableState = VariableState { + variableValue = emptyVariableValue, + variableProperties = defaultProperties +} + +mergeVariableState a b = VariableState { + variableValue = mergeVariableValue (variableValue a) (variableValue b), + variableProperties = S.union (variableProperties a) (variableProperties b) +} + +mergeVariableValue a b = VariableValue { + literalValue = if literalValue a == literalValue b then literalValue a else Nothing, + spaceStatus = mergeSpaceStatus (spaceStatus a) (spaceStatus b), + numericalStatus = mergeNumericalStatus (numericalStatus a) (numericalStatus b) +} + +mergeSpaceStatus a b = + case (a,b) of + (SpaceStatusEmpty, y) -> y + (x, SpaceStatusEmpty) -> x + (SpaceStatusClean, SpaceStatusClean) -> SpaceStatusClean + _ -> SpaceStatusDirty + +mergeNumericalStatus a b = + case (a,b) of + (NumericalStatusDefinitely, NumericalStatusDefinitely) -> NumericalStatusDefinitely + (NumericalStatusDefinitely, _) -> NumericalStatusMaybe + (_, NumericalStatusDefinitely) -> NumericalStatusMaybe + (NumericalStatusMaybe, _) -> NumericalStatusMaybe + (_, NumericalStatusMaybe) -> NumericalStatusMaybe + (NumericalStatusEmpty, NumericalStatusEmpty) -> NumericalStatusEmpty + _ -> NumericalStatusUnknown + +-- A VersionedMap is a Map that keeps an additional integer version to quickly determine if it has changed. +-- * Version -1 means it's unknown (possibly and presumably changed) +-- * Version 0 means it's empty +-- * Version N means it's equal to any other map with Version N (this is required but not enforced) +data VersionedMap k v = VersionedMap { + mapVersion :: Integer, + mapStorage :: M.Map k v +} + deriving (Generic, NFData) + +-- This makes states more readable but inhibits copy-paste +instance (Show k, Show v) => Show (VersionedMap k v) where + show m = (if mapVersion m >= 0 then "V" ++ show (mapVersion m) else "U") ++ " " ++ show (mapStorage m) + +instance Eq InternalState where + (==) a b = stateIsQuickEqual a b || stateIsSlowEqual a b + +instance (Eq k, Eq v) => Eq (VersionedMap k v) where + (==) a b = vmIsQuickEqual a b || mapStorage a == mapStorage b + +instance (Ord k, Ord v) => Ord (VersionedMap k v) where + compare a b = + if vmIsQuickEqual a b + then EQ + else mapStorage a `compare` mapStorage b + + +-- A context with STRefs manually passed around to function. +-- This is done because it was dramatically much faster than any RWS type stack +data Ctx s = Ctx { + -- The current node + cNode :: STRef s Node, + -- The current input state + cInput :: STRef s InternalState, + -- The current output state + cOutput :: STRef s InternalState, + + -- The current functions/subshells stack + cStack :: [StackEntry s], + -- The input graph + cGraph :: CFGraph, + -- An incrementing counter to version maps + cCounter :: STRef s Integer, + -- A cache of input state dependencies to output effects + cCache :: STRef s (M.Map Node [(S.Set StateDependency, InternalState)]), + -- Whether the cache is enabled (see fallbackThreshold) + cEnableCache :: STRef s Bool, + -- The states resulting from data flows per invocation path + cInvocations :: STRef s (M.Map [Node] (S.Set StateDependency, M.Map Node (InternalState, InternalState))) +} + +-- Whenever a function (or subshell) is invoked, a value like this is pushed onto the stack +data StackEntry s = StackEntry { + -- The entry point of this stack entry for the purpose of detecting recursion + entryPoint :: Node, + -- Whether this is a function call (as opposed to a subshell) + isFunctionCall :: Bool, + -- The node where this entry point was invoked + callSite :: Node, + -- A mutable set of dependencies we fetched from here or higher in the stack + dependencies :: STRef s (S.Set StateDependency), + -- The original input state for this stack entry + stackState :: InternalState +} + deriving (Eq, Generic, NFData) + +#if MIN_VERSION_deepseq(1,4,2) +-- Our deepseq already has a STRef instance +#else +-- Older deepseq (for GHC < 8) lacks this instance +instance NFData (STRef s a) where + rnf = (`seq` ()) +#endif + +-- Overwrite a base state with the contents of a diff state +-- This is unrelated to join/merge. +patchState :: InternalState -> InternalState -> InternalState +patchState base diff = + case () of + _ | sVersion diff == 0 -> base + _ | sVersion base == 0 -> diff + _ | stateIsQuickEqual base diff -> diff + _ -> + InternalState { + sVersion = -1, + sGlobalValues = vmPatch (sGlobalValues base) (sGlobalValues diff), + sLocalValues = vmPatch (sLocalValues base) (sLocalValues diff), + sPrefixValues = vmPatch (sPrefixValues base) (sPrefixValues diff), + sFunctionTargets = vmPatch (sFunctionTargets base) (sFunctionTargets diff), + sExitCodes = sExitCodes diff `mplus` sExitCodes base, + sIsReachable = sIsReachable diff `mplus` sIsReachable base + } + +patchOutputM ctx diff = do + let cOut = cOutput ctx + oldState <- readSTRef cOut + let newState = patchState oldState diff + writeSTRef cOut newState + +-- Merge (aka Join) two states. This is monadic because it requires looking up +-- values from the current context. For example: +-- +-- f() { +-- foo || x=2 +-- HERE # This merge requires looking up the value of $x in the parent frame +-- } +-- x=1 +-- f +mergeState :: forall s. Ctx s -> InternalState -> InternalState -> ST s InternalState +mergeState ctx a b = do + -- Kludge: we want `readVariable` & friends not to read from an intermediate state, + -- so temporarily set a blank input. + let cin = cInput ctx + old <- readSTRef cin + writeSTRef cin newInternalState + x <- merge a b + writeSTRef cin old + return x + + where + + merge a b = + case () of + _ | sIsReachable a == Just True && sIsReachable b == Just False + || sIsReachable a == Just False && sIsReachable b == Just True -> + error $ pleaseReport "Unexpected merge of reachable and unreachable state" + _ | sIsReachable a == Just False && sIsReachable b == Just False -> + return unreachableState + _ | sVersion a >= 0 && sVersion b >= 0 && sVersion a == sVersion b -> return a + _ -> do + globals <- mergeMaps ctx mergeVariableState readGlobal (sGlobalValues a) (sGlobalValues b) + locals <- mergeMaps ctx mergeVariableState readVariable (sLocalValues a) (sLocalValues b) + prefix <- mergeMaps ctx mergeVariableState readVariable (sPrefixValues a) (sPrefixValues b) + funcs <- mergeMaps ctx S.union readFunction (sFunctionTargets a) (sFunctionTargets b) + exitCodes <- mergeMaybes ctx S.union readExitCodes (sExitCodes a) (sExitCodes b) + return $ InternalState { + sVersion = -1, + sGlobalValues = globals, + sLocalValues = locals, + sPrefixValues = prefix, + sFunctionTargets = funcs, + sExitCodes = exitCodes, + sIsReachable = liftM2 (&&) (sIsReachable a) (sIsReachable b) + } + +-- Merge a number of states, or return a default if there are no states +-- (it can't fold from newInternalState because this would be equivalent of adding a new input edge). +mergeStates :: forall s. Ctx s -> InternalState -> [InternalState] -> ST s InternalState +mergeStates ctx def list = + case list of + [] -> return def + (first:rest) -> foldM (mergeState ctx) first rest + +-- Merge two maps, key by key. If both maps have a key, the 'merger' is used. +-- If only one has the key, the 'reader' is used to fetch a second, and the two are merged as above. +mergeMaps :: (Ord k) => forall s. + Ctx s -> + (v -> v -> v) -> + (Ctx s -> k -> ST s v) -> + (VersionedMap k v) -> + (VersionedMap k v) -> + ST s (VersionedMap k v) +mergeMaps ctx merger reader a b = + if vmIsQuickEqual a b + then return a + else do + new <- M.fromDistinctAscList <$> reverse <$> f [] (M.toAscList $ mapStorage a) (M.toAscList $ mapStorage b) + vmFromMap ctx new + where + f l [] [] = return l + f l [] b = f l b [] + f l ((k,v):rest1) [] = do + other <- reader ctx k + f ((k, merger v other):l) rest1 [] + f l l1@((k1, v1):rest1) l2@((k2, v2):rest2) = + case k1 `compare` k2 of + EQ -> + f ((k1, merger v1 v2):l) rest1 rest2 + LT -> do + nv2 <- reader ctx k1 + f ((k1, merger v1 nv2):l) rest1 l2 + GT -> do + nv1 <- reader ctx k2 + f ((k2, merger nv1 v2):l) l1 rest2 + +-- Merge two Maybes, like mergeMaps for a single element +mergeMaybes ctx merger reader a b = + case (a, b) of + (Nothing, Nothing) -> return Nothing + (Just v1, Nothing) -> single v1 + (Nothing, Just v2) -> single v2 + (Just v1, Just v2) -> return $ Just $ merger v1 v2 + where + single val = do + result <- merger val <$> reader ctx + return $ Just result + +vmFromMap ctx map = return $ VersionedMap { + mapVersion = -1, + mapStorage = map +} + +-- Give a VersionedMap a version if it does not already have one. +versionMap ctx map = + if mapVersion map >= 0 + then return map + else do + v <- nextVersion ctx + return map { + mapVersion = v + } + +-- Give an InternalState a version if it does not already have one. +versionState ctx state = + if sVersion state >= 0 + then return state + else do + self <- nextVersion ctx + ssGlobalValues <- versionMap ctx $ sGlobalValues state + ssLocalValues <- versionMap ctx $ sLocalValues state + ssFunctionTargets <- versionMap ctx $ sFunctionTargets state + return state { + sVersion = self, + sGlobalValues = ssGlobalValues, + sLocalValues = ssLocalValues, + sFunctionTargets = ssFunctionTargets + } + +-- Like 'not null' but for 2+ elements +is2plus :: [a] -> Bool +is2plus l = case l of + _:_:_ -> True + _ -> False + +-- Use versions to see if two states are trivially identical +stateIsQuickEqual a b = + let + va = sVersion a + vb = sVersion b + in + va >= 0 && vb >= 0 && va == vb + +-- A manual slow path 'Eq' (it's not derived because it's part of the custom Eq instance) +stateIsSlowEqual a b = + check sGlobalValues + && check sLocalValues + && check sPrefixValues + && check sFunctionTargets + && check sIsReachable + where + check f = f a == f b + +-- Check if two VersionedMaps are trivially equal +vmIsQuickEqual :: VersionedMap k v -> VersionedMap k v -> Bool +vmIsQuickEqual a b = + let + va = mapVersion a + vb = mapVersion b + in + va >= 0 && vb >= 0 && va == vb + +-- A new, empty VersionedMap +vmEmpty = VersionedMap { + mapVersion = 0, + mapStorage = M.empty +} + +-- Map.null for VersionedMaps +vmNull :: VersionedMap k v -> Bool +vmNull m = mapVersion m == 0 || (M.null $ mapStorage m) + +-- Map.lookup for VersionedMaps +vmLookup name map = M.lookup name $ mapStorage map + +-- Map.insert for VersionedMaps +vmInsert key val map = VersionedMap { + mapVersion = -1, + mapStorage = M.insert key val $ mapStorage map +} + +-- Overwrite all keys in the first map with values from the second +vmPatch :: (Ord k) => VersionedMap k v -> VersionedMap k v -> VersionedMap k v +vmPatch base diff = + case () of + _ | mapVersion base == 0 -> diff + _ | mapVersion diff == 0 -> base + _ | vmIsQuickEqual base diff -> diff + _ -> VersionedMap { + mapVersion = -1, + mapStorage = M.union (mapStorage diff) (mapStorage base) + } + +-- Set a variable. This includes properties. Applies it to the appropriate scope. +writeVariable :: forall s. Ctx s -> String -> VariableState -> ST s () +writeVariable ctx name val = do + typ <- readVariableScope ctx name + case typ of + GlobalScope -> writeGlobal ctx name val + LocalScope -> writeLocal ctx name val + -- Prefixed variables actually become local variables in the invoked function + PrefixScope -> writeLocal ctx name val + +writeGlobal ctx name val = do + modifySTRef (cOutput ctx) $ insertGlobal name val + +writeLocal ctx name val = do + modifySTRef (cOutput ctx) $ insertLocal name val + +writePrefix ctx name val = do + modifySTRef (cOutput ctx) $ insertPrefix name val + +updateVariableValue ctx name val = do + (props, scope) <- readVariablePropertiesWithScope ctx name + let f = case scope of + GlobalScope -> writeGlobal + LocalScope -> writeLocal + PrefixScope -> writeLocal -- Updates become local + f ctx name $ VariableState { variableValue = val, variableProperties = props } + +updateGlobalValue ctx name val = do + props <- readGlobalProperties ctx name + writeGlobal ctx name VariableState { variableValue = val, variableProperties = props } + +updateLocalValue ctx name val = do + props <- readLocalProperties ctx name + writeLocal ctx name VariableState { variableValue = val, variableProperties = props } + +updatePrefixValue ctx name val = do + -- Prefix variables don't inherit properties + writePrefix ctx name VariableState { variableValue = val, variableProperties = defaultProperties } + + +-- Look up a variable value, and also return its scope +readVariableWithScope :: forall s. Ctx s -> String -> ST s (VariableState, Scope) +readVariableWithScope ctx name = lookupStack get dep def ctx name + where + def = (unknownVariableState, GlobalScope) + get = getVariableWithScope + dep k (val, scope) = DepState scope k val + +-- Look up the variable's properties. This can be done independently to avoid incurring a dependency on the value. +readVariablePropertiesWithScope :: forall s. Ctx s -> String -> ST s (VariableProperties, Scope) +readVariablePropertiesWithScope ctx name = lookupStack get dep def ctx name + where + def = (defaultProperties, GlobalScope) + get s k = do + (val, scope) <- getVariableWithScope s k + return (variableProperties val, scope) + dep k (val, scope) = DepProperties scope k val + +readVariableScope ctx name = snd <$> readVariablePropertiesWithScope ctx name + +getVariableWithScope :: InternalState -> String -> Maybe (VariableState, Scope) +getVariableWithScope s name = + case (vmLookup name $ sPrefixValues s, vmLookup name $ sLocalValues s, vmLookup name $ sGlobalValues s) of + (Just var, _, _) -> return (var, PrefixScope) + (_, Just var, _) -> return (var, LocalScope) + (_, _, Just var) -> return (var, GlobalScope) + _ -> Nothing + +undefineFunction ctx name = + writeFunction ctx name $ FunctionUnknown + +undefineVariable ctx name = + writeVariable ctx name $ unsetVariableState + +readVariable ctx name = fst <$> readVariableWithScope ctx name +readVariableProperties ctx name = fst <$> readVariablePropertiesWithScope ctx name + +readGlobal ctx name = lookupStack get dep def ctx name + where + def = unknownVariableState -- could come from the environment + get s name = vmLookup name $ sGlobalValues s + dep k v = DepState GlobalScope k v + + +readGlobalProperties ctx name = lookupStack get dep def ctx name + where + def = defaultProperties + get s name = variableProperties <$> (vmLookup name $ sGlobalValues s) + -- This dependency will fail to match if it's shadowed by a local variable, + -- such as in x=1; f() { local -i x; declare -ag x; } because we'll look at + -- x and find it to be local and not global. FIXME? + dep k v = DepProperties GlobalScope k v + +readLocal ctx name = lookupStackUntilFunction get dep def ctx name + where + def = unsetVariableState -- can't come from the environment + get s name = vmLookup name $ sLocalValues s + dep k v = DepState LocalScope k v + +-- We only want to look up the local properties of the current function, +-- though preferably even if we're in a subshell. FIXME? +readLocalProperties ctx name = fst <$> lookupStackUntilFunction get dep def ctx name + where + def = (defaultProperties, LocalScope) + with tag f = do + val <- variableProperties <$> f + return (val, tag) + + get s name = (with LocalScope $ vmLookup name $ sLocalValues s) `mplus` (with PrefixScope $ vmLookup name $ sPrefixValues s) + dep k (val, scope) = DepProperties scope k val + +readFunction ctx name = lookupStack get dep def ctx name + where + def = unknownFunctionValue + get s name = vmLookup name $ sFunctionTargets s + dep k v = DepFunction k v + +writeFunction ctx name val = do + modifySTRef (cOutput ctx) $ insertFunction name $ S.singleton val + +readExitCodes ctx = lookupStack get dep def ctx () + where + get s () = sExitCodes s + def = S.empty + dep () v = DepExitCodes v + +-- Look up each state on the stack until a value is found (or the default is used), +-- then add this value as a StateDependency. +lookupStack' :: forall s k v. + -- Whether to stop at function boundaries + Bool + -- A function that maybe finds a value from a state + -> (InternalState -> k -> Maybe v) + -- A function that creates a dependency on what was found + -> (k -> v -> StateDependency) + -- A default value, if the value can't be found anywhere + -> v + -- Context + -> Ctx s + -- The key to look up + -> k + -- Returning the result + -> ST s v +lookupStack' functionOnly get dep def ctx key = do + top <- readSTRef $ cInput ctx + case get top key of + Just v -> return v + Nothing -> f (cStack ctx) + where + f [] = return def + f (s:_) | functionOnly && isFunctionCall s = return def + f (s:rest) = do + -- Go up the stack until we find the value, and add + -- a dependency on each state (including where it was found) + res <- maybe (f rest) return (get (stackState s) key) + modifySTRef (dependencies s) $ S.insert $ dep key res + return res + +lookupStack = lookupStack' False +lookupStackUntilFunction = lookupStack' True + +-- Like lookupStack but without adding dependencies +peekStack get def ctx key = do + top <- readSTRef $ cInput ctx + case get top key of + Just v -> return v + Nothing -> f (cStack ctx) + where + f [] = return def + f (s:rest) = + case get (stackState s) key of + Just v -> return v + Nothing -> f rest + +-- Check if the current context fulfills a StateDependency if entering `entry` +fulfillsDependency ctx entry dep = + case dep of + DepState scope name val -> (== (val, scope)) <$> peek scope ctx name + DepProperties scope name props -> do + (state, s) <- peek scope ctx name + return $ scope == s && variableProperties state == props + DepFunction name val -> (== val) <$> peekFunc ctx name + -- Hack. Since we haven't pushed the soon-to-be invoked function on the stack, + -- it won't be found by the normal check. + DepIsRecursive node val | node == entry -> return True + DepIsRecursive node val -> return $ val == any (\f -> entryPoint f == node) (cStack ctx) + DepExitCodes val -> (== val) <$> peekStack (\s k -> sExitCodes s) S.empty ctx () + -- _ -> error $ "Unknown dep " ++ show dep + where + peek scope = peekStack getVariableWithScope $ if scope == GlobalScope then (unknownVariableState, GlobalScope) else (unsetVariableState, LocalScope) + peekFunc = peekStack (\state name -> vmLookup name $ sFunctionTargets state) unknownFunctionValue + +-- Check if the current context fulfills all StateDependencies +fulfillsDependencies ctx entry deps = + f $ S.toList deps + where + f [] = return True + f (dep:rest) = do + res <- fulfillsDependency ctx entry dep + if res + then f rest + else return False + +-- Create a brand new Ctx given a Control Flow Graph (CFG) +newCtx g = do + c <- newSTRef 1 + input <- newSTRef undefined + output <- newSTRef undefined + node <- newSTRef undefined + cache <- newSTRef M.empty + enableCache <- newSTRef True + invocations <- newSTRef M.empty + return $ Ctx { + cCounter = c, + cInput = input, + cOutput = output, + cNode = node, + cCache = cache, + cEnableCache = enableCache, + cStack = [], + cInvocations = invocations, + cGraph = g + } + +-- The next incrementing version for VersionedMaps +nextVersion ctx = do + let ctr = cCounter ctx + n <- readSTRef ctr + writeSTRef ctr $! n+1 + return n + +-- Create a new StackEntry +newStackEntry ctx point isCall = do + deps <- newSTRef S.empty + state <- readSTRef $ cOutput ctx + callsite <- readSTRef $ cNode ctx + return $ StackEntry { + entryPoint = point, + isFunctionCall = isCall, + callSite = callsite, + dependencies = deps, + stackState = state + } + +-- Call a function with a new stack entry on the stack +withNewStackFrame ctx node isCall f = do + newEntry <- newStackEntry ctx node isCall + newInput <- newSTRef newInternalState + newOutput <- newSTRef newInternalState + newNode <- newSTRef node + let newCtx = ctx { + cInput = newInput, + cOutput = newOutput, + cNode = newNode, + cStack = newEntry : cStack ctx + } + x <- f newCtx + + {- + deps <- readSTRef $ dependencies newEntry + selfcheck <- fulfillsDependencies newCtx deps + unless selfcheck $ error $ pleaseReport $ "Unmet stack dependencies on " ++ show (node, deps) + -} + + return (x, newEntry) + +-- Check if invoking this function would be a recursive loop +-- (i.e. we already have the function on the stack) +wouldBeRecursive ctx node = f (cStack ctx) + where + f [] = return False + f (s:rest) = do + res <- + if entryPoint s == node + then return True + else f rest + modifySTRef (dependencies s) $ S.insert $ DepIsRecursive node res + return res + +-- The main DFA 'transfer' function, applying the effects of a node to the output state +transfer ctx label = + --traceShow ("Transferring", label) $ + case label of + CFStructuralNode -> return () + CFEntryPoint _ -> return () + CFImpliedExit -> return () + CFResolvedExit {} -> return () + + CFExecuteCommand cmd -> transferCommand ctx cmd + CFExecuteSubshell reason entry exit -> transferSubshell ctx reason entry exit + CFApplyEffects effects -> mapM_ (\(IdTagged _ f) -> transferEffect ctx f) effects + CFSetExitCode id -> transferExitCode ctx id + + CFUnresolvedExit -> patchOutputM ctx unreachableState + CFUnreachable -> patchOutputM ctx unreachableState + + -- TODO + CFSetBackgroundPid _ -> return () + CFDropPrefixAssignments {} -> + modifySTRef (cOutput ctx) $ \c -> modified c { sPrefixValues = vmEmpty } +-- _ -> error $ "Unknown " ++ show label + + +-- Transfer the effects of a subshell invocation. This is similar to a function call +-- to allow easily discarding the effects (otherwise the InternalState would have +-- to represent subshell depth, while this way it can simply use the function stack). +transferSubshell ctx reason entry exit = do + let cout = cOutput ctx + initial <- readSTRef cout + runCached ctx entry (f entry exit) + res <- readSTRef cout + -- Clear subshell changes. TODO: track this to warn about modifications. + writeSTRef cout $ initial { + sExitCodes = sExitCodes res + } + where + f entry exit ctx = do + (states, frame) <- withNewStackFrame ctx entry False (flip dataflow $ entry) + let (_, res) = fromMaybe (error $ pleaseReport "Subshell has no exit") $ M.lookup exit states + deps <- readSTRef $ dependencies frame + registerFlowResult ctx entry states deps + return (deps, res) + +-- Transfer the effects of executing a command, i.e. the merged union of all possible function definitions. +transferCommand ctx Nothing = return () +transferCommand ctx (Just name) = do + targets <- readFunction ctx name + logVerbose ("Transferring ",name,targets) + transferMultiple ctx $ map (flip transferFunctionValue) $ S.toList targets + +-- Transfer a set of function definitions and merge the output states. +transferMultiple ctx funcs = do + logVerbose ("Transferring set of ", length funcs) + original <- readSTRef out + branches <- mapM (apply ctx original) funcs + merged <- mergeStates ctx original branches + let patched = patchState original merged + writeSTRef out patched + where + out = cOutput ctx + apply ctx original f = do + writeSTRef out original + f ctx + readSTRef out + +-- Transfer the effects of a single function definition. +transferFunctionValue ctx funcVal = + case funcVal of + FunctionUnknown -> return () + FunctionDefinition name entry exit -> do + isRecursive <- wouldBeRecursive ctx entry + if isRecursive + then return () -- TODO: Find a better strategy for recursion + else runCached ctx entry (f name entry exit) + where + f name entry exit ctx = do + (states, frame) <- withNewStackFrame ctx entry True (flip dataflow $ entry) + deps <- readSTRef $ dependencies frame + let res = + case M.lookup exit states of + Just (input, output) -> do + -- Discard local variables. TODO: track&retain variables declared local in previous scopes? + modified output { sLocalValues = vmEmpty } + Nothing -> do + -- e.g. f() { exit; } + unreachableState + registerFlowResult ctx entry states deps + return (deps, res) + +transferExitCode ctx id = do + modifySTRef (cOutput ctx) $ setExitCode id + +-- Register/save the result of a dataflow of a function. +-- At the end, all the different values from different flows are merged together. +registerFlowResult ctx entry states deps = do + -- This function is called in the context of a CFExecuteCommand and not its invoked function, + -- so manually add the current node to the stack. + current <- readSTRef $ cNode ctx + let parents = map callSite $ cStack ctx + -- A unique path to this flow context. The specific value doesn't matter, as long as it's + -- unique per invocation of the function. This is required so that 'x=1; f; x=2; f' won't + -- overwrite each other. + let path = entry : current : parents + modifySTRef (cInvocations ctx) $ M.insert path (deps, states) + + +-- Look up a node in the cache and see if the dependencies of any entries are matched. +-- In that case, reuse the previous result instead of doing a new data flow. +runCached :: forall s. Ctx s -> Node -> (Ctx s -> ST s (S.Set StateDependency, InternalState)) -> ST s () +runCached ctx node f = do + cache <- getCache ctx node + case cache of + Just v -> do + logInfo ("Running cached", node) + -- do { (deps, diff) <- f ctx; unless (v == diff) $ traceShowM ("Cache FAILED to match actual result", node, deps, diff); } + patchOutputM ctx v + + Nothing -> do + logInfo ("Cache failed", node) + (deps, diff) <- f ctx + modifySTRef (cCache ctx) (M.insertWith (\_ old -> (deps, diff):(take cacheEntries old)) node [(deps,diff)]) + logVerbose ("Recomputed cache for", node, deps) + -- do { f <- fulfillsDependencies ctx node deps; unless (f) $ traceShowM ("New dependencies FAILED to match", node, deps); } + patchOutputM ctx diff + +-- Get a cached version whose dependencies are currently fulfilled, if any. +getCache :: forall s. Ctx s -> Node -> ST s (Maybe InternalState) +getCache ctx node = do + cache <- readSTRef $ cCache ctx + enable <- readSTRef $ cEnableCache ctx + logVerbose ("Cache for", node, "length", length $ M.findWithDefault [] node cache, M.lookup node cache) + if enable + then f $ M.findWithDefault [] node cache + else return Nothing + where + f [] = return Nothing + f ((deps, value):rest) = do + match <- fulfillsDependencies ctx node deps + if match + then return $ Just value + else f rest + +-- Transfer a single CFEffect to the output state. +transferEffect ctx effect = + case effect of + CFReadVariable name -> + case name of + "?" -> void $ readExitCodes ctx + _ -> void $ readVariable ctx name + CFWriteVariable name value -> do + val <- cfValueToVariableValue ctx value + updateVariableValue ctx name val + CFWriteGlobal name value -> do + val <- cfValueToVariableValue ctx value + updateGlobalValue ctx name val + CFWriteLocal name value -> do + val <- cfValueToVariableValue ctx value + updateLocalValue ctx name val + CFWritePrefix name value -> do + val <- cfValueToVariableValue ctx value + updatePrefixValue ctx name val + + CFSetProps scope name props -> + case scope of + Nothing -> do + state <- readVariable ctx name + writeVariable ctx name $ addProperties props state + Just GlobalScope -> do + state <- readGlobal ctx name + writeGlobal ctx name $ addProperties props state + Just LocalScope -> do + out <- readSTRef (cOutput ctx) + state <- readLocal ctx name + writeLocal ctx name $ addProperties props state + Just PrefixScope -> do + -- Prefix values become local + state <- readLocal ctx name + writeLocal ctx name $ addProperties props state + + CFUnsetProps scope name props -> + case scope of + Nothing -> do + state <- readVariable ctx name + writeVariable ctx name $ removeProperties props state + Just GlobalScope -> do + state <- readGlobal ctx name + writeGlobal ctx name $ removeProperties props state + Just LocalScope -> do + out <- readSTRef (cOutput ctx) + state <- readLocal ctx name + writeLocal ctx name $ removeProperties props state + Just PrefixScope -> do + -- Prefix values become local + state <- readLocal ctx name + writeLocal ctx name $ removeProperties props state + + + CFUndefineVariable name -> undefineVariable ctx name + CFUndefineFunction name -> undefineFunction ctx name + CFUndefine name -> do + -- This should really just unset one or the other + undefineVariable ctx name + undefineFunction ctx name + CFDefineFunction name id entry exit -> + writeFunction ctx name $ FunctionDefinition name entry exit + + -- TODO + CFUndefineNameref name -> undefineVariable ctx name + CFHintArray name -> return () + CFHintDefined name -> return () +-- _ -> error $ "Unknown effect " ++ show effect + + +-- Transfer the CFG's idea of a value into our VariableState +cfValueToVariableValue ctx val = + case val of + CFValueArray -> return unknownVariableValue -- TODO: Track array status + CFValueComputed _ parts -> foldM f emptyVariableValue parts + CFValueInteger -> return unknownIntegerValue + CFValueString -> return unknownVariableValue + CFValueUninitialized -> return emptyVariableValue +-- _ -> error $ "Unknown value: " ++ show val + where + f val part = do + next <- computeValue ctx part + return $ val `appendVariableValue` next + +-- A value can be computed from 0 or more parts, such as x="literal$y$z" +computeValue ctx part = + case part of + CFStringLiteral str -> return $ literalToVariableValue str + CFStringInteger -> return unknownIntegerValue + CFStringUnknown -> return unknownVariableValue + CFStringVariable name -> variableStateToValue <$> readVariable ctx name + where + variableStateToValue state = + case () of + _ | all (CFVPInteger `S.member`) $ variableProperties state -> unknownIntegerValue + _ -> variableValue state + +-- Append two VariableValues as if with z="$x$y" +appendVariableValue :: VariableValue -> VariableValue -> VariableValue +appendVariableValue a b = + unknownVariableValue { + literalValue = liftM2 (++) (literalValue a) (literalValue b), + spaceStatus = appendSpaceStatus (spaceStatus a) (spaceStatus b), + numericalStatus = appendNumericalStatus (numericalStatus a) (numericalStatus b) + } + +appendSpaceStatus a b = + case (a,b) of + (SpaceStatusEmpty, _) -> b + (_, SpaceStatusEmpty) -> a + (SpaceStatusClean, SpaceStatusClean) -> a + _ ->SpaceStatusDirty + +appendNumericalStatus a b = + case (a,b) of + (NumericalStatusEmpty, x) -> x + (x, NumericalStatusEmpty) -> x + (NumericalStatusDefinitely, NumericalStatusDefinitely) -> NumericalStatusDefinitely + (NumericalStatusUnknown, _) -> NumericalStatusUnknown + (_, NumericalStatusUnknown) -> NumericalStatusUnknown + _ -> NumericalStatusMaybe + +unknownIntegerValue = unknownVariableValue { + literalValue = Nothing, + spaceStatus = SpaceStatusClean, + numericalStatus = NumericalStatusDefinitely +} + +literalToVariableValue str = unknownVariableValue { + literalValue = Just str, + spaceStatus = literalToSpaceStatus str, + numericalStatus = literalToNumericalStatus str +} + +withoutChanges ctx f = do + let inp = cInput ctx + let out = cOutput ctx + prevInput <- readSTRef inp + prevOutput <- readSTRef out + res <- f + writeSTRef inp prevInput + writeSTRef out prevOutput + return res + +-- Get the SpaceStatus for a literal string, i.e. if it needs quoting +literalToSpaceStatus str = + case str of + "" -> SpaceStatusEmpty + _ | all (`notElem` " \t\n*?[") str -> SpaceStatusClean + _ -> SpaceStatusDirty + +-- Get the NumericalStatus for a literal string, i.e. whether it's an integer +literalToNumericalStatus str = + case str of + "" -> NumericalStatusEmpty + '-':rest -> if isNumeric rest then NumericalStatusDefinitely else NumericalStatusUnknown + rest -> if isNumeric rest then NumericalStatusDefinitely else NumericalStatusUnknown + where + isNumeric = all isDigit + +type StateMap = M.Map Node (InternalState, InternalState) + +-- Classic, iterative Data Flow Analysis. See Wikipedia for a description of the process. +dataflow :: forall s. Ctx s -> Node -> ST s StateMap +dataflow ctx entry = do + pending <- newSTRef $ S.singleton entry + states <- newSTRef $ M.empty + -- Should probably be done via a stack frame instead + withoutChanges ctx $ + f iterationCount pending states + readSTRef states + where + graph = cGraph ctx + f 0 _ _ = error $ pleaseReport "DFA did not reach fix point" + f n pending states = do + ps <- readSTRef pending + + when (n == fallbackThreshold) $ do + -- This should never happen, but has historically been due to caching bugs. + -- Try disabling the cache and continuing. + logInfo "DFA is not stabilizing! Disabling cache." + writeSTRef (cEnableCache ctx) False + + if S.null ps + then return () + else do + let (next, rest) = S.deleteFindMin ps + nexts <- process states next + writeSTRef pending $ S.union (S.fromList nexts) rest + f (n-1) pending states + + process states node = do + stateMap <- readSTRef states + let inputs = filter (\c -> sIsReachable c /= Just False) $ mapMaybe (\c -> fmap snd $ M.lookup c stateMap) incoming + input <- + case incoming of + [] -> return newInternalState + _ -> + case inputs of + [] -> return unreachableState + (x:rest) -> foldM (mergeState ctx) x rest + writeSTRef (cInput ctx) $ input + writeSTRef (cOutput ctx) $ input + writeSTRef (cNode ctx) $ node + transfer ctx label + newOutput <- readSTRef $ cOutput ctx + result <- + if is2plus outgoing + then + -- Version the state because we split and will probably merge later + versionState ctx newOutput + else return newOutput + writeSTRef states $ M.insert node (input, result) stateMap + case M.lookup node stateMap of + Nothing -> return outgoing + Just (oldInput, oldOutput) -> + if oldOutput == result + then return [] + else return outgoing + where + (incomingL, _, label, outgoingL) = context graph $ node + incoming = map snd $ filter isRegular $ incomingL + outgoing = map snd outgoingL + isRegular = ((== CFEFlow) . fst) + +runRoot ctx env entry exit = do + writeSTRef (cInput ctx) $ env + writeSTRef (cOutput ctx) $ env + writeSTRef (cNode ctx) $ entry + (states, frame) <- withNewStackFrame ctx entry False $ \c -> dataflow c entry + deps <- readSTRef $ dependencies frame + registerFlowResult ctx entry states deps + -- Return the final state, used to invoke functions that were declared but not invoked + return $ snd $ fromMaybe (error $ pleaseReport "Missing exit state") $ M.lookup exit states + + +analyzeControlFlow :: CFGParameters -> Token -> CFGAnalysis +analyzeControlFlow params t = + let + cfg = buildGraph params t + (entry, exit) = M.findWithDefault (error $ pleaseReport "Missing root") (getId t) (cfIdToRange cfg) + in + runST $ f cfg entry exit + where + f cfg entry exit = do + let env = createEnvironmentState + ctx <- newCtx $ cfGraph cfg + -- Do a dataflow analysis starting on the root node + exitState <- runRoot ctx env entry exit + + -- All nodes we've touched + invocations <- readSTRef $ cInvocations ctx + let invokedNodes = M.fromSet (const ()) $ S.unions $ map (M.keysSet . snd) $ M.elems invocations + + -- Invoke all functions that were declared but not invoked + -- This is so that we still get warnings for dead code + -- (it's probably not actually dead, just used by a script that sources ours) + let declaredFunctions = getFunctionTargets exitState + let uninvoked = M.difference declaredFunctions invokedNodes + + let stragglerInput = + (env `patchState` exitState) { + -- We don't want `die() { exit $?; }; echo "Sourced"` to assume $? is always echo + sExitCodes = Nothing + } + + analyzeStragglers ctx stragglerInput uninvoked + + -- Now round up all the states from all data flows + -- (FIXME: this excludes functions that were defined in straggling functions) + invocations <- readSTRef $ cInvocations ctx + invokedStates <- flattenByNode ctx $ groupByNode $ M.map addDeps invocations + + -- Fill in the map with unreachable states for anything we didn't get to + let baseStates = M.fromDistinctAscList $ map (\c -> (c, (unreachableState, unreachableState))) $ uncurry enumFromTo $ nodeRange $ cfGraph cfg + let allStates = M.union invokedStates baseStates + + -- Convert to external states + let nodeToData = M.map (\(a,b) -> (internalToExternal a, internalToExternal b)) allStates + + return $ nodeToData `deepseq` CFGAnalysis { + graph = cfGraph cfg, + tokenToRange = cfIdToRange cfg, + tokenToNodes = cfIdToNodes cfg, + nodeToData = nodeToData, + postDominators = cfPostDominators cfg + } + + + -- Include the dependencies in the state of each function, e.g. if it depends on `x=foo` then add that. + addDeps :: (S.Set StateDependency, M.Map Node (InternalState, InternalState)) -> M.Map Node (InternalState, InternalState) + addDeps (deps, m) = let base = depsToState deps in M.map (\(a,b) -> (base `patchState` a, base `patchState` b)) m + + -- Collect all the states that each node has resulted in. + groupByNode :: forall k v. M.Map k (M.Map Node v) -> M.Map Node [v] + groupByNode pathMap = M.fromListWith (++) $ map (\(k,v) -> (k,[v])) $ concatMap M.toList $ M.elems pathMap + + -- Merge all the pre/post states for each node. This would have been a foldM if Map had one. + flattenByNode ctx m = M.fromDistinctAscList <$> (mapM (mergePair ctx) $ M.toList m) + + mergeAllStates ctx pairs = + let + (pres, posts) = unzip pairs + in do + pre <- mergeStates ctx (error $ pleaseReport "Null node states") pres + post <- mergeStates ctx (error $ pleaseReport "Null node states") posts + return (pre, post) + + mergePair ctx (node, list) = do + merged <- mergeAllStates ctx list + return (node, merged) + + -- Get the all the functions defined in an InternalState + getFunctionTargets :: InternalState -> M.Map Node FunctionDefinition + getFunctionTargets state = + let + declaredFuncs = S.unions $ M.elems $ mapStorage $ sFunctionTargets state + getFunc d = + case d of + FunctionDefinition _ entry _ -> Just (entry, d) + _ -> Nothing + funcs = mapMaybe getFunc $ S.toList declaredFuncs + in + M.fromList funcs + + +analyzeStragglers ctx state stragglers = do + mapM_ f $ M.elems stragglers + where + f def@(FunctionDefinition name entry exit) = do + writeSTRef (cInput ctx) state + writeSTRef (cOutput ctx) state + writeSTRef (cNode ctx) entry + transferFunctionValue ctx def + + + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Checker.hs b/src/ShellCheck/Checker.hs new file mode 100644 index 0000000..dd39921 --- /dev/null +++ b/src/ShellCheck/Checker.hs @@ -0,0 +1,567 @@ +{- + Copyright 2012-2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Checker (checkScript, ShellCheck.Checker.runTests) where + +import ShellCheck.Analyzer +import ShellCheck.ASTLib +import ShellCheck.Interface +import ShellCheck.Parser + +import Debug.Trace -- DO NOT SUBMIT +import Data.Either +import Data.Functor +import Data.List +import Data.Maybe +import Data.Ord +import Control.Monad.Identity +import qualified Data.Map as Map +import qualified System.IO +import Prelude hiding (readFile) +import Control.Monad + +import Test.QuickCheck.All + +tokenToPosition startMap t = fromMaybe fail $ do + span <- Map.lookup (tcId t) startMap + return $ newPositionedComment { + pcStartPos = fst span, + pcEndPos = snd span, + pcComment = tcComment t, + pcFix = tcFix t + } + where + fail = error "Internal shellcheck error: id doesn't exist. Please report!" + +shellFromFilename filename = listToMaybe candidates + where + shellExtensions = [(".ksh", Ksh) + ,(".bash", Bash) + ,(".bats", Bash) + ,(".dash", Dash)] + -- The `.sh` is too generic to determine the shell: + -- We fallback to Bash in this case and emit SC2148 if there is no shebang + candidates = + [sh | (ext,sh) <- shellExtensions, ext `isSuffixOf` filename] + +checkScript :: Monad m => SystemInterface m -> CheckSpec -> m CheckResult +checkScript sys spec = do + results <- checkScript (csScript spec) + return emptyCheckResult { + crFilename = csFilename spec, + crComments = results + } + where + checkScript contents = do + result <- parseScript sys newParseSpec { + psFilename = csFilename spec, + psScript = contents, + psCheckSourced = csCheckSourced spec, + psIgnoreRC = csIgnoreRC spec, + psShellTypeOverride = csShellTypeOverride spec + } + let parseMessages = prComments result + let tokenPositions = prTokenPositions result + let analysisSpec root = + as { + asScript = root, + asShellType = csShellTypeOverride spec, + asFallbackShell = shellFromFilename $ csFilename spec, + asCheckSourced = csCheckSourced spec, + asExecutionMode = Executed, + asTokenPositions = tokenPositions, + asExtendedAnalysis = csExtendedAnalysis spec, + asOptionalChecks = getEnableDirectives root ++ csOptionalChecks spec + } where as = newAnalysisSpec root + let analysisMessages = + maybe [] + (arComments . analyzeScript . analysisSpec) + $ prRoot result + let translator = tokenToPosition tokenPositions + return . nub . sortMessages . filter shouldInclude $ + (parseMessages ++ map translator analysisMessages) + + shouldInclude pc = + severity <= csMinSeverity spec && + case csIncludedWarnings spec of + Nothing -> code `notElem` csExcludedWarnings spec + Just includedWarnings -> code `elem` includedWarnings + where + code = cCode (pcComment pc) + severity = cSeverity (pcComment pc) + + sortMessages = sortOn order + order pc = + let pos = pcStartPos pc + comment = pcComment pc in + (posFile pos, + posLine pos, + posColumn pos, + cSeverity comment, + cCode comment, + cMessage comment) + getPosition = pcStartPos + + +getErrors sys spec = + sort . map getCode . crComments $ + runIdentity (checkScript sys spec) + where + getCode = cCode . pcComment + +check = checkWithIncludes [] + +checkWithSpec includes = + getErrors (mockedSystemInterface includes) + +checkWithIncludes includes src = + checkWithSpec includes emptyCheckSpec { + csScript = src, + csExcludedWarnings = [2148] + } + +checkRecursive includes src = + checkWithSpec includes emptyCheckSpec { + csScript = src, + csExcludedWarnings = [2148], + csCheckSourced = True + } + +checkOptionIncludes includes src = + checkWithSpec [] emptyCheckSpec { + csScript = src, + csIncludedWarnings = includes, + csCheckSourced = True + } + +checkWithRc rc = getErrors + (mockRcFile rc $ mockedSystemInterface []) + +checkWithIncludesAndSourcePath includes mapper = getErrors + (mockedSystemInterface includes) { + siFindSource = mapper + } + +checkWithRcIncludesAndSourcePath rc includes mapper = getErrors + (mockRcFile rc $ mockedSystemInterface includes) { + siFindSource = mapper + } + +prop_findsParseIssue = check "echo \"$12\"" == [1037] + +prop_commentDisablesParseIssue1 = + null $ check "#shellcheck disable=SC1037\necho \"$12\"" +prop_commentDisablesParseIssue2 = + null $ check "#shellcheck disable=SC1037\n#lol\necho \"$12\"" + +prop_findsAnalysisIssue = + check "echo $1" == [2086] +prop_commentDisablesAnalysisIssue1 = + null $ check "#shellcheck disable=SC2086\necho $1" +prop_commentDisablesAnalysisIssue2 = + null $ check "#shellcheck disable=SC2086\n#lol\necho $1" + +prop_optionDisablesIssue1 = + null $ getErrors + (mockedSystemInterface []) + emptyCheckSpec { + csScript = "echo $1", + csExcludedWarnings = [2148, 2086] + } + +prop_optionDisablesIssue2 = + null $ getErrors + (mockedSystemInterface []) + emptyCheckSpec { + csScript = "echo \"$10\"", + csExcludedWarnings = [2148, 1037] + } + +prop_wontParseBadShell = + [1071] == check "#!/usr/bin/python\ntrue $1\n" + +prop_optionDisablesBadShebang = + null $ getErrors + (mockedSystemInterface []) + emptyCheckSpec { + csScript = "#!/usr/bin/python\ntrue\n", + csShellTypeOverride = Just Sh + } + +prop_annotationDisablesBadShebang = + null $ check "#!/usr/bin/python\n# shellcheck shell=sh\ntrue\n" + + +prop_canParseDevNull = + null $ check "source /dev/null" + +prop_failsWhenNotSourcing = + [1091, 2154] == check "source lol; echo \"$bar\"" + +prop_worksWhenSourcing = + null $ checkWithIncludes [("lib", "bar=1")] "source lib; echo \"$bar\"" + +prop_worksWhenSourcingWithDashDash = + null $ checkWithIncludes [("lib", "bar=1")] "source -- lib; echo \"$bar\"" + +prop_worksWhenSourcingWithDashP = + null $ checkWithIncludes [("lib", "bar=1")] "source -p \"$MYPATH\" lib; echo \"$bar\"" + +prop_worksWhenDotting = + null $ checkWithIncludes [("lib", "bar=1")] ". lib; echo \"$bar\"" + +-- FIXME: This should really be giving [1093], "recursively sourced" +prop_noInfiniteSourcing = + null $ checkWithIncludes [("lib", "source lib")] "source lib" + +prop_canSourceBadSyntax = + [1094, 2086] == checkWithIncludes [("lib", "for f; do")] "source lib; echo $1" + +prop_cantSourceDynamic = + [1090] == checkWithIncludes [("lib", "")] ". \"$1\"" + +prop_cantSourceDynamic2 = + [1090] == checkWithIncludes [("lib", "")] "source ~/foo" + +prop_canStripPrefixAndSource = + null $ checkWithIncludes [("./lib", "")] "source \"$MYDIR/lib\"" + +prop_canStripPrefixAndSource2 = + null $ checkWithIncludes [("./utils.sh", "")] "source \"$(dirname \"${BASH_SOURCE[0]}\")/utils.sh\"" + +prop_canSourceDynamicWhenRedirected = + null $ checkWithIncludes [("lib", "")] "#shellcheck source=lib\n. \"$1\"" + +prop_canRedirectWithSpaces = + null $ checkWithIncludes [("my file", "")] "#shellcheck source=\"my file\"\n. \"$1\"" + +prop_recursiveAnalysis = + [2086] == checkRecursive [("lib", "echo $1")] "source lib" + +prop_recursiveParsing = + [1037] == checkRecursive [("lib", "echo \"$10\"")] "source lib" + +prop_nonRecursiveAnalysis = + null $ checkWithIncludes [("lib", "echo $1")] "source lib" + +prop_nonRecursiveParsing = + null $ checkWithIncludes [("lib", "echo \"$10\"")] "source lib" + +prop_sourceDirectiveDoesntFollowFile = + null $ checkWithIncludes + [("foo", "source bar"), ("bar", "baz=3")] + "#shellcheck source=foo\n. \"$1\"; echo \"$baz\"" + +prop_filewideAnnotationBase = [2086] == check "#!/bin/sh\necho $1" +prop_filewideAnnotation1 = null $ + check "#!/bin/sh\n# shellcheck disable=2086\necho $1" +prop_filewideAnnotation2 = null $ + check "#!/bin/sh\n# shellcheck disable=2086\ntrue\necho $1" +prop_filewideAnnotation3 = null $ + check "#!/bin/sh\n#unrelated\n# shellcheck disable=2086\ntrue\necho $1" +prop_filewideAnnotation4 = null $ + check "#!/bin/sh\n# shellcheck disable=2086\n#unrelated\ntrue\necho $1" +prop_filewideAnnotation5 = null $ + check "#!/bin/sh\n\n\n\n#shellcheck disable=2086\ntrue\necho $1" +prop_filewideAnnotation6 = null $ + check "#shellcheck shell=sh\n#unrelated\n#shellcheck disable=2086\ntrue\necho $1" +prop_filewideAnnotation7 = null $ + check "#!/bin/sh\n# shellcheck disable=2086\n#unrelated\ntrue\necho $1" + +prop_filewideAnnotationBase2 = [2086, 2181] == check "true\n[ $? == 0 ] && echo $1" +prop_filewideAnnotation8 = null $ + check "# Disable $? warning\n#shellcheck disable=SC2181\n# Disable quoting warning\n#shellcheck disable=2086\ntrue\n[ $? == 0 ] && echo $1" + +prop_sourcePartOfOriginalScript = -- #1181: -x disabled posix warning for 'source' + 3046 `elem` checkWithIncludes [("./saywhat.sh", "echo foo")] "#!/bin/sh\nsource ./saywhat.sh" + +prop_spinBug1413 = null $ check "fun() {\n# shellcheck disable=SC2188\n> /dev/null\n}\n" + +prop_deducesTypeFromExtension = null result + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.ksh", + csScript = "(( 3.14 ))" + } + +prop_deducesTypeFromExtension2 = result == [2079] + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.bash", + csScript = "(( 3.14 ))" + } + +prop_canDisableShebangWarning = null $ result + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.sh", + csScript = "#shellcheck disable=SC2148\nfoo" + } + +prop_canDisableAllWarnings = result == [2086] + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.sh", + csScript = "#!/bin/sh\necho $1\n#shellcheck disable=all\necho `echo $1`" + } + +prop_canDisableParseErrors = null $ result + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.sh", + csScript = "#shellcheck disable=SC1073,SC1072,SC2148\n()" + } + +prop_shExtensionDoesntMatter = result == [2148] + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.sh", + csScript = "echo 'hello world'" + } + +prop_sourcedFileUsesOriginalShellExtension = result == [2079] + where + result = checkWithSpec [("file.ksh", "(( 3.14 ))")] emptyCheckSpec { + csFilename = "file.bash", + csScript = "source file.ksh", + csCheckSourced = True + } + +prop_canEnableOptionalsWithSpec = result == [2244] + where + result = checkWithSpec [] emptyCheckSpec { + csFilename = "file.sh", + csScript = "#!/bin/sh\n[ \"$1\" ]", + csOptionalChecks = ["avoid-nullary-conditions"] + } + +prop_optionIncludes1 = + -- expect 2086, but not included, so nothing reported + null $ checkOptionIncludes (Just [2080]) "#!/bin/sh\n var='a b'\n echo $var" + +prop_optionIncludes2 = + -- expect 2086, included, so it is reported + [2086] == checkOptionIncludes (Just [2086]) "#!/bin/sh\n var='a b'\n echo $var" + +prop_optionIncludes3 = + -- expect 2086, no inclusions provided, so it is reported + [2086] == checkOptionIncludes Nothing "#!/bin/sh\n var='a b'\n echo $var" + +prop_optionIncludes4 = + -- expect 2086 & 2154, only 2154 included, so only that's reported + [2154] == checkOptionIncludes (Just [2154]) "#!/bin/sh\n var='a b'\n echo $var\n echo $bar" + + +prop_readsRcFile = null result + where + result = checkWithRc "disable=2086" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = False + } + +prop_canUseNoRC = result == [2086] + where + result = checkWithRc "disable=2086" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = True + } + +prop_NoRCWontLookAtFile = result == [2086] + where + result = checkWithRc (error "Fail") emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = True + } + +prop_brokenRcGetsWarning = result == [1134, 2086] + where + result = checkWithRc "rofl" emptyCheckSpec { + csScript = "#!/bin/sh\necho $1", + csIgnoreRC = False + } + +prop_canEnableOptionalsWithRc = result == [2244] + where + result = checkWithRc "enable=avoid-nullary-conditions" emptyCheckSpec { + csScript = "#!/bin/sh\n[ \"$1\" ]" + } + +prop_sourcePathRedirectsName = result == [2086] + where + f "dir/myscript" _ _ "lib" = return "foo/lib" + result = checkWithIncludesAndSourcePath [("foo/lib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\nsource lib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_sourcePathAddsAnnotation = result == [2086] + where + f "dir/myscript" _ ["mypath"] "lib" = return "foo/lib" + result = checkWithIncludesAndSourcePath [("foo/lib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\n# shellcheck source-path=mypath\nsource lib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_sourcePathWorksWithSpaces = result == [2086] + where + f "dir/myscript" _ ["my path"] "lib" = return "foo/lib" + result = checkWithIncludesAndSourcePath [("foo/lib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\n# shellcheck source-path='my path'\nsource lib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_sourcePathRedirectsDirective = result == [2086] + where + f "dir/myscript" _ _ "lib" = return "foo/lib" + f _ _ _ _ = return "/dev/null" + result = checkWithIncludesAndSourcePath [("foo/lib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\n# shellcheck source=lib\nsource kittens", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_rcCanAllowExternalSources = result == [2086] + where + f "dir/myscript" (Just True) _ "mylib" = return "resolved/mylib" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "external-sources=true" [("resolved/mylib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\nsource mylib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_rcCanDenyExternalSources = result == [2086] + where + f "dir/myscript" (Just False) _ "mylib" = return "resolved/mylib" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "external-sources=false" [("resolved/mylib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\nsource mylib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_rcCanLeaveExternalSourcesUnspecified = result == [2086] + where + f "dir/myscript" Nothing _ "mylib" = return "resolved/mylib" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "" [("resolved/mylib", "echo $1")] f emptyCheckSpec { + csScript = "#!/bin/bash\nsource mylib", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_fileCanDisableExternalSources = result == [2006, 2086] + where + f "dir/myscript" (Just True) _ "withExternal" = return "withExternal" + f "dir/myscript" (Just False) _ "withoutExternal" = return "withoutExternal" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "external-sources=true" [("withExternal", "echo $1"), ("withoutExternal", "_=`foo`")] f emptyCheckSpec { + csScript = "#!/bin/bash\ntrue\nsource withExternal\n# shellcheck external-sources=false\nsource withoutExternal", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_fileCannotEnableExternalSources = result == [1144] + where + f "dir/myscript" Nothing _ "foo" = return "foo" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "" [("foo", "true")] f emptyCheckSpec { + csScript = "#!/bin/bash\n# shellcheck external-sources=true\nsource foo", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_fileCannotEnableExternalSources2 = result == [1144] + where + f "dir/myscript" (Just False) _ "foo" = return "foo" + f a b c d = error $ show ("Unexpected", a, b, c, d) + result = checkWithRcIncludesAndSourcePath "external-sources=false" [("foo", "true")] f emptyCheckSpec { + csScript = "#!/bin/bash\n# shellcheck external-sources=true\nsource foo", + csFilename = "dir/myscript", + csCheckSourced = True + } + +prop_rcCanSuppressEarlyProblems1 = null result + where + result = checkWithRc "disable=1071" emptyCheckSpec { + csScript = "#!/bin/zsh\necho $1" + } + +prop_rcCanSuppressEarlyProblems2 = null result + where + result = checkWithRc "disable=1104" emptyCheckSpec { + csScript = "!/bin/bash\necho 'hello world'" + } + +prop_sourceWithHereDocWorks = null result + where + result = checkWithIncludes [("bar", "true\n")] "source bar << eof\nlol\neof" + +prop_hereDocsAreParsedWithoutTrailingLinefeed = 1044 `elem` result + where + result = check "cat << eof" + +prop_hereDocsWillHaveParsedIndices = null result + where + result = check "#!/bin/bash\nmy_array=(a b)\ncat <> ./test\n $(( 1 + my_array[1] ))\nEOF" + +prop_rcCanSuppressDfa = null result + where + result = checkWithRc "extended-analysis=false" emptyCheckSpec { + csScript = "#!/bin/sh\nexit; foo;" + } + +prop_fileCanSuppressDfa = null $ traceShowId result + where + result = checkWithRc "" emptyCheckSpec { + csScript = "#!/bin/sh\n# shellcheck extended-analysis=false\nexit; foo;" + } + +prop_fileWinsWhenSuppressingDfa1 = null result + where + result = checkWithRc "extended-analysis=true" emptyCheckSpec { + csScript = "#!/bin/sh\n# shellcheck extended-analysis=false\nexit; foo;" + } + +prop_fileWinsWhenSuppressingDfa2 = result == [2317] + where + result = checkWithRc "extended-analysis=false" emptyCheckSpec { + csScript = "#!/bin/sh\n# shellcheck extended-analysis=true\nexit; foo;" + } + +prop_flagWinsWhenSuppressingDfa1 = result == [2317] + where + result = checkWithRc "extended-analysis=false" emptyCheckSpec { + csScript = "#!/bin/sh\n# shellcheck extended-analysis=false\nexit; foo;", + csExtendedAnalysis = Just True + } + +prop_flagWinsWhenSuppressingDfa2 = null result + where + result = checkWithRc "extended-analysis=true" emptyCheckSpec { + csScript = "#!/bin/sh\n# shellcheck extended-analysis=true\nexit; foo;", + csExtendedAnalysis = Just False + } + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs new file mode 100644 index 0000000..0583566 --- /dev/null +++ b/src/ShellCheck/Checks/Commands.hs @@ -0,0 +1,1475 @@ +{- + Copyright 2012-2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE MultiWayIf #-} +{-# LANGUAGE PatternGuards #-} + +-- This module contains checks that examine specific commands by name. +module ShellCheck.Checks.Commands (checker, optionalChecks, ShellCheck.Checks.Commands.runTests) where + +import ShellCheck.AST +import ShellCheck.ASTLib +import ShellCheck.AnalyzerLib +import ShellCheck.CFG +import qualified ShellCheck.CFGAnalysis as CF +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Parser +import ShellCheck.Prelude +import ShellCheck.Regex + +import Control.Monad +import Control.Monad.RWS +import Data.Char +import Data.Functor.Identity +import qualified Data.Graph.Inductive.Graph as G +import Data.List +import Data.Maybe +import qualified Data.List.NonEmpty as NE +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (quickCheckWithResult, stdArgs, maxSuccess) + +import Debug.Trace -- STRIP + +data CommandName = Exactly String | Basename String + deriving (Eq, Ord) + +data CommandCheck = + CommandCheck CommandName (Token -> Analysis) + + +verify :: CommandCheck -> String -> Bool +verify f s = producesComments (getChecker [f]) s == Just True +verifyNot f s = producesComments (getChecker [f]) s == Just False + +commandChecks :: [CommandCheck] +commandChecks = [ + checkTr + ,checkFindNameGlob + ,checkExpr + ,checkGrepRe + ,checkTrapQuotes + ,checkReturn + ,checkExit + ,checkFindExecWithSingleArgument + ,checkUnusedEchoEscapes + ,checkInjectableFindSh + ,checkFindActionPrecedence + ,checkMkdirDashPM + ,checkNonportableSignals + ,checkInteractiveSu + ,checkSshCommandString + ,checkPrintfVar + ,checkUuoeCmd + ,checkSetAssignment + ,checkExportedExpansions + ,checkAliasesUsesArgs + ,checkAliasesExpandEarly + ,checkUnsetGlobs + ,checkFindWithoutPath + ,checkTimeParameters + ,checkTimedCommand + ,checkLocalScope + ,checkDeprecatedTempfile + ,checkDeprecatedEgrep + ,checkDeprecatedFgrep + ,checkWhileGetoptsCase + ,checkCatastrophicRm + ,checkLetUsage + ,checkMvArguments, checkCpArguments, checkLnArguments + ,checkFindRedirections + ,checkReadExpansions + ,checkSudoRedirect + ,checkSudoArgs + ,checkSourceArgs + ,checkChmodDashr + ,checkXargsDashi + ,checkUnquotedEchoSpaces + ,checkEvalArray + ] + ++ map checkArgComparison ("alias" : declaringCommands) + ++ map checkMaskedReturns declaringCommands + ++ map checkMultipleDeclaring declaringCommands + ++ map checkBackreferencingDeclaration declaringCommands + + +optionalChecks = map fst optionalCommandChecks +optionalCommandChecks :: [(CheckDescription, CommandCheck)] +optionalCommandChecks = [ + (newCheckDescription { + cdName = "deprecate-which", + cdDescription = "Suggest 'command -v' instead of 'which'", + cdPositive = "which javac", + cdNegative = "command -v javac" + }, checkWhich) + ] +optionalCheckMap = M.fromList $ map (\(desc, check) -> (cdName desc, check)) optionalCommandChecks + +prop_verifyOptionalExamples = all check optionalCommandChecks + where + check (desc, check) = + verify check (cdPositive desc) + && verifyNot check (cdNegative desc) + +-- Run a check against the getopt parser. If it fails, the lists are empty. +checkGetOpts str flags args f = + flags == actualFlags && args == actualArgs + where + toTokens = map (T_Literal (Id 0)) . words + opts = fromMaybe [] $ f (toTokens str) + actualFlags = filter (not . null) $ map fst opts + actualArgs = [onlyLiteralString x | ("", (_, x)) <- opts] + +-- Short options +prop_checkGetOptsS1 = checkGetOpts "-f x" ["f"] [] $ getOpts (True, True) "f:" [] +prop_checkGetOptsS2 = checkGetOpts "-fx" ["f"] [] $ getOpts (True, True) "f:" [] +prop_checkGetOptsS3 = checkGetOpts "-f -x" ["f", "x"] [] $ getOpts (True, True) "fx" [] +prop_checkGetOptsS4 = checkGetOpts "-f -x" ["f"] [] $ getOpts (True, True) "f:" [] +prop_checkGetOptsS5 = checkGetOpts "-fx" [] [] $ getOpts (True, True) "fx:" [] + +prop_checkGenericOptsS1 = checkGetOpts "-f x" ["f"] [] $ return . getGenericOpts +prop_checkGenericOptsS2 = checkGetOpts "-abc x" ["a", "b", "c"] [] $ return . getGenericOpts +prop_checkGenericOptsS3 = checkGetOpts "-abc -x" ["a", "b", "c", "x"] [] $ return . getGenericOpts +prop_checkGenericOptsS4 = checkGetOpts "-x" ["x"] [] $ return . getGenericOpts + +-- Long options +prop_checkGetOptsL1 = checkGetOpts "--foo=bar baz" ["foo"] ["baz"] $ getOpts (True, False) "" [("foo", True)] +prop_checkGetOptsL2 = checkGetOpts "--foo bar baz" ["foo"] ["baz"] $ getOpts (True, False) "" [("foo", True)] +prop_checkGetOptsL3 = checkGetOpts "--foo baz" ["foo"] ["baz"] $ getOpts (True, True) "" [] +prop_checkGetOptsL4 = checkGetOpts "--foo baz" [] [] $ getOpts (True, False) "" [] + +prop_checkGenericOptsL1 = checkGetOpts "--foo=bar" ["foo"] [] $ return . getGenericOpts +prop_checkGenericOptsL2 = checkGetOpts "--foo bar" ["foo"] ["bar"] $ return . getGenericOpts +prop_checkGenericOptsL3 = checkGetOpts "-x --foo" ["x", "foo"] [] $ return . getGenericOpts + +-- Know when to terminate +prop_checkGetOptsT1 = checkGetOpts "-a x -b" ["a", "b"] ["x"] $ getOpts (True, True) "ab" [] +prop_checkGetOptsT2 = checkGetOpts "-a x -b" ["a"] ["x","-b"] $ getOpts (False, True) "ab" [] +prop_checkGetOptsT3 = checkGetOpts "-a -- -b" ["a"] ["-b"] $ getOpts (True, True) "ab" [] +prop_checkGetOptsT4 = checkGetOpts "-a -- -b" ["a", "b"] [] $ getOpts (True, True) "a:b" [] + +prop_checkGenericOptsT1 = checkGetOpts "-x -- -y" ["x"] ["-y"] $ return . getGenericOpts +prop_checkGenericOptsT2 = checkGetOpts "-xy --" ["x", "y"] [] $ return . getGenericOpts + + +buildCommandMap :: [CommandCheck] -> M.Map CommandName (Token -> Analysis) +buildCommandMap = foldl' addCheck M.empty + where + addCheck map (CommandCheck name function) = + M.insertWith composeAnalyzers name function map + + +checkCommand :: M.Map CommandName (Token -> Analysis) -> Token -> Analysis +checkCommand map t@(T_SimpleCommand id cmdPrefix (cmd:rest)) = sequence_ $ do + name <- getLiteralString cmd + return $ + if | '/' `elem` name -> + M.findWithDefault nullCheck (Basename $ basename name) map t + | name == "builtin", (h:_) <- rest -> + let t' = T_SimpleCommand id cmdPrefix rest + selectedBuiltin = onlyLiteralString h + in M.findWithDefault nullCheck (Exactly selectedBuiltin) map t' + | otherwise -> do + M.findWithDefault nullCheck (Exactly name) map t + M.findWithDefault nullCheck (Basename name) map t + + where + basename = reverse . takeWhile (/= '/') . reverse +checkCommand _ _ = return () + +getChecker :: [CommandCheck] -> Checker +getChecker list = Checker { + perScript = const $ return (), + perToken = checkCommand map + } + where + map = buildCommandMap list + + +checker :: AnalysisSpec -> Parameters -> Checker +checker spec params = getChecker $ commandChecks ++ optionals + where + keys = asOptionalChecks spec + optionals = + if "all" `elem` keys + then map snd optionalCommandChecks + else mapMaybe (\x -> M.lookup x optionalCheckMap) keys + +prop_checkTr1 = verify checkTr "tr [a-f] [A-F]" +prop_checkTr2 = verify checkTr "tr 'a-z' 'A-Z'" +prop_checkTr2a = verify checkTr "tr '[a-z]' '[A-Z]'" +prop_checkTr3 = verifyNot checkTr "tr -d '[:lower:]'" +prop_checkTr3a = verifyNot checkTr "tr -d '[:upper:]'" +prop_checkTr3b = verifyNot checkTr "tr -d '|/_[:upper:]'" +prop_checkTr4 = verifyNot checkTr "ls [a-z]" +prop_checkTr5 = verify checkTr "tr foo bar" +prop_checkTr6 = verify checkTr "tr 'hello' 'world'" +prop_checkTr8 = verifyNot checkTr "tr aeiou _____" +prop_checkTr9 = verifyNot checkTr "a-z n-za-m" +prop_checkTr10 = verifyNot checkTr "tr --squeeze-repeats rl lr" +prop_checkTr11 = verifyNot checkTr "tr abc '[d*]'" +prop_checkTr12 = verifyNot checkTr "tr '[=e=]' 'e'" +checkTr = CommandCheck (Basename "tr") (mapM_ f . arguments) + where + f w | isGlob w = -- The user will go [ab] -> '[ab]' -> 'ab'. Fixme? + warn (getId w) 2060 "Quote parameters to tr to prevent glob expansion." + f word = + case getLiteralString word of + Just "a-z" -> info (getId word) 2018 "Use '[:lower:]' to support accents and foreign alphabets." + Just "A-Z" -> info (getId word) 2019 "Use '[:upper:]' to support accents and foreign alphabets." + Just s -> do -- Eliminate false positives by only looking for dupes in SET2? + when (not ("-" `isPrefixOf` s || "[:" `isInfixOf` s) && duplicated s) $ + info (getId word) 2020 "tr replaces sets of chars, not words (mentioned due to duplicates)." + unless ("[:" `isPrefixOf` s || "[=" `isPrefixOf` s) $ + when ("[" `isPrefixOf` s && "]" `isSuffixOf` s && (length s > 2) && ('*' `notElem` s)) $ + info (getId word) 2021 "Don't use [] around classes in tr, it replaces literal square brackets." + Nothing -> return () + + duplicated s = + let relevant = filter isAlpha s + in relevant /= nub relevant + +prop_checkFindNameGlob1 = verify checkFindNameGlob "find / -name *.php" +prop_checkFindNameGlob2 = verify checkFindNameGlob "find / -type f -ipath *(foo)" +prop_checkFindNameGlob3 = verifyNot checkFindNameGlob "find * -name '*.php'" +checkFindNameGlob = CommandCheck (Basename "find") (f . arguments) where + acceptsGlob s = s `elem` [ "-ilname", "-iname", "-ipath", "-iregex", "-iwholename", "-lname", "-name", "-path", "-regex", "-wholename" ] + f [] = return () + f (x:xs) = foldr g (const $ return ()) xs x + g b acc a = do + forM_ (getLiteralString a) $ \s -> when (acceptsGlob s && isGlob b) $ + warn (getId b) 2061 $ "Quote the parameter to " ++ s ++ " so the shell won't interpret it." + acc b + + +prop_checkExpr = verify checkExpr "foo=$(expr 3 + 2)" +prop_checkExpr2 = verify checkExpr "foo=`echo \\`expr 3 + 2\\``" +prop_checkExpr3 = verifyNot checkExpr "foo=$(expr foo : regex)" +prop_checkExpr4 = verifyNot checkExpr "foo=$(expr foo \\< regex)" +prop_checkExpr5 = verify checkExpr "# shellcheck disable=SC2003\nexpr match foo bar" +prop_checkExpr6 = verify checkExpr "# shellcheck disable=SC2003\nexpr foo : fo*" +prop_checkExpr7 = verify checkExpr "# shellcheck disable=SC2003\nexpr 5 -3" +prop_checkExpr8 = verifyNot checkExpr "# shellcheck disable=SC2003\nexpr \"$@\"" +prop_checkExpr9 = verifyNot checkExpr "# shellcheck disable=SC2003\nexpr 5 $rest" +prop_checkExpr10 = verify checkExpr "# shellcheck disable=SC2003\nexpr length \"$var\"" +prop_checkExpr11 = verify checkExpr "# shellcheck disable=SC2003\nexpr foo > bar" +prop_checkExpr12 = verify checkExpr "# shellcheck disable=SC2003\nexpr 1 | 2" +prop_checkExpr13 = verify checkExpr "# shellcheck disable=SC2003\nexpr 1 * 2" +prop_checkExpr14 = verify checkExpr "# shellcheck disable=SC2003\nexpr \"$x\" >= \"$y\"" + +checkExpr = CommandCheck (Basename "expr") f where + f t = do + when (all (`notElem` exceptions) (words $ arguments t)) $ + style (getId $ getCommandTokenOrThis t) 2003 + "expr is antiquated. Consider rewriting this using $((..)), ${} or [[ ]]." + + case arguments t of + [lhs, op, rhs] -> do + checkOp lhs + case getWordParts op of + [T_Glob _ "*"] -> + err (getId op) 2304 + "* must be escaped to multiply: \\*. Modern $((x * y)) avoids this issue." + [T_Literal _ ":"] | isGlob rhs -> + warn (getId rhs) 2305 + "Quote regex argument to expr to avoid it expanding as a glob." + _ -> return () + + [single] | not (willSplit single) -> + warn (getId single) 2307 + "'expr' expects 3+ arguments but sees 1. Make sure each operator/operand is a separate argument, and escape <>&|." + + [first, second] | + onlyLiteralString first /= "length" + && not (willSplit first || willSplit second) -> do + checkOp first + warn (getId t) 2307 + "'expr' expects 3+ arguments, but sees 2. Make sure each operator/operand is a separate argument, and escape <>&|." + + (first:rest) -> do + checkOp first + forM_ rest $ \t -> + -- We already find 95%+ of multiplication and regex earlier, so don't bother classifying this further. + when (isGlob t) $ warn (getId t) 2306 "Escape glob characters in arguments to expr to avoid pathname expansion." + + _ -> return () + + -- These operators are hard to replicate in POSIX + exceptions = [ ":", "<", ">", "<=", ">=", + -- We can offer better suggestions for these + "match", "length", "substr", "index"] + words = mapMaybe getLiteralString + + checkOp side = + case getLiteralString side of + Just "match" -> msg "'expr match' has unspecified results. Prefer 'expr str : regex'." + Just "length" -> msg "'expr length' has unspecified results. Prefer ${#var}." + Just "substr" -> msg "'expr substr' has unspecified results. Prefer 'cut' or ${var#???}." + Just "index" -> msg "'expr index' has unspecified results. Prefer x=${var%%[chars]*}; $((${#x}+1))." + _ -> return () + where + msg = info (getId side) 2308 + + +prop_checkGrepRe1 = verify checkGrepRe "cat foo | grep *.mp3" +prop_checkGrepRe2 = verify checkGrepRe "grep -Ev cow*test *.mp3" +prop_checkGrepRe3 = verify checkGrepRe "grep --regex=*.mp3 file" +prop_checkGrepRe4 = verifyNot checkGrepRe "grep foo *.mp3" +prop_checkGrepRe5 = verifyNot checkGrepRe "grep-v --regex=moo *" +prop_checkGrepRe6 = verifyNot checkGrepRe "grep foo \\*.mp3" +prop_checkGrepRe7 = verify checkGrepRe "grep *foo* file" +prop_checkGrepRe8 = verify checkGrepRe "ls | grep foo*.jpg" +prop_checkGrepRe9 = verifyNot checkGrepRe "grep '[0-9]*' file" +prop_checkGrepRe10 = verifyNot checkGrepRe "grep '^aa*' file" +prop_checkGrepRe11 = verifyNot checkGrepRe "grep --include=*.png foo" +prop_checkGrepRe12 = verifyNot checkGrepRe "grep -F 'Foo*' file" +prop_checkGrepRe13 = verifyNot checkGrepRe "grep -- -foo bar*" +prop_checkGrepRe14 = verifyNot checkGrepRe "grep -e -foo bar*" +prop_checkGrepRe15 = verifyNot checkGrepRe "grep --regex -foo bar*" +prop_checkGrepRe16 = verifyNot checkGrepRe "grep --include 'Foo*' file" +prop_checkGrepRe17 = verifyNot checkGrepRe "grep --exclude 'Foo*' file" +prop_checkGrepRe18 = verifyNot checkGrepRe "grep --exclude-dir 'Foo*' file" +prop_checkGrepRe19 = verify checkGrepRe "grep -- 'Foo*' file" +prop_checkGrepRe20 = verifyNot checkGrepRe "grep --fixed-strings 'Foo*' file" +prop_checkGrepRe21 = verifyNot checkGrepRe "grep -o 'x*' file" +prop_checkGrepRe22 = verifyNot checkGrepRe "grep --only-matching 'x*' file" +prop_checkGrepRe23 = verifyNot checkGrepRe "grep '.*' file" + +checkGrepRe = CommandCheck (Basename "grep") check where + check cmd = f cmd (arguments cmd) + -- --regex=*(extglob) doesn't work. Fixme? + skippable s = not ("--regex=" `isPrefixOf` s) && "-" `isPrefixOf` s + f _ [] = return () + f cmd (x:r) = + let str = getLiteralStringDef "_" x + in + if str `elem` ["--", "-e", "--regex"] + then checkRE cmd r -- Regex is *after* this + else + if skippable str + then f cmd r -- Regex is elsewhere + else checkRE cmd (x:r) -- Regex is this + + checkRE _ [] = return () + checkRE cmd (re:_) = do + when (isGlob re) $ + warn (getId re) 2062 "Quote the grep pattern so the shell won't interpret it." + + unless (any (`elem` flags) grepGlobFlags) $ do + let string = concat $ oversimplify re + if isConfusedGlobRegex string then + warn (getId re) 2063 "Grep uses regex, but this looks like a glob." + else sequence_ $ do + char <- getSuspiciousRegexWildcard string + return $ info (getId re) 2022 $ + "Note that unlike globs, " ++ [char] ++ "* here matches '" ++ [char, char, char] ++ "' but not '" ++ wordStartingWith char ++ "'." + where + flags = map snd $ getAllFlags cmd + grepGlobFlags = ["fixed-strings", "F", "include", "exclude", "exclude-dir", "o", "only-matching"] + + wordStartingWith c = + headOrDefault (c:"test") . filter ([c] `isPrefixOf`) $ candidates + where + candidates = + sampleWords ++ map (\(x:r) -> toUpper x : r) sampleWords + + getSuspiciousRegexWildcard str = case matchRegex suspicious str of + Just [[c]] | not (str `matches` contra) -> Just c + _ -> fail "looks good" + suspicious = mkRegex "([A-Za-z1-9])\\*" + contra = mkRegex "[^a-zA-Z1-9]\\*|[][^$+\\\\]" + + +prop_checkTrapQuotes1 = verify checkTrapQuotes "trap \"echo $num\" INT" +prop_checkTrapQuotes1a = verify checkTrapQuotes "trap \"echo `ls`\" INT" +prop_checkTrapQuotes2 = verifyNot checkTrapQuotes "trap 'echo $num' INT" +prop_checkTrapQuotes3 = verify checkTrapQuotes "trap \"echo $((1+num))\" EXIT DEBUG" +checkTrapQuotes = CommandCheck (Exactly "trap") (f . arguments) where + f (x:_) = checkTrap x + f _ = return () + checkTrap (T_NormalWord _ [T_DoubleQuoted _ rs]) = mapM_ checkExpansions rs + checkTrap _ = return () + warning id = warn id 2064 "Use single quotes, otherwise this expands now rather than when signalled." + checkExpansions (T_DollarExpansion id _) = warning id + checkExpansions (T_Backticked id _) = warning id + checkExpansions (T_DollarBraced id _ _) = warning id + checkExpansions (T_DollarArithmetic id _) = warning id + checkExpansions _ = return () + + +prop_checkReturn1 = verifyNot checkReturn "return" +prop_checkReturn2 = verifyNot checkReturn "return 1" +prop_checkReturn3 = verifyNot checkReturn "return $var" +prop_checkReturn4 = verifyNot checkReturn "return $((a|b))" +prop_checkReturn5 = verify checkReturn "return -1" +prop_checkReturn6 = verify checkReturn "return 1000" +prop_checkReturn7 = verify checkReturn "return 'hello world'" +checkReturn = CommandCheck (Exactly "return") (returnOrExit + (\c -> err c 2151 "Only one integer 0-255 can be returned. Use stdout for other data.") + (\c -> err c 2152 "Can only return 0-255. Other data should be written to stdout.")) + +prop_checkExit1 = verifyNot checkExit "exit" +prop_checkExit2 = verifyNot checkExit "exit 1" +prop_checkExit3 = verifyNot checkExit "exit $var" +prop_checkExit4 = verifyNot checkExit "exit $((a|b))" +prop_checkExit5 = verify checkExit "exit -1" +prop_checkExit6 = verify checkExit "exit 1000" +prop_checkExit7 = verify checkExit "exit 'hello world'" +checkExit = CommandCheck (Exactly "exit") (returnOrExit + (\c -> err c 2241 "The exit status can only be one integer 0-255. Use stdout for other data.") + (\c -> err c 2242 "Can only exit with status 0-255. Other data should be written to stdout/stderr.")) + +returnOrExit multi invalid = (f . arguments) + where + f (first:second:_) = + multi (getId first) + f [value] = + when (isInvalid $ literal value) $ + invalid (getId value) + f _ = return () + + isInvalid s = null s || any (not . isDigit) s || length s > 5 + || let value = (read s :: Integer) in value > 255 + + literal token = runIdentity $ getLiteralStringExt lit token + lit (T_DollarBraced {}) = return "0" + lit (T_DollarArithmetic {}) = return "0" + lit (T_DollarExpansion {}) = return "0" + lit (T_Backticked {}) = return "0" + lit _ = return "WTF" + + +prop_checkFindExecWithSingleArgument1 = verify checkFindExecWithSingleArgument "find . -exec 'cat {} | wc -l' \\;" +prop_checkFindExecWithSingleArgument2 = verify checkFindExecWithSingleArgument "find . -execdir 'cat {} | wc -l' +" +prop_checkFindExecWithSingleArgument3 = verifyNot checkFindExecWithSingleArgument "find . -exec wc -l {} \\;" +checkFindExecWithSingleArgument = CommandCheck (Basename "find") (f . arguments) + where + f = void . sequence . mapMaybe check . tails + check (exec:arg:term:_) = do + execS <- getLiteralString exec + termS <- getLiteralString term + let cmdS = getLiteralStringDef " " arg + + guard $ execS `elem` ["-exec", "-execdir"] && termS `elem` [";", "+"] + guard $ cmdS `matches` commandRegex + return $ warn (getId exec) 2150 "-exec does not invoke a shell. Rewrite or use -exec sh -c .. ." + check _ = Nothing + commandRegex = mkRegex "[ |;]" + + +prop_checkUnusedEchoEscapes1 = verify checkUnusedEchoEscapes "echo 'foo\\nbar\\n'" +prop_checkUnusedEchoEscapes2 = verifyNot checkUnusedEchoEscapes "echo -e 'foi\\nbar'" +prop_checkUnusedEchoEscapes3 = verify checkUnusedEchoEscapes "echo \"n:\\t42\"" +prop_checkUnusedEchoEscapes4 = verifyNot checkUnusedEchoEscapes "echo lol" +prop_checkUnusedEchoEscapes5 = verifyNot checkUnusedEchoEscapes "echo -n -e '\n'" +prop_checkUnusedEchoEscapes6 = verify checkUnusedEchoEscapes "echo '\\506'" +prop_checkUnusedEchoEscapes7 = verify checkUnusedEchoEscapes "echo '\\5a'" +prop_checkUnusedEchoEscapes8 = verifyNot checkUnusedEchoEscapes "echo '\\8a'" +prop_checkUnusedEchoEscapes9 = verifyNot checkUnusedEchoEscapes "echo '\\d5a'" +prop_checkUnusedEchoEscapes10 = verify checkUnusedEchoEscapes "echo '\\x4a'" +prop_checkUnusedEchoEscapes11 = verify checkUnusedEchoEscapes "echo '\\xat'" +prop_checkUnusedEchoEscapes12 = verifyNot checkUnusedEchoEscapes "echo '\\xth'" +checkUnusedEchoEscapes = CommandCheck (Basename "echo") f + where + hasEscapes = mkRegex "\\\\([rntabefv\\']|[0-7]{1,3}|x([0-9]|[A-F]|[a-f]){1,2})" + f cmd = + whenShell [Sh, Bash, Ksh] $ + unless (cmd `hasFlag` "e") $ + mapM_ examine $ arguments cmd + + examine token = do + let str = onlyLiteralString token + when (str `matches` hasEscapes) $ + info (getId token) 2028 "echo may not expand escape sequences. Use printf." + + +prop_checkInjectableFindSh1 = verify checkInjectableFindSh "find . -exec sh -c 'echo {}' \\;" +prop_checkInjectableFindSh2 = verify checkInjectableFindSh "find . -execdir bash -c 'rm \"{}\"' ';'" +prop_checkInjectableFindSh3 = verifyNot checkInjectableFindSh "find . -exec sh -c 'rm \"$@\"' _ {} \\;" +checkInjectableFindSh = CommandCheck (Basename "find") (check . arguments) + where + check args = do + let idStrings = map (\x -> (getId x, onlyLiteralString x)) args + match pattern idStrings + + match _ [] = return () + match [] (next:_) = action next + match (p:tests) ((id, arg):args) = do + when (p arg) $ match tests args + match (p:tests) args + + pattern = [ + (`elem` ["-exec", "-execdir"]), + (`elem` ["sh", "bash", "dash", "ksh"]), + (== "-c") + ] + action (id, arg) = + when ("{}" `isInfixOf` arg) $ + warn id 2156 "Injecting filenames is fragile and insecure. Use parameters." + + +prop_checkFindActionPrecedence1 = verify checkFindActionPrecedence "find . -name '*.wav' -o -name '*.au' -exec rm {} +" +prop_checkFindActionPrecedence2 = verifyNot checkFindActionPrecedence "find . -name '*.wav' -o \\( -name '*.au' -exec rm {} + \\)" +prop_checkFindActionPrecedence3 = verifyNot checkFindActionPrecedence "find . -name '*.wav' -o -name '*.au'" +checkFindActionPrecedence = CommandCheck (Basename "find") (f . arguments) + where + pattern = [isMatch, const True, isParam ["-o", "-or"], isMatch, const True, isAction] + f list | length list < length pattern = return () + f list@(_:rest) = + if and (zipWith ($) pattern list) + then warnFor (list !! (length pattern - 1)) + else f rest + isMatch = isParam [ "-name", "-regex", "-iname", "-iregex", "-wholename", "-iwholename" ] + isAction = isParam [ "-exec", "-execdir", "-delete", "-print", "-print0", "-fls", "-fprint", "-fprint0", "-fprintf", "-ls", "-ok", "-okdir", "-printf" ] + isParam strs t = fromMaybe False $ do + param <- getLiteralString t + return $ param `elem` strs + warnFor t = warn (getId t) 2146 "This action ignores everything before the -o. Use \\( \\) to group." + + +prop_checkMkdirDashPM0 = verify checkMkdirDashPM "mkdir -p -m 0755 a/b" +prop_checkMkdirDashPM1 = verify checkMkdirDashPM "mkdir -pm 0755 $dir" +prop_checkMkdirDashPM2 = verify checkMkdirDashPM "mkdir -vpm 0755 a/b" +prop_checkMkdirDashPM3 = verify checkMkdirDashPM "mkdir -pm 0755 -v a/b" +prop_checkMkdirDashPM4 = verify checkMkdirDashPM "mkdir --parents --mode=0755 a/b" +prop_checkMkdirDashPM5 = verify checkMkdirDashPM "mkdir --parents --mode 0755 a/b" +prop_checkMkdirDashPM6 = verify checkMkdirDashPM "mkdir -p --mode=0755 a/b" +prop_checkMkdirDashPM7 = verify checkMkdirDashPM "mkdir --parents -m 0755 a/b" +prop_checkMkdirDashPM8 = verifyNot checkMkdirDashPM "mkdir -p a/b" +prop_checkMkdirDashPM9 = verifyNot checkMkdirDashPM "mkdir -m 0755 a/b" +prop_checkMkdirDashPM10 = verifyNot checkMkdirDashPM "mkdir a/b" +prop_checkMkdirDashPM11 = verifyNot checkMkdirDashPM "mkdir --parents a/b" +prop_checkMkdirDashPM12 = verifyNot checkMkdirDashPM "mkdir --mode=0755 a/b" +prop_checkMkdirDashPM13 = verifyNot checkMkdirDashPM "mkdir_func -pm 0755 a/b" +prop_checkMkdirDashPM14 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 singlelevel" +prop_checkMkdirDashPM15 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 ../bin" +prop_checkMkdirDashPM16 = verify checkMkdirDashPM "mkdir -p -m 0755 ../bin/laden" +prop_checkMkdirDashPM17 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 ./bin" +prop_checkMkdirDashPM18 = verify checkMkdirDashPM "mkdir -p -m 0755 ./bin/laden" +prop_checkMkdirDashPM19 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 ./../bin" +prop_checkMkdirDashPM20 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 .././bin" +prop_checkMkdirDashPM21 = verifyNot checkMkdirDashPM "mkdir -p -m 0755 ../../bin" +checkMkdirDashPM = CommandCheck (Basename "mkdir") check + where + check t = sequence_ $ do + let flags = getAllFlags t + dashP <- find (\(_,f) -> f == "p" || f == "parents") flags + dashM <- find (\(_,f) -> f == "m" || f == "mode") flags + -- mkdir -pm 0700 dir is fine, so is ../dir, but dir/subdir is not. + guard $ any couldHaveSubdirs (drop 1 $ arguments t) + return $ warn (getId $ fst dashM) 2174 "When used with -p, -m only applies to the deepest directory." + couldHaveSubdirs t = fromMaybe True $ do + name <- getLiteralString t + return $ '/' `elem` name && not (name `matches` re) + re = mkRegex "^(\\.\\.?\\/)+[^/]+$" + + +prop_checkNonportableSignals1 = verify checkNonportableSignals "trap f 8" +prop_checkNonportableSignals2 = verifyNot checkNonportableSignals "trap f 0" +prop_checkNonportableSignals3 = verifyNot checkNonportableSignals "trap f 14" +prop_checkNonportableSignals4 = verify checkNonportableSignals "trap f SIGKILL" +prop_checkNonportableSignals5 = verify checkNonportableSignals "trap f 9" +prop_checkNonportableSignals6 = verify checkNonportableSignals "trap f stop" +prop_checkNonportableSignals7 = verifyNot checkNonportableSignals "trap 'stop' int" +checkNonportableSignals = CommandCheck (Exactly "trap") (f . arguments) + where + f args = case args of + first:rest | not $ isFlag first -> mapM_ check rest + _ -> return () + + check param = sequence_ $ do + str <- getLiteralString param + let id = getId param + return $ sequence_ $ mapMaybe (\f -> f id str) [ + checkNumeric, + checkUntrappable + ] + + checkNumeric id str = do + guard $ not (null str) + guard $ all isDigit str + guard $ str /= "0" -- POSIX exit trap + guard $ str `notElem` ["1", "2", "3", "6", "9", "14", "15" ] -- XSI + return $ warn id 2172 + "Trapping signals by number is not well defined. Prefer signal names." + + checkUntrappable id str = do + guard $ map toLower str `elem` ["kill", "9", "sigkill", "stop", "sigstop"] + return $ err id 2173 + "SIGKILL/SIGSTOP can not be trapped." + + +prop_checkInteractiveSu1 = verify checkInteractiveSu "su; rm file; su $USER" +prop_checkInteractiveSu2 = verify checkInteractiveSu "su foo; something; exit" +prop_checkInteractiveSu3 = verifyNot checkInteractiveSu "echo rm | su foo" +prop_checkInteractiveSu4 = verifyNot checkInteractiveSu "su root < script" +checkInteractiveSu = CommandCheck (Basename "su") f + where + f cmd = when (length (arguments cmd) <= 1) $ do + path <- getPathM cmd + when (all undirected path) $ + info (getId cmd) 2117 + "To run commands as another user, use su -c or sudo." + + undirected (T_Pipeline _ _ (_:_:_)) = False + -- This should really just be modifications to stdin, but meh + undirected (T_Redirecting _ (_:_) _) = False + undirected _ = True + + +-- This is hard to get right without properly parsing ssh args +prop_checkSshCmdStr1 = verify checkSshCommandString "ssh host \"echo $PS1\"" +prop_checkSshCmdStr2 = verifyNot checkSshCommandString "ssh host \"ls foo\"" +prop_checkSshCmdStr3 = verifyNot checkSshCommandString "ssh \"$host\"" +prop_checkSshCmdStr4 = verifyNot checkSshCommandString "ssh -i key \"$host\"" +checkSshCommandString = CommandCheck (Basename "ssh") (f . arguments) + where + isOption x = "-" `isPrefixOf` (concat $ oversimplify x) + f args = + case partition isOption args of + ([], hostport:r@(_:_)) -> checkArg $ last r + _ -> return () + checkArg (T_NormalWord _ [T_DoubleQuoted id parts]) = + forM_ (find (not . isConstant) parts) $ + \x -> info (getId x) 2029 + "Note that, unescaped, this expands on the client side." + checkArg _ = return () + + +prop_checkPrintfVar1 = verify checkPrintfVar "printf \"Lol: $s\"" +prop_checkPrintfVar2 = verifyNot checkPrintfVar "printf 'Lol: $s'" +prop_checkPrintfVar3 = verify checkPrintfVar "printf -v cow $(cmd)" +prop_checkPrintfVar4 = verifyNot checkPrintfVar "printf \"%${count}s\" var" +prop_checkPrintfVar5 = verify checkPrintfVar "printf '%s %s %s' foo bar" +prop_checkPrintfVar6 = verify checkPrintfVar "printf foo bar baz" +prop_checkPrintfVar7 = verify checkPrintfVar "printf -- foo bar baz" +prop_checkPrintfVar8 = verifyNot checkPrintfVar "printf '%s %s %s' \"${var[@]}\"" +prop_checkPrintfVar9 = verifyNot checkPrintfVar "printf '%s %s %s\\n' *.png" +prop_checkPrintfVar10 = verifyNot checkPrintfVar "printf '%s %s %s' foo bar baz" +prop_checkPrintfVar11 = verifyNot checkPrintfVar "printf '%(%s%s)T' -1" +prop_checkPrintfVar12 = verify checkPrintfVar "printf '%s %s\\n' 1 2 3" +prop_checkPrintfVar13 = verifyNot checkPrintfVar "printf '%s %s\\n' 1 2 3 4" +prop_checkPrintfVar14 = verify checkPrintfVar "printf '%*s\\n' 1" +prop_checkPrintfVar15 = verifyNot checkPrintfVar "printf '%*s\\n' 1 2" +prop_checkPrintfVar16 = verifyNot checkPrintfVar "printf $'string'" +prop_checkPrintfVar17 = verify checkPrintfVar "printf '%-*s\\n' 1" +prop_checkPrintfVar18 = verifyNot checkPrintfVar "printf '%-*s\\n' 1 2" +prop_checkPrintfVar19 = verifyNot checkPrintfVar "printf '%(%s)T'" +prop_checkPrintfVar20 = verifyNot checkPrintfVar "printf '%d %(%s)T' 42" +prop_checkPrintfVar21 = verify checkPrintfVar "printf '%d %(%s)T'" +prop_checkPrintfVar22 = verify checkPrintfVar "printf '%s\n%s' foo" + +checkPrintfVar = CommandCheck (Exactly "printf") (f . arguments) where + f (doubledash:rest) | getLiteralString doubledash == Just "--" = f rest + f (dashv:var:rest) | getLiteralString dashv == Just "-v" = f rest + f (format:params) = check format params + f _ = return () + + check format more = do + sequence_ $ do + string <- getLiteralString format + let formats = getPrintfFormats string + let formatCount = length formats + let argCount = length more + let pluraliseIfMany word n = if n > 1 then word ++ "s" else word + + return $ if + | argCount == 0 && formatCount == 0 -> + return () -- This is fine + | formatCount == 0 && argCount > 0 -> + err (getId format) 2182 + "This printf format string has no variables. Other arguments are ignored." + | any mayBecomeMultipleArgs more -> + return () -- We don't know so trust the user + | argCount < formatCount && onlyTrailingTs formats argCount -> + return () -- Allow trailing %()Ts since they use the current time + | argCount > 0 && argCount `mod` formatCount == 0 -> + return () -- Great: a suitable number of arguments + | otherwise -> + warn (getId format) 2183 $ + "This format string has " ++ show formatCount ++ " " ++ pluraliseIfMany "variable" formatCount ++ + ", but is passed " ++ show argCount ++ pluraliseIfMany " argument" argCount ++ "." + + unless ('%' `elem` concat (oversimplify format) || isLiteral format) $ + info (getId format) 2059 + "Don't use variables in the printf format string. Use printf '..%s..' \"$foo\"." + where + onlyTrailingTs format argCount = + all (== 'T') $ drop argCount format + + +prop_checkGetPrintfFormats1 = getPrintfFormats "%s" == "s" +prop_checkGetPrintfFormats2 = getPrintfFormats "%0*s" == "*s" +prop_checkGetPrintfFormats3 = getPrintfFormats "%(%s)T" == "T" +prop_checkGetPrintfFormats4 = getPrintfFormats "%d%%%(%s)T" == "dT" +prop_checkGetPrintfFormats5 = getPrintfFormats "%bPassed: %d, %bFailed: %d%b, Skipped: %d, %bErrored: %d%b\\n" == "bdbdbdbdb" +prop_checkGetPrintfFormats6 = getPrintfFormats "%s%s" == "ss" +prop_checkGetPrintfFormats7 = getPrintfFormats "%s\n%s" == "ss" +prop_checkGetPrintfFormats8 = getPrintfFormats "%ld" == "d" +prop_checkGetPrintfFormats9 = getPrintfFormats "%lld" == "d" +prop_checkGetPrintfFormats10 = getPrintfFormats "%Q" == "Q" +getPrintfFormats = getFormats + where + -- Get the arguments in the string as a string of type characters, + -- e.g. "Hello %s" -> "s" and "%(%s)T %0*d\n" -> "T*d" + getFormats :: String -> String + getFormats string = + case string of + '%':'%':rest -> getFormats rest + '%':'(':rest -> + case dropWhile (/= ')') rest of + ')':c:trailing -> c : getFormats trailing + _ -> "" + '%':rest -> regexBasedGetFormats rest + _:rest -> getFormats rest + [] -> "" + + regexBasedGetFormats rest = + case matchRegex re rest of + Just [width, precision, len, typ, rest, _] -> + (if width == "*" then "*" else "") ++ + (if precision == "*" then "*" else "") ++ + typ ++ getFormats rest + Nothing -> take 1 rest ++ getFormats rest + where + -- constructed based on specifications in "man printf" + re = mkRegex "^#?-?\\+? ?0?(\\*|\\d*)\\.?(\\d*|\\*)(hh|h|l|ll|q|L|j|z|Z|t)?([diouxXfFeEgGaAcsbqQSC])((\n|.)*)" + -- \____ _____/\___ ____/ \____ ____/\__________ ___________/\___________ ___________/\___ ___/ + -- V V V V V V + -- flags field width precision length modifier format character rest + -- field width and precision can be specified with an '*' instead of a digit, + -- in which case printf will accept one more argument for each '*' used + + +prop_checkUuoeCmd1 = verify checkUuoeCmd "echo $(date)" +prop_checkUuoeCmd2 = verify checkUuoeCmd "echo `date`" +prop_checkUuoeCmd3 = verify checkUuoeCmd "echo \"$(date)\"" +prop_checkUuoeCmd4 = verify checkUuoeCmd "echo \"`date`\"" +prop_checkUuoeCmd5 = verifyNot checkUuoeCmd "echo \"The time is $(date)\"" +prop_checkUuoeCmd6 = verifyNot checkUuoeCmd "echo \"$( True + _ -> False + +-- Return the single variable expansion that makes up this word, if any. +-- e.g. $foo -> $foo, "$foo"'' -> $foo , "hello $name" -> Nothing +getSingleUnmodifiedBracedString :: Token -> Maybe String +getSingleUnmodifiedBracedString word = + case getWordParts word of + [T_DollarBraced _ _ l] -> + let contents = concat $ oversimplify l + name = getBracedReference contents + in guard (contents == name) >> return contents + _ -> Nothing + +prop_checkAliasesUsesArgs1 = verify checkAliasesUsesArgs "alias a='cp $1 /a'" +prop_checkAliasesUsesArgs2 = verifyNot checkAliasesUsesArgs "alias $1='foo'" +prop_checkAliasesUsesArgs3 = verify checkAliasesUsesArgs "alias a=\"echo \\${@}\"" +checkAliasesUsesArgs = CommandCheck (Exactly "alias") (f . arguments) + where + re = mkRegex "\\$\\{?[0-9*@]" + f = mapM_ checkArg + checkArg arg = + let string = getLiteralStringDef "_" arg in + when ('=' `elem` string && string `matches` re) $ + err (getId arg) 2142 + "Aliases can't use positional parameters. Use a function." + + +prop_checkAliasesExpandEarly1 = verify checkAliasesExpandEarly "alias foo=\"echo $PWD\"" +prop_checkAliasesExpandEarly2 = verifyNot checkAliasesExpandEarly "alias -p" +prop_checkAliasesExpandEarly3 = verifyNot checkAliasesExpandEarly "alias foo='echo {1..10}'" +checkAliasesExpandEarly = CommandCheck (Exactly "alias") (f . arguments) + where + f = mapM_ checkArg + checkArg arg | '=' `elem` concat (oversimplify arg) = + forM_ (find (not . isLiteral) $ getWordParts arg) $ + \x -> warn (getId x) 2139 "This expands when defined, not when used. Consider escaping." + checkArg _ = return () + + +prop_checkUnsetGlobs1 = verify checkUnsetGlobs "unset foo[1]" +prop_checkUnsetGlobs2 = verifyNot checkUnsetGlobs "unset foo" +prop_checkUnsetGlobs3 = verify checkUnsetGlobs "unset foo[$i]" +prop_checkUnsetGlobs4 = verify checkUnsetGlobs "unset foo[x${i}y]" +prop_checkUnsetGlobs5 = verifyNot checkUnsetGlobs "unset foo][" +checkUnsetGlobs = CommandCheck (Exactly "unset") (mapM_ check . arguments) + where + check arg = + when (isGlob arg) $ + warn (getId arg) 2184 "Quote arguments to unset so they're not glob expanded." + + +prop_checkFindWithoutPath1 = verify checkFindWithoutPath "find -type f" +prop_checkFindWithoutPath2 = verify checkFindWithoutPath "find" +prop_checkFindWithoutPath3 = verifyNot checkFindWithoutPath "find . -type f" +prop_checkFindWithoutPath4 = verifyNot checkFindWithoutPath "find -H -L \"$path\" -print" +prop_checkFindWithoutPath5 = verifyNot checkFindWithoutPath "find -O3 ." +prop_checkFindWithoutPath6 = verifyNot checkFindWithoutPath "find -D exec ." +prop_checkFindWithoutPath7 = verifyNot checkFindWithoutPath "find --help" +prop_checkFindWithoutPath8 = verifyNot checkFindWithoutPath "find -Hx . -print" +checkFindWithoutPath = CommandCheck (Basename "find") f + where + f t@(T_SimpleCommand _ _ (cmd:args)) = + unless (t `hasFlag` "help" || hasPath args) $ + info (getId cmd) 2185 "Some finds don't have a default path. Specify '.' explicitly." + + -- This is a bit of a kludge. find supports flag arguments both before and + -- after the path, as well as multiple non-flag arguments that are not the + -- path. We assume that all the pre-path flags are single characters from a + -- list of GNU and macOS flags. + hasPath (first:rest) = + let flag = getLiteralStringDef "___" first in + not ("-" `isPrefixOf` flag) || isLeadingFlag flag && hasPath rest + hasPath [] = False + isLeadingFlag flag = length flag <= 2 || all (`elem` leadingFlagChars) flag + leadingFlagChars="-EHLPXdfsxO0123456789" + + +prop_checkTimeParameters1 = verify checkTimeParameters "time -f lol sleep 10" +prop_checkTimeParameters2 = verifyNot checkTimeParameters "time sleep 10" +prop_checkTimeParameters3 = verifyNot checkTimeParameters "time -p foo" +prop_checkTimeParameters4 = verifyNot checkTimeParameters "command time -f lol sleep 10" +checkTimeParameters = CommandCheck (Exactly "time") f + where + f (T_SimpleCommand _ _ (cmd:args:_)) = + whenShell [Bash, Sh] $ + let s = concat $ oversimplify args in + when ("-" `isPrefixOf` s && s /= "-p") $ + info (getId cmd) 2023 "The shell may override 'time' as seen in man time(1). Use 'command time ..' for that one." + + f _ = return () + +prop_checkTimedCommand1 = verify checkTimedCommand "#!/bin/sh\ntime -p foo | bar" +prop_checkTimedCommand2 = verify checkTimedCommand "#!/bin/dash\ntime ( foo; bar; )" +prop_checkTimedCommand3 = verifyNot checkTimedCommand "#!/bin/sh\ntime sleep 1" +checkTimedCommand = CommandCheck (Exactly "time") f where + f (T_SimpleCommand _ _ (c:args@(_:_))) = + whenShell [Sh, Dash, BusyboxSh] $ do + let cmd = last args -- "time" is parsed with a command as argument + when (isPiped cmd) $ + warn (getId c) 2176 "'time' is undefined for pipelines. time single stage or bash -c instead." + when (isSimple cmd == Just False) $ + warn (getId cmd) 2177 "'time' is undefined for compound commands, time sh -c instead." + f _ = return () + isPiped cmd = + case cmd of + T_Pipeline _ _ (_:_:_) -> True + _ -> False + getCommand cmd = + case cmd of + T_Pipeline _ _ (T_Redirecting _ _ a : _) -> return a + _ -> fail "" + isSimple cmd = do + innerCommand <- getCommand cmd + case innerCommand of + T_SimpleCommand {} -> return True + _ -> return False + +prop_checkLocalScope1 = verify checkLocalScope "local foo=3" +prop_checkLocalScope2 = verifyNot checkLocalScope "f() { local foo=3; }" +checkLocalScope = CommandCheck (Exactly "local") $ \t -> + whenShell [Bash, Dash, BusyboxSh] $ do -- Ksh allows it, Sh doesn't support local + path <- getPathM t + unless (any isFunctionLike path) $ + err (getId $ getCommandTokenOrThis t) 2168 "'local' is only valid in functions." + +prop_checkMultipleDeclaring1 = verify (checkMultipleDeclaring "local") "q() { local readonly var=1; }" +prop_checkMultipleDeclaring2 = verifyNot (checkMultipleDeclaring "local") "q() { local var=1; }" +prop_checkMultipleDeclaring3 = verify (checkMultipleDeclaring "readonly") "readonly local foo=5" +prop_checkMultipleDeclaring4 = verify (checkMultipleDeclaring "export") "export readonly foo=5" +prop_checkMultipleDeclaring5 = verifyNot (checkMultipleDeclaring "local") "f() { local -r foo=5; }" +prop_checkMultipleDeclaring6 = verifyNot (checkMultipleDeclaring "declare") "declare -rx foo=5" +prop_checkMultipleDeclaring7 = verifyNot (checkMultipleDeclaring "readonly") "readonly 'local' foo=5" +checkMultipleDeclaring cmd = CommandCheck (Exactly cmd) (mapM_ check . arguments) + where + check t = sequence_ $ do + lit <- getUnquotedLiteral t + guard $ lit `elem` declaringCommands + return $ err (getId $ getCommandTokenOrThis t) 2316 $ + "This applies " ++ cmd ++ " to the variable named " ++ lit ++ + ", which is probably not what you want. Use a separate command or the appropriate `declare` options instead." + +prop_checkDeprecatedTempfile1 = verify checkDeprecatedTempfile "var=$(tempfile)" +prop_checkDeprecatedTempfile2 = verifyNot checkDeprecatedTempfile "tempfile=$(mktemp)" +checkDeprecatedTempfile = CommandCheck (Basename "tempfile") $ + \t -> warn (getId $ getCommandTokenOrThis t) 2186 "tempfile is deprecated. Use mktemp instead." + +prop_checkDeprecatedEgrep = verify checkDeprecatedEgrep "egrep '.+'" +checkDeprecatedEgrep = CommandCheck (Basename "egrep") $ + \t -> info (getId $ getCommandTokenOrThis t) 2196 "egrep is non-standard and deprecated. Use grep -E instead." + +prop_checkDeprecatedFgrep = verify checkDeprecatedFgrep "fgrep '*' files" +checkDeprecatedFgrep = CommandCheck (Basename "fgrep") $ + \t -> info (getId $ getCommandTokenOrThis t) 2197 "fgrep is non-standard and deprecated. Use grep -F instead." + +prop_checkWhileGetoptsCase1 = verify checkWhileGetoptsCase "while getopts 'a:b' x; do case $x in a) foo;; esac; done" +prop_checkWhileGetoptsCase2 = verify checkWhileGetoptsCase "while getopts 'a:' x; do case $x in a) foo;; b) bar;; esac; done" +prop_checkWhileGetoptsCase3 = verifyNot checkWhileGetoptsCase "while getopts 'a:b' x; do case $x in a) foo;; b) bar;; *) :;esac; done" +prop_checkWhileGetoptsCase4 = verifyNot checkWhileGetoptsCase "while getopts 'a:123' x; do case $x in a) foo;; [0-9]) bar;; esac; done" +prop_checkWhileGetoptsCase5 = verifyNot checkWhileGetoptsCase "while getopts 'a:' x; do case $x in a) foo;; \\?) bar;; *) baz;; esac; done" +prop_checkWhileGetoptsCase6 = verifyNot checkWhileGetoptsCase "while getopts 'a:b' x; do case $y in a) foo;; esac; done" +prop_checkWhileGetoptsCase7 = verifyNot checkWhileGetoptsCase "while getopts 'a:b' x; do case x$x in xa) foo;; xb) foo;; esac; done" +prop_checkWhileGetoptsCase8 = verifyNot checkWhileGetoptsCase "while getopts 'a:b' x; do x=a; case $x in a) foo;; esac; done" +checkWhileGetoptsCase = CommandCheck (Exactly "getopts") f + where + f :: Token -> Analysis + f t@(T_SimpleCommand _ _ (cmd:arg1:name:_)) = do + path <- getPathM t + params <- ask + sequence_ $ do + options <- getLiteralString arg1 + getoptsVar <- getLiteralString name + (T_WhileExpression _ _ body) <- findFirst whileLoop (NE.toList path) + T_CaseExpression id var list <- mapMaybe findCase body !!! 0 + + -- Make sure getopts name and case variable matches + [T_DollarBraced _ _ bracedWord] <- return $ getWordParts var + [T_Literal _ caseVar] <- return $ getWordParts bracedWord + guard $ caseVar == getoptsVar + + -- Make sure the variable isn't modified + guard . not $ modifiesVariable params (T_BraceGroup (Id 0) body) getoptsVar + + return $ check (getId arg1) (map (:[]) $ filter (/= ':') options) id list + f _ = return () + + check :: Id -> [String] -> Id -> [(CaseType, [Token], [Token])] -> Analysis + check optId opts id list = do + unless (Nothing `M.member` handledMap) $ do + mapM_ (warnUnhandled optId id) $ catMaybes $ M.keys notHandled + + unless (any (`M.member` handledMap) [Just "*",Just "?"]) $ + warn id 2220 "Invalid flags are not handled. Add a *) case." + + mapM_ warnRedundant $ M.toList notRequested + + where + handledMap = M.fromList (concatMap getHandledStrings list) + requestedMap = M.fromList $ map (\x -> (Just x, ())) opts + + notHandled = M.difference requestedMap handledMap + notRequested = M.difference handledMap requestedMap + + warnUnhandled optId caseId str = + warn caseId 2213 $ "getopts specified -" ++ (e4m str) ++ ", but it's not handled by this 'case'." + + warnRedundant (Just str, expr) + | str `notElem` ["*", ":", "?"] = + warn (getId expr) 2214 "This case is not specified by getopts." + warnRedundant _ = return () + + getHandledStrings (_, globs, _) = + map (\x -> (literal x, x)) globs + + literal :: Token -> Maybe String + literal t = do + getLiteralString t <> fromGlob t + + fromGlob t = + case t of + T_Glob _ ['[', c, ']'] -> return [c] + T_Glob _ "*" -> return "*" + T_Glob _ "?" -> return "?" + _ -> Nothing + + whileLoop t = + case t of + T_WhileExpression {} -> return True + T_Script {} -> return False + _ -> Nothing + + findCase t = + case t of + T_Annotation _ _ x -> findCase x + T_Pipeline _ _ [x] -> findCase x + T_Redirecting _ _ x@(T_CaseExpression {}) -> return x + _ -> Nothing + +prop_checkCatastrophicRm1 = verify checkCatastrophicRm "rm -r $1/$2" +prop_checkCatastrophicRm2 = verify checkCatastrophicRm "rm -r /home/$foo" +prop_checkCatastrophicRm3 = verifyNot checkCatastrophicRm "rm -r /home/${USER:?}/*" +prop_checkCatastrophicRm4 = verify checkCatastrophicRm "rm -fr /home/$(whoami)/*" +prop_checkCatastrophicRm5 = verifyNot checkCatastrophicRm "rm -r /home/${USER:-thing}/*" +prop_checkCatastrophicRm6 = verify checkCatastrophicRm "rm --recursive /etc/*$config*" +prop_checkCatastrophicRm8 = verify checkCatastrophicRm "rm -rf /home" +prop_checkCatastrophicRm10 = verifyNot checkCatastrophicRm "rm -r \"${DIR}\"/{.gitignore,.gitattributes,ci}" +prop_checkCatastrophicRm11 = verify checkCatastrophicRm "rm -r /{bin,sbin}/$exec" +prop_checkCatastrophicRm12 = verify checkCatastrophicRm "rm -r /{{usr,},{bin,sbin}}/$exec" +prop_checkCatastrophicRm13 = verifyNot checkCatastrophicRm "rm -r /{{a,b},{c,d}}/$exec" +prop_checkCatastrophicRmA = verify checkCatastrophicRm "rm -rf /usr /lib/nvidia-current/xorg/xorg" +prop_checkCatastrophicRmB = verify checkCatastrophicRm "rm -rf \"$STEAMROOT/\"*" +checkCatastrophicRm = CommandCheck (Basename "rm") $ \t -> + when (isRecursive t) $ + mapM_ (mapM_ checkWord . braceExpand) $ arguments t + where + isRecursive = any ((`elem` ["r", "R", "recursive"]) . snd) . getAllFlags + + checkWord token = + case getLiteralString token of + Just str -> + when (fixPath str `elem` importantPaths) $ + warn (getId token) 2114 "Warning: deletes a system directory." + Nothing -> + checkWord' token + + checkWord' token = sequence_ $ do + filename <- getPotentialPath token + let path = fixPath filename + return . when (path `elem` importantPaths) $ + warn (getId token) 2115 $ "Use \"${var:?}\" to ensure this never expands to " ++ path ++ " ." + + fixPath filename = + let normalized = skipRepeating '/' . skipRepeating '*' $ filename in + if normalized == "/" then normalized else stripTrailing '/' normalized + + getPotentialPath = getLiteralStringExt f + where + f (T_Glob _ str) = return str + f (T_DollarBraced _ _ word) = + let var = onlyLiteralString word in + -- This shouldn't handle non-colon cases. + if any (`isInfixOf` var) [":?", ":-", ":="] + then Nothing + else return "" + f _ = return "" + + stripTrailing c = reverse . dropWhile (== c) . reverse + skipRepeating c = foldr go [] + where + go a r = a : case r of b:rest | b == c && a == b -> rest; _ -> r + + paths = [ + "", "/bin", "/etc", "/home", "/mnt", "/usr", "/usr/share", "/usr/local", + "/var", "/lib", "/dev", "/media", "/boot", "/lib64", "/usr/bin" + ] + importantPaths = filter (not . null) $ + ["", "/", "/*", "/*/*"] >>= (\x -> map (++x) paths) + + +prop_checkLetUsage1 = verify checkLetUsage "let a=1" +prop_checkLetUsage2 = verifyNot checkLetUsage "(( a=1 ))" +checkLetUsage = CommandCheck (Exactly "let") f + where + f t = whenShell [Bash,Ksh] $ do + style (getId t) 2219 $ "Instead of 'let expr', prefer (( expr )) ." + + +missingDestination handler token = do + case params of + [single] -> do + unless (hasTarget || mayBecomeMultipleArgs single) $ + handler token + _ -> return () + where + args = getAllFlags token + params = [x | (x,"") <- args] + hasTarget = + any (\(_,x) -> x /= "" && x `isPrefixOf` "target-directory") args + +prop_checkMvArguments1 = verify checkMvArguments "mv 'foo bar'" +prop_checkMvArguments2 = verifyNot checkMvArguments "mv foo bar" +prop_checkMvArguments3 = verifyNot checkMvArguments "mv 'foo bar'{,bak}" +prop_checkMvArguments4 = verifyNot checkMvArguments "mv \"$@\"" +prop_checkMvArguments5 = verifyNot checkMvArguments "mv -t foo bar" +prop_checkMvArguments6 = verifyNot checkMvArguments "mv --target-directory=foo bar" +prop_checkMvArguments7 = verifyNot checkMvArguments "mv --target-direc=foo bar" +prop_checkMvArguments8 = verifyNot checkMvArguments "mv --version" +prop_checkMvArguments9 = verifyNot checkMvArguments "mv \"${!var}\"" +checkMvArguments = CommandCheck (Basename "mv") $ missingDestination f + where + f t = err (getId t) 2224 "This mv has no destination. Check the arguments." + +checkCpArguments = CommandCheck (Basename "cp") $ missingDestination f + where + f t = err (getId t) 2225 "This cp has no destination. Check the arguments." + +checkLnArguments = CommandCheck (Basename "ln") $ missingDestination f + where + f t = warn (getId t) 2226 "This ln has no destination. Check the arguments, or specify '.' explicitly." + + +prop_checkFindRedirections1 = verify checkFindRedirections "find . -exec echo {} > file \\;" +prop_checkFindRedirections2 = verifyNot checkFindRedirections "find . -exec echo {} \\; > file" +prop_checkFindRedirections3 = verifyNot checkFindRedirections "find . -execdir sh -c 'foo > file' \\;" +checkFindRedirections = CommandCheck (Basename "find") f + where + f t = do + redirecting <- getClosestCommandM t + case redirecting of + Just (T_Redirecting _ redirs@(_:_) (T_SimpleCommand _ _ args@(_:_:_))) -> do + -- This assumes IDs are sequential, which is mostly but not always true. + let minRedir = minimum $ map getId redirs + let maxArg = maximum $ map getId args + when (minRedir < maxArg) $ + warn minRedir 2227 + "Redirection applies to the find command itself. Rewrite to work per action (or move to end)." + _ -> return () + +prop_checkWhich = verify checkWhich "which '.+'" +checkWhich = CommandCheck (Basename "which") $ + \t -> info (getId $ getCommandTokenOrThis t) 2230 "'which' is non-standard. Use builtin 'command -v' instead." + +prop_checkSudoRedirect1 = verify checkSudoRedirect "sudo echo 3 > /proc/file" +prop_checkSudoRedirect2 = verify checkSudoRedirect "sudo cmd < input" +prop_checkSudoRedirect3 = verify checkSudoRedirect "sudo cmd >> file" +prop_checkSudoRedirect4 = verify checkSudoRedirect "sudo cmd &> file" +prop_checkSudoRedirect5 = verifyNot checkSudoRedirect "sudo cmd 2>&1" +prop_checkSudoRedirect6 = verifyNot checkSudoRedirect "sudo cmd 2> log" +prop_checkSudoRedirect7 = verifyNot checkSudoRedirect "sudo cmd > /dev/null 2>&1" +checkSudoRedirect = CommandCheck (Basename "sudo") f + where + f t = do + t_redir <- getClosestCommandM t + case t_redir of + Just (T_Redirecting _ redirs _) -> + mapM_ warnAbout redirs + warnAbout (T_FdRedirect _ s (T_IoFile id op file)) + | (null s || s == "&") && not (special file) = + case op of + T_Less _ -> + info (getId op) 2024 + "sudo doesn't affect redirects. Use sudo cat file | .." + T_Greater _ -> + warn (getId op) 2024 + "sudo doesn't affect redirects. Use ..| sudo tee file" + T_DGREAT _ -> + warn (getId op) 2024 + "sudo doesn't affect redirects. Use .. | sudo tee -a file" + _ -> return () + warnAbout _ = return () + special file = concat (oversimplify file) == "/dev/null" + +prop_checkSudoArgs1 = verify checkSudoArgs "sudo cd /root" +prop_checkSudoArgs2 = verify checkSudoArgs "sudo export x=3" +prop_checkSudoArgs3 = verifyNot checkSudoArgs "sudo ls /usr/local/protected" +prop_checkSudoArgs4 = verifyNot checkSudoArgs "sudo ls && export x=3" +prop_checkSudoArgs5 = verifyNot checkSudoArgs "sudo echo ls" +prop_checkSudoArgs6 = verifyNot checkSudoArgs "sudo -n -u export ls" +prop_checkSudoArgs7 = verifyNot checkSudoArgs "sudo docker export foo" +checkSudoArgs = CommandCheck (Basename "sudo") f + where + f t = sequence_ $ do + opts <- parseOpts $ arguments t + (_,(commandArg, _)) <- find (null . fst) opts + command <- getLiteralString commandArg + guard $ command `elem` builtins + return $ warn (getId t) 2232 $ "Can't use sudo with builtins like " ++ command ++ ". Did you want sudo sh -c .. instead?" + builtins = [ "cd", "command", "declare", "eval", "exec", "exit", "export", "hash", "history", "local", "popd", "pushd", "read", "readonly", "return", "set", "source", "trap", "type", "typeset", "ulimit", "umask", "unset", "wait" ] + -- This mess is why ShellCheck prefers not to know. + parseOpts = getBsdOpts "vAknSbEHPa:g:h:p:u:c:T:r:" + +prop_checkSourceArgs1 = verify checkSourceArgs "#!/bin/sh\n. script arg" +prop_checkSourceArgs2 = verifyNot checkSourceArgs "#!/bin/sh\n. script" +prop_checkSourceArgs3 = verifyNot checkSourceArgs "#!/bin/bash\n. script arg" +checkSourceArgs = CommandCheck (Exactly ".") f + where + f t = whenShell [Sh, Dash] $ + case arguments t of + (file:arg1:_) -> warn (getId arg1) 2240 $ + "The dot command does not support arguments in sh/dash. Set them as variables." + _ -> return () + +prop_checkChmodDashr1 = verify checkChmodDashr "chmod -r 0755 dir" +prop_checkChmodDashr2 = verifyNot checkChmodDashr "chmod -R 0755 dir" +prop_checkChmodDashr3 = verifyNot checkChmodDashr "chmod a-r dir" +checkChmodDashr = CommandCheck (Basename "chmod") f + where + f t = mapM_ check $ arguments t + check t = sequence_ $ do + flag <- getLiteralString t + guard $ flag == "-r" + return $ warn (getId t) 2253 "Use -R to recurse, or explicitly a-r to remove read permissions." + +prop_checkXargsDashi1 = verify checkXargsDashi "xargs -i{} echo {}" +prop_checkXargsDashi2 = verifyNot checkXargsDashi "xargs -I{} echo {}" +prop_checkXargsDashi3 = verifyNot checkXargsDashi "xargs sed -i -e foo" +prop_checkXargsDashi4 = verify checkXargsDashi "xargs -e sed -i foo" +prop_checkXargsDashi5 = verifyNot checkXargsDashi "xargs -x sed -i foo" +checkXargsDashi = CommandCheck (Basename "xargs") f + where + f t = sequence_ $ do + opts <- parseOpts $ arguments t + (option, value) <- lookup "i" opts + return $ info (getId option) 2267 "GNU xargs -i is deprecated in favor of -I{}" + parseOpts = getBsdOpts "0oprtxadR:S:J:L:l:n:P:s:e:E:i:I:" + + +prop_checkArgComparison1 = verify (checkArgComparison "declare") "declare a = b" +prop_checkArgComparison2 = verify (checkArgComparison "declare") "declare a =b" +prop_checkArgComparison3 = verifyNot (checkArgComparison "declare") "declare a=b" +prop_checkArgComparison4 = verify (checkArgComparison "export") "export a +=b" +prop_checkArgComparison7 = verifyNot (checkArgComparison "declare") "declare -a +i foo" +prop_checkArgComparison8 = verify (checkArgComparison "let") "let x = 0" +prop_checkArgComparison9 = verify (checkArgComparison "alias") "alias x =0" +-- This mirrors checkSecondArgIsComparison but for arguments to local/readonly/declare/export +checkArgComparison cmd = CommandCheck (Exactly cmd) wordsWithEqual + where + wordsWithEqual t = mapM_ check $ arguments t + check arg = do + sequence_ $ do + str <- getLeadingUnquotedString arg + case str of + '=':_ -> + return $ err (headId arg) 2290 $ + "Remove spaces around = to assign." + '+':'=':_ -> + return $ err (headId arg) 2290 $ + "Remove spaces around += to append." + _ -> Nothing + + -- 'let' is parsed as a sequence of arithmetic expansions, + -- so we want the additional warning for "x=" + when (cmd == "let") $ sequence_ $ do + token <- getTrailingUnquotedLiteral arg + str <- getLiteralString token + guard $ "=" `isSuffixOf` str + return $ err (getId token) 2290 $ + "Remove spaces around = to assign." + + headId t = + case t of + T_NormalWord _ (x:_) -> getId x + _ -> getId t + + +prop_checkMaskedReturns1 = verify (checkMaskedReturns "local") "f() { local a=$(false); }" +prop_checkMaskedReturns2 = verify (checkMaskedReturns "declare") "declare a=$(false)" +prop_checkMaskedReturns3 = verify (checkMaskedReturns "declare") "declare a=\"`false`\"" +prop_checkMaskedReturns4 = verify (checkMaskedReturns "readonly") "readonly a=$(false)" +prop_checkMaskedReturns5 = verify (checkMaskedReturns "readonly") "readonly a=\"`false`\"" +prop_checkMaskedReturns6 = verifyNot (checkMaskedReturns "declare") "declare a; a=$(false)" +prop_checkMaskedReturns7 = verifyNot (checkMaskedReturns "local") "f() { local -r a=$(false); }" +prop_checkMaskedReturns8 = verifyNot (checkMaskedReturns "readonly") "a=$(false); readonly a" +prop_checkMaskedReturns9 = verify (checkMaskedReturns "typeset") "#!/bin/ksh\n f() { typeset -r x=$(false); }" +prop_checkMaskedReturns10 = verifyNot (checkMaskedReturns "typeset") "#!/bin/ksh\n function f { typeset -r x=$(false); }" +prop_checkMaskedReturns11 = verifyNot (checkMaskedReturns "typeset") "#!/bin/bash\n f() { typeset -r x=$(false); }" +prop_checkMaskedReturns12 = verify (checkMaskedReturns "typeset") "typeset -r x=$(false);" +prop_checkMaskedReturns13 = verify (checkMaskedReturns "typeset") "f() { typeset -g x=$(false); }" +prop_checkMaskedReturns14 = verify (checkMaskedReturns "declare") "declare x=${ false; }" +prop_checkMaskedReturns15 = verify (checkMaskedReturns "declare") "f() { declare x=$(false); }" +checkMaskedReturns str = CommandCheck (Exactly str) checkCmd + where + checkCmd t = do + path <- getPathM t + shell <- asks shellType + sequence_ $ do + name <- getCommandName t + + let flags = map snd (getAllFlags t) + let hasDashR = "r" `elem` flags + let hasDashG = "g" `elem` flags + let isInScopedFunction = any (isScopedFunction shell) path + + let isLocal = not hasDashG && isLocalInFunction name && isInScopedFunction + let isReadOnly = name == "readonly" || hasDashR + + -- Don't warn about local variables that are declared readonly, + -- because the workaround `local x; x=$(false); local -r x;` is annoying + guard . not $ isLocal && isReadOnly + + return $ mapM_ checkArgs $ arguments t + + checkArgs (T_Assignment id _ _ _ word) | any hasReturn $ getWordParts word = + warn id 2155 "Declare and assign separately to avoid masking return values." + checkArgs _ = return () + + isLocalInFunction = (`elem` ["local", "declare", "typeset"]) + isScopedFunction shell t = + case t of + T_BatsTest {} -> True + -- In ksh, only functions declared with 'function' have their own scope + T_Function _ (FunctionKeyword hasFunction) _ _ _ -> shell /= Ksh || hasFunction + _ -> False + + hasReturn t = case t of + T_Backticked {} -> True + T_DollarExpansion {} -> True + T_DollarBraceCommandExpansion {} -> True + _ -> False + + +prop_checkUnquotedEchoSpaces1 = verify checkUnquotedEchoSpaces "echo foo bar" +prop_checkUnquotedEchoSpaces2 = verifyNot checkUnquotedEchoSpaces "echo foo" +prop_checkUnquotedEchoSpaces3 = verifyNot checkUnquotedEchoSpaces "echo foo bar" +prop_checkUnquotedEchoSpaces4 = verifyNot checkUnquotedEchoSpaces "echo 'foo bar'" +prop_checkUnquotedEchoSpaces5 = verifyNot checkUnquotedEchoSpaces "echo a > myfile.txt b" +prop_checkUnquotedEchoSpaces6 = verifyNot checkUnquotedEchoSpaces " echo foo\\\n bar" +checkUnquotedEchoSpaces = CommandCheck (Basename "echo") check + where + check t = do + let args = arguments t + m <- asks tokenPositions + redir <- getClosestCommandM t + sequence_ $ do + let positions = mapMaybe (\c -> M.lookup (getId c) m) args + let pairs = zip positions (drop 1 positions) + (T_Redirecting _ redirTokens _) <- redir + let redirPositions = mapMaybe (\c -> fst <$> M.lookup (getId c) m) redirTokens + guard $ any (hasSpacesBetween redirPositions) pairs + return $ info (getId t) 2291 "Quote repeated spaces to avoid them collapsing into one." + + hasSpacesBetween redirs ((a,b), (c,d)) = + posLine a == posLine d + && ((posColumn c) - (posColumn b)) >= 4 + && not (any (\x -> b < x && x < c) redirs) + + +prop_checkEvalArray1 = verify checkEvalArray "eval $@" +prop_checkEvalArray2 = verify checkEvalArray "eval \"${args[@]}\"" +prop_checkEvalArray3 = verify checkEvalArray "eval \"${args[@]@Q}\"" +prop_checkEvalArray4 = verifyNot checkEvalArray "eval \"${args[*]@Q}\"" +prop_checkEvalArray5 = verifyNot checkEvalArray "eval \"$*\"" +checkEvalArray = CommandCheck (Exactly "eval") (mapM_ check . concatMap getWordParts . arguments) + where + check t = + when (isArrayExpansion t) $ + if isEscaped t + then style (getId t) 2293 "When eval'ing @Q-quoted words, use * rather than @ as the index." + else warn (getId t) 2294 "eval negates the benefit of arrays. Drop eval to preserve whitespace/symbols (or eval as string)." + + isEscaped q = + case q of + -- Match ${arr[@]@Q} and ${@@Q} and such + T_DollarBraced _ _ l -> 'Q' `elem` getBracedModifier (concat $ oversimplify l) + _ -> False + + +prop_checkBackreferencingDeclaration1 = verify (checkBackreferencingDeclaration "declare") "declare x=1 y=foo$x" +prop_checkBackreferencingDeclaration2 = verify (checkBackreferencingDeclaration "readonly") "readonly x=1 y=$((1+x))" +prop_checkBackreferencingDeclaration3 = verify (checkBackreferencingDeclaration "local") "local x=1 y=$(echo $x)" +prop_checkBackreferencingDeclaration4 = verify (checkBackreferencingDeclaration "local") "local x=1 y[$x]=z" +prop_checkBackreferencingDeclaration5 = verify (checkBackreferencingDeclaration "declare") "declare x=var $x=1" +prop_checkBackreferencingDeclaration6 = verify (checkBackreferencingDeclaration "declare") "declare x=var $x=1" +prop_checkBackreferencingDeclaration7 = verify (checkBackreferencingDeclaration "declare") "declare x=var $k=$x" +checkBackreferencingDeclaration cmd = CommandCheck (Exactly cmd) check + where + check t = do + maybeCfga <- asks cfgAnalysis + mapM_ (\cfga -> foldM_ (perArg cfga) M.empty $ arguments t) maybeCfga + + perArg cfga leftArgs t = + case t of + T_Assignment id _ name idx t -> do + warnIfBackreferencing cfga leftArgs $ t:idx + return $ M.insert name id leftArgs + t -> do + warnIfBackreferencing cfga leftArgs [t] + return leftArgs + + warnIfBackreferencing cfga backrefs l = do + references <- findReferences cfga l + let reused = M.intersection backrefs references + mapM msg $ M.toList reused + + msg (name, id) = warn id 2318 $ "This assignment is used again in this '" ++ cmd ++ "', but won't have taken effect. Use two '" ++ cmd ++ "'s." + + findReferences cfga list = do + let graph = CF.graph cfga + let nodesMap = CF.tokenToNodes cfga + let nodes = S.unions $ map (\id -> M.findWithDefault S.empty id nodesMap) $ map getId $ list + let labels = mapMaybe (G.lab graph) $ S.toList nodes + let references = M.fromList $ concatMap refFromLabel labels + return references + + refFromLabel lab = + case lab of + CFApplyEffects effects -> mapMaybe refFromEffect effects + _ -> [] + refFromEffect e = + case e of + IdTagged id (CFReadVariable name) -> return (name, id) + _ -> Nothing + + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/Checks/ControlFlow.hs b/src/ShellCheck/Checks/ControlFlow.hs new file mode 100644 index 0000000..9f63141 --- /dev/null +++ b/src/ShellCheck/Checks/ControlFlow.hs @@ -0,0 +1,101 @@ +{- + Copyright 2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} + +-- Checks that run on the Control Flow Graph (as opposed to the AST) +-- This is scaffolding for a work in progress. + +module ShellCheck.Checks.ControlFlow (checker, optionalChecks, ShellCheck.Checks.ControlFlow.runTests) where + +import ShellCheck.AST +import ShellCheck.ASTLib +import ShellCheck.CFG hiding (cfgAnalysis) +import ShellCheck.CFGAnalysis +import ShellCheck.AnalyzerLib +import ShellCheck.Data +import ShellCheck.Interface + +import Control.Monad +import Control.Monad.Reader +import Data.Graph.Inductive.Graph +import qualified Data.Map as M +import qualified Data.Set as S +import Data.List +import Data.Maybe + +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (quickCheckWithResult, stdArgs, maxSuccess) + + +optionalChecks :: [CheckDescription] +optionalChecks = [] + +-- A check that runs on the entire graph +type ControlFlowCheck = Analysis +-- A check invoked once per node, with its (pre,post) data +type ControlFlowNodeCheck = LNode CFNode -> (ProgramState, ProgramState) -> Analysis +-- A check invoked once per effect, with its node's (pre,post) data +type ControlFlowEffectCheck = IdTagged CFEffect -> Node -> (ProgramState, ProgramState) -> Analysis + + +checker :: AnalysisSpec -> Parameters -> Checker +checker spec params = Checker { + perScript = const $ sequence_ controlFlowChecks, + perToken = const $ return () +} + +controlFlowChecks :: [ControlFlowCheck] +controlFlowChecks = [ + runNodeChecks controlFlowNodeChecks + ] + +controlFlowNodeChecks :: [ControlFlowNodeCheck] +controlFlowNodeChecks = [ + runEffectChecks controlFlowEffectChecks + ] + +controlFlowEffectChecks :: [ControlFlowEffectCheck] +controlFlowEffectChecks = [ + ] + +runNodeChecks :: [ControlFlowNodeCheck] -> ControlFlowCheck +runNodeChecks perNode = do + cfg <- asks cfgAnalysis + mapM_ runOnAll cfg + where + getData datas n@(node, label) = do + (pre, post) <- M.lookup node datas + return (n, (pre, post)) + + runOn :: (LNode CFNode, (ProgramState, ProgramState)) -> Analysis + runOn (node, prepost) = mapM_ (\c -> c node prepost) perNode + runOnAll cfg = mapM_ runOn $ mapMaybe (getData $ nodeToData cfg) $ labNodes (graph cfg) + +runEffectChecks :: [ControlFlowEffectCheck] -> ControlFlowNodeCheck +runEffectChecks list = checkNode + where + checkNode (node, label) prepost = + case label of + CFApplyEffects effects -> mapM_ (\effect -> mapM_ (\c -> c effect node prepost) list) effects + _ -> return () + + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/Checks/Custom.hs b/src/ShellCheck/Checks/Custom.hs new file mode 100644 index 0000000..17e9c9e --- /dev/null +++ b/src/ShellCheck/Checks/Custom.hs @@ -0,0 +1,21 @@ +{- + This empty file is provided for ease of patching in site specific checks. + However, there are no guarantees regarding compatibility between versions. +-} + +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Checks.Custom (checker, ShellCheck.Checks.Custom.runTests) where + +import ShellCheck.AnalyzerLib +import Test.QuickCheck + +checker :: Parameters -> Checker +checker params = Checker { + perScript = const $ return (), + perToken = const $ return () + } + +prop_CustomTestsWork = True + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Checks/ShellSupport.hs b/src/ShellCheck/Checks/ShellSupport.hs new file mode 100644 index 0000000..b664879 --- /dev/null +++ b/src/ShellCheck/Checks/ShellSupport.hs @@ -0,0 +1,701 @@ +{- + Copyright 2012-2020 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE ViewPatterns #-} +module ShellCheck.Checks.ShellSupport (checker , ShellCheck.Checks.ShellSupport.runTests) where + +import ShellCheck.AST +import ShellCheck.ASTLib +import ShellCheck.AnalyzerLib +import ShellCheck.Interface +import ShellCheck.Prelude +import ShellCheck.Regex + +import Control.Monad +import Control.Monad.RWS +import Data.Char +import Data.Functor.Identity +import Data.List +import Data.Maybe +import qualified Data.Map as Map +import qualified Data.Set as Set +import Test.QuickCheck.All (forAllProperties) +import Test.QuickCheck.Test (quickCheckWithResult, stdArgs, maxSuccess) + +data ForShell = ForShell [Shell] (Token -> Analysis) + +getChecker params list = Checker { + perScript = nullCheck, + perToken = foldl composeAnalyzers nullCheck $ mapMaybe include list + } + where + shell = shellType params + include (ForShell list a) = do + guard $ shell `elem` list + return a + +checker params = getChecker params checks + +checks = [ + checkForDecimals + ,checkBashisms + ,checkEchoSed + ,checkBraceExpansionVars + ,checkMultiDimensionalArrays + ,checkPS1Assignments + ,checkMultipleBangs + ,checkBangAfterPipe + ,checkNegatedUnaryOps + ] + +testChecker (ForShell _ t) = + Checker { + perScript = nullCheck, + perToken = t + } +verify c s = producesComments (testChecker c) s == Just True +verifyNot c s = producesComments (testChecker c) s == Just False + +prop_checkForDecimals1 = verify checkForDecimals "((3.14*c))" +prop_checkForDecimals2 = verify checkForDecimals "foo[1.2]=bar" +prop_checkForDecimals3 = verifyNot checkForDecimals "declare -A foo; foo[1.2]=bar" +checkForDecimals = ForShell [Sh, Dash, BusyboxSh, Bash] f + where + f t@(TA_Expansion id _) = sequence_ $ do + first:rest <- getLiteralString t + guard $ isDigit first && '.' `elem` rest + return $ err id 2079 "(( )) doesn't support decimals. Use bc or awk." + f _ = return () + + +prop_checkBashisms = verify checkBashisms "while read a; do :; done < <(a)" +prop_checkBashisms2 = verifyNot checkBashisms "[ foo -nt bar ]" +prop_checkBashisms3 = verify checkBashisms "echo $((i++))" +prop_checkBashisms4 = verify checkBashisms "rm !(*.hs)" +prop_checkBashisms5 = verify checkBashisms "source file" +prop_checkBashisms6 = verify checkBashisms "[ \"$a\" == 42 ]" +prop_checkBashisms6b = verify checkBashisms "test \"$a\" == 42" +prop_checkBashisms6c = verify checkBashisms "[ foo =~ bar ]" +prop_checkBashisms6d = verify checkBashisms "test foo =~ bar" +prop_checkBashisms7 = verify checkBashisms "echo ${var[1]}" +prop_checkBashisms8 = verify checkBashisms "echo ${!var[@]}" +prop_checkBashisms9 = verify checkBashisms "echo ${!var*}" +prop_checkBashisms10 = verify checkBashisms "echo ${var:4:12}" +prop_checkBashisms11 = verifyNot checkBashisms "echo ${var:-4}" +prop_checkBashisms12 = verify checkBashisms "echo ${var//foo/bar}" +prop_checkBashisms13 = verify checkBashisms "exec -c env" +prop_checkBashisms14 = verify checkBashisms "echo -n \"Foo: \"" +prop_checkBashisms15 = verify checkBashisms "let n++" +prop_checkBashisms16 = verify checkBashisms "echo $RANDOM" +prop_checkBashisms17 = verify checkBashisms "echo $((RANDOM%6+1))" +prop_checkBashisms18 = verify checkBashisms "foo &> /dev/null" +prop_checkBashisms19 = verify checkBashisms "foo > file*.txt" +prop_checkBashisms20 = verify checkBashisms "read -ra foo" +prop_checkBashisms21 = verify checkBashisms "[ -a foo ]" +prop_checkBashisms21b = verify checkBashisms "test -a foo" +prop_checkBashisms22 = verifyNot checkBashisms "[ foo -a bar ]" +prop_checkBashisms23 = verify checkBashisms "trap mything ERR INT" +prop_checkBashisms24 = verifyNot checkBashisms "trap mything INT TERM" +prop_checkBashisms25 = verify checkBashisms "cat < /dev/tcp/host/123" +prop_checkBashisms26 = verify checkBashisms "trap mything ERR SIGTERM" +prop_checkBashisms27 = verify checkBashisms "echo *[^0-9]*" +prop_checkBashisms28 = verify checkBashisms "exec {n}>&2" +prop_checkBashisms29 = verify checkBashisms "echo ${!var}" +prop_checkBashisms30 = verify checkBashisms "printf -v '%s' \"$1\"" +prop_checkBashisms31 = verify checkBashisms "printf '%q' \"$1\"" +prop_checkBashisms32 = verifyNot checkBashisms "#!/bin/dash\n[ foo -nt bar ]" +prop_checkBashisms33 = verify checkBashisms "#!/bin/sh\necho -n foo" +prop_checkBashisms34 = verifyNot checkBashisms "#!/bin/dash\necho -n foo" +prop_checkBashisms35 = verifyNot checkBashisms "#!/bin/dash\nlocal foo" +prop_checkBashisms36 = verifyNot checkBashisms "#!/bin/dash\nread -p foo -r bar" +prop_checkBashisms37 = verifyNot checkBashisms "HOSTNAME=foo; echo $HOSTNAME" +prop_checkBashisms38 = verify checkBashisms "RANDOM=9; echo $RANDOM" +prop_checkBashisms39 = verify checkBashisms "foo-bar() { true; }" +prop_checkBashisms40 = verify checkBashisms "echo $(/dev/null" +prop_checkBashisms48 = verifyNot checkBashisms "#!/bin/sh\necho $LINENO" +prop_checkBashisms49 = verify checkBashisms "#!/bin/dash\necho $MACHTYPE" +prop_checkBashisms50 = verify checkBashisms "#!/bin/sh\ncmd >& file" +prop_checkBashisms51 = verifyNot checkBashisms "#!/bin/sh\ncmd 2>&1" +prop_checkBashisms52 = verifyNot checkBashisms "#!/bin/sh\ncmd >&2" +prop_checkBashisms52b = verifyNot checkBashisms "#!/bin/sh\ncmd >& $var" +prop_checkBashisms52c = verify checkBashisms "#!/bin/sh\ncmd >& $dir/$var" +prop_checkBashisms53 = verifyNot checkBashisms "#!/bin/sh\nprintf -- -f\n" +prop_checkBashisms54 = verify checkBashisms "#!/bin/sh\nfoo+=bar" +prop_checkBashisms55 = verify checkBashisms "#!/bin/sh\necho ${@%foo}" +prop_checkBashisms56 = verifyNot checkBashisms "#!/bin/sh\necho ${##}" +prop_checkBashisms57 = verifyNot checkBashisms "#!/bin/dash\nulimit -c 0" +prop_checkBashisms58 = verify checkBashisms "#!/bin/sh\nulimit -c 0" +prop_checkBashisms59 = verify checkBashisms "#!/bin/sh\njobs -s" +prop_checkBashisms60 = verifyNot checkBashisms "#!/bin/sh\njobs -p" +prop_checkBashisms61 = verifyNot checkBashisms "#!/bin/sh\njobs -lp" +prop_checkBashisms62 = verify checkBashisms "#!/bin/sh\nexport -f foo" +prop_checkBashisms63 = verifyNot checkBashisms "#!/bin/sh\nexport -p" +prop_checkBashisms64 = verify checkBashisms "#!/bin/sh\nreadonly -a" +prop_checkBashisms65 = verifyNot checkBashisms "#!/bin/sh\nreadonly -p" +prop_checkBashisms66 = verifyNot checkBashisms "#!/bin/sh\ncd -P ." +prop_checkBashisms67 = verify checkBashisms "#!/bin/sh\ncd -P -e ." +prop_checkBashisms68 = verify checkBashisms "#!/bin/sh\numask -p" +prop_checkBashisms69 = verifyNot checkBashisms "#!/bin/sh\numask -S" +prop_checkBashisms70 = verify checkBashisms "#!/bin/sh\ntrap -l" +prop_checkBashisms71 = verify checkBashisms "#!/bin/sh\ntype -a ls" +prop_checkBashisms72 = verifyNot checkBashisms "#!/bin/sh\ntype ls" +prop_checkBashisms73 = verify checkBashisms "#!/bin/sh\nunset -n namevar" +prop_checkBashisms74 = verifyNot checkBashisms "#!/bin/sh\nunset -f namevar" +prop_checkBashisms75 = verifyNot checkBashisms "#!/bin/sh\necho \"-n foo\"" +prop_checkBashisms76 = verifyNot checkBashisms "#!/bin/sh\necho \"-ne foo\"" +prop_checkBashisms77 = verifyNot checkBashisms "#!/bin/sh\necho -Q foo" +prop_checkBashisms78 = verify checkBashisms "#!/bin/sh\necho -ne foo" +prop_checkBashisms79 = verify checkBashisms "#!/bin/sh\nhash -l" +prop_checkBashisms80 = verifyNot checkBashisms "#!/bin/sh\nhash -r" +prop_checkBashisms81 = verifyNot checkBashisms "#!/bin/dash\nhash -v" +prop_checkBashisms82 = verifyNot checkBashisms "#!/bin/sh\nset -v +o allexport -o errexit -C" +prop_checkBashisms83 = verifyNot checkBashisms "#!/bin/sh\nset --" +prop_checkBashisms84 = verify checkBashisms "#!/bin/sh\nset -o pipefail" +prop_checkBashisms85 = verify checkBashisms "#!/bin/sh\nset -B" +prop_checkBashisms86 = verifyNot checkBashisms "#!/bin/dash\nset -o emacs" +prop_checkBashisms87 = verify checkBashisms "#!/bin/sh\nset -o emacs" +prop_checkBashisms88 = verifyNot checkBashisms "#!/bin/sh\nset -- wget -o foo 'https://some.url'" +prop_checkBashisms89 = verifyNot checkBashisms "#!/bin/sh\nopts=$-\nset -\"$opts\"" +prop_checkBashisms90 = verifyNot checkBashisms "#!/bin/sh\nset -o \"$opt\"" +prop_checkBashisms91 = verify checkBashisms "#!/bin/sh\nwait -n" +prop_checkBashisms92 = verify checkBashisms "#!/bin/sh\necho $((16#FF))" +prop_checkBashisms93 = verify checkBashisms "#!/bin/sh\necho $(( 10#$(date +%m) ))" +prop_checkBashisms94 = verify checkBashisms "#!/bin/sh\n[ -v var ]" +prop_checkBashisms95 = verify checkBashisms "#!/bin/sh\necho $_" +prop_checkBashisms96 = verifyNot checkBashisms "#!/bin/dash\necho $_" +prop_checkBashisms97 = verify checkBashisms "#!/bin/sh\necho ${var,}" +prop_checkBashisms98 = verify checkBashisms "#!/bin/sh\necho ${var^^}" +prop_checkBashisms99 = verify checkBashisms "#!/bin/dash\necho [^f]oo" +prop_checkBashisms100 = verify checkBashisms "read -r" +prop_checkBashisms101 = verify checkBashisms "read" +prop_checkBashisms102 = verifyNot checkBashisms "read -r foo" +prop_checkBashisms103 = verifyNot checkBashisms "read foo" +prop_checkBashisms104 = verifyNot checkBashisms "read ''" +prop_checkBashisms105 = verifyNot checkBashisms "#!/bin/busybox sh\nset -o pipefail" +prop_checkBashisms106 = verifyNot checkBashisms "#!/bin/busybox sh\nx=x\n[[ \"$x\" = \"$x\" ]]" +prop_checkBashisms107 = verifyNot checkBashisms "#!/bin/busybox sh\nx=x\n[ \"$x\" == \"$x\" ]" +prop_checkBashisms108 = verifyNot checkBashisms "#!/bin/busybox sh\necho magic &> /dev/null" +prop_checkBashisms109 = verifyNot checkBashisms "#!/bin/busybox sh\ntrap stop EXIT SIGTERM" +prop_checkBashisms110 = verifyNot checkBashisms "#!/bin/busybox sh\nsource /dev/null" +prop_checkBashisms111 = verify checkBashisms "#!/bin/dash\nx='test'\n${x:0:3}" -- SC3057 +prop_checkBashisms112 = verifyNot checkBashisms "#!/bin/busybox sh\nx='test'\n${x:0:3}" -- SC3057 +prop_checkBashisms113 = verify checkBashisms "#!/bin/dash\nx='test'\n${x/st/xt}" -- SC3060 +prop_checkBashisms114 = verifyNot checkBashisms "#!/bin/busybox sh\nx='test'\n${x/st/xt}" -- SC3060 +prop_checkBashisms115 = verify checkBashisms "#!/bin/busybox sh\nx='test'\n${!x}" -- SC3053 +prop_checkBashisms116 = verify checkBashisms "#!/bin/busybox sh\nx='test'\n${x[1]}" -- SC3054 +prop_checkBashisms117 = verify checkBashisms "#!/bin/busybox sh\nx='test'\n${!x[@]}" -- SC3055 +prop_checkBashisms118 = verify checkBashisms "#!/bin/busybox sh\nxyz=1\n${!x*}" -- SC3056 +prop_checkBashisms119 = verify checkBashisms "#!/bin/busybox sh\nx='test'\n${x^^[t]}" -- SC3059 +prop_checkBashisms120 = verify checkBashisms "#!/bin/sh\n[ x == y ]" +prop_checkBashisms121 = verifyNot checkBashisms "#!/bin/sh\n# shellcheck shell=busybox\n[ x == y ]" +prop_checkBashisms122 = verify checkBashisms "#!/bin/dash\n$'a'" +prop_checkBashisms123 = verifyNot checkBashisms "#!/bin/busybox sh\n$'a'" +prop_checkBashisms124 = verify checkBashisms "#!/bin/dash\ntype -p test" +prop_checkBashisms125 = verifyNot checkBashisms "#!/bin/busybox sh\ntype -p test" +prop_checkBashisms126 = verifyNot checkBashisms "#!/bin/busybox sh\nread -p foo -r bar" +prop_checkBashisms127 = verifyNot checkBashisms "#!/bin/busybox sh\necho -ne foo" +prop_checkBashisms128 = verify checkBashisms "#!/bin/dash\ntype -p test" +prop_checkBashisms129 = verify checkBashisms "#!/bin/sh\n[ -k /tmp ]" +prop_checkBashisms130 = verifyNot checkBashisms "#!/bin/dash\ntest -k /tmp" +prop_checkBashisms131 = verify checkBashisms "#!/bin/sh\n[ -o errexit ]" +checkBashisms = ForShell [Sh, Dash, BusyboxSh] $ \t -> do + params <- ask + kludge params t + where + -- This code was copy-pasted from Analytics where params was a variable + kludge params = bashism + where + isBusyboxSh = shellType params == BusyboxSh + isDash = shellType params == Dash || isBusyboxSh + warnMsg id code s = + if isDash + then err id code $ "In dash, " ++ s ++ " not supported." + else warn id code $ "In POSIX sh, " ++ s ++ " undefined." + asStr = getLiteralString + + bashism (T_ProcSub id _ _) = warnMsg id 3001 "process substitution is" + bashism (T_Extglob id _ _) = warnMsg id 3002 "extglob is" + bashism (T_DollarSingleQuoted id _) = + unless isBusyboxSh $ warnMsg id 3003 "$'..' is" + bashism (T_DollarDoubleQuoted id _) = warnMsg id 3004 "$\"..\" is" + bashism (T_ForArithmetic id _ _ _ _) = warnMsg id 3005 "arithmetic for loops are" + bashism (T_Arithmetic id _) = warnMsg id 3006 "standalone ((..)) is" + bashism (T_DollarBracket id _) = warnMsg id 3007 "$[..] in place of $((..)) is" + bashism (T_SelectIn id _ _ _) = warnMsg id 3008 "select loops are" + bashism (T_BraceExpansion id _) = warnMsg id 3009 "brace expansion is" + bashism (T_Condition id DoubleBracket _) = + unless isBusyboxSh $ warnMsg id 3010 "[[ ]] is" + bashism (T_HereString id _) = warnMsg id 3011 "here-strings are" + + bashism (TC_Binary id _ op _ _) = + checkTestOp bashismBinaryTestFlags op id + bashism (T_SimpleCommand id _ [asStr -> Just "test", lhs, asStr -> Just op, rhs]) = + checkTestOp bashismBinaryTestFlags op id + bashism (TC_Unary id _ op _) = + checkTestOp bashismUnaryTestFlags op id + bashism (T_SimpleCommand id _ [asStr -> Just "test", asStr -> Just op, _]) = + checkTestOp bashismUnaryTestFlags op id + + bashism (TA_Unary id op _) + | op `elem` [ "|++", "|--", "++|", "--|"] = + warnMsg id 3018 $ filter (/= '|') op ++ " is" + bashism (TA_Binary id "**" _ _) = warnMsg id 3019 "exponentials are" + bashism (T_FdRedirect id "&" (T_IoFile _ (T_Greater _) _)) = + unless isBusyboxSh $ warnMsg id 3020 "&> is" + bashism (T_FdRedirect id "" (T_IoFile _ (T_GREATAND _) file)) = + unless (all isDigit $ onlyLiteralString file) $ warnMsg id 3021 ">& filename (as opposed to >& fd) is" + bashism (T_FdRedirect id ('{':_) _) = warnMsg id 3022 "named file descriptors are" + bashism (T_FdRedirect id num _) + | all isDigit num && length num > 1 = warnMsg id 3023 "FDs outside 0-9 are" + bashism (T_Assignment id Append _ _ _) = + warnMsg id 3024 "+= is" + bashism (T_IoFile id _ word) | isNetworked = + warnMsg id 3025 "/dev/{tcp,udp} is" + where + file = onlyLiteralString word + isNetworked = any (`isPrefixOf` file) ["/dev/tcp", "/dev/udp"] + bashism (T_Glob id str) | "[^" `isInfixOf` str = + warnMsg id 3026 "^ in place of ! in glob bracket expressions is" + + bashism t@(TA_Variable id str _) | isBashVariable str = + warnMsg id 3028 $ str ++ " is" + + bashism t@(T_DollarBraced id _ token) = do + unless isBusyboxSh $ mapM_ check simpleExpansions + mapM_ check advancedExpansions + when (isBashVariable var) $ + warnMsg id 3028 $ var ++ " is" + where + str = concat $ oversimplify token + var = getBracedReference str + check (regex, code, feature) = + when (isJust $ matchRegex regex str) $ warnMsg id code feature + + bashism t@(T_Pipe id "|&") = + warnMsg id 3029 "|& in place of 2>&1 | is" + bashism (T_Array id _) = + warnMsg id 3030 "arrays are" + bashism (T_IoFile id _ t) | isGlob t = + warnMsg id 3031 "redirecting to/from globs is" + bashism (T_CoProc id _ _) = + warnMsg id 3032 "coproc is" + + bashism (T_Function id _ _ str _) | not (isVariableName str) = + warnMsg id 3033 "naming functions outside [a-zA-Z_][a-zA-Z0-9_]* is" + + bashism (T_DollarExpansion id [x]) | isOnlyRedirection x = + warnMsg id 3034 "$( [(Id, String)] + getLiteralArgs = foldr go [] + where + go first rest = case getLiteralString first of + Just str -> (getId first, str) : rest + Nothing -> [] + + -- Check a flag-option pair (such as -o errexit) + checkOptions (flag@(fid,flag') : opt@(oid,opt') : rest) + | flag' `matches` oFlagRegex = do + when (opt' `notElem` longOptions) $ + warnMsg oid 3040 $ "set option " <> opt' <> " is" + checkFlags (flag:rest) + | otherwise = checkFlags (flag:opt:rest) + checkOptions (flag:rest) = checkFlags (flag:rest) + checkOptions _ = return () + + -- Check that each option in a sequence of flags + -- (such as -aveo) is valid + checkFlags (flag@(fid, flag'):rest) + | startsOption flag' = do + unless (flag' `matches` validFlagsRegex) $ + forM_ (tail flag') $ \letter -> + when (letter `notElem` optionsSet) $ + warnMsg fid 3041 $ "set flag " <> ('-':letter:" is") + checkOptions rest + | beginsWithDoubleDash flag' = do + warnMsg fid 3042 $ "set flag " <> flag' <> " is" + checkOptions rest + -- Either a word that doesn't start with a dash, or simply '--', + -- so stop checking. + | otherwise = return () + checkFlags [] = return () + + options = "abCefhmnuvxo" + optionsSet = Set.fromList options + startsOption = (`matches` mkRegex "^(\\+|-[^-])") + oFlagRegex = mkRegex $ "^[-+][" <> options <> "]*o$" + validFlagsRegex = mkRegex $ "^[-+]([" <> options <> "]+o?|o)$" + beginsWithDoubleDash = (`matches` mkRegex "^--.+$") + longOptions = Set.fromList + [ "allexport", "errexit", "ignoreeof", "monitor", "noclobber" + , "noexec", "noglob", "nolog", "notify" , "nounset", "verbose" + , "vi", "xtrace" ] + + bashism t@(T_SimpleCommand id _ (cmd:rest)) = + let name = fromMaybe "" $ getCommandName t + flags = getLeadingFlags t + in do + when (name == "local" && not isDash) $ + -- This is so commonly accepted that we'll make it a special case + warnMsg id 3043 $ "'local' is" + when (name `elem` unsupportedCommands) $ + warnMsg id 3044 $ "'" ++ name ++ "' is" + sequence_ $ do + allowed' <- Map.lookup name allowedFlags + allowed <- allowed' + (word, flag) <- find + (\x -> (not . null . snd $ x) && snd x `notElem` allowed) flags + return . warnMsg (getId word) 3045 $ name ++ " -" ++ flag ++ " is" + + when (name == "source" && not isBusyboxSh) $ + warnMsg id 3046 "'source' in place of '.' is" + when (name == "trap") $ + let + check token = sequence_ $ do + str <- getLiteralString token + let upper = map toUpper str + return $ do + when (upper `elem` ["ERR", "DEBUG", "RETURN"]) $ + warnMsg (getId token) 3047 $ "trapping " ++ str ++ " is" + when (not isBusyboxSh && "SIG" `isPrefixOf` upper) $ + warnMsg (getId token) 3048 + "prefixing signal names with 'SIG' is" + when (not isDash && upper /= str) $ + warnMsg (getId token) 3049 + "using lower/mixed case for signal names is" + in + mapM_ check (drop 1 rest) + + when (name == "printf") $ sequence_ $ do + format <- rest !!! 0 -- flags are covered by allowedFlags + let literal = onlyLiteralString format + guard $ "%q" `isInfixOf` literal + return $ warnMsg (getId format) 3050 "printf %q is" + + when (name == "read" && all isFlag rest) $ + warnMsg (getId cmd) 3061 "read without a variable is" + where + unsupportedCommands = [ + "let", "caller", "builtin", "complete", "compgen", "declare", "dirs", "disown", + "enable", "mapfile", "readarray", "pushd", "popd", "shopt", "suspend", + "typeset" + ] + allowedFlags = Map.fromList [ + ("cd", Just ["L", "P"]), + ("exec", Just []), + ("export", Just ["p"]), + ("hash", Just $ if isDash then ["r", "v"] else ["r"]), + ("jobs", Just ["l", "p"]), + ("printf", Just []), + ("read", Just $ if isDash || isBusyboxSh then ["r", "p"] else ["r"]), + ("readonly", Just ["p"]), + ("trap", Just []), + ("type", Just $ if isBusyboxSh then ["p"] else []), + ("ulimit", if isDash then Nothing else Just ["f"]), + ("umask", Just ["S"]), + ("unset", Just ["f", "v"]), + ("wait", Just []) + ] + bashism t@(T_SourceCommand id src _) + | getCommandName src == Just "source" = + unless isBusyboxSh $ + warnMsg id 3051 "'source' in place of '.' is" + bashism (TA_Expansion _ (T_Literal id str : _)) + | str `matches` radix = warnMsg id 3052 "arithmetic base conversion is" + where + radix = mkRegex "^[0-9]+#" + bashism _ = return () + + varChars="_0-9a-zA-Z" + advancedExpansions = let re = mkRegex in [ + (re $ "^![" ++ varChars ++ "]", 3053, "indirect expansion is"), + (re $ "^[" ++ varChars ++ "]+\\[.*\\]$", 3054, "array references are"), + (re $ "^![" ++ varChars ++ "]+\\[[*@]]$", 3055, "array key expansion is"), + (re $ "^![" ++ varChars ++ "]+[*@]$", 3056, "name matching prefixes are"), + (re $ "^[" ++ varChars ++ "*@]+(\\[.*\\])?[,^]", 3059, "case modification is") + ] + simpleExpansions = let re = mkRegex in [ + (re $ "^[" ++ varChars ++ "*@]+:[^-=?+]", 3057, "string indexing is"), + (re $ "^([*@][%#]|#[@*])", 3058, "string operations on $@/$* are"), + (re $ "^[" ++ varChars ++ "*@]+(\\[.*\\])?/", 3060, "string replacement is") + ] + bashVars = [ + -- This list deliberately excludes $BASH_VERSION as it's often used + -- for shell identification. + "OSTYPE", "MACHTYPE", "HOSTTYPE", "HOSTNAME", + "DIRSTACK", "EUID", "UID", "SHLVL", "PIPESTATUS", "SHELLOPTS", + "_", "BASHOPTS", "BASHPID", "BASH_ALIASES", "BASH_ARGC", + "BASH_ARGV", "BASH_ARGV0", "BASH_CMDS", "BASH_COMMAND", + "BASH_EXECUTION_STRING", "BASH_LINENO", "BASH_REMATCH", "BASH_SOURCE", + "BASH_SUBSHELL", "BASH_VERSINFO", "EPOCHREALTIME", "EPOCHSECONDS", + "FUNCNAME", "GROUPS", "MACHTYPE", "MAPFILE" + ] + bashDynamicVars = [ "RANDOM", "SECONDS" ] + dashVars = [ "_" ] + isBashVariable var = + (var `elem` bashDynamicVars + || var `elem` bashVars && not (isAssigned var)) + && not (isDash && var `elem` dashVars) + isAssigned var = any f (variableFlow params) + where + f x = case x of + Assignment (_, _, name, _) -> name == var + _ -> False + + checkTestOp table op id = sequence_ $ do + (code, shells, msg) <- Map.lookup op table + guard . not $ shellType params `elem` shells + return $ warnMsg id code (msg op) + + +buildTestFlagMap list = Map.fromList $ concatMap (\(x,y) -> map (\c -> (c,y)) x) list +bashismBinaryTestFlags = buildTestFlagMap [ + -- ([list of applicable flags], + -- (error code, exempt shells, message builder :: String -> String)), + -- + -- Distinct error codes allow the wiki to give more helpful, targeted + -- information. + (["<", ">", "\\<", "\\>", "<=", ">=", "\\<=", "\\>="], + (3012, [Dash, BusyboxSh], \op -> "lexicographical " ++ op ++ " is")), + (["=="], + (3014, [BusyboxSh], \op -> op ++ " in place of = is")), + (["=~"], + (3015, [], \op -> op ++ " regex matching is")), + + ([], (0,[],const "")) + ] +bashismUnaryTestFlags = buildTestFlagMap [ + (["-v"], + (3016, [], \op -> "test " ++ op ++ " (in place of [ -n \"${var+x}\" ]) is")), + (["-a"], + (3017, [], \op -> "unary " ++ op ++ " in place of -e is")), + (["-o"], + (3062, [], \op -> "test " ++ op ++ " to check options is")), + (["-R"], + (3063, [], \op -> "test " ++ op ++ " and namerefs in general are")), + (["-N"], + (3064, [], \op -> "test " ++ op ++ " is")), + (["-k"], + (3065, [Dash, BusyboxSh], \op -> "test " ++ op ++ " is")), + (["-G"], + (3066, [Dash, BusyboxSh], \op -> "test " ++ op ++ " is")), + (["-O"], + (3067, [Dash, BusyboxSh], \op -> "test " ++ op ++ " is")), + + ([], (0,[],const "")) + ] + + +prop_checkEchoSed1 = verify checkEchoSed "FOO=$(echo \"$cow\" | sed 's/foo/bar/g')" +prop_checkEchoSed1b = verify checkEchoSed "FOO=$(sed 's/foo/bar/g' <<< \"$cow\")" +prop_checkEchoSed2 = verify checkEchoSed "rm $(echo $cow | sed -e 's,foo,bar,')" +prop_checkEchoSed2b = verify checkEchoSed "rm $(sed -e 's,foo,bar,' <<< $cow)" +checkEchoSed = ForShell [Bash, Ksh] f + where + f (T_Redirecting id lefts r) = + when (any redirectHereString lefts) $ + checkSed id rcmd + where + redirectHereString :: Token -> Bool + redirectHereString t = case t of + (T_FdRedirect _ _ T_HereString{}) -> True + _ -> False + rcmd = oversimplify r + + f (T_Pipeline id _ [a, b]) = + when (acmd == ["echo", "${VAR}"]) $ + checkSed id bcmd + where + acmd = oversimplify a + bcmd = oversimplify b + + f _ = return () + + checkSed id ["sed", v] = checkIn id v + checkSed id ["sed", "-e", v] = checkIn id v + checkSed _ _ = return () + + -- This should have used backreferences, but TDFA doesn't support them + sedRe = mkRegex "^s(.)([^\n]*)g?$" + isSimpleSed s = isJust $ do + [h:_,rest] <- matchRegex sedRe s + let delimiters = filter (== h) rest + guard $ length delimiters == 2 + checkIn id s = + when (isSimpleSed s) $ + style id 2001 "See if you can use ${variable//search/replace} instead." + + +prop_checkBraceExpansionVars1 = verify checkBraceExpansionVars "echo {1..$n}" +prop_checkBraceExpansionVars2 = verifyNot checkBraceExpansionVars "echo {1,3,$n}" +prop_checkBraceExpansionVars3 = verify checkBraceExpansionVars "eval echo DSC{0001..$n}.jpg" +prop_checkBraceExpansionVars4 = verify checkBraceExpansionVars "echo {$i..100}" +checkBraceExpansionVars = ForShell [Bash] f + where + f t@(T_BraceExpansion id list) = mapM_ check list + where + check element = + when (any (`isInfixOf` toString element) ["$..", "..$"]) $ do + c <- isEvaled element + if c + then style id 2175 "Quote this invalid brace expansion since it should be passed literally to eval." + else warn id 2051 "Bash doesn't support variables in brace range expansions." + f _ = return () + + literalExt t = + case t of + T_DollarBraced {} -> return "$" + T_DollarExpansion {} -> return "$" + T_DollarArithmetic {} -> return "$" + _ -> return "-" + toString t = runIdentity $ getLiteralStringExt literalExt t + isEvaled t = do + cmd <- getClosestCommandM t + return $ maybe False (`isUnqualifiedCommand` "eval") cmd + + +prop_checkMultiDimensionalArrays1 = verify checkMultiDimensionalArrays "foo[a][b]=3" +prop_checkMultiDimensionalArrays2 = verifyNot checkMultiDimensionalArrays "foo[a]=3" +prop_checkMultiDimensionalArrays3 = verify checkMultiDimensionalArrays "foo=( [a][b]=c )" +prop_checkMultiDimensionalArrays4 = verifyNot checkMultiDimensionalArrays "foo=( [a]=c )" +prop_checkMultiDimensionalArrays5 = verify checkMultiDimensionalArrays "echo ${foo[bar][baz]}" +prop_checkMultiDimensionalArrays6 = verifyNot checkMultiDimensionalArrays "echo ${foo[bar]}" +checkMultiDimensionalArrays = ForShell [Bash] f + where + f token = + case token of + T_Assignment _ _ name (first:second:_) _ -> about second + T_IndexedElement _ (first:second:_) _ -> about second + T_DollarBraced _ _ l -> + when (isMultiDim l) $ about token + _ -> return () + about t = warn (getId t) 2180 "Bash does not support multidimensional arrays. Use 1D or associative arrays." + + re = mkRegex "^\\[.*\\]\\[.*\\]" -- Fixme, this matches ${foo:- [][]} and such as well + isMultiDim l = getBracedModifier (concat $ oversimplify l) `matches` re + +prop_checkPS11 = verify checkPS1Assignments "PS1='\\033[1;35m\\$ '" +prop_checkPS11a = verify checkPS1Assignments "export PS1='\\033[1;35m\\$ '" +prop_checkPSf2 = verify checkPS1Assignments "PS1='\\h \\e[0m\\$ '" +prop_checkPS13 = verify checkPS1Assignments "PS1=$'\\x1b[c '" +prop_checkPS14 = verify checkPS1Assignments "PS1=$'\\e[3m; '" +prop_checkPS14a = verify checkPS1Assignments "export PS1=$'\\e[3m; '" +prop_checkPS15 = verifyNot checkPS1Assignments "PS1='\\[\\033[1;35m\\]\\$ '" +prop_checkPS16 = verifyNot checkPS1Assignments "PS1='\\[\\e1m\\e[1m\\]\\$ '" +prop_checkPS17 = verifyNot checkPS1Assignments "PS1='e033x1B'" +prop_checkPS18 = verifyNot checkPS1Assignments "PS1='\\[\\e\\]'" +checkPS1Assignments = ForShell [Bash] f + where + f token = case token of + (T_Assignment _ _ "PS1" _ word) -> warnFor word + _ -> return () + + warnFor word = + let contents = concat $ oversimplify word in + when (containsUnescaped contents) $ + info (getId word) 2025 "Make sure all escape sequences are enclosed in \\[..\\] to prevent line wrapping issues" + containsUnescaped s = + let unenclosed = subRegex enclosedRegex s "" in + isJust $ matchRegex escapeRegex unenclosed + enclosedRegex = mkRegex "\\\\\\[.*\\\\\\]" -- FIXME: shouldn't be eager + escapeRegex = mkRegex "\\\\x1[Bb]|\\\\e|\x1B|\\\\033" + + +prop_checkMultipleBangs1 = verify checkMultipleBangs "! ! true" +prop_checkMultipleBangs2 = verifyNot checkMultipleBangs "! true" +checkMultipleBangs = ForShell [Dash, BusyboxSh, Sh] f + where + f token = case token of + T_Banged id (T_Banged _ _) -> + err id 2325 "Multiple ! in front of pipelines are a bash/ksh extension. Use only 0 or 1." + _ -> return () + + +prop_checkBangAfterPipe1 = verify checkBangAfterPipe "true | ! true" +prop_checkBangAfterPipe2 = verifyNot checkBangAfterPipe "true | ( ! true )" +prop_checkBangAfterPipe3 = verifyNot checkBangAfterPipe "! ! true | true" +checkBangAfterPipe = ForShell [Dash, BusyboxSh, Sh, Bash] f + where + f token = case token of + T_Pipeline _ _ cmds -> mapM_ check cmds + _ -> return () + + check token = case token of + T_Banged id _ -> + err id 2326 "! is not allowed in the middle of pipelines. Use command group as in cmd | { ! cmd; } if necessary." + _ -> return () + + +prop_checkNegatedUnaryOps1 = verify checkNegatedUnaryOps "[ ! -o braceexpand ]" +prop_checkNegatedUnaryOps2 = verifyNot checkNegatedUnaryOps "[ -o braceexpand ]" +prop_checkNegatedUnaryOps3 = verifyNot checkNegatedUnaryOps "[[ ! -o braceexpand ]]" +prop_checkNegatedUnaryOps4 = verifyNot checkNegatedUnaryOps "! [ -o braceexpand ]" +prop_checkNegatedUnaryOps5 = verify checkNegatedUnaryOps "[ ! -a file ]" +checkNegatedUnaryOps = ForShell [Bash] f + where + f token = case token of + TC_Unary id SingleBracket "!" (TC_Unary _ _ op _) | op `elem` ["-a", "-o"] -> + err id 2332 $ msg op + _ -> return () + + msg "-o" = "[ ! -o opt ] is always true because -o becomes logical OR. Use [[ ]] or ! [ -o opt ]." + msg "-a" = "[ ! -a file ] is always true because -a becomes logical AND. Use -e instead." + msg _ = pleaseReport "unhandled negated unary message" + +return [] +runTests = $( [| $(forAllProperties) (quickCheckWithResult (stdArgs { maxSuccess = 1 }) ) |]) diff --git a/src/ShellCheck/Data.hs b/src/ShellCheck/Data.hs new file mode 100644 index 0000000..688d0d7 --- /dev/null +++ b/src/ShellCheck/Data.hs @@ -0,0 +1,177 @@ +module ShellCheck.Data where + +import ShellCheck.Interface +import Data.Version (showVersion) + + +{- +If you are here because you saw an error about Paths_ShellCheck in this file, +simply comment out the import below and define the version as a constant string. + +Instead of: + + import Paths_ShellCheck (version) + shellcheckVersion = showVersion version + +Use: + + -- import Paths_ShellCheck (version) + shellcheckVersion = "kludge" + +-} + +import Paths_ShellCheck (version) +shellcheckVersion = showVersion version -- VERSIONSTRING + + +internalVariables = [ + -- Generic + "", "_", "rest", "REST", + + -- Bash + "BASH", "BASHOPTS", "BASHPID", "BASH_ALIASES", "BASH_ARGC", + "BASH_ARGV", "BASH_ARGV0", "BASH_CMDS", "BASH_COMMAND", + "BASH_EXECUTION_STRING", "BASH_LINENO", "BASH_LOADABLES_PATH", + "BASH_REMATCH", "BASH_SOURCE", "BASH_SUBSHELL", "BASH_VERSINFO", + "BASH_VERSION", "COMP_CWORD", "COMP_KEY", "COMP_LINE", "COMP_POINT", + "COMP_TYPE", "COMP_WORDBREAKS", "COMP_WORDS", "COPROC", "DIRSTACK", + "EPOCHREALTIME", "EPOCHSECONDS", "EUID", "FUNCNAME", "GROUPS", "HISTCMD", + "HOSTNAME", "HOSTTYPE", "LINENO", "MACHTYPE", "MAPFILE", "OLDPWD", + "OPTARG", "OPTIND", "OSTYPE", "PIPESTATUS", "PPID", "PWD", "RANDOM", + "READLINE_ARGUMENT", "READLINE_LINE", "READLINE_MARK", "READLINE_POINT", + "REPLY", "SECONDS", "SHELLOPTS", "SHLVL", "SRANDOM", "UID", "BASH_COMPAT", + "BASH_ENV", "BASH_XTRACEFD", "CDPATH", "CHILD_MAX", "COLUMNS", + "COMPREPLY", "EMACS", "ENV", "EXECIGNORE", "FCEDIT", "FIGNORE", + "FUNCNEST", "GLOBIGNORE", "HISTCONTROL", "HISTFILE", "HISTFILESIZE", + "HISTIGNORE", "HISTSIZE", "HISTTIMEFORMAT", "HOME", "HOSTFILE", "IFS", + "IGNOREEOF", "INPUTRC", "INSIDE_EMACS", "LANG", "LC_ALL", "LC_COLLATE", + "LC_CTYPE", "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME", + "LINES", "MAIL", "MAILCHECK", "MAILPATH", "OPTERR", "PATH", + "POSIXLY_CORRECT", "PROMPT_COMMAND", "PROMPT_DIRTRIM", "PS0", "PS1", + "PS2", "PS3", "PS4", "SHELL", "TIMEFORMAT", "TMOUT", "TMPDIR", + "BASH_MONOSECONDS", "BASH_TRAPSIG", "GLOBSORT", + "auto_resume", "histchars", + + -- Other + "USER", "TZ", "TERM", "LOGNAME", "LD_LIBRARY_PATH", "LANGUAGE", "DISPLAY", + "HOSTNAME", "KRB5CCNAME", "XAUTHORITY" + + -- Ksh + , ".sh.version" + + -- shflags + , "FLAGS_ARGC", "FLAGS_ARGV", "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_HELP", + "FLAGS_PARENT", "FLAGS_RESERVED", "FLAGS_TRUE", "FLAGS_VERSION", + "flags_error", "flags_return" + + -- Bats + ,"stderr", "stderr_lines" + ] + +specialIntegerVariables = [ + "$", "?", "!", "#" + ] + +specialVariablesWithoutSpaces = "-" : specialIntegerVariables + +variablesWithoutSpaces = specialVariablesWithoutSpaces ++ [ + "BASHPID", "BASH_ARGC", "BASH_LINENO", "BASH_SUBSHELL", "EUID", + "EPOCHREALTIME", "EPOCHSECONDS", "LINENO", "OPTIND", "PPID", "RANDOM", + "READLINE_ARGUMENT", "READLINE_MARK", "READLINE_POINT", "SECONDS", + "SHELLOPTS", "SHLVL", "SRANDOM", "UID", "COLUMNS", "HISTFILESIZE", + "HISTSIZE", "LINES", "BASH_MONOSECONDS", "BASH_TRAPSIG" + + -- shflags + , "FLAGS_ERROR", "FLAGS_FALSE", "FLAGS_TRUE" + ] + +specialVariables = specialVariablesWithoutSpaces ++ ["@", "*"] + +unbracedVariables = specialVariables ++ [ + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" + ] + +arrayVariables = [ + "BASH_ALIASES", "BASH_ARGC", "BASH_ARGV", "BASH_CMDS", "BASH_LINENO", + "BASH_REMATCH", "BASH_SOURCE", "BASH_VERSINFO", "COMP_WORDS", "COPROC", + "DIRSTACK", "FUNCNAME", "GROUPS", "MAPFILE", "PIPESTATUS", "COMPREPLY" + ] + +commonCommands = [ + "admin", "alias", "ar", "asa", "at", "awk", "basename", "batch", + "bc", "bg", "break", "c99", "cal", "cat", "cd", "cflow", "chgrp", + "chmod", "chown", "cksum", "cmp", "colon", "comm", "command", + "compress", "continue", "cp", "crontab", "csplit", "ctags", "cut", + "cxref", "date", "dd", "delta", "df", "diff", "dirname", "dot", + "du", "echo", "ed", "env", "eval", "ex", "exec", "exit", "expand", + "export", "expr", "fc", "fg", "file", "find", "fold", "fort77", + "fuser", "gencat", "get", "getconf", "getopts", "grep", "hash", + "head", "iconv", "ipcrm", "ipcs", "jobs", "join", "kill", "lex", + "link", "ln", "locale", "localedef", "logger", "logname", "lp", + "ls", "m4", "mailx", "make", "man", "mesg", "mkdir", "mkfifo", + "more", "mv", "newgrp", "nice", "nl", "nm", "nohup", "od", "paste", + "patch", "pathchk", "pax", "pr", "printf", "prs", "ps", "pwd", + "qalter", "qdel", "qhold", "qmove", "qmsg", "qrerun", "qrls", + "qselect", "qsig", "qstat", "qsub", "read", "readonly", "renice", + "return", "rm", "rmdel", "rmdir", "sact", "sccs", "sed", "set", + "sh", "shift", "sleep", "sort", "split", "strings", "strip", "stty", + "tabs", "tail", "talk", "tee", "test", "time", "times", "touch", + "tput", "tr", "trap", "tsort", "tty", "type", "ulimit", "umask", + "unalias", "uname", "uncompress", "unexpand", "unget", "uniq", + "unlink", "unset", "uucp", "uudecode", "uuencode", "uustat", "uux", + "val", "vi", "wait", "wc", "what", "who", "write", "xargs", "yacc", + "zcat" + ] + +nonReadingCommands = [ + "alias", "basename", "bg", "cal", "cd", "chgrp", "chmod", "chown", + "cp", "du", "echo", "export", "fg", "fuser", "getconf", + "getopt", "getopts", "ipcrm", "ipcs", "jobs", "kill", "ln", "ls", + "locale", "mv", "printf", "ps", "pwd", "renice", "rm", "rmdir", + "set", "sleep", "touch", "trap", "ulimit", "unalias", "uname" + ] + +sampleWords = [ + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", + "golf", "hotel", "india", "juliett", "kilo", "lima", "mike", + "november", "oscar", "papa", "quebec", "romeo", "sierra", + "tango", "uniform", "victor", "whiskey", "xray", "yankee", + "zulu" + ] + +binaryTestOps = [ + "-nt", "-ot", "-ef", "==", "!=", "<=", ">=", "-eq", "-ne", "-lt", "-le", + "-gt", "-ge", "=~", ">", "<", "=", "\\<", "\\>", "\\<=", "\\>=" + ] + +arithmeticBinaryTestOps = [ + "-eq", "-ne", "-lt", "-le", "-gt", "-ge" + ] + +unaryTestOps = [ + "!", "-a", "-b", "-c", "-d", "-e", "-f", "-g", "-h", "-L", "-k", "-p", + "-r", "-s", "-S", "-t", "-u", "-w", "-x", "-O", "-G", "-N", "-z", "-n", + "-o", "-v", "-R" + ] + +shellForExecutable :: String -> Maybe Shell +shellForExecutable name = + case name of + "sh" -> return Sh + "bash" -> return Bash + "bats" -> return Bash + "busybox" -> return BusyboxSh -- Used for directives and --shell=busybox + "busybox sh" -> return BusyboxSh + "busybox ash" -> return BusyboxSh + "dash" -> return Dash + "ash" -> return Dash -- There's also a warning for this. + "ksh" -> return Ksh + "ksh88" -> return Ksh + "ksh93" -> return Ksh + "oksh" -> return Ksh + _ -> Nothing + +flagsForRead = "sreu:n:N:i:p:a:t:" +flagsForMapfile = "d:n:O:s:u:C:c:t" + +declaringCommands = ["local", "declare", "export", "readonly", "typeset", "let"] diff --git a/src/ShellCheck/Debug.hs b/src/ShellCheck/Debug.hs new file mode 100644 index 0000000..b6015e5 --- /dev/null +++ b/src/ShellCheck/Debug.hs @@ -0,0 +1,313 @@ +{- + +This file contains useful functions for debugging and developing ShellCheck. + +To invoke them interactively, run: + + cabal repl + +At the ghci prompt, enter: + + :load ShellCheck.Debug + +You can now invoke the functions. Here are some examples: + + shellcheckString "echo $1" + stringToAst "(( x+1 ))" + stringToCfg "if foo; then bar; else baz; fi" + writeFile "/tmp/test.dot" $ stringToCfgViz "while foo; do bar; done" + +The latter file can be rendered to png with GraphViz: + + dot -Tpng /tmp/test.dot > /tmp/test.png + +To run all unit tests in a module: + + ShellCheck.Parser.runTests + ShellCheck.Analytics.runTests + +To run a specific test: + + :load ShellCheck.Analytics + prop_checkUuoc3 + +If you make code changes, reload in seconds at any time with: + + :r + +=========================================================================== + +Crash course in printf debugging in Haskell: + + import Debug.Trace + + greet 0 = return () + -- Print when a function is invoked + greet n | trace ("calling greet " ++ show n) False = undefined + greet n = do + putStrLn "Enter name" + name <- getLine + -- Print at some point in any monadic function + traceM $ "user entered " ++ name + putStrLn $ "Hello " ++ name + -- Print a value before passing it on + greet $ traceShowId (n - 1) + + +=========================================================================== + +If you want to invoke `ghci` directly, such as on `shellcheck.hs`, to +debug all of ShellCheck including I/O, you may see an error like this: + + src/ShellCheck/Data.hs:5:1: error: + Could not load module ‘Paths_ShellCheck’ + it is a hidden module in the package ‘ShellCheck-0.8.0’ + +This can easily be circumvented by running `./setgitversion` or manually +editing src/ShellCheck/Data.hs to replace the auto-deduced version number +with a constant string as indicated. + +Afterwards, you can run the ShellCheck tool, as if from the shell, with: + + $ ghci shellcheck.hs + ghci> runMain ["-x", "file.sh"] + +-} + +module ShellCheck.Debug () where + +import ShellCheck.Analyzer +import ShellCheck.AST +import ShellCheck.CFG +import ShellCheck.Checker +import ShellCheck.CFGAnalysis as CF +import ShellCheck.Interface +import ShellCheck.Parser +import ShellCheck.Prelude + +import Control.Monad +import Control.Monad.Identity +import Control.Monad.RWS +import Control.Monad.Writer +import Data.Graph.Inductive.Graph as G +import Data.List +import Data.Maybe +import qualified Data.Map as M +import qualified Data.Set as S + + +-- Run all of ShellCheck (minus output formatters) +shellcheckString :: String -> CheckResult +shellcheckString scriptString = + runIdentity $ checkScript dummySystemInterface checkSpec + where + checkSpec :: CheckSpec + checkSpec = emptyCheckSpec { + csScript = scriptString + } + +dummySystemInterface :: SystemInterface Identity +dummySystemInterface = mockedSystemInterface [ + -- A tiny, fake filesystem for sourced files + ("lib/mylib1.sh", "foo=$(cat $1 | wc -l)"), + ("lib/mylib2.sh", "bar=42") + ] + +-- Parameters used when generating Control Flow Graphs +cfgParams :: CFGParameters +cfgParams = CFGParameters { + cfLastpipe = False, + cfPipefail = False +} + +-- An example script to play with +exampleScript :: String +exampleScript = unlines [ + "#!/bin/sh", + "count=0", + "for file in *", + "do", + " (( count++ ))", + "done", + "echo $count" + ] + +-- Parse the script string into ShellCheck's ParseResult +parseScriptString :: String -> ParseResult +parseScriptString scriptString = + runIdentity $ parseScript dummySystemInterface parseSpec + where + parseSpec :: ParseSpec + parseSpec = newParseSpec { + psFilename = "myscript", + psScript = scriptString + } + + +-- Parse the script string into an Abstract Syntax Tree +stringToAst :: String -> Token +stringToAst scriptString = + case maybeRoot of + Just root -> root + Nothing -> error $ "Script failed to parse: " ++ show parserWarnings + where + parseResult :: ParseResult + parseResult = parseScriptString scriptString + + maybeRoot :: Maybe Token + maybeRoot = prRoot parseResult + + parserWarnings :: [PositionedComment] + parserWarnings = prComments parseResult + + +astToCfgResult :: Token -> CFGResult +astToCfgResult = buildGraph cfgParams + +astToDfa :: Token -> CFGAnalysis +astToDfa = analyzeControlFlow cfgParams + +astToCfg :: Token -> CFGraph +astToCfg = cfGraph . astToCfgResult + +stringToCfg :: String -> CFGraph +stringToCfg = astToCfg . stringToAst + +stringToDfa :: String -> CFGAnalysis +stringToDfa = astToDfa . stringToAst + +cfgToGraphViz :: CFGraph -> String +cfgToGraphViz = cfgToGraphVizWith show + +stringToCfgViz :: String -> String +stringToCfgViz = cfgToGraphViz . stringToCfg + +stringToDfaViz :: String -> String +stringToDfaViz = dfaToGraphViz . stringToDfa + +-- Dump a Control Flow Graph as GraphViz with extended information +stringToDetailedCfgViz :: String -> String +stringToDetailedCfgViz scriptString = cfgToGraphVizWith nodeLabel graph + where + ast :: Token + ast = stringToAst scriptString + + cfgResult :: CFGResult + cfgResult = astToCfgResult ast + + graph :: CFGraph + graph = cfGraph cfgResult + + idToToken :: M.Map Id Token + idToToken = M.fromList $ execWriter $ doAnalysis (\c -> tell [(getId c, c)]) ast + + idToNode :: M.Map Id (Node, Node) + idToNode = cfIdToRange cfgResult + + nodeToStartIds :: M.Map Node (S.Set Id) + nodeToStartIds = + M.fromListWith S.union $ + map (\(id, (start, _)) -> (start, S.singleton id)) $ + M.toList idToNode + + nodeToEndIds :: M.Map Node (S.Set Id) + nodeToEndIds = + M.fromListWith S.union $ + map (\(id, (_, end)) -> (end, S.singleton id)) $ + M.toList idToNode + + formatId :: Id -> String + formatId id = fromMaybe ("Unknown " ++ show id) $ do + (OuterToken _ token) <- M.lookup id idToToken + firstWord <- words (show token) !!! 0 + -- Strip off "Inner_" + (_ : tokenName) <- return $ dropWhile (/= '_') firstWord + return $ tokenName ++ " " ++ show id + + formatGroup :: S.Set Id -> String + formatGroup set = intercalate ", " $ map formatId $ S.toList set + + nodeLabel (node, label) = unlines [ + show node ++ ". " ++ show label, + "Begin: " ++ formatGroup (M.findWithDefault S.empty node nodeToStartIds), + "End: " ++ formatGroup (M.findWithDefault S.empty node nodeToEndIds) + ] + + +-- Dump a Control Flow Graph with Data Flow Analysis as GraphViz +dfaToGraphViz :: CF.CFGAnalysis -> String +dfaToGraphViz analysis = cfgToGraphVizWith label $ CF.graph analysis + where + label (node, label) = + let + desc = show node ++ ". " ++ show label + in + fromMaybe ("No DFA available\n\n" ++ desc) $ do + (pre, post) <- M.lookup node $ CF.nodeToData analysis + return $ unlines [ + "Precondition: " ++ show pre, + "", + desc, + "", + "Postcondition: " ++ show post + ] + + +-- Dump an Control Flow Graph to GraphViz with a given node formatter +cfgToGraphVizWith :: (LNode CFNode -> String) -> CFGraph -> String +cfgToGraphVizWith nodeLabel graph = concat [ + "digraph {\n", + concatMap dumpNode (labNodes graph), + concatMap dumpLink (labEdges graph), + tagVizEntries graph, + "}\n" + ] + where + dumpNode l@(node, label) = show node ++ " [label=" ++ quoteViz (nodeLabel l) ++ "]\n" + dumpLink (from, to, typ) = show from ++ " -> " ++ show to ++ " [style=" ++ quoteViz (edgeStyle typ) ++ "]\n" + edgeStyle CFEFlow = "solid" + edgeStyle CFEExit = "bold" + edgeStyle CFEFalseFlow = "dotted" + +quoteViz str = "\"" ++ escapeViz str ++ "\"" +escapeViz [] = [] +escapeViz (c:rest) = + case c of + '\"' -> '\\' : '\"' : escapeViz rest + '\n' -> '\\' : 'l' : escapeViz rest + '\\' -> '\\' : '\\' : escapeViz rest + _ -> c : escapeViz rest + + +-- Dump an Abstract Syntax Tree (or branch thereof) to GraphViz format +astToGraphViz :: Token -> String +astToGraphViz token = concat [ + "digraph {\n", + formatTree token, + "}\n" + ] + where + formatTree :: Token -> String + formatTree t = snd $ execRWS (doStackAnalysis push pop t) () [] + + push :: Token -> RWS () String [Int] () + push (OuterToken (Id n) inner) = do + stack <- get + put (n : stack) + case stack of + [] -> return () + (top:_) -> tell $ show top ++ " -> " ++ show n ++ "\n" + tell $ show n ++ " [label=" ++ quoteViz (show n ++ ": " ++ take 32 (show inner)) ++ "]\n" + + pop :: Token -> RWS () String [Int] () + pop _ = modify tail + + +-- For each entry point, set the rank so that they'll align in the graph +tagVizEntries :: CFGraph -> String +tagVizEntries graph = "{ rank=same " ++ rank ++ " }" + where + entries = mapMaybe find $ labNodes graph + find (node, CFEntryPoint name) = return (node, name) + find _ = Nothing + rank = unwords $ map (\(c, _) -> show c) entries diff --git a/src/ShellCheck/Fixer.hs b/src/ShellCheck/Fixer.hs new file mode 100644 index 0000000..0d3c8f4 --- /dev/null +++ b/src/ShellCheck/Fixer.hs @@ -0,0 +1,412 @@ +{- + Copyright 2018-2019 Vidar Holen, Ng Zhi An + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} + +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Fixer (applyFix, removeTabStops, mapPositions, Ranged(..), runTests) where + +import ShellCheck.Interface +import ShellCheck.Prelude +import Control.Monad +import Control.Monad.State +import Data.Array +import Data.List +import Data.Semigroup +import GHC.Exts (sortWith) +import Test.QuickCheck + +-- The Ranged class is used for types that has a start and end position. +class Ranged a where + start :: a -> Position + end :: a -> Position + overlap :: a -> a -> Bool + overlap x y = + xEnd > yStart && yEnd > xStart + where + yStart = start y + yEnd = end y + xStart = start x + xEnd = end x + -- Set a new start and end position on a Ranged + setRange :: (Position, Position) -> a -> a + +-- Tests auto-verify that overlap commutes +assertOverlap x y = overlap x y && overlap y x +assertNoOverlap x y = not (overlap x y) && not (overlap y x) + +prop_overlap_contiguous = assertNoOverlap + (tFromStart 10 12 "foo" 1) + (tFromStart 12 14 "bar" 2) + +prop_overlap_adjacent_zerowidth = assertNoOverlap + (tFromStart 3 3 "foo" 1) + (tFromStart 3 3 "bar" 2) + +prop_overlap_enclosed = assertOverlap + (tFromStart 3 5 "foo" 1) + (tFromStart 1 10 "bar" 2) + +prop_overlap_partial = assertOverlap + (tFromStart 1 5 "foo" 1) + (tFromStart 3 7 "bar" 2) + + +instance Ranged PositionedComment where + start = pcStartPos + end = pcEndPos + setRange (s, e) pc = pc { + pcStartPos = s, + pcEndPos = e + } + +instance Ranged Replacement where + start = repStartPos + end = repEndPos + setRange (s, e) r = r { + repStartPos = s, + repEndPos = e + } + +-- The Monoid instance for Fix merges fixes that do not conflict. +-- TODO: Make an efficient 'mconcat' +instance Monoid Fix where + mempty = newFix + mappend = (<>) + mconcat = foldl mappend mempty -- fold left to right since <> discards right on overlap + +instance Semigroup Fix where + f1 <> f2 = + -- FIXME: This might need to also discard adjacent zero-width ranges for + -- when two fixes change the same AST node, e.g. `foo` -> "$(foo)" + if or [ r2 `overlap` r1 | r1 <- fixReplacements f1, r2 <- fixReplacements f2 ] + then f1 + else newFix { + fixReplacements = fixReplacements f1 ++ fixReplacements f2 + } + +-- Conveniently apply a transformation to positions in a Fix +mapPositions :: (Position -> Position) -> Fix -> Fix +mapPositions f = adjustFix + where + adjustReplacement rep = + rep { + repStartPos = f $ repStartPos rep, + repEndPos = f $ repEndPos rep + } + adjustFix fix = + fix { + fixReplacements = map adjustReplacement $ fixReplacements fix + } + +-- Rewrite a Ranged from a tabstop of 8 to 1 +removeTabStops :: Ranged a => a -> Array Int String -> a +removeTabStops range ls = + let startColumn = realignColumn lineNo colNo range + endColumn = realignColumn endLineNo endColNo range + startPosition = (start range) { posColumn = startColumn } + endPosition = (end range) { posColumn = endColumn } in + setRange (startPosition, endPosition) range + where + realignColumn lineNo colNo c = + if lineNo c > 0 && lineNo c <= fromIntegral (length ls) + then real (ls ! fromIntegral (lineNo c)) 0 0 (colNo c) + else colNo c + real _ r v target | target <= v = r + -- hit this case at the end of line, and if we don't hit the target + -- return real + (target - v) + real [] r v target = r + (target - v) + real ('\t':rest) r v target = real rest (r+1) (v + 8 - (v `mod` 8)) target + real (_:rest) r v target = real rest (r+1) (v+1) target + lineNo = posLine . start + endLineNo = posLine . end + colNo = posColumn . start + endColNo = posColumn . end + + +-- A replacement that spans multiple line is applied by: +-- 1. merging the affected lines into a single string using `unlines` +-- 2. apply the replacement as if it only spanned a single line +-- The tricky part is adjusting the end column of the replacement +-- (the end line doesn't matter because there is only one line) +-- +-- aaS <--- start of replacement (row 1 column 3) +-- bbbb +-- cEc +-- \------- end of replacement (row 3 column 2) +-- +-- a flattened string will look like: +-- +-- "aaS\nbbbb\ncEc\n" +-- +-- The column of E has to be adjusted by: +-- 1. lengths of lines to be replaced, except the end row itself +-- 2. end column of the replacement +-- 3. number of '\n' by `unlines` +multiToSingleLine :: [Fix] -> Array Int String -> ([Fix], String) +multiToSingleLine fixes lines = + (map (mapPositions adjust) fixes, unlines $ elems lines) + where + -- A prefix sum tree from line number to column shift. + -- FIXME: The tree will be totally unbalanced. + shiftTree :: PSTree Int + shiftTree = + foldl (\t (n,s) -> addPSValue (n+1) (length s + 1) t) newPSTree $ + assocs lines + singleString = unlines $ elems lines + adjust pos = + pos { + posLine = 1, + posColumn = (posColumn pos) + + (fromIntegral $ getPrefixSum (fromIntegral $ posLine pos) shiftTree) + } + +-- Apply a fix and return resulting lines. +-- The number of lines can increase or decrease with no obvious mapping back, so +-- the function does not return an array. +applyFix :: Fix -> Array Int String -> [String] +applyFix fix fileLines = + let + untabbed = fix { + fixReplacements = + map (\c -> removeTabStops c fileLines) $ + fixReplacements fix + } + (adjustedFixes, singleLine) = multiToSingleLine [untabbed] fileLines + in + lines . runFixer $ applyFixes2 adjustedFixes singleLine + + +-- start and end comes from pos, which is 1 based +prop_doReplace1 = doReplace 0 0 "1234" "A" == "A1234" -- technically not valid +prop_doReplace2 = doReplace 1 1 "1234" "A" == "A1234" +prop_doReplace3 = doReplace 1 2 "1234" "A" == "A234" +prop_doReplace4 = doReplace 3 3 "1234" "A" == "12A34" +prop_doReplace5 = doReplace 4 4 "1234" "A" == "123A4" +prop_doReplace6 = doReplace 5 5 "1234" "A" == "1234A" +doReplace start end o r = + let si = fromIntegral (start-1) + ei = fromIntegral (end-1) + (x, xs) = splitAt si o + z = drop (ei - si) xs + in + x ++ r ++ z + +-- Fail if the 'expected' string is not result when applying 'fixes' to 'original'. +testFixes :: String -> String -> [Fix] -> Bool +testFixes expected original fixes = + actual == expected + where + actual = runFixer (applyFixes2 fixes original) + + +-- A Fixer allows doing repeated modifications of a string where each +-- replacement automatically accounts for shifts from previous ones. +type Fixer a = State (PSTree Int) a + +-- Apply a single replacement using its indices into the original string. +-- It does not handle multiple lines, all line indices must be 1. +applyReplacement2 :: Replacement -> String -> Fixer String +applyReplacement2 rep string = do + tree <- get + let transform pos = pos + getPrefixSum pos tree + let originalPos = (repStartPos rep, repEndPos rep) + (oldStart, oldEnd) = tmap (fromInteger . posColumn) originalPos + (newStart, newEnd) = tmap transform (oldStart, oldEnd) + + let (l1, l2) = tmap posLine originalPos in + when (l1 /= 1 || l2 /= 1) $ + error $ pleaseReport "bad cross-line fix" + + let replacer = repString rep + let shift = (length replacer) - (oldEnd - oldStart) + let insertionPoint = + case repInsertionPoint rep of + InsertBefore -> oldStart + InsertAfter -> oldEnd+1 + put $ addPSValue insertionPoint shift tree + + return $ doReplace newStart newEnd string replacer + where + tmap f (a,b) = (f a, f b) + +-- Apply a list of Replacements in the correct order +applyReplacements2 :: [Replacement] -> String -> Fixer String +applyReplacements2 reps str = + foldM (flip applyReplacement2) str $ + reverse $ sortWith repPrecedence reps + +-- Apply all fixes with replacements in the correct order +applyFixes2 :: [Fix] -> String -> Fixer String +applyFixes2 fixes = applyReplacements2 (concatMap fixReplacements fixes) + +-- Get the final value of a Fixer. +runFixer :: Fixer a -> a +runFixer f = evalState f newPSTree + + + +-- A Prefix Sum Tree that lets you look up the sum of values at and below an index. +-- It's implemented essentially as a Fenwick tree without the bit-based balancing. +-- The last Num is the sum of the left branch plus current element. +data PSTree n = PSBranch n (PSTree n) (PSTree n) n | PSLeaf + deriving (Show) + +newPSTree :: Num n => PSTree n +newPSTree = PSLeaf + +-- Get the sum of values whose keys are <= 'target' +getPrefixSum :: (Ord n, Num n) => n -> PSTree n -> n +getPrefixSum = f 0 + where + f sum _ PSLeaf = sum + f sum target (PSBranch pivot left right cumulative) = + case target `compare` pivot of + LT -> f sum target left + GT -> f (sum+cumulative) target right + EQ -> sum+cumulative + +-- Add a value to the Prefix Sum tree at the given index. +-- Values accumulate: addPSValue 42 2 . addPSValue 42 3 == addPSValue 42 5 +addPSValue :: (Ord n, Num n) => n -> n -> PSTree n -> PSTree n +addPSValue key value tree = if value == 0 then tree else f tree + where + f PSLeaf = PSBranch key PSLeaf PSLeaf value + f (PSBranch pivot left right sum) = + case key `compare` pivot of + LT -> PSBranch pivot (f left) right (sum + value) + GT -> PSBranch pivot left (f right) sum + EQ -> PSBranch pivot left right (sum + value) + +prop_pstreeSumsCorrectly kvs targets = + let + -- Trivial O(n * m) implementation + dumbPrefixSums :: [(Int, Int)] -> [Int] -> [Int] + dumbPrefixSums kvs targets = + let prefixSum target = sum [v | (k,v) <- kvs, k <= target] + in map prefixSum targets + -- PSTree O(n * log m) implementation + smartPrefixSums :: [(Int, Int)] -> [Int] -> [Int] + smartPrefixSums kvs targets = + let tree = foldl (\tree (pos, shift) -> addPSValue pos shift tree) PSLeaf kvs + in map (\x -> getPrefixSum x tree) targets + in smartPrefixSums kvs targets == dumbPrefixSums kvs targets + + +-- Semi-convenient functions for constructing tests. +testFix :: [Replacement] -> Fix +testFix list = newFix { + fixReplacements = list + } + +tFromStart :: Int -> Int -> String -> Int -> Replacement +tFromStart start end repl order = + newReplacement { + repStartPos = newPosition { + posLine = 1, + posColumn = fromIntegral start + }, + repEndPos = newPosition { + posLine = 1, + posColumn = fromIntegral end + }, + repString = repl, + repPrecedence = order, + repInsertionPoint = InsertAfter + } + +tFromEnd start end repl order = + (tFromStart start end repl order) { + repInsertionPoint = InsertBefore + } + +prop_simpleFix1 = testFixes "hello world" "hell world" [ + testFix [ + tFromEnd 5 5 "o" 1 + ]] + +prop_anchorsLeft = testFixes "-->foobar<--" "--><--" [ + testFix [ + tFromStart 4 4 "foo" 1, + tFromStart 4 4 "bar" 2 + ]] + +prop_anchorsRight = testFixes "-->foobar<--" "--><--" [ + testFix [ + tFromEnd 4 4 "bar" 1, + tFromEnd 4 4 "foo" 2 + ]] + +prop_anchorsBoth1 = testFixes "-->foobar<--" "--><--" [ + testFix [ + tFromStart 4 4 "bar" 2, + tFromEnd 4 4 "foo" 1 + ]] + +prop_anchorsBoth2 = testFixes "-->foobar<--" "--><--" [ + testFix [ + tFromEnd 4 4 "foo" 2, + tFromStart 4 4 "bar" 1 + ]] + +prop_composeFixes1 = testFixes "cd \"$1\" || exit" "cd $1" [ + testFix [ + tFromStart 4 4 "\"" 10, + tFromEnd 6 6 "\"" 10 + ], + testFix [ + tFromEnd 6 6 " || exit" 5 + ]] + +prop_composeFixes2 = testFixes "$(\"$1\")" "`$1`" [ + testFix [ + tFromStart 1 2 "$(" 5, + tFromEnd 4 5 ")" 5 + ], + testFix [ + tFromStart 2 2 "\"" 10, + tFromEnd 4 4 "\"" 10 + ]] + +prop_composeFixes3 = testFixes "(x)[x]" "xx" [ + testFix [ + tFromStart 1 1 "(" 4, + tFromEnd 2 2 ")" 3, + tFromStart 2 2 "[" 2, + tFromEnd 3 3 "]" 1 + ]] + +prop_composeFixes4 = testFixes "(x)[x]" "xx" [ + testFix [ + tFromStart 1 1 "(" 4, + tFromStart 2 2 "[" 3, + tFromEnd 2 2 ")" 2, + tFromEnd 3 3 "]" 1 + ]] + +prop_composeFixes5 = testFixes "\"$(x)\"" "`x`" [ + testFix [ + tFromStart 1 2 "$(" 2, + tFromEnd 3 4 ")" 2, + tFromStart 1 1 "\"" 1, + tFromEnd 4 4 "\"" 1 + ]] + + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Formatter/CheckStyle.hs b/src/ShellCheck/Formatter/CheckStyle.hs new file mode 100644 index 0000000..3f898c3 --- /dev/null +++ b/src/ShellCheck/Formatter/CheckStyle.hs @@ -0,0 +1,95 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.CheckStyle (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Data.Char +import Data.List +import System.IO +import qualified Data.List.NonEmpty as NE + +format :: IO Formatter +format = return Formatter { + header = do + putStrLn "" + putStrLn "", + + onFailure = outputError, + onResult = outputResults, + + footer = putStrLn "" +} + +outputResults cr sys = + if null comments + then outputFile (crFilename cr) "" [] + else mapM_ outputGroup fileGroups + where + comments = crComments cr + fileGroups = NE.groupWith sourceFile comments + outputGroup group = do + let filename = sourceFile (NE.head group) + result <- siReadFile sys (Just True) filename + let contents = either (const "") id result + outputFile filename contents (NE.toList group) + +outputFile filename contents warnings = do + let comments = makeNonVirtual warnings contents + putStrLn . formatFile filename $ comments + +formatFile name comments = concat [ + "\n", + concatMap formatComment comments, + "" + ] + +formatComment c = concat [ + "\n" + ] + +outputError file error = putStrLn $ concat [ + "\n", + "\n", + "" + ] + + +attr s v = concat [ s, "='", escape v, "' " ] +escape = concatMap escape' +escape' c = if isOk c then [c] else "&#" ++ show (ord c) ++ ";" +isOk x = any ($ x) [isAsciiUpper, isAsciiLower, isDigit, (`elem` " ./")] + +severity "error" = "error" +severity "warning" = "warning" +severity _ = "info" diff --git a/src/ShellCheck/Formatter/Diff.hs b/src/ShellCheck/Formatter/Diff.hs new file mode 100644 index 0000000..c00da1a --- /dev/null +++ b/src/ShellCheck/Formatter/Diff.hs @@ -0,0 +1,260 @@ +{- + Copyright 2019 Vidar 'koala_man' Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +module ShellCheck.Formatter.Diff (format, ShellCheck.Formatter.Diff.runTests) where + +import ShellCheck.Interface +import ShellCheck.Fixer +import ShellCheck.Formatter.Format + +import Control.Monad +import Data.Algorithm.Diff +import Data.Array +import Data.IORef +import Data.List +import qualified Data.Monoid as Monoid +import Data.Maybe +import qualified Data.Map as M +import GHC.Exts (sortWith) +import System.IO +import System.FilePath + +import Test.QuickCheck + +format :: FormatterOptions -> IO Formatter +format options = do + foundIssues <- newIORef False + reportedIssues <- newIORef False + shouldColor <- shouldOutputColor (foColorOption options) + let color = if shouldColor then colorize else nocolor + return Formatter { + header = return (), + footer = checkFooter foundIssues reportedIssues color, + onFailure = reportFailure color, + onResult = reportResult foundIssues reportedIssues color + } + + +contextSize = 3 +red = 31 +green = 32 +yellow = 33 +cyan = 36 +bold = 1 + +nocolor n = id +colorize n s = (ansi n) ++ s ++ (ansi 0) +ansi n = "\x1B[" ++ show n ++ "m" + +printErr :: ColorFunc -> String -> IO () +printErr color = hPutStrLn stderr . color bold . color red +reportFailure color file msg = printErr color $ file ++ ": " ++ msg + +checkFooter foundIssues reportedIssues color = do + found <- readIORef foundIssues + output <- readIORef reportedIssues + when (found && not output) $ + printErr color "Issues were detected, but none were auto-fixable. Use another format to see them." + +type ColorFunc = (Int -> String -> String) +data LFStatus = LinefeedMissing | LinefeedOk +data DiffDoc a = DiffDoc String LFStatus [DiffRegion a] +data DiffRegion a = DiffRegion (Int, Int) (Int, Int) [Diff a] + +reportResult :: (IORef Bool) -> (IORef Bool) -> ColorFunc -> CheckResult -> SystemInterface IO -> IO () +reportResult foundIssues reportedIssues color result sys = do + let comments = crComments result + unless (null comments) $ writeIORef foundIssues True + let suggestedFixes = mapMaybe pcFix comments + let fixmap = buildFixMap suggestedFixes + mapM_ output $ M.toList fixmap + where + output (name, fix) = do + file <- siReadFile sys (Just True) name + case file of + Right contents -> do + putStrLn $ formatDoc color $ makeDiff name contents fix + writeIORef reportedIssues True + Left msg -> reportFailure color name msg + +hasTrailingLinefeed str = + case str of + [] -> True + _ -> last str == '\n' + +coversLastLine regions = + case regions of + [] -> False + _ -> (fst $ last regions) + +-- TODO: Factor this out into a unified diff library because we're doing a lot +-- of the heavy lifting anyways. +makeDiff :: String -> String -> Fix -> DiffDoc String +makeDiff name contents fix = do + let hunks = groupDiff $ computeDiff contents fix + let lf = if coversLastLine hunks && not (hasTrailingLinefeed contents) + then LinefeedMissing + else LinefeedOk + DiffDoc name lf $ findRegions hunks + +computeDiff :: String -> Fix -> [Diff String] +computeDiff contents fix = + let old = lines contents + array = listArray (1, fromIntegral $ (length old)) old + new = applyFix fix array + in getDiff old new + +-- Group changes into hunks +groupDiff :: [Diff a] -> [(Bool, [Diff a])] +groupDiff = filter (\(_, l) -> not (null l)) . hunt [] + where + -- Churn through 'Both's until we find a difference + hunt current [] = [(False, reverse current)] + hunt current (x@Both {}:rest) = hunt (x:current) rest + hunt current list = + let (context, previous) = splitAt contextSize current + in (False, reverse previous) : gather context 0 list + + -- Pick out differences until we find a run of Both's + gather current n [] = + let (extras, patch) = splitAt (max 0 $ n - contextSize) current + in [(True, reverse patch), (False, reverse extras)] + + gather current n list@(Both {}:_) | n == contextSize*2 = + let (context, previous) = splitAt contextSize current + in (True, reverse previous) : hunt context list + + gather current n (x@Both {}:rest) = gather (x:current) (n+1) rest + gather current n (x:rest) = gather (x:current) 0 rest + +-- Get line numbers for hunks +findRegions :: [(Bool, [Diff String])] -> [DiffRegion String] +findRegions = find' 1 1 + where + find' _ _ [] = [] + find' left right ((output, run):rest) = + let (dl, dr) = countDelta run + remainder = find' (left+dl) (right+dr) rest + in + if output + then DiffRegion (left, dl) (right, dr) run : remainder + else remainder + +-- Get left/right line counts for a hunk +countDelta :: [Diff a] -> (Int, Int) +countDelta = count' 0 0 + where + count' left right [] = (left, right) + count' left right (x:rest) = + case x of + Both {} -> count' (left+1) (right+1) rest + First {} -> count' (left+1) right rest + Second {} -> count' left (right+1) rest + +formatRegion :: ColorFunc -> LFStatus -> DiffRegion String -> String +formatRegion color lf (DiffRegion left right diffs) = + let header = color cyan ("@@ -" ++ (tup left) ++ " +" ++ (tup right) ++" @@") + in + unlines $ header : reverse (getStrings lf (reverse diffs)) + where + noLF = "\\ No newline at end of file" + + getStrings LinefeedOk list = map format list + getStrings LinefeedMissing list@((Both _ _):_) = noLF : map format list + getStrings LinefeedMissing list@((First _):_) = noLF : map format list + getStrings LinefeedMissing (last:rest) = format last : getStrings LinefeedMissing rest + + tup (a,b) = (show a) ++ "," ++ (show b) + format (Both x _) = ' ':x + format (First x) = color red $ '-':x + format (Second x) = color green $ '+':x + +splitLast [] = ([], []) +splitLast x = + let (last, rest) = splitAt 1 $ reverse x + in (reverse rest, last) + +-- git patch does not like `\` on Windows +normalizePath path = + case path of + c:rest -> (if c == pathSeparator then '/' else c) : normalizePath rest + [] -> [] + +formatDoc color (DiffDoc name lf regions) = + let (most, last) = splitLast regions + in + (color bold $ "--- " ++ (normalizePath $ "a" name)) ++ "\n" ++ + (color bold $ "+++ " ++ (normalizePath $ "b" name)) ++ "\n" ++ + concatMap (formatRegion color LinefeedOk) most ++ + concatMap (formatRegion color lf) last + +-- Create a Map from filename to Fix +buildFixMap :: [Fix] -> M.Map String Fix +buildFixMap fixes = perFile + where + splitFixes = splitFixByFile $ mconcat fixes + perFile = groupByMap (posFile . repStartPos . head . fixReplacements) splitFixes + +splitFixByFile :: Fix -> [Fix] +splitFixByFile fix = map makeFix $ groupBy sameFile (fixReplacements fix) + where + sameFile rep1 rep2 = (posFile $ repStartPos rep1) == (posFile $ repStartPos rep2) + makeFix reps = newFix { fixReplacements = reps } + +groupByMap :: (Ord k, Monoid v) => (v -> k) -> [v] -> M.Map k v +groupByMap f = M.fromListWith Monoid.mappend . map (\x -> (f x, x)) + +-- For building unit tests +b n = Both n n +l = First +r = Second + +prop_identifiesProperContext = groupDiff [b 1, b 2, b 3, b 4, l 5, b 6, b 7, b 8, b 9] == + [(False, [b 1]), -- Omitted + (True, [b 2, b 3, b 4, l 5, b 6, b 7, b 8]), -- A change with three lines of context + (False, [b 9])] -- Omitted + +prop_includesContextFromStartIfNecessary = groupDiff [b 4, l 5, b 6, b 7, b 8, b 9] == + [ -- Nothing omitted + (True, [b 4, l 5, b 6, b 7, b 8]), -- A change with three lines of context + (False, [b 9])] -- Omitted + +prop_includesContextUntilEndIfNecessary = groupDiff [b 4, l 5] == + [ -- Nothing omitted + (True, [b 4, l 5]) + ] -- Nothing Omitted + +prop_splitsIntoMultipleHunks = groupDiff [l 1, b 1, b 2, b 3, b 4, b 5, b 6, b 7, r 8] == + [ -- Nothing omitted + (True, [l 1, b 1, b 2, b 3]), + (False, [b 4]), + (True, [b 5, b 6, b 7, r 8]) + ] -- Nothing Omitted + +prop_splitsIntoMultipleHunksUnlessTouching = groupDiff [l 1, b 1, b 2, b 3, b 4, b 5, b 6, r 7] == + [ + (True, [l 1, b 1, b 2, b 3, b 4, b 5, b 6, r 7]) + ] + +prop_countDeltasWorks = countDelta [b 1, l 2, r 3, r 4, b 5] == (3,4) +prop_countDeltasWorks2 = countDelta [] == (0,0) + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Formatter/Format.hs b/src/ShellCheck/Formatter/Format.hs new file mode 100644 index 0000000..53b59a4 --- /dev/null +++ b/src/ShellCheck/Formatter/Format.hs @@ -0,0 +1,82 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.Format where + +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Fixer + +import Control.Monad +import Data.Array +import Data.List +import System.IO +import System.Info +import System.Environment + +-- A formatter that carries along an arbitrary piece of data +data Formatter = Formatter { + header :: IO (), + onResult :: CheckResult -> SystemInterface IO -> IO (), + onFailure :: FilePath -> ErrorMessage -> IO (), + footer :: IO () +} + +sourceFile = posFile . pcStartPos +lineNo = posLine . pcStartPos +endLineNo = posLine . pcEndPos +colNo = posColumn . pcStartPos +endColNo = posColumn . pcEndPos +codeNo = cCode . pcComment +messageText = cMessage . pcComment + +severityText :: PositionedComment -> String +severityText pc = + case cSeverity (pcComment pc) of + ErrorC -> "error" + WarningC -> "warning" + InfoC -> "info" + StyleC -> "style" + +-- Realign comments from a tabstop of 8 to 1 +makeNonVirtual comments contents = + map fix comments + where + list = lines contents + arr = listArray (1, length list) list + untabbedFix f = newFix { + fixReplacements = map (\r -> removeTabStops r arr) (fixReplacements f) + } + fix c = (removeTabStops c arr) { + pcFix = fmap untabbedFix (pcFix c) + } + + +shouldOutputColor :: ColorOption -> IO Bool +shouldOutputColor colorOption = + case colorOption of + ColorAlways -> return True + ColorNever -> return False + ColorAuto -> do + isTerminal <- hIsTerminalDevice stdout + term <- lookupEnv "TERM" + let windows = "mingw" `isPrefixOf` os + let dumbTerm = term `elem` [Just "dumb", Just "", Nothing] + let isUsableTty = isTerminal && not windows && not dumbTerm + return isUsableTty diff --git a/src/ShellCheck/Formatter/GCC.hs b/src/ShellCheck/Formatter/GCC.hs new file mode 100644 index 0000000..b921753 --- /dev/null +++ b/src/ShellCheck/Formatter/GCC.hs @@ -0,0 +1,65 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.GCC (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Data.List +import System.IO +import qualified Data.List.NonEmpty as NE + +format :: IO Formatter +format = return Formatter { + header = return (), + footer = return (), + onFailure = outputError, + onResult = outputAll +} + +outputError file error = hPutStrLn stderr $ file ++ ": " ++ error + +outputAll cr sys = mapM_ f groups + where + comments = crComments cr + groups = NE.groupWith sourceFile comments + f :: NE.NonEmpty PositionedComment -> IO () + f group = do + let filename = sourceFile (NE.head group) + result <- siReadFile sys (Just True) filename + let contents = either (const "") id result + outputResult filename contents (NE.toList group) + +outputResult filename contents warnings = do + let comments = makeNonVirtual warnings contents + mapM_ (putStrLn . formatComment filename) comments + +formatComment filename c = concat [ + filename, ":", + show $ lineNo c, ":", + show $ colNo c, ": ", + case severityText c of + "error" -> "error" + "warning" -> "warning" + _ -> "note", + ": ", + concat . lines $ messageText c, + " [SC", show $ codeNo c, "]" + ] diff --git a/src/ShellCheck/Formatter/JSON.hs b/src/ShellCheck/Formatter/JSON.hs new file mode 100644 index 0000000..6b38532 --- /dev/null +++ b/src/ShellCheck/Formatter/JSON.hs @@ -0,0 +1,111 @@ +{-# LANGUAGE OverloadedStrings #-} +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.JSON (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Control.DeepSeq +import Data.Aeson +import Data.IORef +import Data.Monoid +import GHC.Exts +import System.IO +import qualified Data.ByteString.Lazy.Char8 as BL + +format :: IO Formatter +format = do + ref <- newIORef [] + return Formatter { + header = return (), + onResult = collectResult ref, + onFailure = outputError, + footer = finish ref + } + +instance ToJSON Replacement where + toJSON replacement = + let start = repStartPos replacement + end = repEndPos replacement + str = repString replacement in + object [ + "precedence" .= repPrecedence replacement, + "insertionPoint" .= + case repInsertionPoint replacement of + InsertBefore -> "beforeStart" :: String + InsertAfter -> "afterEnd", + "line" .= posLine start, + "column" .= posColumn start, + "endLine" .= posLine end, + "endColumn" .= posColumn end, + "replacement" .= str + ] + +instance ToJSON PositionedComment where + toJSON comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + object [ + "file" .= posFile start, + "line" .= posLine start, + "endLine" .= posLine end, + "column" .= posColumn start, + "endColumn" .= posColumn end, + "level" .= severityText comment, + "code" .= cCode c, + "message" .= cMessage c, + "fix" .= pcFix comment + ] + + toEncoding comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + pairs ( + "file" .= posFile start + <> "line" .= posLine start + <> "endLine" .= posLine end + <> "column" .= posColumn start + <> "endColumn" .= posColumn end + <> "level" .= severityText comment + <> "code" .= cCode c + <> "message" .= cMessage c + <> "fix" .= pcFix comment + ) + +instance ToJSON Fix where + toJSON fix = object [ + "replacements" .= fixReplacements fix + ] + +outputError file msg = hPutStrLn stderr $ file ++ ": " ++ msg + +collectResult ref cr sys = mapM_ f groups + where + comments = crComments cr + groups = groupWith sourceFile comments + f :: [PositionedComment] -> IO () + f group = deepseq comments $ modifyIORef ref (\x -> comments ++ x) + +finish ref = do + list <- readIORef ref + BL.putStrLn $ encode list diff --git a/src/ShellCheck/Formatter/JSON1.hs b/src/ShellCheck/Formatter/JSON1.hs new file mode 100644 index 0000000..b4dbe35 --- /dev/null +++ b/src/ShellCheck/Formatter/JSON1.hs @@ -0,0 +1,128 @@ +{-# LANGUAGE OverloadedStrings #-} +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.JSON1 (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Control.DeepSeq +import Data.Aeson +import Data.IORef +import Data.Monoid +import System.IO +import qualified Data.ByteString.Lazy.Char8 as BL +import qualified Data.List.NonEmpty as NE + +format :: IO Formatter +format = do + ref <- newIORef [] + return Formatter { + header = return (), + onResult = collectResult ref, + onFailure = outputError, + footer = finish ref + } + +data Json1Output = Json1Output { + comments :: [PositionedComment] + } + +instance ToJSON Json1Output where + toJSON result = object [ + "comments" .= comments result + ] + toEncoding result = pairs ( + "comments" .= comments result + ) + +instance ToJSON Replacement where + toJSON replacement = + let start = repStartPos replacement + end = repEndPos replacement + str = repString replacement in + object [ + "precedence" .= repPrecedence replacement, + "insertionPoint" .= + case repInsertionPoint replacement of + InsertBefore -> "beforeStart" :: String + InsertAfter -> "afterEnd", + "line" .= posLine start, + "column" .= posColumn start, + "endLine" .= posLine end, + "endColumn" .= posColumn end, + "replacement" .= str + ] + +instance ToJSON PositionedComment where + toJSON comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + object [ + "file" .= posFile start, + "line" .= posLine start, + "endLine" .= posLine end, + "column" .= posColumn start, + "endColumn" .= posColumn end, + "level" .= severityText comment, + "code" .= cCode c, + "message" .= cMessage c, + "fix" .= pcFix comment + ] + + toEncoding comment = + let start = pcStartPos comment + end = pcEndPos comment + c = pcComment comment in + pairs ( + "file" .= posFile start + <> "line" .= posLine start + <> "endLine" .= posLine end + <> "column" .= posColumn start + <> "endColumn" .= posColumn end + <> "level" .= severityText comment + <> "code" .= cCode c + <> "message" .= cMessage c + <> "fix" .= pcFix comment + ) + +instance ToJSON Fix where + toJSON fix = object [ + "replacements" .= fixReplacements fix + ] + +outputError file msg = hPutStrLn stderr $ file ++ ": " ++ msg + +collectResult ref cr sys = mapM_ f groups + where + comments = crComments cr + groups = NE.groupWith sourceFile comments + f :: NE.NonEmpty PositionedComment -> IO () + f group = do + let filename = sourceFile (NE.head group) + result <- siReadFile sys (Just True) filename + let contents = either (const "") id result + let comments' = makeNonVirtual comments contents + deepseq comments' $ modifyIORef ref (\x -> comments' ++ x) + +finish ref = do + list <- readIORef ref + BL.putStrLn $ encode $ Json1Output { comments = list } diff --git a/src/ShellCheck/Formatter/Quiet.hs b/src/ShellCheck/Formatter/Quiet.hs new file mode 100644 index 0000000..b7e0ee9 --- /dev/null +++ b/src/ShellCheck/Formatter/Quiet.hs @@ -0,0 +1,36 @@ +{- + Copyright 2019 Austin Voecks + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.Quiet (format) where + +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Control.Monad +import Data.IORef +import System.Exit + +format :: FormatterOptions -> IO Formatter +format options = + return Formatter { + header = return (), + footer = return (), + onFailure = \ _ _ -> exitFailure, + onResult = \ result _ -> unless (null $ crComments result) exitFailure + } diff --git a/src/ShellCheck/Formatter/TTY.hs b/src/ShellCheck/Formatter/TTY.hs new file mode 100644 index 0000000..117da6e --- /dev/null +++ b/src/ShellCheck/Formatter/TTY.hs @@ -0,0 +1,197 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +module ShellCheck.Formatter.TTY (format) where + +import ShellCheck.Fixer +import ShellCheck.Interface +import ShellCheck.Formatter.Format + +import Control.DeepSeq +import Control.Monad +import Data.Array +import Data.Foldable +import Data.Ord +import Data.IORef +import Data.List +import Data.Maybe +import System.IO +import System.Info +import qualified Data.List.NonEmpty as NE + +wikiLink = "https://www.shellcheck.net/wiki/" + +-- An arbitrary Ord thing to order warnings +type Ranking = (Char, Severity, Integer) +-- Ansi coloring function +type ColorFunc = (String -> String -> String) + +format :: FormatterOptions -> IO Formatter +format options = do + topErrorRef <- newIORef [] + return Formatter { + header = return (), + footer = outputWiki topErrorRef, + onFailure = outputError options, + onResult = outputResult options topErrorRef + } + +colorForLevel level = + case level of + "error" -> 31 -- red + "warning" -> 33 -- yellow + "info" -> 32 -- green + "style" -> 32 -- green + "verbose" -> 32 -- green + "message" -> 1 -- bold + "source" -> 0 -- none + _ -> 0 -- none + +rankError :: PositionedComment -> Ranking +rankError err = (ranking, cSeverity $ pcComment err, cCode $ pcComment err) + where + ranking = + if cCode (pcComment err) `elem` uninteresting + then 'Z' + else 'A' + + -- A list of the most generic, least directly helpful + -- error codes to downrank. + uninteresting = [ + 1009, -- Mentioned parser error was.. + 1019, -- Expected this to be an argument + 1036, -- ( is invalid here + 1047, -- Expected 'fi' + 1062, -- Expected 'done' + 1070, -- Parsing stopped here (generic) + 1072, -- Missing/unexpected .. + 1073, -- Couldn't parse this .. + 1088, -- Parsing stopped here (paren) + 1089 -- Parsing stopped here (keyword) + ] + +appendComments errRef comments max = do + previous <- readIORef errRef + let current = map (\x -> (rankError x, cCode $ pcComment x, cMessage $ pcComment x)) comments + writeIORef errRef $! force . take max . nubBy equal . sort $ previous ++ current + where + fst3 (x,_,_) = x + equal x y = fst3 x == fst3 y + +outputWiki :: IORef [(Ranking, Integer, String)] -> IO () +outputWiki errRef = do + issues <- readIORef errRef + unless (null issues) $ do + putStrLn "For more information:" + mapM_ showErr issues + where + showErr (_, code, msg) = + putStrLn $ " " ++ wikiLink ++ "SC" ++ show code ++ " -- " ++ shorten msg + limit = 36 + shorten msg = + if length msg < limit + then msg + else (take (limit-3) msg) ++ "..." + +outputError options file error = do + color <- getColorFunc $ foColorOption options + hPutStrLn stderr $ color "error" $ file ++ ": " ++ error + +outputResult options ref result sys = do + color <- getColorFunc $ foColorOption options + let comments = crComments result + appendComments ref comments (fromIntegral $ foWikiLinkCount options) + let fileGroups = NE.groupWith sourceFile comments + mapM_ (outputForFile color sys) fileGroups + +outputForFile color sys comments = do + let fileName = sourceFile (NE.head comments) + result <- siReadFile sys (Just True) fileName + let contents = either (const "") id result + let fileLinesList = lines contents + let lineCount = length fileLinesList + let fileLines = listArray (1, lineCount) fileLinesList + let groups = NE.groupWith lineNo comments + forM_ groups $ \commentsForLine -> do + let lineNum = fromIntegral $ lineNo (NE.head commentsForLine) + let line = if lineNum < 1 || lineNum > lineCount + then "" + else fileLines ! fromIntegral lineNum + putStrLn "" + putStrLn $ color "message" $ + "In " ++ fileName ++" line " ++ show lineNum ++ ":" + putStrLn (color "source" line) + forM_ commentsForLine $ \c -> putStrLn $ color (severityText c) $ cuteIndent c + putStrLn "" + showFixedString color (toList commentsForLine) (fromIntegral lineNum) fileLines + +-- Pick out only the lines necessary to show a fix in action +sliceFile :: Fix -> Array Int String -> (Fix, Array Int String) +sliceFile fix lines = + (mapPositions adjust fix, sliceLines lines) + where + (minLine, maxLine) = + foldl (\(mm, mx) pos -> ((min mm $ fromIntegral $ posLine pos), (max mx $ fromIntegral $ posLine pos))) + (maxBound, minBound) $ + concatMap (\x -> [repStartPos x, repEndPos x]) $ fixReplacements fix + sliceLines :: Array Int String -> Array Int String + sliceLines = ixmap (1, maxLine - minLine + 1) (\x -> x + minLine - 1) + adjust pos = + pos { + posLine = posLine pos - (fromIntegral minLine) + 1 + } + +showFixedString :: ColorFunc -> [PositionedComment] -> Int -> Array Int String -> IO () +showFixedString color comments lineNum fileLines = + let line = fileLines ! fromIntegral lineNum in + case mapMaybe pcFix comments of + [] -> return () + fixes -> do + -- Folding automatically removes overlap + let mergedFix = fold fixes + -- We show the complete, associated fixes, whether or not it includes this + -- and/or other unrelated lines. + let (excerptFix, excerpt) = sliceFile mergedFix fileLines + -- in the spirit of error prone + putStrLn $ color "message" "Did you mean:" + putStrLn $ unlines $ applyFix excerptFix excerpt + +cuteIndent :: PositionedComment -> String +cuteIndent comment = + replicate (fromIntegral $ colNo comment - 1) ' ' ++ + makeArrow ++ " " ++ code (codeNo comment) ++ " (" ++ severityText comment ++ "): " ++ messageText comment + where + arrow n = '^' : replicate (fromIntegral $ n-2) '-' ++ "^" + makeArrow = + let sameLine = lineNo comment == endLineNo comment + delta = endColNo comment - colNo comment + in + if sameLine && delta > 2 && delta < 32 then arrow delta else "^--" + +code num = "SC" ++ show num + +getColorFunc :: ColorOption -> IO ColorFunc +getColorFunc colorOption = do + useColor <- shouldOutputColor colorOption + return $ if useColor then colorComment else const id + where + colorComment level comment = + ansi (colorForLevel level) ++ comment ++ clear + clear = ansi 0 + ansi n = "\x1B[" ++ show n ++ "m" diff --git a/src/ShellCheck/Interface.hs b/src/ShellCheck/Interface.hs new file mode 100644 index 0000000..16a7e36 --- /dev/null +++ b/src/ShellCheck/Interface.hs @@ -0,0 +1,341 @@ +{- + Copyright 2012-2024 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE DeriveGeneric, DeriveAnyClass #-} +module ShellCheck.Interface + ( + SystemInterface(..) + , CheckSpec(csFilename, csScript, csCheckSourced, csIncludedWarnings, csExcludedWarnings, csShellTypeOverride, csMinSeverity, csIgnoreRC, csExtendedAnalysis, csOptionalChecks) + , CheckResult(crFilename, crComments) + , ParseSpec(psFilename, psScript, psCheckSourced, psIgnoreRC, psShellTypeOverride) + , ParseResult(prComments, prTokenPositions, prRoot) + , AnalysisSpec(asScript, asShellType, asFallbackShell, asExecutionMode, asCheckSourced, asTokenPositions, asExtendedAnalysis, asOptionalChecks) + , AnalysisResult(arComments) + , FormatterOptions(foColorOption, foWikiLinkCount) + , Shell(Ksh, Sh, Bash, Dash, BusyboxSh) + , ExecutionMode(Executed, Sourced) + , ErrorMessage + , Code + , Severity(ErrorC, WarningC, InfoC, StyleC) + , Position(posFile, posLine, posColumn) + , Comment(cSeverity, cCode, cMessage) + , PositionedComment(pcStartPos , pcEndPos , pcComment, pcFix) + , ColorOption(ColorAuto, ColorAlways, ColorNever) + , TokenComment(tcId, tcComment, tcFix) + , emptyCheckResult + , newAnalysisResult + , newAnalysisSpec + , newFormatterOptions + , newParseResult + , newPosition + , newSystemInterface + , newTokenComment + , mockedSystemInterface + , mockRcFile + , newParseSpec + , emptyCheckSpec + , newPositionedComment + , newComment + , Fix(fixReplacements) + , newFix + , InsertionPoint(InsertBefore, InsertAfter) + , Replacement(repStartPos, repEndPos, repString, repPrecedence, repInsertionPoint) + , newReplacement + , CheckDescription(cdName, cdDescription, cdPositive, cdNegative) + , newCheckDescription + ) where + +import ShellCheck.AST + +import Control.DeepSeq +import Control.Monad.Identity +import Data.List +import Data.Monoid +import Data.Ord +import Data.Semigroup +import GHC.Generics (Generic) +import qualified Data.Map as Map + + +data SystemInterface m = SystemInterface { + -- | Given: + -- What annotations say about including external files (if anything) + -- A resolved filename from siFindSource + -- Read the file or return an error + siReadFile :: Maybe Bool -> String -> m (Either ErrorMessage String), + -- | Given: + -- the current script, + -- what annotations say about including external files (if anything) + -- a list of source-path annotations in effect, + -- and a sourced file, + -- find the sourced file + siFindSource :: String -> Maybe Bool -> [String] -> String -> m FilePath, + -- | Get the configuration file (name, contents) for a filename + siGetConfig :: String -> m (Maybe (FilePath, String)) +} + +-- ShellCheck input and output +data CheckSpec = CheckSpec { + csFilename :: String, + csScript :: String, + csCheckSourced :: Bool, + csIgnoreRC :: Bool, + csExcludedWarnings :: [Integer], + csIncludedWarnings :: Maybe [Integer], + csShellTypeOverride :: Maybe Shell, + csMinSeverity :: Severity, + csExtendedAnalysis :: Maybe Bool, + csOptionalChecks :: [String] +} deriving (Show, Eq) + +data CheckResult = CheckResult { + crFilename :: String, + crComments :: [PositionedComment] +} deriving (Show, Eq) + +emptyCheckResult :: CheckResult +emptyCheckResult = CheckResult { + crFilename = "", + crComments = [] +} + +emptyCheckSpec :: CheckSpec +emptyCheckSpec = CheckSpec { + csFilename = "", + csScript = "", + csCheckSourced = False, + csIgnoreRC = False, + csExcludedWarnings = [], + csIncludedWarnings = Nothing, + csShellTypeOverride = Nothing, + csMinSeverity = StyleC, + csExtendedAnalysis = Nothing, + csOptionalChecks = [] +} + +newParseSpec :: ParseSpec +newParseSpec = ParseSpec { + psFilename = "", + psScript = "", + psCheckSourced = False, + psIgnoreRC = False, + psShellTypeOverride = Nothing +} + +newSystemInterface :: Monad m => SystemInterface m +newSystemInterface = + SystemInterface { + siReadFile = \_ _ -> return $ Left "Not implemented", + siFindSource = \_ _ _ name -> return name, + siGetConfig = \_ -> return Nothing + } + +-- Parser input and output +data ParseSpec = ParseSpec { + psFilename :: String, + psScript :: String, + psCheckSourced :: Bool, + psIgnoreRC :: Bool, + psShellTypeOverride :: Maybe Shell +} deriving (Show, Eq) + +data ParseResult = ParseResult { + prComments :: [PositionedComment], + prTokenPositions :: Map.Map Id (Position, Position), + prRoot :: Maybe Token +} deriving (Show, Eq) + +newParseResult :: ParseResult +newParseResult = ParseResult { + prComments = [], + prTokenPositions = Map.empty, + prRoot = Nothing +} + +-- Analyzer input and output +data AnalysisSpec = AnalysisSpec { + asScript :: Token, + asShellType :: Maybe Shell, + asFallbackShell :: Maybe Shell, + asExecutionMode :: ExecutionMode, + asCheckSourced :: Bool, + asOptionalChecks :: [String], + asExtendedAnalysis :: Maybe Bool, + asTokenPositions :: Map.Map Id (Position, Position) +} + +newAnalysisSpec token = AnalysisSpec { + asScript = token, + asShellType = Nothing, + asFallbackShell = Nothing, + asExecutionMode = Executed, + asCheckSourced = False, + asOptionalChecks = [], + asExtendedAnalysis = Nothing, + asTokenPositions = Map.empty +} + +newtype AnalysisResult = AnalysisResult { + arComments :: [TokenComment] +} + +newAnalysisResult = AnalysisResult { + arComments = [] +} + +-- Formatter options +data FormatterOptions = FormatterOptions { + foColorOption :: ColorOption, + foWikiLinkCount :: Integer +} + +newFormatterOptions = FormatterOptions { + foColorOption = ColorAuto, + foWikiLinkCount = 3 +} + +data CheckDescription = CheckDescription { + cdName :: String, + cdDescription :: String, + cdPositive :: String, + cdNegative :: String + } + +newCheckDescription = CheckDescription { + cdName = "", + cdDescription = "", + cdPositive = "", + cdNegative = "" + } + +-- Supporting data types +data Shell = Ksh | Sh | Bash | Dash | BusyboxSh deriving (Show, Eq) +data ExecutionMode = Executed | Sourced deriving (Show, Eq) + +type ErrorMessage = String +type Code = Integer + +data Severity = ErrorC | WarningC | InfoC | StyleC + deriving (Show, Eq, Ord, Generic, NFData) +data Position = Position { + posFile :: String, -- Filename + posLine :: Integer, -- 1 based source line + posColumn :: Integer -- 1 based source column, where tabs are 8 +} deriving (Show, Eq, Generic, NFData, Ord) + +newPosition :: Position +newPosition = Position { + posFile = "", + posLine = 1, + posColumn = 1 +} + +data Comment = Comment { + cSeverity :: Severity, + cCode :: Code, + cMessage :: String +} deriving (Show, Eq, Generic, NFData) + +newComment :: Comment +newComment = Comment { + cSeverity = StyleC, + cCode = 0, + cMessage = "" +} + +-- only support single line for now +data Replacement = Replacement { + repStartPos :: Position, + repEndPos :: Position, + repString :: String, + -- Order in which the replacements should happen: highest precedence first. + repPrecedence :: Int, + -- Whether to insert immediately before or immediately after the specified region. + repInsertionPoint :: InsertionPoint +} deriving (Show, Eq, Generic, NFData) + +data InsertionPoint = InsertBefore | InsertAfter + deriving (Show, Eq, Generic, NFData) + +newReplacement = Replacement { + repStartPos = newPosition, + repEndPos = newPosition, + repString = "", + repPrecedence = 1, + repInsertionPoint = InsertAfter +} + +data Fix = Fix { + fixReplacements :: [Replacement] +} deriving (Show, Eq, Generic, NFData) + +newFix = Fix { + fixReplacements = [] +} + +data PositionedComment = PositionedComment { + pcStartPos :: Position, + pcEndPos :: Position, + pcComment :: Comment, + pcFix :: Maybe Fix +} deriving (Show, Eq, Generic, NFData) + +newPositionedComment :: PositionedComment +newPositionedComment = PositionedComment { + pcStartPos = newPosition, + pcEndPos = newPosition, + pcComment = newComment, + pcFix = Nothing +} + +data TokenComment = TokenComment { + tcId :: Id, + tcComment :: Comment, + tcFix :: Maybe Fix +} deriving (Show, Eq, Generic, NFData) + +newTokenComment = TokenComment { + tcId = Id 0, + tcComment = newComment, + tcFix = Nothing +} + +data ColorOption = + ColorAuto + | ColorAlways + | ColorNever + deriving (Ord, Eq, Show) + +-- For testing +mockedSystemInterface :: [(String, String)] -> SystemInterface Identity +mockedSystemInterface files = (newSystemInterface :: SystemInterface Identity) { + siReadFile = rf, + siFindSource = fs, + siGetConfig = const $ return Nothing +} + where + rf _ file = return $ + case find ((== file) . fst) files of + Nothing -> Left "File not included in mock." + Just (_, contents) -> Right contents + fs _ _ _ file = return file + +mockRcFile rcfile mock = mock { + siGetConfig = const . return $ Just (".shellcheckrc", rcfile) +} diff --git a/src/ShellCheck/Parser.hs b/src/ShellCheck/Parser.hs new file mode 100644 index 0000000..f8a94bc --- /dev/null +++ b/src/ShellCheck/Parser.hs @@ -0,0 +1,3668 @@ +{- + Copyright 2012-2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE NoMonomorphismRestriction #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE MultiWayIf #-} +module ShellCheck.Parser (parseScript, runTests) where + +import ShellCheck.AST +import ShellCheck.ASTLib hiding (runTests) +import ShellCheck.Data +import ShellCheck.Interface +import ShellCheck.Prelude + +import Control.Applicative ((<*), (*>)) +import Control.Monad +import Control.Monad.Identity +import Control.Monad.Trans +import Data.Char +import Data.Functor +import Data.List (isPrefixOf, isInfixOf, isSuffixOf, partition, sortBy, intercalate, nub, find) +import Data.Maybe +import Data.Monoid +import GHC.Exts (sortWith) +import Prelude hiding (readList) +import System.IO +import Text.Parsec hiding (runParser, ()) +import Text.Parsec.Error +import Text.Parsec.Pos +import qualified Control.Monad.Reader as Mr +import qualified Control.Monad.State as Ms +import qualified Data.List.NonEmpty as NE +import qualified Data.Map.Strict as Map +import Debug.Trace + +import Test.QuickCheck.All (quickCheckAll) + +type SCBase m = Mr.ReaderT (Environment m) (Ms.StateT SystemState m) +type SCParser m v = ParsecT String UserState (SCBase m) v + +backslash :: Monad m => SCParser m Char +backslash = char '\\' +linefeed :: Monad m => SCParser m Char +linefeed = do + optional carriageReturn + c <- char '\n' + readPendingHereDocs + return c +singleQuote = char '\'' +doubleQuote = char '"' +variableStart = upper <|> lower <|> oneOf "_" +variableChars = upper <|> lower <|> digit <|> oneOf "_" +-- Chars to allow function names to start with +functionStartChars = variableChars <|> oneOf ":+?-./^@," +-- Chars to allow inside function names +functionChars = variableChars <|> oneOf "#:+?-./^@," +-- Chars to allow function names to start with, using the 'function' keyword +extendedFunctionStartChars = functionStartChars <|> oneOf "[]*=!" +-- Chars to allow in functions using the 'function' keyword +extendedFunctionChars = extendedFunctionStartChars <|> oneOf "[]*=!" +specialVariable = oneOf (concat specialVariables) +paramSubSpecialChars = oneOf "/:+-=%" +quotableChars = "|&;<>()\\ '\t\n\r\xA0" ++ doubleQuotableChars +quotable = almostSpace <|> oneOf quotableChars +bracedQuotable = oneOf "}\"$`'" +doubleQuotableChars = "\\\"$`" +doubleQuotable = oneOf doubleQuotableChars +whitespace = oneOf " \t" <|> carriageReturn <|> almostSpace <|> linefeed +linewhitespace = oneOf " \t" <|> almostSpace + +suspectCharAfterQuotes = variableChars <|> char '%' + +extglobStartChars = "?*@!+" +extglobStart = oneOf extglobStartChars + +unicodeDoubleQuotes = "\x201C\x201D\x2033\x2036" +unicodeSingleQuotes = "\x2018\x2019" + +prop_spacing1 = isOk spacing " \\\n # Comment" +prop_spacing2 = isOk spacing "# We can continue lines with \\" +prop_spacing3 = isWarning spacing " \\\n # --verbose=true \\" +spacing = do + x <- many (many1 linewhitespace <|> continuation) + optional readComment + return $ concat x + where + continuation = do + try (string "\\\n") + -- The line was continued. Warn if this next line is a comment with a trailing \ + whitespace <- many linewhitespace + optional $ do + x <- readComment + when ("\\" `isSuffixOf` x) $ + parseProblem ErrorC 1143 "This backslash is part of a comment and does not continue the line." + return whitespace + +spacing1 = do + spacing <- spacing + when (null spacing) $ fail "Expected whitespace" + return spacing + +prop_allspacing = isOk allspacing "#foo" +prop_allspacing2 = isOk allspacing " #foo\n # bar\n#baz\n" +prop_allspacing3 = isOk allspacing "#foo\n#bar\n#baz\n" +allspacing = do + s <- spacing + more <- option False (linefeed >> return True) + if more then do + rest <- allspacing + return $ s ++ "\n" ++ rest + else + return s + +allspacingOrFail = do + s <- allspacing + when (null s) $ fail "Expected whitespace" + return s + +readUnicodeQuote = do + start <- startSpan + c <- oneOf (unicodeSingleQuotes ++ unicodeDoubleQuotes) + id <- endSpan start + parseProblemAtId id WarningC 1110 "This is a unicode quote. Delete and retype it (or quote to make literal)." + return $ T_Literal id [c] + +carriageReturn = do + pos <- getPosition + char '\r' + parseProblemAt pos ErrorC 1017 "Literal carriage return. Run script through tr -d '\\r' ." + return '\r' + +almostSpace = do + parseNote ErrorC 1018 $ "This is a unicode space. Delete and retype it." + oneOf "\xA0\x2002\x2003\x2004\x2005\x2006\x2007\x2008\x2009\x200B\x202F" + return ' ' + +--------- Message/position annotation on top of user state +data ParseNote = ParseNote SourcePos SourcePos Severity Code String deriving (Show, Eq) +data Context = + ContextName SourcePos String + | ContextAnnotation [Annotation] + | ContextSource String + deriving (Show) + +data HereDocContext = + HereDocPending Id Dashed Quoted String [Context] -- on linefeed, read this T_HereDoc + deriving (Show) + +data UserState = UserState { + lastId :: Id, + positionMap :: Map.Map Id (SourcePos, SourcePos), + parseNotes :: [ParseNote], + hereDocMap :: Map.Map Id [Token], + pendingHereDocs :: [HereDocContext] +} +initialUserState = UserState { + lastId = Id $ -1, + positionMap = Map.empty, + parseNotes = [], + hereDocMap = Map.empty, + pendingHereDocs = [] +} + +codeForParseNote (ParseNote _ _ _ code _) = code + +getLastId = lastId <$> getState + +getNextIdBetween startPos endPos = do + state <- getState + let newId = incId (lastId state) + let newMap = Map.insert newId (startPos, endPos) (positionMap state) + putState $ state { + lastId = newId, + positionMap = newMap + } + return newId + where incId (Id n) = Id $ n+1 + +getNextIdSpanningTokens startTok endTok = do + (start, _) <- getSpanForId (getId startTok) + (_, end) <- getSpanForId (getId endTok) + getNextIdBetween start end + +-- Get an ID starting from the first token of the list, and ending after the last +getNextIdSpanningTokenList list = + case list of + [] -> do + pos <- getPosition + getNextIdBetween pos pos + (h:_) -> + getNextIdSpanningTokens h (last list) + +-- Get the span covered by an id +getSpanForId :: Monad m => Id -> SCParser m (SourcePos, SourcePos) +getSpanForId id = + Map.findWithDefault (error $ pleaseReport "no parser span for id") id <$> + getMap + +-- Create a new id with the same span as an existing one +getNewIdFor :: Monad m => Id -> SCParser m Id +getNewIdFor id = getSpanForId id >>= uncurry getNextIdBetween + +data IncompleteInterval = IncompleteInterval SourcePos + +startSpan = IncompleteInterval <$> getPosition + +endSpan (IncompleteInterval start) = do + endPos <- getPosition + getNextIdBetween start endPos + +getSpanPositionsFor m = do + start <- getPosition + m + end <- getPosition + return (start, end) + +addToHereDocMap id list = do + state <- getState + let map = hereDocMap state + putState $ state { + hereDocMap = Map.insert id list map + } + +addPendingHereDoc id d q str = do + state <- getState + context <- getCurrentContexts + let docs = pendingHereDocs state + putState $ state { + pendingHereDocs = HereDocPending id d q str context : docs + } + +popPendingHereDocs = do + state <- getState + let pending = pendingHereDocs state + putState $ state { + pendingHereDocs = [] + } + return . reverse $ pendingHereDocs state + +getMap = positionMap <$> getState +getParseNotes = parseNotes <$> getState + +addParseNote n = do + irrelevant <- shouldIgnoreCode (codeForParseNote n) + unless irrelevant $ do + state <- getState + putState $ state { + parseNotes = n : parseNotes state + } + +ignoreProblemsOf p = do + systemState <- lift . lift $ Ms.get + p <* (lift . lift . Ms.put $ systemState) + +shouldIgnoreCode code = do + context <- getCurrentContexts + checkSourced <- Mr.asks checkSourced + return $ any (contextItemDisablesCode checkSourced code) context + +-- Does this item on the context stack disable warnings for 'code'? +contextItemDisablesCode :: Bool -> Integer -> Context -> Bool +contextItemDisablesCode alsoCheckSourced code = disabling alsoCheckSourced + where + disabling checkSourced item = + case item of + ContextAnnotation list -> any disabling' list + ContextSource _ -> not $ checkSourced + _ -> False + disabling' (DisableComment n m) = code >= n && code < m + disabling' _ = False + + + +getCurrentAnnotations includeSource = + concatMap get . takeWhile (not . isBoundary) <$> getCurrentContexts + where + get (ContextAnnotation list) = list + get _ = [] + isBoundary (ContextSource _) = not includeSource + isBoundary _ = False + + +shouldFollow file = do + context <- getCurrentContexts + if any isThisFile context + then return False + else + if length (filter isSource context) >= 100 + then do + parseProblem ErrorC 1092 "Stopping at 100 'source' frames :O" + return False + else + return True + where + isSource (ContextSource _) = True + isSource _ = False + isThisFile (ContextSource name) | name == file = True + isThisFile _= False + +getSourceOverride = do + context <- getCurrentContexts + return . msum . map findFile $ takeWhile isSameFile context + where + isSameFile (ContextSource _) = False + isSameFile _ = True + + findFile (ContextAnnotation list) = msum $ map getFile list + findFile _ = Nothing + getFile (SourceOverride str) = Just str + getFile _ = Nothing + +-- Store potential parse problems outside of parsec + +data SystemState = SystemState { + contextStack :: [Context], + parseProblems :: [ParseNote] +} +initialSystemState = SystemState { + contextStack = [], + parseProblems = [] +} + +data Environment m = Environment { + systemInterface :: SystemInterface m, + checkSourced :: Bool, + ignoreRC :: Bool, + currentFilename :: String, + shellTypeOverride :: Maybe Shell +} + +parseProblem level code msg = do + pos <- getPosition + parseProblemAt pos level code msg + +setCurrentContexts c = Ms.modify (\state -> state { contextStack = c }) +getCurrentContexts = Ms.gets contextStack + +popContext = do + v <- getCurrentContexts + case v of + (a:r) -> do + setCurrentContexts r + return $ Just a + [] -> + return Nothing + +pushContext c = do + v <- getCurrentContexts + setCurrentContexts (c:v) + +parseProblemAtWithEnd start end level code msg = do + irrelevant <- shouldIgnoreCode code + unless irrelevant $ + addParseProblem note + where + note = ParseNote start end level code msg + +addParseProblem note = + Ms.modify (\state -> state { + parseProblems = note:parseProblems state + }) + +parseProblemAt pos = parseProblemAtWithEnd pos pos + +parseProblemAtId :: Monad m => Id -> Severity -> Integer -> String -> SCParser m () +parseProblemAtId id level code msg = do + (start, end) <- getSpanForId id + parseProblemAtWithEnd start end level code msg + +-- Store non-parse problems inside + +parseNote c l a = do + pos <- getPosition + parseNoteAt pos c l a + +parseNoteAt pos c l a = addParseNote $ ParseNote pos pos c l a +parseNoteAtId id c l a = do + (start, end) <- getSpanForId id + addParseNote $ ParseNote start end c l a + +parseNoteAtWithEnd start end c l a = addParseNote $ ParseNote start end c l a + +--------- Convenient combinators +thenSkip main follow = main <* optional follow + +unexpecting s p = try $ + (try p >> fail ("Unexpected " ++ s)) <|> return () + +notFollowedBy2 = unexpecting "" + +isFollowedBy p = (lookAhead . try $ p $> True) <|> return False + +reluctantlyTill p end = + (lookAhead (void (try end) <|> eof) >> return []) <|> do + x <- p + more <- reluctantlyTill p end + return $ x:more + <|> return [] + +reluctantlyTill1 p end = do + notFollowedBy2 end + x <- p + more <- reluctantlyTill p end + return $ x:more + +attempting rest branch = + (try branch >> rest) <|> rest + +orFail parser errorAction = + try parser <|> (errorAction >>= fail) + +-- Construct a node with a parser, e.g. T_Literal `withParser` (readGenericLiteral ",") +withParser node parser = do + start <- startSpan + contents <- parser + id <- endSpan start + return $ node id contents + +wasIncluded p = option False (p >> return True) + +acceptButWarn parser level code note = + optional $ try (do + pos <- getPosition + parser + parseProblemAt pos level code note + ) + +parsecBracket before after op = do + val <- before + op val `thenSkip` after val <|> (after val *> fail "") + +swapContext contexts p = + parsecBracket (getCurrentContexts <* setCurrentContexts contexts) + setCurrentContexts + (const p) + +withContext entry p = parsecBracket (pushContext entry) (const popContext) (const p) + +called s p = do + pos <- getPosition + withContext (ContextName pos s) p + +withAnnotations anns p = + if null anns then p else withContext (ContextAnnotation anns) p + +readConditionContents single = + readCondContents `attempting` lookAhead (do + pos <- getPosition + s <- readVariableName + spacing1 + when (s `elem` commonCommands) $ + parseProblemAt pos WarningC 1014 "Use 'if cmd; then ..' to check exit code, or 'if [[ $(cmd) == .. ]]' to check output.") + + where + spacingOrLf = condSpacing True + condSpacing required = do + pos <- getPosition + space <- allspacing + when (required && null space) $ + parseProblemAt pos ErrorC 1035 "You are missing a required space here." + when (single && '\n' `elem` space) $ + parseProblemAt pos ErrorC 1080 "When breaking lines in [ ], you need \\ before the linefeed." + return space + + typ = if single then SingleBracket else DoubleBracket + readCondBinaryOp = try $ do + optional guardArithmetic + op <- getOp + spacingOrLf + return op + where + flaglessOps = [ "==", "!=", "<=", ">=", "=~", ">", "<", "=" ] + + getOp = do + start <- startSpan + op <- readRegularOrEscaped anyOp + id <- endSpan start + return $ TC_Binary id typ op + + anyOp = flagOp <|> flaglessOp <|> fail + "Expected comparison operator (don't wrap commands in []/[[]])" + flagOp = try $ do + s <- readOp + when (s == "-a" || s == "-o") $ fail "Unexpected operator" + return s + flaglessOp = + choice $ map (try . string) flaglessOps + + -- hacks to read quoted operators without having to read a shell word + readEscaped p = try $ withEscape <|> withQuotes + where + withEscape = do + char '\\' + escaped <$> p + withQuotes = do + c <- oneOf "'\"" + s <- p + char c + return $ escaped s + escaped s = if any (`elem` s) "<>()" then '\\':s else s + + readRegularOrEscaped p = readEscaped p <|> p + + + guardArithmetic = do + try . lookAhead $ void (oneOf "+*/%") <|> void (string "- ") + parseProblem ErrorC 1076 $ + if single + then "Trying to do math? Use e.g. [ $((i/2+7)) -ge 18 ]." + else "Trying to do math? Use e.g. [[ $((i/2+7)) -ge 18 ]]." + + readCondUnaryExp = do + op <- readCondUnaryOp + pos <- getPosition + liftM op readCondWord `orFail` do + parseProblemAt pos ErrorC 1019 "Expected this to be an argument to the unary condition." + return "Expected an argument for the unary operator" + + readCondUnaryOp = try $ do + start <- startSpan + s <- readOp + id <- endSpan start + spacingOrLf + return $ TC_Unary id typ s + + readOp = try $ do + char '-' <|> weirdDash + s <- many1 letter <|> fail "Expected a test operator" + return ('-':s) + + weirdDash = do + pos <- getPosition + oneOf "\x058A\x05BE\x2010\x2011\x2012\x2013\x2014\x2015\xFE63\xFF0D" + parseProblemAt pos ErrorC 1100 + "This is a unicode dash. Delete and retype as ASCII minus." + return '-' + + readCondWord = do + notFollowedBy2 (try (spacing >> string "]")) + x <- readNormalWord + pos <- getPosition + when (notArrayIndex x && endedWith "]" x && not (x `containsLiteral` "[")) $ do + parseProblemAt pos ErrorC 1020 $ + "You need a space before the " ++ (if single then "]" else "]]") ++ "." + fail "Missing space before ]" + when (single && endedWith ")" x) $ do + parseProblemAt pos ErrorC 1021 + "You need a space before the \\)" + fail "Missing space before )" + void spacing + return x + where endedWith str (T_NormalWord id s@(_:_)) = + case last s of T_Literal id s -> str `isSuffixOf` s + _ -> False + endedWith _ _ = False + notArrayIndex (T_NormalWord id s@(_:T_Literal _ t:_)) = t /= "[" + notArrayIndex _ = True + containsLiteral x s = s `isInfixOf` onlyLiteralString x + + readCondAndOp = readAndOrOp TC_And "&&" False <|> readAndOrOp TC_And "-a" True + + readCondOrOp = do + optional guardArithmetic + readAndOrOp TC_Or "||" False <|> readAndOrOp TC_Or "-o" True + + readAndOrOp node op requiresSpacing = do + optional $ lookAhead weirdDash + start <- startSpan + x <- try $ string op + id <- endSpan start + condSpacing requiresSpacing + return $ node id typ x + + readCondNullaryOrBinary = do + start <- startSpan + x <- readCondWord `attempting` (do + pos <- getPosition + lookAhead (char '[') + parseProblemAt pos ErrorC 1026 $ if single + then "If grouping expressions inside [..], use \\( ..\\)." + else "If grouping expressions inside [[..]], use ( .. )." + ) + id <- endSpan start + (do + pos <- getPosition + isRegex <- regexOperatorAhead + op <- readCondBinaryOp + y <- if isRegex + then readRegex + else readCondWord <|> (parseProblemAt pos ErrorC 1027 "Expected another argument for this operator." >> mzero) + return (x `op` y) + ) <|> ( do + checkTrailingOp x + return $ TC_Nullary id typ x + ) + + checkTrailingOp x = sequence_ $ do + (T_Literal id str) <- getTrailingUnquotedLiteral x + trailingOp <- find (`isSuffixOf` str) binaryTestOps + return $ parseProblemAtId id ErrorC 1108 $ + "You need a space before and after the " ++ trailingOp ++ " ." + + readCondGroup = do + start <- startSpan + pos <- getPosition + lparen <- try $ readRegularOrEscaped (string "(") + when (single && lparen == "(") $ + singleWarning pos + when (not single && lparen == "\\(") $ + doubleWarning pos + condSpacing single + x <- readCondContents + cpos <- getPosition + rparen <- readRegularOrEscaped (string ")") + id <- endSpan start + condSpacing single + when (single && rparen == ")") $ + singleWarning cpos + when (not single && rparen == "\\)") $ + doubleWarning cpos + return $ TC_Group id typ x + + where + singleWarning pos = + parseProblemAt pos ErrorC 1028 "In [..] you have to escape \\( \\) or preferably combine [..] expressions." + doubleWarning pos = + parseProblemAt pos ErrorC 1029 "In [[..]] you shouldn't escape ( or )." + + + -- Currently a bit of a hack since parsing rules are obscure + regexOperatorAhead = lookAhead (do + try (string "=~") <|> try (string "~=") + return True) + <|> return False + readRegex = called "regex" $ do + start <- startSpan + parts <- many1 readPart + id <- endSpan start + void spacing + return $ T_NormalWord id parts + where + readPart = choice [ + readGroup, + readSingleQuoted, + readDoubleQuoted, + readDollarExpression, + readLiteralForParser $ readNormalLiteral "( ", + readLiteralString "|", + readGlobLiteral + ] + readGlobLiteral = do + start <- startSpan + s <- extglobStart <|> oneOf "{}[]$" + id <- endSpan start + return $ T_Literal id [s] + readGroup = called "regex grouping" $ do + start <- startSpan + p1 <- readLiteralString "(" + parts <- many (readPart <|> readRegexLiteral) + p2 <- readLiteralString ")" + id <- endSpan start + return $ T_NormalWord id (p1:(parts ++ [p2])) + readRegexLiteral = do + start <- startSpan + str <- readGenericLiteral1 (singleQuote <|> doubleQuotable <|> oneOf "()") + id <- endSpan start + return $ T_Literal id str + readLiteralString s = do + start <- startSpan + str <- string s + id <- endSpan start + return $ T_Literal id str + + readCondTerm = do + term <- readCondNot <|> readCondExpr + condSpacing False + return term + + readCondNot = do + start <- startSpan + char '!' + id <- endSpan start + spacingOrLf + expr <- readCondExpr + return $ TC_Unary id typ "!" expr + + readCondExpr = + readCondGroup <|> readCondUnaryExp <|> readCondNullaryOrBinary + + readCondOr = chainl1 readCondAnd readCondAndOp + readCondAnd = chainl1 readCondTerm readCondOrOp + readCondContents = readCondOr + + +prop_a1 = isOk readArithmeticContents " n++ + ++c" +prop_a2 = isOk readArithmeticContents "$N*4-(3,2)" +prop_a3 = isOk readArithmeticContents "n|=2<<1" +prop_a4 = isOk readArithmeticContents "n &= 2 **3" +prop_a5 = isOk readArithmeticContents "1 |= 4 && n >>= 4" +prop_a6 = isOk readArithmeticContents " 1 | 2 ||3|4" +prop_a7 = isOk readArithmeticContents "3*2**10" +prop_a8 = isOk readArithmeticContents "3" +prop_a9 = isOk readArithmeticContents "a^!-b" +prop_a10 = isOk readArithmeticContents "! $?" +prop_a11 = isOk readArithmeticContents "10#08 * 16#f" +prop_a12 = isOk readArithmeticContents "\"$((3+2))\" + '37'" +prop_a13 = isOk readArithmeticContents "foo[9*y+x]++" +prop_a14 = isOk readArithmeticContents "1+`echo 2`" +prop_a15 = isOk readArithmeticContents "foo[`echo foo | sed s/foo/4/g` * 3] + 4" +prop_a16 = isOk readArithmeticContents "$foo$bar" +prop_a17 = isOk readArithmeticContents "i<(0+(1+1))" +prop_a18 = isOk readArithmeticContents "a?b:c" +prop_a19 = isOk readArithmeticContents "\\\n3 +\\\n 2" +prop_a20 = isOk readArithmeticContents "a ? b ? c : d : e" +prop_a21 = isOk readArithmeticContents "a ? b : c ? d : e" +prop_a22 = isOk readArithmeticContents "!!a" +prop_a23 = isOk readArithmeticContents "~0" +readArithmeticContents :: Monad m => SCParser m Token +readArithmeticContents = + readSequence + where + spacing = + let lf = try (string "\\\n") >> return '\n' + in many (whitespace <|> lf) + + splitBy x ops = chainl1 x (readBinary ops) + readBinary ops = readComboOp ops TA_Binary + readComboOp op token = do + start <- startSpan + op <- choice (map (\x -> try $ do + s <- string x + failIfIncompleteOp + return s + ) op) + id <- endSpan start + spacing + return $ token id op + + failIfIncompleteOp = notFollowedBy2 $ oneOf "&|<>=" + + -- Read binary minus, but also check for -lt, -gt and friends: + readMinusOp = do + start <- startSpan + pos <- getPosition + try $ do + char '-' + failIfIncompleteOp + optional $ do + (str, alt) <- lookAhead . choice $ map tryOp [ + ("lt", "<"), + ("gt", ">"), + ("le", "<="), + ("ge", ">="), + ("eq", "=="), + ("ne", "!=") + ] + parseProblemAt pos ErrorC 1106 $ "In arithmetic contexts, use " ++ alt ++ " instead of -" ++ str + id <- endSpan start + spacing + return $ TA_Binary id "-" + where + tryOp (str, alt) = try $ do + string str + spacing1 + return (str, alt) + + readArrayIndex = do + start <- startSpan + char '[' + pos <- getPosition + middle <- readStringForParser readArithmeticContents + char ']' + id <- endSpan start + return $ T_UnparsedIndex id pos middle + + literal s = do + start <- startSpan + string s + id <- endSpan start + return $ T_Literal id s + + readVariable = do + start <- startSpan + name <- readVariableName + indices <- many readArrayIndex + id <- endSpan start + spacing + return $ TA_Variable id name indices + + readExpansion = do + start <- startSpan + pieces <- many1 $ choice [ + readSingleQuoted, + readDoubleQuoted, + readNormalDollar, + readBraced, + readUnquotedBackTicked, + literal "#", + readNormalLiteral "+-*/=%^,]?:" + ] + id <- endSpan start + spacing + return $ TA_Expansion id pieces + + readGroup = do + start <- startSpan + char '(' + s <- readSequence + char ')' + id <- endSpan start + spacing + return $ TA_Parenthesis id s + + readArithTerm = readGroup <|> readVariable <|> readExpansion + + readSequence = do + spacing + start <- startSpan + l <- readAssignment `sepBy` (char ',' >> spacing) + id <- endSpan start + return $ TA_Sequence id l + + readAssignment = chainr1 readTrinary readAssignmentOp + readAssignmentOp = readComboOp ["=", "*=", "/=", "%=", "+=", "-=", "<<=", ">>=", "&=", "^=", "|="] TA_Assignment + + readTrinary = do + x <- readLogicalOr + do + start <- startSpan + string "?" + spacing + y <- readTrinary + string ":" + spacing + z <- readTrinary + id <- endSpan start + return $ TA_Trinary id x y z + <|> + return x + + readLogicalOr = readLogicalAnd `splitBy` ["||"] + readLogicalAnd = readBitOr `splitBy` ["&&"] + readBitOr = readBitXor `splitBy` ["|"] + readBitXor = readBitAnd `splitBy` ["^"] + readBitAnd = readEquated `splitBy` ["&"] + readEquated = readCompared `splitBy` ["==", "!="] + readCompared = readShift `splitBy` ["<=", ">=", "<", ">"] + readShift = readAddition `splitBy` ["<<", ">>"] + readAddition = chainl1 readMultiplication (readBinary ["+"] <|> readMinusOp) + readMultiplication = readExponential `splitBy` ["*", "/", "%"] + readExponential = readAnyNegated `splitBy` ["**"] + + readAnyNegated = readNegated <|> readAnySigned + readNegated = do + start <- startSpan + op <- oneOf "!~" + id <- endSpan start + spacing + x <- readAnyNegated + return $ TA_Unary id [op] x + + readAnySigned = readSigned <|> readAnycremented + readSigned = do + start <- startSpan + op <- choice (map readSignOp "+-") + id <- endSpan start + spacing + x <- readAnycremented + return $ TA_Unary id [op] x + where + readSignOp c = try $ do + char c + notFollowedBy2 $ char c + spacing + return c + + readAnycremented = readNormalOrPostfixIncremented <|> readPrefixIncremented + readPrefixIncremented = do + start <- startSpan + op <- try $ string "++" <|> string "--" + id <- endSpan start + spacing + x <- readArithTerm + return $ TA_Unary id (op ++ "|") x + + readNormalOrPostfixIncremented = do + x <- readArithTerm + spacing + do + start <- startSpan + op <- try $ string "++" <|> string "--" + id <- endSpan start + spacing + return $ TA_Unary id ('|':op) x + <|> + return x + + + +prop_readCondition = isOk readCondition "[ \\( a = b \\) -a \\( c = d \\) ]" +prop_readCondition2 = isOk readCondition "[[ (a = b) || (c = d) ]]" +prop_readCondition3 = isOk readCondition "[[ $c = [[:alpha:].~-] ]]" +prop_readCondition4 = isOk readCondition "[[ $c =~ *foo* ]]" +prop_readCondition5 = isOk readCondition "[[ $c =~ f( ]] )* ]]" +prop_readCondition5a = isOk readCondition "[[ $c =~ a(b) ]]" +prop_readCondition5b = isOk readCondition "[[ $c =~ f( ($var ]]) )* ]]" +prop_readCondition6 = isOk readCondition "[[ $c =~ ^[yY]$ ]]" +prop_readCondition7 = isOk readCondition "[[ ${line} =~ ^[[:space:]]*# ]]" +prop_readCondition8 = isOk readCondition "[[ $l =~ ogg|flac ]]" +prop_readCondition9 = isOk readCondition "[ foo -a -f bar ]" +prop_readCondition10 = isOk readCondition "[[\na == b\n||\nc == d ]]" +prop_readCondition10a = isOk readCondition "[[\na == b ||\nc == d ]]" +prop_readCondition10b = isOk readCondition "[[ a == b\n||\nc == d ]]" +prop_readCondition11 = isOk readCondition "[[ a == b ||\n c == d ]]" +prop_readCondition12 = isWarning readCondition "[ a == b \n -o c == d ]" +prop_readCondition13 = isOk readCondition "[[ foo =~ ^fo{1,3}$ ]]" +prop_readCondition14 = isOk readCondition "[ foo '>' bar ]" +prop_readCondition15 = isOk readCondition "[ foo \">=\" bar ]" +prop_readCondition16 = isOk readCondition "[ foo \\< bar ]" +prop_readCondition17 = isOk readCondition "[[ ${file::1} = [-.\\|/\\\\] ]]" +prop_readCondition18 = isOk readCondition "[ ]" +prop_readCondition19 = isOk readCondition "[ '(' x \")\" ]" +prop_readCondition20 = isOk readCondition "[[ echo_rc -eq 0 ]]" +prop_readCondition21 = isOk readCondition "[[ $1 =~ ^(a\\ b)$ ]]" +prop_readCondition22 = isOk readCondition "[[ $1 =~ \\.a\\.(\\.b\\.)\\.c\\. ]]" +prop_readCondition23 = isOk readCondition "[[ -v arr[$var] ]]" +prop_readCondition25 = isOk readCondition "[[ lex.yy.c -ot program.l ]]" +prop_readCondition26 = isOk readScript "[[ foo ]]\\\n && bar" +prop_readCondition27 = not $ isOk readConditionCommand "[[ x ]] foo" +prop_readCondition28 = isOk readCondition "[[ x = [\"$1\"] ]]" +prop_readCondition29 = isOk readCondition "[[ x = [*] ]]" + +readCondition = called "test expression" $ do + opos <- getPosition + start <- startSpan + open <- try (string "[[") <|> string "[" + let single = open == "[" + let typ = if single then SingleBracket else DoubleBracket + + pos <- getPosition + space <- allspacing + when (null space) $ + parseProblemAtWithEnd opos pos ErrorC 1035 $ "You need a space after the " ++ + if single + then "[ and before the ]." + else "[[ and before the ]]." + when (single && '\n' `elem` space) $ + parseProblemAt pos ErrorC 1080 "You need \\ before line feeds to break lines in [ ]." + + condition <- readConditionContents single <|> do + guard . not . null $ space + lookAhead $ string "]" + id <- endSpan start + return $ TC_Empty id typ + + cpos <- getPosition + close <- try (string "]]") <|> string "]" <|> fail "Expected test to end here (don't wrap commands in []/[[]])" + id <- endSpan start + when (open == "[[" && close /= "]]") $ parseProblemAt cpos ErrorC 1033 "Test expression was opened with double [[ but closed with single ]. Make sure they match." + when (open == "[" && close /= "]" ) $ parseProblemAt opos ErrorC 1034 "Test expression was opened with single [ but closed with double ]]. Make sure they match." + spacing + return $ T_Condition id typ condition + +readAnnotationPrefix = do + char '#' + many linewhitespace + string "shellcheck" + +prop_readAnnotation1 = isOk readAnnotation "# shellcheck disable=1234,5678\n" +prop_readAnnotation2 = isOk readAnnotation "# shellcheck disable=SC1234 disable=SC5678\n" +prop_readAnnotation3 = isOk readAnnotation "# shellcheck disable=SC1234 source=/dev/null disable=SC5678\n" +prop_readAnnotation4 = isWarning readAnnotation "# shellcheck cats=dogs disable=SC1234\n" +prop_readAnnotation5 = isOk readAnnotation "# shellcheck disable=SC2002 # All cats are precious\n" +prop_readAnnotation6 = isOk readAnnotation "# shellcheck disable=SC1234 # shellcheck foo=bar\n" +prop_readAnnotation7 = isOk readAnnotation "# shellcheck disable=SC1000,SC2000-SC3000,SC1001\n" +prop_readAnnotation8 = isOk readAnnotation "# shellcheck disable=all\n" +prop_readAnnotation9 = isOk readAnnotation "# shellcheck source='foo bar' source-path=\"baz etc\"\n" +prop_readAnnotation10 = isOk readAnnotation "# shellcheck disable='SC1234,SC2345' enable=\"foo\" shell='bash'\n" +prop_readAnnotation11 = isOk (readAnnotationWithoutPrefix False) "external-sources='true'" + +readAnnotation = called "shellcheck directive" $ do + try readAnnotationPrefix + many1 linewhitespace + readAnnotationWithoutPrefix True + +readAnnotationWithoutPrefix sandboxed = do + values <- many1 readKey + optional readAnyComment + void linefeed <|> eof <|> do + parseNote ErrorC 1125 "Invalid key=value pair? Ignoring the rest of this directive starting here." + many (noneOf "\n") + void linefeed <|> eof + many linewhitespace + return $ concat values + where + plainOrQuoted p = quoted p <|> p + quoted p = do + c <- oneOf "'\"" + start <- getPosition + str <- many1 $ noneOf (c:"\n") + char c <|> fail "Missing terminating quote for directive." + subParse start p str + readKey = do + keyPos <- getPosition + key <- many1 (letter <|> char '-') + char '=' <|> fail "Expected '=' after directive key" + annotations <- case key of + "disable" -> plainOrQuoted $ readElement `sepBy` char ',' + where + readElement = readRange <|> readAll + readAll = do + string "all" + return $ DisableComment 0 1000000 + readRange = do + from <- readCode + to <- choice [ char '-' *> readCode, return $ from+1 ] + return $ DisableComment from to + readCode = do + optional $ string "SC" + int <- many1 digit + return $ read int + + "enable" -> plainOrQuoted $ readName `sepBy` char ',' + where + readName = EnableComment <$> many1 (letter <|> char '-') + + "source" -> do + filename <- quoted (many1 anyChar) <|> (many1 $ noneOf " \n") + return [SourceOverride filename] + + "source-path" -> do + dirname <- quoted (many1 anyChar) <|> (many1 $ noneOf " \n") + return [SourcePath dirname] + + "shell" -> do + pos <- getPosition + shell <- quoted (many1 anyChar) <|> (many1 $ noneOf " \n") + when (isNothing $ shellForExecutable shell) $ + parseNoteAt pos ErrorC 1103 + "This shell type is unknown. Use e.g. sh or bash." + return [ShellOverride shell] + + "extended-analysis" -> do + pos <- getPosition + value <- plainOrQuoted $ many1 letter + case value of + "true" -> return [ExtendedAnalysis True] + "false" -> return [ExtendedAnalysis False] + _ -> do + parseNoteAt pos ErrorC 1146 "Unknown extended-analysis value. Expected true/false." + return [] + + "external-sources" -> do + pos <- getPosition + value <- plainOrQuoted $ many1 letter + case value of + "true" -> + if sandboxed + then do + parseNoteAt pos ErrorC 1144 "external-sources can only be enabled in .shellcheckrc, not in individual files." + return [] + else return [ExternalSources True] + "false" -> return [ExternalSources False] + _ -> do + parseNoteAt pos ErrorC 1145 "Unknown external-sources value. Expected true/false." + return [] + + _ -> do + parseNoteAt keyPos WarningC 1107 "This directive is unknown. It will be ignored." + anyChar `reluctantlyTill` whitespace + return [] + + many linewhitespace + return annotations + +readAnnotations = do + annotations <- many (readAnnotation `thenSkip` allspacing) + return $ concat annotations + +readComment = do + unexpecting "shellcheck annotation" readAnnotationPrefix + readAnyComment + +prop_readAnyComment = isOk readAnyComment "# Comment" +readAnyComment = do + char '#' + many $ noneOf "\r\n" + +prop_readNormalWord = isOk readNormalWord "'foo'\"bar\"{1..3}baz$(lol)" +prop_readNormalWord2 = isOk readNormalWord "foo**(foo)!!!(@@(bar))" +prop_readNormalWord3 = isOk readNormalWord "foo#" +prop_readNormalWord4 = isOk readNormalWord "$\"foo\"$'foo\nbar'" +prop_readNormalWord5 = isWarning readNormalWord "${foo}}" +prop_readNormalWord6 = isOk readNormalWord "foo/{}" +prop_readNormalWord7 = isOk readNormalWord "foo\\\nbar" +prop_readNormalWord8 = isWarning readSubshell "(foo\\ \nbar)" +prop_readNormalWord9 = isOk readSubshell "(foo\\ ;\nbar)" +prop_readNormalWord10 = isWarning readNormalWord "\x201Chello\x201D" +prop_readNormalWord11 = isWarning readNormalWord "\x2018hello\x2019" +prop_readNormalWord12 = isWarning readNormalWord "hello\x2018" +readNormalWord = readNormalishWord "" ["do", "done", "then", "fi", "esac"] + +readPatternWord = readNormalishWord "" ["esac"] + +readNormalishWord end terms = do + start <- startSpan + pos <- getPosition + x <- many1 (readNormalWordPart end) + id <- endSpan start + checkPossibleTermination pos x terms + return $ T_NormalWord id x + +readIndexSpan = do + start <- startSpan + x <- many (readNormalWordPart "]" <|> someSpace <|> otherLiteral) + id <- endSpan start + return $ T_NormalWord id x + where + someSpace = do + start <- startSpan + str <- spacing1 + id <- endSpan start + return $ T_Literal id str + otherLiteral = do + start <- startSpan + str <- many1 $ oneOf quotableChars + id <- endSpan start + return $ T_Literal id str + +checkPossibleTermination pos [T_Literal _ x] terminators = + when (x `elem` terminators) $ + parseProblemAt pos WarningC 1010 $ "Use semicolon or linefeed before '" ++ x ++ "' (or quote to make it literal)." +checkPossibleTermination _ _ _ = return () + +readNormalWordPart end = do + notFollowedBy2 $ oneOf end + checkForParenthesis + choice [ + readSingleQuoted, + readDoubleQuoted, + readGlob, + readNormalDollar, + readBraced, + readUnquotedBackTicked, + readProcSub, + readUnicodeQuote, + readNormalLiteral end, + readLiteralCurlyBraces + ] + where + checkForParenthesis = + return () `attempting` do + pos <- getPosition + lookAhead $ char '(' + parseProblemAt pos ErrorC 1036 "'(' is invalid here. Did you forget to escape it?" + + readLiteralCurlyBraces = do + start <- startSpan + str <- findParam <|> literalBraces + id <- endSpan start + return $ T_Literal id str + + findParam = try $ string "{}" + literalBraces = do + pos <- getPosition + c <- oneOf "{}" + parseProblemAt pos WarningC 1083 $ + "This " ++ [c] ++ " is literal. Check expression (missing ;/\\n?) or quote it." + return [c] + + +readSpacePart = do + start <- startSpan + x <- many1 whitespace + id <- endSpan start + return $ T_Literal id x + +readDollarBracedWord = do + start <- startSpan + list <- many readDollarBracedPart + id <- endSpan start + return $ T_NormalWord id list + +readDollarBracedPart = readSingleQuoted <|> readDoubleQuoted <|> + readParamSubSpecialChar <|> readExtglob <|> readNormalDollar <|> + readUnquotedBackTicked <|> readDollarBracedLiteral + +readDollarBracedLiteral = do + start <- startSpan + vars <- (readBraceEscaped <|> ((\x -> [x]) <$> anyChar)) `reluctantlyTill1` bracedQuotable + id <- endSpan start + return $ T_Literal id $ concat vars + +readParamSubSpecialChar = do + start <- startSpan + x <- many1 paramSubSpecialChars + id <- endSpan start + return $ T_ParamSubSpecialChar id x + +prop_readProcSub1 = isOk readProcSub "<(echo test | wc -l)" +prop_readProcSub2 = isOk readProcSub "<( if true; then true; fi )" +prop_readProcSub3 = isOk readProcSub "<( # nothing here \n)" +readProcSub = called "process substitution" $ do + start <- startSpan + dir <- try $ do + x <- oneOf "<>" + char '(' + return [x] + list <- readCompoundListOrEmpty + allspacing + char ')' + id <- endSpan start + return $ T_ProcSub id dir list + +prop_readSingleQuoted = isOk readSingleQuoted "'foo bar'" +prop_readSingleQuoted2 = isWarning readSingleQuoted "'foo bar\\'" +prop_readSingleQuoted4 = isWarning readNormalWord "'it's" +prop_readSingleQuoted5 = isWarning readSimpleCommand "foo='bar\ncow 'arg" +prop_readSingleQuoted6 = isOk readSimpleCommand "foo='bar cow 'arg" +prop_readSingleQuoted7 = isOk readSingleQuoted "'foo\x201C\&bar'" +prop_readSingleQuoted8 = isWarning readSingleQuoted "'foo\x2018\&bar'" +readSingleQuoted = called "single quoted string" $ do + start <- startSpan + startPos <- getPosition + singleQuote + s <- many readSingleQuotedPart + let string = concat s + endPos <- getPosition + singleQuote <|> fail "Expected end of single quoted string" + + optional $ do + c <- try . lookAhead $ suspectCharAfterQuotes <|> oneOf "'" + if not (null string) && isAlpha c && isAlpha (last string) + then + parseProblemAt endPos WarningC 1011 + "This apostrophe terminated the single quoted string!" + else + when ('\n' `elem` string && not ("\n" `isPrefixOf` string)) $ + suggestForgotClosingQuote startPos endPos "single quoted string" + + id <- endSpan start + return (T_SingleQuoted id string) + +readSingleQuotedLiteral = do + singleQuote + strs <- many1 readSingleQuotedPart + singleQuote + return $ concat strs + +readSingleQuotedPart = + readSingleEscaped + <|> many1 (noneOf $ "'\\" ++ unicodeSingleQuotes) + <|> readUnicodeQuote + where + readUnicodeQuote = do + pos <- getPosition + x <- oneOf unicodeSingleQuotes + parseProblemAt pos WarningC 1112 + "This is a unicode quote. Delete and retype it (or ignore/doublequote for literal)." + return [x] + + +prop_readBackTicked = isOk (readBackTicked False) "`ls *.mp3`" +prop_readBackTicked2 = isOk (readBackTicked False) "`grep \"\\\"\"`" +prop_readBackTicked3 = isWarning (readBackTicked False) "´grep \"\\\"\"´" +prop_readBackTicked4 = isOk readSimpleCommand "`echo foo\necho bar`" +prop_readBackTicked5 = isOk readSimpleCommand "echo `foo`bar" +prop_readBackTicked6 = isWarning readSimpleCommand "echo `foo\necho `bar" +prop_readBackTicked7 = isOk readSimpleCommand "`#inline comment`" +prop_readBackTicked8 = isOk readSimpleCommand "echo `#comment` \\\nbar baz" +readQuotedBackTicked = readBackTicked True +readUnquotedBackTicked = readBackTicked False +readBackTicked quoted = called "backtick expansion" $ do + start <- startSpan + startPos <- getPosition + backtick + subStart <- getPosition + subString <- readGenericLiteral "`´" + endPos <- getPosition + backtick + id <- endSpan start + + optional $ do + c <- try . lookAhead $ suspectCharAfterQuotes + when ('\n' `elem` subString && not ("\n" `isPrefixOf` subString)) $ + suggestForgotClosingQuote startPos endPos "backtick expansion" + + -- Result positions may be off due to escapes + result <- subParse subStart (tryWithErrors subParser <|> return []) (unEscape subString) + return $ T_Backticked id result + where + unEscape [] = [] + unEscape ('\\':'"':rest) | quoted = '"' : unEscape rest + unEscape ('\\':x:rest) | x `elem` "$`\\" = x : unEscape rest + unEscape ('\\':'\n':rest) = unEscape rest + unEscape (c:rest) = c : unEscape rest + subParser = do + cmds <- readCompoundListOrEmpty + verifyEof + return cmds + backtick = + void (char '`') <|> do + pos <- getPosition + char '´' + parseProblemAt pos ErrorC 1077 + "For command expansion, the tick should slant left (` vs ´). Use $(..) instead." + +-- Run a parser on a new input, such as for `..` or here documents. +subParse pos parser input = do + lastPosition <- getPosition + lastInput <- getInput + setPosition pos + setInput input + result <- parser + setInput lastInput + setPosition lastPosition + return result + +-- Parse something, but forget all parseProblems +inSeparateContext = parseForgettingContext True +-- Parse something, but forget all parseProblems on failure +forgetOnFailure = parseForgettingContext False + +parseForgettingContext alsoOnSuccess parser = do + context <- Ms.get + success context <|> failure context + where + success c = do + res <- try parser + when alsoOnSuccess $ Ms.put c + return res + failure c = do + Ms.put c + fail "" + +prop_readDoubleQuoted = isOk readDoubleQuoted "\"Hello $FOO\"" +prop_readDoubleQuoted2 = isOk readDoubleQuoted "\"$'\"" +prop_readDoubleQuoted3 = isOk readDoubleQuoted "\"\x2018hello\x2019\"" +prop_readDoubleQuoted4 = isWarning readSimpleCommand "\"foo\nbar\"foo" +prop_readDoubleQuoted5 = isOk readSimpleCommand "lol \"foo\nbar\" etc" +prop_readDoubleQuoted6 = isOk readSimpleCommand "echo \"${ ls; }\"" +prop_readDoubleQuoted7 = isOk readSimpleCommand "echo \"${ ls;}bar\"" +prop_readDoubleQuoted8 = isWarning readDoubleQuoted "\"\x201Chello\x201D\"" +prop_readDoubleQuoted10 = isOk readDoubleQuoted "\"foo\\\\n\"" +readDoubleQuoted = called "double quoted string" $ do + start <- startSpan + startPos <- getPosition + doubleQuote + x <- many doubleQuotedPart + endPos <- getPosition + doubleQuote <|> fail "Expected end of double quoted string" + id <- endSpan start + optional $ do + try . lookAhead $ suspectCharAfterQuotes <|> oneOf "$\"" + when (any hasLineFeed x && not (startsWithLineFeed x)) $ + suggestForgotClosingQuote startPos endPos "double quoted string" + return $ T_DoubleQuoted id x + where + startsWithLineFeed (T_Literal _ ('\n':_):_) = True + startsWithLineFeed _ = False + hasLineFeed (T_Literal _ str) | '\n' `elem` str = True + hasLineFeed _ = False + +suggestForgotClosingQuote startPos endPos name = do + parseProblemAt startPos WarningC 1078 $ + "Did you forget to close this " ++ name ++ "?" + parseProblemAt endPos InfoC 1079 + "This is actually an end quote, but due to next char it looks suspect." + +doubleQuotedPart = readDoubleLiteral <|> readDoubleQuotedDollar <|> readQuotedBackTicked <|> readUnicodeQuote + where + readUnicodeQuote = do + pos <- getPosition + start <- startSpan + c <- oneOf unicodeDoubleQuotes + id <- endSpan start + parseProblemAt pos WarningC 1111 + "This is a unicode quote. Delete and retype it (or ignore/singlequote for literal)." + return $ T_Literal id [c] + +readDoubleLiteral = do + start <- startSpan + s <- many1 readDoubleLiteralPart + id <- endSpan start + return $ T_Literal id (concat s) + +readDoubleLiteralPart = do + x <- many1 (readDoubleEscaped <|> many1 (noneOf (doubleQuotableChars ++ unicodeDoubleQuotes))) + return $ concat x + +readNormalLiteral end = do + start <- startSpan + s <- many1 (readNormalLiteralPart end) + id <- endSpan start + return $ T_Literal id (concat s) + +prop_readGlob1 = isOk readGlob "*" +prop_readGlob2 = isOk readGlob "[^0-9]" +prop_readGlob3 = isOk readGlob "[a[:alpha:]]" +prop_readGlob4 = isOk readGlob "[[:alnum:]]" +prop_readGlob5 = isOk readGlob "[^[:alpha:]1-9]" +prop_readGlob6 = isOk readGlob "[\\|]" +prop_readGlob7 = isOk readGlob "[^[]" +prop_readGlob8 = isOk readGlob "[*?]" +prop_readGlob9 = isOk readGlob "[!]^]" +prop_readGlob10 = isOk readGlob "[]]" +readGlob = readExtglob <|> readSimple <|> readClass <|> readGlobbyLiteral + where + readSimple = do + start <- startSpan + c <- oneOf "*?" + id <- endSpan start + return $ T_Glob id [c] + readClass = try $ do + start <- startSpan + char '[' + negation <- charToString (oneOf "!^") <|> return "" + leadingBracket <- charToString (oneOf "]") <|> return "" + s <- many (predefined <|> readNormalLiteralPart "]" <|> globchars) + guard $ not (null leadingBracket) || not (null s) + char ']' + id <- endSpan start + return $ T_Glob id $ "[" ++ concat (negation:leadingBracket:s) ++ "]" + where + globchars = charToString $ oneOf $ "![" ++ extglobStartChars + predefined = do + try $ string "[:" + s <- many1 letter + string ":]" + return $ "[:" ++ s ++ ":]" + + charToString = fmap return + readGlobbyLiteral = do + start <- startSpan + c <- extglobStart <|> char '[' + id <- endSpan start + return $ T_Literal id [c] + +readNormalLiteralPart customEnd = + readNormalEscaped <|> + many1 (noneOf (customEnd ++ standardEnd)) + where + standardEnd = "[{}" + ++ quotableChars + ++ extglobStartChars + ++ unicodeDoubleQuotes + ++ unicodeSingleQuotes + +readNormalEscaped = called "escaped char" $ do + pos <- getPosition + backslash + do + next <- quotable <|> oneOf "?*@!+[]{}.,~#" + when (next == ' ') $ checkTrailingSpaces pos <|> return () + -- Check if this line is followed by a commented line with a trailing backslash + when (next == '\n') $ try . lookAhead $ void spacing + return $ if next == '\n' then "" else [next] + <|> + do + next <- anyChar + case escapedChar next of + Just name -> parseNoteAt pos WarningC 1012 $ "\\" ++ [next] ++ " is just literal '" ++ [next] ++ "' here. For " ++ name ++ ", use " ++ alternative next ++ " instead." + Nothing -> parseNoteAt pos InfoC 1001 $ "This \\" ++ [next] ++ " will be a regular '" ++ [next] ++ "' in this context." + return [next] + where + alternative 'n' = "a quoted, literal line feed" + alternative t = "\"$(printf '\\" ++ [t] ++ "')\"" + escapedChar 'n' = Just "line feed" + escapedChar 't' = Just "tab" + escapedChar 'r' = Just "carriage return" + escapedChar _ = Nothing + + checkTrailingSpaces pos = lookAhead . try $ do + many linewhitespace + void linefeed <|> eof + parseProblemAt pos ErrorC 1101 "Delete trailing spaces after \\ to break line (or use quotes for literal space)." + + +prop_readExtglob1 = isOk readExtglob "!(*.mp3)" +prop_readExtglob2 = isOk readExtglob "!(*.mp3|*.wmv)" +prop_readExtglob4 = isOk readExtglob "+(foo \\) bar)" +prop_readExtglob5 = isOk readExtglob "+(!(foo *(bar)))" +prop_readExtglob6 = isOk readExtglob "*(((||))|())" +prop_readExtglob7 = isOk readExtglob "*(<>)" +prop_readExtglob8 = isOk readExtglob "@(|*())" +readExtglob = called "extglob" $ do + start <- startSpan + c <- try $ do + f <- extglobStart + char '(' + return f + contents <- readExtglobPart `sepBy` char '|' + char ')' + id <- endSpan start + return $ T_Extglob id [c] contents + +readExtglobPart = do + start <- startSpan + x <- many (readExtglobGroup <|> readNormalWordPart "" <|> readSpacePart <|> readExtglobLiteral) + id <- endSpan start + return $ T_NormalWord id x + where + readExtglobGroup = do + char '(' + start <- startSpan + contents <- readExtglobPart `sepBy` char '|' + id <- endSpan start + char ')' + return $ T_Extglob id "" contents + readExtglobLiteral = do + start <- startSpan + str <- many1 (oneOf "<>#;&") + id <- endSpan start + return $ T_Literal id str + + +readSingleEscaped = do + pos <- getPosition + s <- backslash + x <- lookAhead anyChar + + case x of + '\'' -> parseProblemAt pos InfoC 1003 "Want to escape a single quote? echo 'This is how it'\\''s done'."; + _ -> return () + + return [s] + +readDoubleEscaped = do + pos <- getPosition + bs <- backslash + (linefeed >> return "") + <|> fmap return doubleQuotable + <|> do + c <- anyChar + -- This is an invalid escape sequence where the \ is literal. + -- Previously this caused a SC1117, which may be re-enabled as + -- as a pedantic warning. + return [bs, c] + +readBraceEscaped = do + bs <- backslash + (linefeed >> return "") + <|> fmap return bracedQuotable + <|> fmap (\ x -> [bs, x]) anyChar + + +readGenericLiteral endChars = do + strings <- many (readGenericEscaped <|> many1 (noneOf ('\\':endChars))) + return $ concat strings + +readGenericLiteral1 endExp = do + strings <- (readGenericEscaped <|> ((\x -> [x]) <$> anyChar)) `reluctantlyTill1` endExp + return $ concat strings + +readGenericEscaped = do + backslash + x <- anyChar + return $ if x == '\n' then [] else ['\\', x] + +prop_readBraced = isOk readBraced "{1..4}" +prop_readBraced2 = isOk readBraced "{foo,bar,\"baz lol\"}" +prop_readBraced3 = isOk readBraced "{1,\\},2}" +prop_readBraced4 = isOk readBraced "{1,{2,3}}" +prop_readBraced5 = isOk readBraced "{JP{,E}G,jp{,e}g}" +prop_readBraced6 = isOk readBraced "{foo,bar,$((${var}))}" +prop_readBraced7 = isNotOk readBraced "{}" +prop_readBraced8 = isNotOk readBraced "{foo}" +readBraced = try braceExpansion + where + braceExpansion = + T_BraceExpansion `withParser` do + char '{' + elements <- bracedElement `sepBy1` char ',' + guard $ + case elements of + (_:_:_) -> True + [t] -> ".." `isInfixOf` onlyLiteralString t + [] -> False + char '}' + return elements + bracedElement = + T_NormalWord `withParser` do + many $ choice [ + braceExpansion, + readDollarExpression, + readSingleQuoted, + readDoubleQuoted, + braceLiteral + ] + braceLiteral = + T_Literal `withParser` readGenericLiteral1 (oneOf "{}\"$'," <|> whitespace) + +ensureDollar = + -- The grammar should have been designed along the lines of readDollarExpr = char '$' >> stuff, but + -- instead, each subunit parses its own $. This results in ~7 1-3 char lookaheads instead of one 1-char. + -- Instead of optimizing the grammar, here's a green cut that decreases shellcheck runtime by 10%: + lookAhead $ char '$' + +readNormalDollar = do + ensureDollar + readDollarExp <|> readDollarDoubleQuote <|> readDollarSingleQuote <|> readDollarLonely False +readDoubleQuotedDollar = do + ensureDollar + readDollarExp <|> readDollarLonely True + + +prop_readDollarExpression1 = isOk readDollarExpression "$(((1) && 3))" +prop_readDollarExpression2 = isWarning readDollarExpression "$(((1)) && 3)" +prop_readDollarExpression3 = isWarning readDollarExpression "$((\"$@\" &); foo;)" +readDollarExpression :: Monad m => SCParser m Token +readDollarExpression = do + ensureDollar + readDollarExp + +readDollarExp = arithmetic <|> readDollarExpansion <|> readDollarBracket <|> readDollarBraceCommandExpansion <|> readDollarBraced <|> readDollarVariable + where + arithmetic = readAmbiguous "$((" readDollarArithmetic readDollarExpansion (\pos -> + parseNoteAt pos ErrorC 1102 "Shells disambiguate $(( differently or not at all. For $(command substitution), add space after $( . For $((arithmetics)), fix parsing errors.") + +prop_readDollarSingleQuote = isOk readDollarSingleQuote "$'foo\\\'lol'" +readDollarSingleQuote = called "$'..' expression" $ do + start <- startSpan + try $ string "$'" + str <- readGenericLiteral "'" + char '\'' + id <- endSpan start + return $ T_DollarSingleQuoted id str + +prop_readDollarDoubleQuote = isOk readDollarDoubleQuote "$\"hello\"" +readDollarDoubleQuote = do + lookAhead . try $ string "$\"" + start <- startSpan + char '$' + doubleQuote + x <- many doubleQuotedPart + doubleQuote <|> fail "Expected end of translated double quoted string" + id <- endSpan start + return $ T_DollarDoubleQuoted id x + +prop_readDollarArithmetic = isOk readDollarArithmetic "$(( 3 * 4 +5))" +prop_readDollarArithmetic2 = isOk readDollarArithmetic "$(((3*4)+(1*2+(3-1))))" +readDollarArithmetic = called "$((..)) expression" $ do + start <- startSpan + try (string "$((") + c <- readArithmeticContents + pos <- getPosition + char ')' + char ')' <|> fail "Expected a double )) to end the $((..))" + id <- endSpan start + return (T_DollarArithmetic id c) + +readDollarBracket = called "$[..] expression" $ do + start <- startSpan + try (string "$[") + c <- readArithmeticContents + string "]" + id <- endSpan start + return (T_DollarBracket id c) + +prop_readArithmeticExpression = isOk readArithmeticExpression "((a?b:c))" +readArithmeticExpression = called "((..)) command" $ do + start <- startSpan + try (string "((") + c <- readArithmeticContents + string "))" + id <- endSpan start + spacing + return (T_Arithmetic id c) + +-- If the next characters match prefix, try two different parsers and warn if the alternate parser had to be used +readAmbiguous :: Monad m => String -> SCParser m p -> SCParser m p -> (SourcePos -> SCParser m ()) -> SCParser m p +readAmbiguous prefix expected alternative warner = do + pos <- getPosition + try . lookAhead $ string prefix + -- If the expected parser fails, try the alt. + -- If the alt fails, run the expected one again for the errors. + try expected <|> try (withAlt pos) <|> expected + where + withAlt pos = do + t <- forgetOnFailure alternative + warner pos + return t + +prop_readDollarBraceCommandExpansion1 = isOk readDollarBraceCommandExpansion "${ ls; }" +prop_readDollarBraceCommandExpansion2 = isOk readDollarBraceCommandExpansion "${\nls\n}" +prop_readDollarBraceCommandExpansion3 = isOk readDollarBraceCommandExpansion "${| REPLY=42; }" +readDollarBraceCommandExpansion = called "ksh-style ${ ..; } command expansion" $ do + start <- startSpan + c <- try $ do + string "${" + char '|' <|> whitespace + allspacing + term <- readTerm + char '}' <|> fail "Expected } to end the ksh-style ${ ..; } command expansion" + id <- endSpan start + return $ T_DollarBraceCommandExpansion id (if c == '|' then Piped else Unpiped) term + +prop_readDollarBraced1 = isOk readDollarBraced "${foo//bar/baz}" +prop_readDollarBraced2 = isOk readDollarBraced "${foo/'{cow}'}" +prop_readDollarBraced3 = isOk readDollarBraced "${foo%%$(echo cow\\})}" +prop_readDollarBraced4 = isOk readDollarBraced "${foo#\\}}" +readDollarBraced = called "parameter expansion" $ do + start <- startSpan + try (string "${") + word <- readDollarBracedWord + char '}' + id <- endSpan start + return $ T_DollarBraced id True word + +prop_readDollarExpansion1 = isOk readDollarExpansion "$(echo foo; ls\n)" +prop_readDollarExpansion2 = isOk readDollarExpansion "$( )" +prop_readDollarExpansion3 = isOk readDollarExpansion "$( command \n#comment \n)" +readDollarExpansion = called "command expansion" $ do + start <- startSpan + try (string "$(") + cmds <- readCompoundListOrEmpty + char ')' <|> fail "Expected end of $(..) expression" + id <- endSpan start + return $ T_DollarExpansion id cmds + +prop_readDollarVariable = isOk readDollarVariable "$@" +prop_readDollarVariable2 = isOk (readDollarVariable >> anyChar) "$?!" +prop_readDollarVariable3 = isWarning (readDollarVariable >> anyChar) "$10" +prop_readDollarVariable4 = isWarning (readDollarVariable >> string "[@]") "$arr[@]" +prop_readDollarVariable5 = isWarning (readDollarVariable >> string "[f") "$arr[f" + +readDollarVariable :: Monad m => SCParser m Token +readDollarVariable = do + start <- startSpan + pos <- getPosition + + let singleCharred p = do + value <- wrapString ((:[]) <$> p) + id <- endSpan start + return $ (T_DollarBraced id False value) + + let positional = do + value <- singleCharred digit + return value `attempting` do + lookAhead digit + parseNoteAt pos ErrorC 1037 "Braces are required for positionals over 9, e.g. ${10}." + + let special = singleCharred specialVariable + + let regular = do + value <- wrapString readVariableName + id <- endSpan start + return (T_DollarBraced id False value) `attempting` do + lookAhead $ char '[' + parseNoteAt pos ErrorC 1087 "Use braces when expanding arrays, e.g. ${array[idx]} (or ${var}[.. to quiet)." + + try $ char '$' >> (positional <|> special <|> regular) + + where + wrapString p = do + start <- getPosition + s <- p + end <- getPosition + id1 <- getNextIdBetween start end + id2 <- getNextIdBetween start end + return $ T_NormalWord id1 [T_Literal id2 s] + +readVariableName = do + f <- variableStart + rest <- many variableChars + return (f:rest) + + +prop_readDollarLonely1 = isWarning readNormalWord "\"$\"var" +prop_readDollarLonely2 = isWarning readNormalWord "\"$\"\"var\"" +prop_readDollarLonely3 = isOk readNormalWord "\"$\"$var" +prop_readDollarLonely4 = isOk readNormalWord "\"$\"*" +prop_readDollarLonely5 = isOk readNormalWord "$\"str\"" +readDollarLonely quoted = do + start <- startSpan + char '$' + id <- endSpan start + when quoted $ do + isHack <- quoteForEscape + when isHack $ + parseProblemAtId id StyleC 1135 + "Prefer escape over ending quote to make $ literal. Instead of \"It costs $\"5, use \"It costs \\$5\"." + return $ T_Literal id "$" + where + quoteForEscape = option False $ try . lookAhead $ do + char '"' + -- Check for "foo $""bar" + optional $ char '"' + c <- anyVar + -- Don't trigger on [[ x == "$"* ]] or "$"$pattern + return $ c `notElem` "*$" + anyVar = variableStart <|> digit <|> specialVariable + + +prop_readHereDoc = isOk readScript "cat << foo\nlol\ncow\nfoo" +prop_readHereDoc2 = isNotOk readScript "cat <<- EOF\n cow\n EOF" +prop_readHereDoc3 = isOk readScript "cat << foo\n$\"\nfoo" +prop_readHereDoc4 = isNotOk readScript "cat << foo\n`\nfoo" +prop_readHereDoc5 = isOk readScript "cat <<- !foo\nbar\n!foo" +prop_readHereDoc6 = isOk readScript "cat << foo\\ bar\ncow\nfoo bar" +prop_readHereDoc7 = isOk readScript "cat << foo\n\\$(f ())\nfoo" +prop_readHereDoc8 = isOk readScript "cat <>bar\netc\nfoo" +prop_readHereDoc9 = isOk readScript "if true; then cat << foo; fi\nbar\nfoo\n" +prop_readHereDoc10 = isOk readScript "if true; then cat << foo << bar; fi\nfoo\nbar\n" +prop_readHereDoc11 = isOk readScript "cat << foo $(\nfoo\n)lol\nfoo\n" +prop_readHereDoc12 = isOk readScript "cat << foo|cat\nbar\nfoo" +prop_readHereDoc13 = isOk readScript "cat <<'#!'\nHello World\n#!\necho Done" +prop_readHereDoc14 = isWarning readScript "cat << foo\nbar\nfoo \n" +prop_readHereDoc15 = isWarning readScript "cat <> return Dashed) <|> return Undashed + sp <- spacing + optional $ do + try . lookAhead $ char '(' + let message = "Shells are space sensitive. Use '< <(cmd)', not '<<" ++ sp ++ "(cmd)'." + parseProblemAt pos ErrorC 1038 message + start <- startSpan + (quoted, endToken) <- readToken + hid <- endSpan start + + -- add empty tokens for now, read the rest in readPendingHereDocs + let doc = T_HereDoc hid dashed quoted endToken [] + addPendingHereDoc hid dashed quoted endToken + return doc + where + unquote :: String -> (Quoted, String) + unquote "" = (Unquoted, "") + unquote [c] = (Unquoted, [c]) + unquote s@(cl:tl) = + case reverse tl of + (cr:tr) | cr == cl && cl `elem` "\"'" -> (Quoted, reverse tr) + _ -> (if '\\' `elem` s then (Quoted, filter ((/=) '\\') s) else (Unquoted, s)) + -- Fun fact: bash considers << foo"" quoted, but not << <("foo"). + readToken = do + str <- readStringForParser readNormalWord + -- A here doc actually works with \r\n because the \r becomes part of the token + crstr <- (carriageReturn >> (return $ str ++ "\r")) <|> return str + return $ unquote crstr + +readPendingHereDocs = do + docs <- popPendingHereDocs + mapM_ readDoc docs + where + readDoc (HereDocPending id dashed quoted endToken ctx) = + swapContext ctx $ + do + docStartPos <- getPosition + (terminated, wasWarned, lines) <- readDocLines dashed endToken + docEndPos <- getPosition + let hereData = unlines lines + unless terminated $ do + unless wasWarned $ + debugHereDoc id endToken hereData + fail "Here document was not correctly terminated" + list <- parseHereData quoted (docStartPos, docEndPos) hereData + addToHereDocMap id list + + -- Read the lines making up the here doc. Returns (IsTerminated, Lines) + readDocLines :: Monad m => Dashed -> String -> SCParser m (Bool, Bool, [String]) + readDocLines dashed endToken = do + pos <- getPosition + str <- rawLine + isEof <- option False (eof >> return True) + (isEnd, wasWarned) <- subParse pos checkEnd str + if + | isEnd -> return (True, wasWarned, []) + | isEof -> return (False, wasWarned, [str]) + | True -> do + (ok, previousWarning, rest) <- readDocLines dashed endToken + return (ok, wasWarned || previousWarning, str:rest) + where + -- Check if this is the actual end, or a plausible false end + checkEnd = option (False, False) $ try $ do + -- Match what's basically '^( *)token( *)(.*)$' + leadingSpacePos <- getPosition + leadingSpace <- linewhitespace `reluctantlyTill` string endToken + string endToken + trailingSpacePos <- getPosition + trailingSpace <- many linewhitespace + trailerPos <- getPosition + trailer <- many anyChar + + let leadingSpacesAreTabs = all (== '\t') leadingSpace + let thereIsNoTrailer = null trailingSpace && null trailer + let leaderIsOk = null leadingSpace + || dashed == Dashed && leadingSpacesAreTabs + let trailerStart = case trailer of [] -> '\0'; (h:_) -> h + let hasTrailingSpace = not $ null trailingSpace + let hasTrailer = not $ null trailer + let ppt = parseProblemAt trailerPos ErrorC + + if leaderIsOk && thereIsNoTrailer + then return (True, False) + else do + let foundCause = return (False, True) + let skipLine = return (False, False) + -- This may be intended as an end token. Debug why it isn't. + if + | trailerStart == ')' -> do + ppt 1119 $ "Add a linefeed between end token and terminating ')'." + foundCause + | trailerStart == '#' -> do + ppt 1120 "No comments allowed after here-doc token. Comment the next line instead." + foundCause + | trailerStart `elem` ";>|&" -> do + ppt 1121 "Add ;/& terminators (and other syntax) on the line with the <<, not here." + foundCause + | hasTrailingSpace && hasTrailer -> do + ppt 1122 "Nothing allowed after end token. To continue a command, put it on the line with the <<." + foundCause + | leaderIsOk && hasTrailingSpace && not hasTrailer -> do + parseProblemAt trailingSpacePos ErrorC 1118 "Delete whitespace after the here-doc end token." + -- Parse as if it's the actual end token. Will koala_man regret this once again? + return (True, True) + | not hasTrailingSpace && hasTrailer -> + -- The end token is just a prefix + skipLine + | hasTrailer -> + error $ pleaseReport "unexpected heredoc trailer" + + -- The following cases assume no trailing text: + | dashed == Undashed && (not $ null leadingSpace) -> do + parseProblemAt leadingSpacePos ErrorC 1039 "Remove indentation before end token (or use <<- and indent with tabs)." + foundCause + | dashed == Dashed && not leadingSpacesAreTabs -> do + parseProblemAt leadingSpacePos ErrorC 1040 "When using <<-, you can only indent with tabs." + foundCause + | True -> skipLine + + rawLine = do + c <- many $ noneOf "\n" + void (char '\n') <|> eof + return c + + parseHereData Quoted (start,end) hereData = do + id <- getNextIdBetween start end + return [T_Literal id hereData] + + parseHereData Unquoted (startPos, _) hereData = + subParse startPos readHereData hereData + + readHereData = many $ doubleQuotedPart <|> readHereLiteral + + readHereLiteral = do + start <- startSpan + chars <- many1 $ noneOf "`$\\" + id <- endSpan start + return $ T_Literal id chars + + debugHereDoc tokenId endToken doc + | endToken `isInfixOf` doc = + let lookAt line = when (endToken `isInfixOf` line) $ + parseProblemAtId tokenId ErrorC 1042 ("Close matches include '" ++ (e4m line) ++ "' (!= '" ++ (e4m endToken) ++ "').") + in do + parseProblemAtId tokenId ErrorC 1041 ("Found '" ++ (e4m endToken) ++ "' further down, but not on a separate line.") + mapM_ lookAt (lines doc) + | map toLower endToken `isInfixOf` map toLower doc = + parseProblemAtId tokenId ErrorC 1043 ("Found " ++ (e4m endToken) ++ " further down, but with wrong casing.") + | otherwise = + parseProblemAtId tokenId ErrorC 1044 ("Couldn't find end token `" ++ (e4m endToken) ++ "' in the here document.") + + +readFilename = readNormalWord +readIoFileOp = choice [g_DGREAT, g_LESSGREAT, g_GREATAND, g_LESSAND, g_CLOBBER, redirToken '<' T_Less, redirToken '>' T_Greater ] + +readIoDuplicate = try $ do + start <- startSpan + op <- g_GREATAND <|> g_LESSAND + target <- readIoVariable <|> digitsAndOrDash + id <- endSpan start + return $ T_IoDuplicate id op target + where + -- either digits with optional dash, or a required dash + digitsAndOrDash = do + str <- many digit + dash <- (if null str then id else option "") $ string "-" + return $ str ++ dash + + +prop_readIoFile = isOk readIoFile ">> \"$(date +%YYmmDD)\"" +readIoFile = called "redirection" $ do + start <- startSpan + op <- readIoFileOp + spacing + file <- readFilename + id <- endSpan start + return $ T_IoFile id op file + +readIoVariable = try $ do + char '{' + x <- readVariableName + char '}' + return $ "{" ++ x ++ "}" + +readIoSource = try $ do + x <- string "&" <|> readIoVariable <|> many digit + lookAhead $ void readIoFileOp <|> void (string "<<") + return x + +prop_readIoRedirect = isOk readIoRedirect "3>&2" +prop_readIoRedirect2 = isOk readIoRedirect "2> lol" +prop_readIoRedirect3 = isOk readIoRedirect "4>&-" +prop_readIoRedirect4 = isOk readIoRedirect "&> lol" +prop_readIoRedirect5 = isOk readIoRedirect "{foo}>&2" +prop_readIoRedirect6 = isOk readIoRedirect "{foo}<&-" +prop_readIoRedirect7 = isOk readIoRedirect "{foo}>&1-" +readIoRedirect = do + start <- startSpan + n <- readIoSource + redir <- readHereString <|> readHereDoc <|> readIoDuplicate <|> readIoFile + id <- endSpan start + skipAnnotationAndWarn + spacing + return $ T_FdRedirect id n redir + +prop_readHereString = isOk readHereString "<<< \"Hello $world\"" +readHereString = called "here string" $ do + start <- startSpan + try $ string "<<<" + id <- endSpan start + spacing + word <- readNormalWord + return $ T_HereString id word + +prop_readNewlineList1 = isOk readScript "&> /dev/null echo foo" +readNewlineList = + many1 ((linefeed <|> carriageReturn) `thenSkip` spacing) <* checkBadBreak + where + checkBadBreak = optional $ do + pos <- getPosition + try $ lookAhead (oneOf "|&") -- See if the next thing could be |, || or && + notFollowedBy2 (string "&>") -- Except &> or &>> which is valid + parseProblemAt pos ErrorC 1133 + "Unexpected start of line. If breaking lines, |/||/&& should be at the end of the previous one." +readLineBreak = optional readNewlineList + +prop_readSeparator1 = isWarning readScript "a &; b" +prop_readSeparator2 = isOk readScript "a & b" +prop_readSeparator3 = isWarning readScript "a & b" +prop_readSeparator4 = isWarning readScript "a > file; b" +prop_readSeparator5 = isWarning readScript "curl https://example.com/?foo=moo&bar=cow" +readSeparatorOp = do + notFollowedBy2 (void g_AND_IF <|> void readCaseSeparator) + notFollowedBy2 (string "&>") + start <- getPosition + f <- try (do + pos <- getPosition + char '&' + optional $ choice [ + do + s <- lookAhead . choice . map (try . string) $ + ["amp;", "gt;", "lt;"] + parseProblemAt pos ErrorC 1109 "This is an unquoted HTML entity. Replace with corresponding character.", + + do + try . lookAhead $ variableStart + parseProblemAt pos WarningC 1132 "This & terminates the command. Escape it or add space after & to silence." + ] + + spacing + pos <- getPosition + char ';' + -- In case statements we might have foo & ;; + notFollowedBy2 $ char ';' + parseProblemAt pos ErrorC 1045 "It's not 'foo &; bar', just 'foo & bar'." + return '&' + ) <|> char ';' <|> char '&' + end <- getPosition + spacing + return (f, (start, end)) + +readSequentialSep = void (g_Semi >> readLineBreak) <|> void readNewlineList +readSeparator = + do + separator <- readSeparatorOp + readLineBreak + return separator + <|> + do + start <- getPosition + readNewlineList + end <- getPosition + return ('\n', (start, end)) + +prop_readSimpleCommand = isOk readSimpleCommand "echo test > file" +prop_readSimpleCommand2 = isOk readSimpleCommand "cmd &> file" +prop_readSimpleCommand3 = isOk readSimpleCommand "export foo=(bar baz)" +prop_readSimpleCommand4 = isOk readSimpleCommand "typeset -a foo=(lol)" +prop_readSimpleCommand5 = isOk readSimpleCommand "time if true; then echo foo; fi" +prop_readSimpleCommand6 = isOk readSimpleCommand "time -p ( ls -l; )" +prop_readSimpleCommand7 = isOk readSimpleCommand "\\ls" +prop_readSimpleCommand7b = isOk readSimpleCommand "\\:" +prop_readSimpleCommand8 = isWarning readSimpleCommand "// Lol" +prop_readSimpleCommand9 = isWarning readSimpleCommand "/* Lolbert */" +prop_readSimpleCommand10 = isWarning readSimpleCommand "/**** Lolbert */" +prop_readSimpleCommand11 = isOk readSimpleCommand "/\\* foo" +prop_readSimpleCommand12 = isWarning readSimpleCommand "elsif foo" +prop_readSimpleCommand13 = isWarning readSimpleCommand "ElseIf foo" +prop_readSimpleCommand14 = isWarning readSimpleCommand "elseif[$i==2]" +prop_readSimpleCommand15 = isWarning readSimpleCommand "trap 'foo\"bar' INT" +readSimpleCommand = called "simple command" $ do + prefix <- option [] readCmdPrefix + skipAnnotationAndWarn + cmd <- option Nothing $ Just <$> readCmdName + when (null prefix && isNothing cmd) $ fail "Expected a command" + + case cmd of + Nothing -> do + id1 <- getNextIdSpanningTokenList prefix + id2 <- getNewIdFor id1 + return $ makeSimpleCommand id1 id2 prefix [] [] + + Just cmd -> do + validateCommand cmd + -- We have to ignore possible parsing problems from the lookAhead parser + firstArgument <- ignoreProblemsOf . optionMaybe . try . lookAhead $ readCmdWord + suffix <- option [] $ getParser readCmdSuffix + -- If `export` or other modifier commands are called with `builtin` we have to look at the first argument + (if isCommand ["builtin"] cmd then fromMaybe cmd firstArgument else cmd) [ + (["declare", "export", "local", "readonly", "typeset"], readModifierSuffix), + (["time"], readTimeSuffix), + (["let"], readLetSuffix), + (["eval"], readEvalSuffix) + ] + + id1 <- getNextIdSpanningTokenList (prefix ++ (cmd:suffix)) + id2 <- getNewIdFor id1 + + let result = makeSimpleCommand id1 id2 prefix [cmd] suffix + case () of + _ | isCommand ["source", "."] cmd -> readSource result + _ | isCommand ["trap"] cmd -> do + syntaxCheckTrap result + return result + _ -> return result + where + isCommand strings (T_NormalWord _ [T_Literal _ s]) = s `elem` strings + isCommand _ _ = False + getParser def cmd [] = def + getParser def cmd ((list, action):rest) = + if isCommand list cmd + then action + else getParser def cmd rest + + validateCommand cmd = + case cmd of + (T_NormalWord _ [T_Literal _ "//"]) -> commentWarning (getId cmd) + (T_NormalWord _ (T_Literal _ "/" : T_Glob _ "*" :_)) -> commentWarning (getId cmd) + (T_NormalWord _ (T_Literal _ str:_)) -> do + let cmdString = map toLower $ takeWhile isAlpha str + when (cmdString `elem` ["elsif", "elseif"]) $ + parseProblemAtId (getId cmd) ErrorC 1131 "Use 'elif' to start another branch." + _ -> return () + + syntaxCheckTrap cmd = + case cmd of + (T_Redirecting _ _ (T_SimpleCommand _ _ (cmd:arg:_))) -> checkArg arg (getLiteralString arg) + _ -> return () + where + checkArg _ Nothing = return () + checkArg arg (Just ('-':_)) = return () + checkArg arg (Just str) = do + (start,end) <- getSpanForId (getId arg) + subParse start (tryWithErrors (readCompoundListOrEmpty >> verifyEof) <|> return ()) str + + commentWarning id = + parseProblemAtId id ErrorC 1127 "Was this intended as a comment? Use # in sh." + + makeSimpleCommand id1 id2 prefix cmd suffix = + let + (preAssigned, preRest) = partition assignment prefix + (preRedirected, preRest2) = partition redirection preRest + (postRedirected, postRest) = partition redirection suffix + + redirs = preRedirected ++ postRedirected + assigns = preAssigned + args = cmd ++ preRest2 ++ postRest + in + T_Redirecting id1 redirs $ T_SimpleCommand id2 assigns args + where + assignment (T_Assignment {}) = True + assignment _ = False + redirection (T_FdRedirect {}) = True + redirection _ = False + + +readSource :: Monad m => Token -> SCParser m Token +readSource t@(T_Redirecting _ _ (T_SimpleCommand cmdId _ (cmd:args'))) = do + let file = getFile args' + override <- getSourceOverride + let literalFile = do + name <- override `mplus` (getLiteralString =<< file) `mplus` (stripDynamicPrefix =<< file) + -- Hack to avoid 'source ~/foo' trying to read from literal tilde + guard . not $ "~/" `isPrefixOf` name + return name + let fileId = fromMaybe (getId cmd) (getId <$> file) + case literalFile of + Nothing -> do + parseNoteAtId fileId WarningC 1090 + "ShellCheck can't follow non-constant source. Use a directive to specify location." + return t + Just filename -> do + proceed <- shouldFollow filename + if not proceed + then do + -- FIXME: This actually gets squashed without -a + parseNoteAtId fileId InfoC 1093 + "This file appears to be recursively sourced. Ignoring." + return t + else do + sys <- Mr.asks systemInterface + (input, resolvedFile) <- + if filename == "/dev/null" -- always allow /dev/null + then return (Right "", filename) + else do + allAnnotations <- getCurrentAnnotations True + currentScript <- Mr.asks currentFilename + let paths = mapMaybe getSourcePath allAnnotations + let externalSources = listToMaybe $ mapMaybe getExternalSources allAnnotations + resolved <- system $ siFindSource sys currentScript externalSources paths filename + contents <- system $ siReadFile sys externalSources resolved + return (contents, resolved) + case input of + Left err -> do + parseNoteAtId fileId InfoC 1091 $ + "Not following: " ++ err + return t + Right script -> do + id1 <- getNewIdFor cmdId + id2 <- getNewIdFor cmdId + + let included = do + src <- subRead resolvedFile script + return $ T_SourceCommand id1 t (T_Include id2 src) + + let failed = do + parseNoteAtId fileId WarningC 1094 + "Parsing of sourced file failed. Ignoring it." + return t + + included <|> failed + where + getFile :: [Token] -> Maybe Token + getFile (first:rest) = + case getLiteralString first of + Just "--" -> rest !!! 0 + Just "-p" -> rest !!! 1 + _ -> return first + getFile _ = Nothing + + getSourcePath t = + case t of + SourcePath x -> Just x + _ -> Nothing + + getExternalSources t = + case t of + ExternalSources b -> Just b + _ -> Nothing + + -- If the word has a single expansion as the directory, try stripping it + -- This affects `$foo/bar` but not `${foo}-dir/bar` or `/foo/$file` + stripDynamicPrefix word = + case getWordParts word of + exp : rest | isStringExpansion exp -> do + str <- getLiteralString (T_NormalWord (Id 0) rest) + guard $ "/" `isPrefixOf` str + return $ "." ++ str + _ -> Nothing + + subRead name script = + withContext (ContextSource name) $ + inSeparateContext $ do + oldState <- getState + setState $ oldState { pendingHereDocs = [] } + result <- subParse (initialPos name) (readScriptFile True) script + newState <- getState + setState $ newState { pendingHereDocs = pendingHereDocs oldState } + return result +readSource t = return t + + +prop_readPipeline = isOk readPipeline "! cat /etc/issue | grep -i ubuntu" +prop_readPipeline2 = isWarning readPipeline "!cat /etc/issue | grep -i ubuntu" +prop_readPipeline3 = isOk readPipeline "for f; do :; done|cat" +prop_readPipeline4 = isOk readPipeline "! ! true" +prop_readPipeline5 = isOk readPipeline "true | ! true" +readPipeline = do + unexpecting "keyword/token" readKeyword + readBanged readPipeSequence + +readBanged parser = do + pos <- getPosition + (T_Bang id) <- g_Bang + next <- readBanged parser + return $ T_Banged id next + <|> parser + +prop_readAndOr = isOk readAndOr "grep -i lol foo || exit 1" +prop_readAndOr1 = isOk readAndOr "# shellcheck disable=1\nfoo" +prop_readAndOr2 = isOk readAndOr "# shellcheck disable=1\n# lol\n# shellcheck disable=3\nfoo" +readAndOr = do + start <- startSpan + apos <- getPosition + annotations <- readAnnotations + aid <- endSpan start + + unless (null annotations) $ optional $ do + try . lookAhead $ readKeyword + parseProblemAt apos ErrorC 1123 "ShellCheck directives are only valid in front of complete compound commands, like 'if', not e.g. individual 'elif' branches." + + andOr <- withAnnotations annotations $ + chainl1 readPipeline $ do + op <- g_AND_IF <|> g_OR_IF + readLineBreak + return $ case op of T_AND_IF id -> T_AndIf id + T_OR_IF id -> T_OrIf id + + return $ if null annotations + then andOr + else T_Annotation aid annotations andOr + +readTermOrNone = do + allspacing + readTerm <|> do + eof + return [] + +prop_readTerm = isOk readTerm "time ( foo; bar; )" +readTerm = do + allspacing + m <- readAndOr + readTerm' m + where + readTerm' current = + do + (sep, (start, end)) <- readSeparator + id <- getNextIdBetween start end + more <- option (T_EOF id) readAndOr + case more of (T_EOF _) -> return [transformWithSeparator id sep current] + _ -> do + list <- readTerm' more + return (transformWithSeparator id sep current : list) + <|> + return [current] + where + transformWithSeparator i '&' = T_Backgrounded i + transformWithSeparator i _ = id + + +readPipeSequence = do + start <- startSpan + (cmds, pipes) <- sepBy1WithSeparators (readBanged readCommand) + (readPipe `thenSkip` (spacing >> readLineBreak)) + id <- endSpan start + spacing + return $ T_Pipeline id pipes cmds + where + sepBy1WithSeparators p s = do + let elems = (\x -> ([x], [])) <$> p + let seps = do + separator <- s + return $ \(a,b) (c,d) -> (a++c, b ++ d ++ [separator]) + elems `chainl1` seps + +readPipe = do + notFollowedBy2 g_OR_IF + start <- startSpan + char '|' + qualifier <- string "&" <|> return "" + id <- endSpan start + spacing + return $ T_Pipe id ('|':qualifier) + +readCommand = choice [ + readCompoundCommand, + readConditionCommand, + readCoProc, + readSimpleCommand + ] + +readCmdName = do + -- If the command name is `!` then + optional . lookAhead . try $ do + char '!' + whitespace + -- Ignore alias suppression + optional . try $ do + char '\\' + lookAhead $ variableChars <|> oneOf ":." + readCmdWord + +readCmdWord = do + skipAnnotationAndWarn + readNormalWord <* spacing + +-- Due to poor planning, annotations after commands isn't handled well. +-- At the time this function is used, it's usually too late to skip +-- comments, so you end up with a parse failure instead. +skipAnnotationAndWarn = optional $ do + try . lookAhead $ readAnnotationPrefix + parseProblem ErrorC 1126 "Place shellcheck directives before commands, not after." + readAnyComment + +prop_readIfClause = isOk readIfClause "if false; then foo; elif true; then stuff; more stuff; else cows; fi" +prop_readIfClause2 = isWarning readIfClause "if false; then; echo oo; fi" +prop_readIfClause3 = isWarning readIfClause "if false; then true; else; echo lol; fi" +prop_readIfClause4 = isWarning readIfClause "if false; then true; else if true; then echo lol; fi; fi" +prop_readIfClause5 = isOk readIfClause "if false; then true; else\nif true; then echo lol; fi; fi" +prop_readIfClause6 = isWarning readIfClause "if true\nthen\nDo the thing\nfi" +readIfClause = called "if expression" $ do + start <- startSpan + pos <- getPosition + (condition, action) <- readIfPart + elifs <- many readElifPart + elses <- option [] readElsePart + + g_Fi `orFail` do + parseProblemAt pos ErrorC 1046 "Couldn't find 'fi' for this 'if'." + parseProblem ErrorC 1047 "Expected 'fi' matching previously mentioned 'if'." + return "Expected 'fi'" + id <- endSpan start + + return $ T_IfExpression id ((condition, action):elifs) elses + + +verifyNotEmptyIf s = + optional (do + emptyPos <- getPosition + try . lookAhead $ (g_Fi <|> g_Elif <|> g_Else) + parseProblemAt emptyPos ErrorC 1048 $ "Can't have empty " ++ s ++ " clauses (use 'true' as a no-op).") +readIfPart = do + pos <- getPosition + g_If + allspacing + condition <- readTerm + + ifNextToken (g_Fi <|> g_Elif <|> g_Else) $ + parseProblemAt pos ErrorC 1049 "Did you forget the 'then' for this 'if'?" + + called "then clause" $ do + g_Then `orFail` do + parseProblem ErrorC 1050 "Expected 'then'." + return "Expected 'then'" + + acceptButWarn g_Semi ErrorC 1051 "Semicolons directly after 'then' are not allowed. Just remove it." + allspacing + verifyNotEmptyIf "then" + + action <- readTerm + return (condition, action) + +readElifPart = called "elif clause" $ do + pos <- getPosition + g_Elif + allspacing + condition <- readTerm + ifNextToken (g_Fi <|> g_Elif <|> g_Else) $ + parseProblemAt pos ErrorC 1049 "Did you forget the 'then' for this 'elif'?" + + g_Then + acceptButWarn g_Semi ErrorC 1052 "Semicolons directly after 'then' are not allowed. Just remove it." + allspacing + verifyNotEmptyIf "then" + action <- readTerm + return (condition, action) + +readElsePart = called "else clause" $ do + pos <- getPosition + g_Else + optional $ do + try . lookAhead $ g_If + parseProblemAt pos ErrorC 1075 "Use 'elif' instead of 'else if' (or put 'if' on new line if nesting)." + + acceptButWarn g_Semi ErrorC 1053 "Semicolons directly after 'else' are not allowed. Just remove it." + allspacing + verifyNotEmptyIf "else" + readTerm + +ifNextToken parser action = + optional $ do + try . lookAhead $ parser + action + +prop_readSubshell = isOk readSubshell "( cd /foo; tar cf stuff.tar * )" +readSubshell = called "explicit subshell" $ do + start <- startSpan + char '(' + allspacing + list <- readCompoundList + allspacing + char ')' <|> fail "Expected ) closing the subshell" + id <- endSpan start + spacing + return $ T_Subshell id list + +prop_readBraceGroup = isOk readBraceGroup "{ a; b | c | d; e; }" +prop_readBraceGroup2 = isWarning readBraceGroup "{foo;}" +prop_readBraceGroup3 = isOk readBraceGroup "{(foo)}" +readBraceGroup = called "brace group" $ do + start <- startSpan + char '{' + void allspacingOrFail <|> optional (do + lookAhead $ noneOf "(" -- {( is legal + parseProblem ErrorC 1054 "You need a space after the '{'.") + optional $ do + pos <- getPosition + lookAhead $ char '}' + parseProblemAt pos ErrorC 1055 "You need at least one command here. Use 'true;' as a no-op." + list <- readTerm + char '}' <|> do + parseProblem ErrorC 1056 "Expected a '}'. If you have one, try a ; or \\n in front of it." + fail "Missing '}'" + id <- endSpan start + spacing + return $ T_BraceGroup id list + +prop_readBatsTest1 = isOk readBatsTest "@test 'can parse' {\n true\n}" +prop_readBatsTest2 = isOk readBatsTest "@test random text !(@*$Y&! {\n true\n}" +prop_readBatsTest3 = isOk readBatsTest "@test foo { bar { baz {\n true\n}" +prop_readBatsTest4 = isNotOk readBatsTest "@test foo \n{\n true\n}" +readBatsTest = called "bats @test" $ do + start <- startSpan + try $ string "@test " + spacing + name <- readBatsName + spacing + test <- readBraceGroup + id <- endSpan start + return $ T_BatsTest id name test + where + readBatsName = do + line <- try . lookAhead $ many1 $ noneOf "\n" + let name = reverse $ f $ reverse line + string name + + -- We want everything before the last " {" in a string, so we find everything after "{ " in its reverse + f ('{':' ':rest) = dropWhile isSpace rest + f (a:rest) = f rest + f [] = "" + +prop_readWhileClause = isOk readWhileClause "while [[ -e foo ]]; do sleep 1; done" +readWhileClause = called "while loop" $ do + start <- startSpan + kwId <- getId <$> g_While + condition <- readTerm + statements <- readDoGroup kwId + id <- endSpan start + return $ T_WhileExpression id condition statements + +prop_readUntilClause = isOk readUntilClause "until kill -0 $PID; do sleep 1; done" +readUntilClause = called "until loop" $ do + start <- startSpan + kwId <- getId <$> g_Until + condition <- readTerm + statements <- readDoGroup kwId + id <- endSpan start + return $ T_UntilExpression id condition statements + +readDoGroup kwId = do + optional (do + try . lookAhead $ g_Done + parseProblemAtId kwId ErrorC 1057 "Did you forget the 'do' for this loop?") + + doKw <- g_Do `orFail` do + parseProblem ErrorC 1058 "Expected 'do'." + return "Expected 'do'" + + acceptButWarn g_Semi ErrorC 1059 "Semicolon is not allowed directly after 'do'. You can just delete it." + allspacing + + optional (do + try . lookAhead $ g_Done + parseProblemAtId (getId doKw) ErrorC 1060 "Can't have empty do clauses (use 'true' as a no-op).") + + commands <- readCompoundList + g_Done `orFail` do + parseProblemAtId (getId doKw) ErrorC 1061 "Couldn't find 'done' for this 'do'." + parseProblem ErrorC 1062 "Expected 'done' matching previously mentioned 'do'." + return "Expected 'done'" + + optional . lookAhead $ do + pos <- getPosition + try $ string "<(" + parseProblemAt pos ErrorC 1142 "Use 'done < <(cmd)' to redirect from process substitution (currently missing one '<')." + return commands + + +prop_readForClause = isOk readForClause "for f in *; do rm \"$f\"; done" +prop_readForClause1 = isOk readForClause "for f in *; { rm \"$f\"; }" +prop_readForClause3 = isOk readForClause "for f; do foo; done" +prop_readForClause4 = isOk readForClause "for((i=0; i<10; i++)); do echo $i; done" +prop_readForClause5 = isOk readForClause "for ((i=0;i<10 && n>x;i++,--n))\ndo \necho $i\ndone" +prop_readForClause6 = isOk readForClause "for ((;;))\ndo echo $i\ndone" +prop_readForClause7 = isOk readForClause "for ((;;)) do echo $i\ndone" +prop_readForClause8 = isOk readForClause "for ((;;)) ; do echo $i\ndone" +prop_readForClause9 = isOk readForClause "for i do true; done" +prop_readForClause10 = isOk readForClause "for ((;;)) { true; }" +prop_readForClause12 = isWarning readForClause "for $a in *; do echo \"$a\"; done" +prop_readForClause13 = isOk readForClause "for foo\nin\\\n bar\\\n baz\ndo true; done" +readForClause = called "for loop" $ do + pos <- getPosition + (T_For id) <- g_For + spacing + readArithmetic id <|> readRegular id + where + readArithmetic id = called "arithmetic for condition" $ do + readArithmeticDelimiter '(' "Missing second '(' to start arithmetic for ((;;)) loop" + x <- readArithmeticContents + char ';' >> spacing + y <- readArithmeticContents + char ';' >> spacing + z <- readArithmeticContents + spacing + readArithmeticDelimiter ')' "Missing second ')' to terminate 'for ((;;))' loop condition" + spacing + optional $ readSequentialSep >> spacing + group <- readBraced <|> readDoGroup id + return $ T_ForArithmetic id x y z group + + -- For c='(' read "((" and be lenient about spaces + readArithmeticDelimiter c msg = do + char c + startPos <- getPosition + sp <- spacing + endPos <- getPosition + char c <|> do + parseProblemAt startPos ErrorC 1137 msg + fail "" + unless (null sp) $ + parseProblemAtWithEnd startPos endPos ErrorC 1138 $ "Remove spaces between " ++ [c,c] ++ " in arithmetic for loop." + + readBraced = do + (T_BraceGroup _ list) <- readBraceGroup + return list + + readRegular id = do + acceptButWarn (char '$') ErrorC 1086 + "Don't use $ on the iterator name in for loops." + name <- readVariableName `thenSkip` allspacing + values <- readInClause <|> (optional readSequentialSep >> return []) + group <- readBraced <|> readDoGroup id + return $ T_ForIn id name values group + +prop_readSelectClause1 = isOk readSelectClause "select foo in *; do echo $foo; done" +prop_readSelectClause2 = isOk readSelectClause "select foo; do echo $foo; done" +readSelectClause = called "select loop" $ do + (T_Select id) <- g_Select + spacing + typ <- readRegular + group <- readDoGroup id + typ id group + where + readRegular = do + name <- readVariableName + spacing + values <- readInClause <|> (readSequentialSep >> return []) + return $ \id group -> (return $ T_SelectIn id name values group) + +readInClause = do + g_In + things <- readCmdWord `reluctantlyTill` + (void g_Semi <|> void linefeed <|> void g_Do) + + do { + lookAhead g_Do; + parseNote ErrorC 1063 "You need a line feed or semicolon before the 'do'."; + } <|> do { + optional g_Semi; + void allspacing; + } + + return things + +prop_readCaseClause = isOk readCaseClause "case foo in a ) lol; cow;; b|d) fooo; esac" +prop_readCaseClause2 = isOk readCaseClause "case foo\n in * ) echo bar;; esac" +prop_readCaseClause3 = isOk readCaseClause "case foo\n in * ) echo bar & ;; esac" +prop_readCaseClause4 = isOk readCaseClause "case foo\n in *) echo bar ;& bar) foo; esac" +prop_readCaseClause5 = isOk readCaseClause "case foo\n in *) echo bar;;& foo) baz;; esac" +prop_readCaseClause6 = isOk readCaseClause "case foo\n in if) :;; done) :;; esac" +readCaseClause = called "case expression" $ do + start <- startSpan + g_Case + word <- readNormalWord + allspacing + g_In <|> fail "Expected 'in'" + readLineBreak + list <- readCaseList + g_Esac <|> fail "Expected 'esac' to close the case statement" + id <- endSpan start + return $ T_CaseExpression id word list + +readCaseList = many readCaseItem + +readCaseItem = called "case item" $ do + notFollowedBy2 g_Esac + optional $ do + try . lookAhead $ readAnnotationPrefix + parseProblem ErrorC 1124 "ShellCheck directives are only valid in front of complete commands like 'case' statements, not individual case branches." + optional g_Lparen + spacing + pattern' <- readPattern + void g_Rparen <|> do + parseProblem ErrorC 1085 + "Did you forget to move the ;; after extending this case item?" + fail "Expected ) to open a new case item" + readLineBreak + list <- (lookAhead readCaseSeparator >> return []) <|> readCompoundList + separator <- readCaseSeparator `attempting` do + pos <- getPosition + lookAhead g_Rparen + parseProblemAt pos ErrorC 1074 + "Did you forget the ;; after the previous case item?" + readLineBreak + return (separator, pattern', list) + +readCaseSeparator = choice [ + tryToken ";;&" (const ()) >> return CaseContinue, + tryToken ";&" (const ()) >> return CaseFallThrough, + g_DSEMI >> return CaseBreak, + lookAhead (readLineBreak >> g_Esac) >> return CaseBreak + ] + +prop_readFunctionDefinition = isOk readFunctionDefinition "foo() { command foo --lol \"$@\"; }" +prop_readFunctionDefinition1 = isOk readFunctionDefinition "foo (){ command foo --lol \"$@\"; }" +prop_readFunctionDefinition4 = isWarning readFunctionDefinition "foo(a, b) { true; }" +prop_readFunctionDefinition5 = isOk readFunctionDefinition ":(){ :|:;}" +prop_readFunctionDefinition6 = isOk readFunctionDefinition "?(){ foo; }" +prop_readFunctionDefinition7 = isOk readFunctionDefinition "..(){ cd ..; }" +prop_readFunctionDefinition8 = isOk readFunctionDefinition "foo() (ls)" +prop_readFunctionDefinition9 = isOk readFunctionDefinition "function foo { true; }" +prop_readFunctionDefinition10 = isOk readFunctionDefinition "function foo () { true; }" +prop_readFunctionDefinition11 = isWarning readFunctionDefinition "function foo{\ntrue\n}" +prop_readFunctionDefinition12 = isOk readFunctionDefinition "function []!() { true; }" +prop_readFunctionDefinition13 = isOk readFunctionDefinition "@require(){ true; }" +prop_readFunctionDefinition14 = isOk readFunctionDefinition "foo#bar(){ :; }" +prop_readFunctionDefinition15 = isNotOk readFunctionDefinition "#bar(){ :; }" +readFunctionDefinition = called "function" $ do + start <- startSpan + functionSignature <- try readFunctionSignature + allspacing + void (lookAhead $ oneOf "{(") <|> parseProblem ErrorC 1064 "Expected a { to open the function definition." + group <- readBraceGroup <|> readSubshell + id <- endSpan start + return $ functionSignature id group + where + readFunctionSignature = + readWithFunction <|> readWithoutFunction + where + readWithFunction = do + try $ do + string "function" + whitespace + spacing + name <- (:) <$> extendedFunctionStartChars <*> many extendedFunctionChars + spaces <- spacing + hasParens <- wasIncluded readParens + when (not hasParens && null spaces) $ + acceptButWarn (lookAhead (oneOf "{(")) + ErrorC 1095 "You need a space or linefeed between the function name and body." + return $ \id -> T_Function id (FunctionKeyword True) (FunctionParentheses hasParens) name + + readWithoutFunction = try $ do + name <- (:) <$> functionStartChars <*> many functionChars + guard $ name /= "time" -- Interferes with time ( foo ) + spacing + readParens + return $ \id -> T_Function id (FunctionKeyword False) (FunctionParentheses True) name + + readParens = do + g_Lparen + spacing + g_Rparen <|> do + parseProblem ErrorC 1065 "Trying to declare parameters? Don't. Use () and refer to params as $1, $2.." + many $ noneOf "\n){" + g_Rparen + return () + +prop_readCoProc1 = isOk readCoProc "coproc foo { echo bar; }" +prop_readCoProc2 = isOk readCoProc "coproc { echo bar; }" +prop_readCoProc3 = isOk readCoProc "coproc echo bar" +prop_readCoProc4 = isOk readCoProc "coproc a=b echo bar" +prop_readCoProc5 = isOk readCoProc "coproc 'foo' { echo bar; }" +prop_readCoProc6 = isOk readCoProc "coproc \"foo$$\" { echo bar; }" +prop_readCoProc7 = isOk readCoProc "coproc 'foo' ( echo bar )" +prop_readCoProc8 = isOk readCoProc "coproc \"foo$$\" while true; do true; done" +readCoProc = called "coproc" $ do + start <- startSpan + try $ do + string "coproc" + spacing1 + choice [ try $ readCompoundCoProc start, readSimpleCoProc start ] + where + readCompoundCoProc start = do + notFollowedBy2 readAssignmentWord + (var, body) <- choice [ + try $ do + body <- readBody readCompoundCommand + return (Nothing, body), + try $ do + var <- readNormalWord `thenSkip` spacing + body <- readBody readCompoundCommand + return (Just var, body) + ] + id <- endSpan start + return $ T_CoProc id var body + readSimpleCoProc start = do + body <- readBody readSimpleCommand + id <- endSpan start + return $ T_CoProc id Nothing body + readBody parser = do + start <- startSpan + body <- parser + id <- endSpan start + return $ T_CoProcBody id body + +readPattern = (readPatternWord `thenSkip` spacing) `sepBy1` (char '|' `thenSkip` spacing) + +prop_readConditionCommand = isOk readConditionCommand "[[ x ]] > foo 2>&1" +readConditionCommand = do + cmd <- readCondition + redirs <- many readIoRedirect + id <- getNextIdSpanningTokenList (cmd:redirs) + + pos <- getPosition + hasDashAo <- isFollowedBy $ do + c <- choice $ try . string <$> ["-o", "-a", "or", "and"] + posEnd <- getPosition + parseProblemAtWithEnd pos posEnd ErrorC 1139 $ + "Use " ++ alt c ++ " instead of '" ++ c ++ "' between test commands." + + -- If the next word is a keyword, readNormalWord will trigger a warning + hasKeyword <- isFollowedBy readKeyword + hasWord <- isFollowedBy readNormalWord + + when (hasWord && not (hasKeyword || hasDashAo)) $ do + -- We have other words following, and no error has been emitted. + posEnd <- getPosition + parseProblemAtWithEnd pos posEnd ErrorC 1140 "Unexpected parameters after condition. Missing &&/||, or bad expression?" + + return $ T_Redirecting id redirs cmd + where + alt "or" = "||" + alt "-o" = "||" + alt "and" = "&&" + alt "-a" = "&&" + alt _ = "|| or &&" + +prop_readCompoundCommand = isOk readCompoundCommand "{ echo foo; }>/dev/null" +readCompoundCommand = do + cmd <- choice [ + readBraceGroup, + readAmbiguous "((" readArithmeticExpression readSubshell (\pos -> + parseNoteAt pos ErrorC 1105 "Shells disambiguate (( differently or not at all. For subshell, add spaces around ( . For ((, fix parsing errors."), + readSubshell, + readWhileClause, + readUntilClause, + readIfClause, + readForClause, + readSelectClause, + readCaseClause, + readBatsTest, + readFunctionDefinition + ] + redirs <- many readIoRedirect + id <- getNextIdSpanningTokenList (cmd:redirs) + optional . lookAhead $ do + notFollowedBy2 $ choice [readKeyword, g_Lbrace] + pos <- getPosition + many1 readNormalWord + posEnd <- getPosition + parseProblemAtWithEnd pos posEnd ErrorC 1141 "Unexpected tokens after compound command. Bad redirection or missing ;/&&/||/|?" + return $ T_Redirecting id redirs cmd + + +readCompoundList = readTerm +readCompoundListOrEmpty = do + allspacing + readTerm <|> return [] + +readCmdPrefix = many1 (readIoRedirect <|> readAssignmentWord) +readCmdSuffix = many1 (readIoRedirect <|> readCmdWord) +readModifierSuffix = many1 (readIoRedirect <|> readWellFormedAssignment <|> readCmdWord) +readTimeSuffix = do + flags <- many readFlag + pipeline <- readPipeline + return $ flags ++ [pipeline] + where + -- This fails for quoted variables and such. Fixme? + readFlag = do + lookAhead $ char '-' + readCmdWord + +-- Fixme: this is a hack that doesn't handle let c='4'"5" or let a\>b +readLetSuffix :: Monad m => SCParser m [Token] +readLetSuffix = many1 (readIoRedirect <|> try readLetExpression <|> readCmdWord) + where + readLetExpression :: Monad m => SCParser m Token + readLetExpression = do + startPos <- getPosition + expression <- readStringForParser readCmdWord + let (unQuoted, newPos) = kludgeAwayQuotes expression startPos + subParse newPos (readArithmeticContents <* eof) unQuoted + + kludgeAwayQuotes :: String -> SourcePos -> (String, SourcePos) + kludgeAwayQuotes s p = + case s of + first:second:rest -> + let (last NE.:| backwards) = NE.reverse (second NE.:| rest) + middle = reverse backwards + in + if first `elem` "'\"" && first == last + then (middle, updatePosChar p first) + else (s, p) + x -> (s, p) + + +-- bash allows a=(b), ksh allows $a=(b). dash allows neither. Let's warn. +readEvalSuffix = many1 (readIoRedirect <|> readCmdWord <|> evalFallback) + where + evalFallback = do + pos <- getPosition + lookAhead $ char '(' + parseProblemAt pos WarningC 1098 "Quote/escape special characters when using eval, e.g. eval \"a=(b)\"." + fail "Unexpected parentheses. Make sure to quote when eval'ing as shell parsers differ." + +-- Get whatever a parser would parse as a string +readStringForParser parser = do + pos <- inSeparateContext $ lookAhead (parser >> getPosition) + readUntil pos + where + readUntil endPos = anyChar `reluctantlyTill` (getPosition >>= guard . (== endPos)) + +-- Like readStringForParser, returning the span as a T_Literal +readLiteralForParser parser = do + start <- startSpan + str <- readStringForParser parser + id <- endSpan start + return $ T_Literal id str + +prop_readAssignmentWord = isOk readAssignmentWord "a=42" +prop_readAssignmentWord2 = isOk readAssignmentWord "b=(1 2 3)" +prop_readAssignmentWord5 = isOk readAssignmentWord "b+=lol" +prop_readAssignmentWord7 = isOk readAssignmentWord "a[3$n'']=42" +prop_readAssignmentWord8 = isOk readAssignmentWord "a[4''$(cat foo)]=42" +prop_readAssignmentWord9 = isOk readAssignmentWord "IFS= " +prop_readAssignmentWord9a = isOk readAssignmentWord "foo=" +prop_readAssignmentWord9b = isOk readAssignmentWord "foo= " +prop_readAssignmentWord9c = isOk readAssignmentWord "foo= #bar" +prop_readAssignmentWord11 = isOk readAssignmentWord "foo=([a]=b [c] [d]= [e f )" +prop_readAssignmentWord12 = isOk readAssignmentWord "a[b <<= 3 + c]='thing'" +prop_readAssignmentWord13 = isOk readAssignmentWord "var=( (1 2) (3 4) )" +prop_readAssignmentWord14 = isOk readAssignmentWord "var=( 1 [2]=(3 4) )" +prop_readAssignmentWord15 = isOk readAssignmentWord "var=(1 [2]=(3 4))" +readAssignmentWord = readAssignmentWordExt True +readWellFormedAssignment = readAssignmentWordExt False +readAssignmentWordExt lenient = called "variable assignment" $ do + -- Parse up to and including the = in a 'try' + (id, variable, op, indices) <- try $ do + start <- startSpan + pos <- getPosition + -- Check for a leading $ at parse time, to warn for $foo=(bar) which + -- would otherwise cause a parse failure so it can't be checked later. + leadingDollarPos <- + if lenient + then optionMaybe $ getSpanPositionsFor (char '$') + else return Nothing + variable <- readVariableName + indices <- many readArrayIndex + hasLeftSpace <- fmap (not . null) spacing + opStart <- getPosition + id <- endSpan start + op <- readAssignmentOp + opEnd <- getPosition + + when (isJust leadingDollarPos || hasLeftSpace) $ do + hasParen <- isFollowedBy (spacing >> char '(') + when hasParen $ + sequence_ $ do + (l, r) <- leadingDollarPos + return $ parseProblemAtWithEnd l r ErrorC 1066 "Don't use $ on the left side of assignments." + + -- Fail so that this is not parsed as an assignment. + fail "" + -- At this point we know for sure. + return (id, variable, op, indices) + + rightPosStart <- getPosition + hasRightSpace <- fmap (not . null) spacing + rightPosEnd <- getPosition + isEndOfCommand <- fmap isJust $ optionMaybe (try . lookAhead $ (void (oneOf "\r\n;&|)") <|> eof)) + + if hasRightSpace || isEndOfCommand + then do + when (variable /= "IFS" && hasRightSpace && not isEndOfCommand) $ do + parseProblemAtWithEnd rightPosStart rightPosEnd WarningC 1007 + "Remove space after = if trying to assign a value (for empty string, use var='' ... )." + value <- readEmptyLiteral + return $ T_Assignment id op variable indices value + else do + optional $ do + lookAhead $ char '=' + parseProblem ErrorC 1097 "Unexpected ==. For assignment, use =. For comparison, use [/[[. Or quote for literal string." + + value <- readArray <|> readNormalWord + spacing + return $ T_Assignment id op variable indices value + where + readAssignmentOp = do + -- This is probably some kind of ascii art border + unexpecting "===" (string "===") + choice [ + string "+=" >> return Append, + string "=" >> return Assign + ] + +readEmptyLiteral = do + start <- startSpan + id <- endSpan start + return $ T_Literal id "" + +readArrayIndex = do + start <- startSpan + char '[' + pos <- getPosition + str <- readStringForParser readIndexSpan + char ']' + id <- endSpan start + return $ T_UnparsedIndex id pos str + +readArray :: Monad m => SCParser m Token +readArray = called "array assignment" $ do + start <- startSpan + opening <- getPosition + char '(' + optional $ do + lookAhead $ char '(' + parseProblemAt opening ErrorC 1116 "Missing $ on a $((..)) expression? (or use ( ( for arrays)." + allspacing + words <- readElement `reluctantlyTill` char ')' + char ')' <|> fail "Expected ) to close array assignment" + id <- endSpan start + return $ T_Array id words + where + readElement = (readIndexed <|> readRegular) `thenSkip` allspacing + readIndexed = do + start <- startSpan + index <- try $ do + x <- many1 readArrayIndex + char '=' + return x + value <- readRegular <|> nothing + id <- endSpan start + return $ T_IndexedElement id index value + readRegular = readArray <|> readNormalWord + + nothing = do + start <- startSpan + id <- endSpan start + return $ T_Literal id "" + +tryToken s t = try $ do + start <- startSpan + string s + id <- endSpan start + spacing + return $ t id + +redirToken c t = try $ do + start <- startSpan + char c + id <- endSpan start + notFollowedBy2 $ char '(' + return $ t id + +tryWordToken s t = tryParseWordToken s t `thenSkip` spacing +tryParseWordToken keyword t = try $ do + pos <- getPosition + start <- startSpan + str <- anycaseString keyword + id <- endSpan start + + optional $ do + c <- try . lookAhead $ anyChar + let warning code = parseProblem ErrorC code $ "You need a space before the " ++ [c] ++ "." + case c of + '[' -> warning 1069 + '#' -> warning 1099 + '!' -> warning 1129 + ':' -> warning 1130 + _ -> return () + + lookAhead keywordSeparator + when (str /= keyword) $ do + parseProblemAt pos ErrorC 1081 $ + "Scripts are case sensitive. Use '" ++ keyword ++ "', not '" ++ str ++ "' (or quote if literal)." + fail "" + return $ t id + +anycaseString = + mapM anycaseChar + where + anycaseChar c = char (toLower c) <|> char (toUpper c) + +g_AND_IF = tryToken "&&" T_AND_IF +g_OR_IF = tryToken "||" T_OR_IF +g_DSEMI = tryToken ";;" T_DSEMI +g_DLESS = tryToken "<<" T_DLESS +g_DGREAT = tryToken ">>" T_DGREAT +g_LESSAND = tryToken "<&" T_LESSAND +g_GREATAND = tryToken ">&" T_GREATAND +g_LESSGREAT = tryToken "<>" T_LESSGREAT +g_DLESSDASH = tryToken "<<-" T_DLESSDASH +g_CLOBBER = tryToken ">|" T_CLOBBER +g_OPERATOR = g_AND_IF <|> g_OR_IF <|> g_DSEMI <|> g_DLESSDASH <|> g_DLESS <|> g_DGREAT <|> g_LESSAND <|> g_GREATAND <|> g_LESSGREAT + +g_If = tryWordToken "if" T_If +g_Then = tryWordToken "then" T_Then +g_Else = tryWordToken "else" T_Else +g_Elif = tryWordToken "elif" T_Elif +g_Fi = tryWordToken "fi" T_Fi +g_Do = tryWordToken "do" T_Do +g_Done = tryWordToken "done" T_Done +g_Case = tryWordToken "case" T_Case +g_Esac = tryWordToken "esac" T_Esac +g_While = tryWordToken "while" T_While +g_Until = tryWordToken "until" T_Until +g_For = tryWordToken "for" T_For +g_Select = tryWordToken "select" T_Select +g_In = tryWordToken "in" T_In <* skipAnnotationAndWarn +g_Lbrace = tryWordToken "{" T_Lbrace +g_Rbrace = do -- handled specially due to ksh echo "${ foo; }bar" + start <- startSpan + char '}' + id <- endSpan start + return $ T_Rbrace id + +g_Lparen = tryToken "(" T_Lparen +g_Rparen = tryToken ")" T_Rparen +g_Bang = do + start <- startSpan + char '!' + id <- endSpan start + void spacing1 <|> do + pos <- getPosition + parseProblemAt pos ErrorC 1035 + "You are missing a required space after the !." + return $ T_Bang id + +g_Semi = do + notFollowedBy2 g_DSEMI + tryToken ";" T_Semi + +keywordSeparator = + eof <|> void (try allspacingOrFail) <|> void (oneOf ";()[<>&|") + +readKeyword = choice [ g_Then, g_Else, g_Elif, g_Fi, g_Do, g_Done, g_Esac, g_Rbrace, g_Rparen, g_DSEMI ] + +ifParse p t f = + (lookAhead (try p) >> t) <|> f + +prop_readShebang1 = isOk readShebang "#!/bin/sh\n" +prop_readShebang2 = isWarning readShebang "!# /bin/sh\n" +prop_readShebang3 = isNotOk readShebang "#shellcheck shell=/bin/sh\n" +prop_readShebang4 = isWarning readShebang "! /bin/sh" +prop_readShebang5 = isWarning readShebang "\n#!/bin/sh" +prop_readShebang6 = isWarning readShebang " # Copyright \n!#/bin/bash" +prop_readShebang7 = isNotOk readShebang "# Copyright \nfoo\n#!/bin/bash" +readShebang = do + start <- startSpan + anyShebang <|> try readMissingBang <|> withHeader + many linewhitespace + str <- many $ noneOf "\r\n" + id <- endSpan start + optional carriageReturn + optional linefeed + return $ T_Literal id str + where + anyShebang = choice $ map try [ + readCorrect, + readSwapped, + readTooManySpaces, + readMissingHash + ] + readCorrect = void $ string "#!" + + readSwapped = do + start <- startSpan + string "!#" + id <- endSpan start + parseProblemAtId id ErrorC 1084 + "Use #!, not !#, for the shebang." + + skipSpaces = fmap (not . null) $ many linewhitespace + readTooManySpaces = do + startPos <- getPosition + startSpaces <- skipSpaces + char '#' + middlePos <- getPosition + middleSpaces <- skipSpaces + char '!' + when startSpaces $ + parseProblemAt startPos ErrorC 1114 + "Remove leading spaces before the shebang." + when middleSpaces $ + parseProblemAt middlePos ErrorC 1115 + "Remove spaces between # and ! in the shebang." + + readMissingHash = do + pos <- getPosition + char '!' + ensurePathAhead + parseProblemAt pos ErrorC 1104 + "Use #!, not just !, for the shebang." + + readMissingBang = do + char '#' + pos <- getPosition + ensurePathAhead + parseProblemAt pos ErrorC 1113 + "Use #!, not just #, for the shebang." + + ensurePathAhead = lookAhead $ do + many linewhitespace + char '/' + + withHeader = try $ do + many1 headerLine + pos <- getPosition + anyShebang <* + parseProblemAt pos ErrorC 1128 "The shebang must be on the first line. Delete blanks and move comments." + + headerLine = do + notFollowedBy2 anyShebang + many linewhitespace + optional readAnyComment + linefeed + +verifyEof = eof <|> choice [ + ifParsable g_Lparen $ + parseProblem ErrorC 1088 "Parsing stopped here. Invalid use of parentheses?", + + ifParsable readKeyword $ + parseProblem ErrorC 1089 "Parsing stopped here. Is this keyword correctly matched up?", + + parseProblem ErrorC 1070 "Parsing stopped here. Mismatched keywords or invalid parentheses?" + ] + where + ifParsable p action = do + try (lookAhead p) + action + + +readConfigFile :: Monad m => FilePath -> SCParser m [Annotation] +readConfigFile filename = do + shouldIgnore <- Mr.asks ignoreRC + if shouldIgnore then return [] else read' filename + where + read' filename = do + sys <- Mr.asks systemInterface + contents <- system $ siGetConfig sys filename + case contents of + Nothing -> return [] + Just (file, str) -> readConfig file str + + readConfig filename contents = do + result <- lift $ runParserT readConfigKVs initialUserState filename contents + case result of + Right result -> + return result + + Left err -> do + parseProblem ErrorC 1134 $ errorFor filename err + return [] + + errorFor filename err = + let line = "line " ++ (show . sourceLine $ errorPos err) + suggestion = getStringFromParsec $ errorMessages err + in + "Failed to process " ++ (e4m filename) ++ ", " ++ line ++ ": " + ++ suggestion + +prop_readConfigKVs1 = isOk readConfigKVs "disable=1234" +prop_readConfigKVs2 = isOk readConfigKVs "# Comment\ndisable=1234 # Comment\n" +prop_readConfigKVs3 = isOk readConfigKVs "" +prop_readConfigKVs4 = isOk readConfigKVs "\n\n\n\n\t \n" +prop_readConfigKVs5 = isOk readConfigKVs "# shellcheck accepts annotation-like comments in rc files\ndisable=1234" +readConfigKVs = do + anySpacingOrComment + annotations <- many (readAnnotationWithoutPrefix False <* anySpacingOrComment) + eof + return $ concat annotations +anySpacingOrComment = + many (void allspacingOrFail <|> void readAnyComment) + +prop_readScript1 = isOk readScript "#!/bin/bash\necho hello world\n" +prop_readScript2 = isWarning readScript "#!/bin/bash\r\necho hello world\n" +prop_readScript3 = isWarning readScript "#!/bin/bash\necho hello\xA0world" +prop_readScript4 = isWarning readScript "#!/usr/bin/perl\nfoo=(" +prop_readScript5 = isOk readScript "#!/bin/bash\n#This is an empty script\n\n" +prop_readScript6 = isOk readScript "#!/usr/bin/env -S X=FOO bash\n#This is an empty script\n\n" +prop_readScript7 = isOk readScript "#!/bin/zsh\n# shellcheck disable=SC1071\nfor f (a b); echo $f\n" +readScriptFile sourced = do + start <- startSpan + pos <- getPosition + rcAnnotations <- if sourced + then return [] + else do + filename <- Mr.asks currentFilename + readConfigFile filename + + -- Put the rc annotations on the stack so that one can ignore e.g. SC1084 in .shellcheckrc + withAnnotations rcAnnotations $ do + hasBom <- wasIncluded readUtf8Bom + shebang <- readShebang <|> readEmptyLiteral + let (T_Literal _ shebangString) = shebang + allspacing + annotationStart <- startSpan + fileAnnotations <- readAnnotations + + -- Similarly put the filewide annotations on the stack to allow earlier suppression + withAnnotations fileAnnotations $ do + when (hasBom) $ + parseProblemAt pos ErrorC 1082 + "This file has a UTF-8 BOM. Remove it with: LC_CTYPE=C sed '1s/^...//' < yourscript ." + let annotations = fileAnnotations ++ rcAnnotations + annotationId <- endSpan annotationStart + let shellAnnotationSpecified = + any (\x -> case x of ShellOverride {} -> True; _ -> False) annotations + shellFlagSpecified <- isJust <$> Mr.asks shellTypeOverride + let ignoreShebang = shellAnnotationSpecified || shellFlagSpecified + + unless ignoreShebang $ + verifyShebang pos (executableFromShebang shebangString) + if ignoreShebang || isValidShell (executableFromShebang shebangString) /= Just False + then do + commands <- readCompoundListOrEmpty + id <- endSpan start + readPendingHereDocs + verifyEof + let script = T_Annotation annotationId annotations $ + T_Script id shebang commands + userstate <- getState + reparseIndices $ reattachHereDocs script (hereDocMap userstate) + else do + many anyChar + id <- endSpan start + return $ T_Script id shebang [] + + where + verifyShebang pos s = do + case isValidShell s of + Just True -> return () + Just False -> parseProblemAt pos ErrorC 1071 "ShellCheck only supports sh/bash/dash/ksh/'busybox sh' scripts. Sorry!" + Nothing -> parseProblemAt pos ErrorC 1008 "This shebang was unrecognized. ShellCheck only supports sh/bash/dash/ksh/'busybox sh'. Add a 'shell' directive to specify." + + isValidShell s = + let good = null s || any (`isPrefixOf` s) goodShells + bad = any (`isPrefixOf` s) badShells + in + if good + then Just True + else if bad + then Just False + else Nothing + + goodShells = [ + "sh", + "ash", + "dash", + "busybox sh", + "bash", + "bats", + "ksh", + "oksh" + ] + badShells = [ + "awk", + "csh", + "expect", + "fish", + "perl", + "python", + "python3", + "ruby", + "tcsh", + "zsh" + ] + + readUtf8Bom = called "Byte Order Mark" $ string "\xFEFF" + +readScript = readScriptFile False + +-- Interactively run a specific parser in ghci: +-- debugParse readSimpleCommand "echo 'hello world'" +debugParse p string = runIdentity $ do + (res, _) <- runParser testEnvironment p "-" string + return res + +-- Interactively run the complete parser in ghci: +-- debugParseScript "#!/bin/bash\necho 'Hello World'\n" +debugParseScript string = + result { + -- Remove the noisiest parts + prTokenPositions = Map.fromList [ + (Id 0, (newPosition { + posFile = "removed for clarity", + posLine = -1, + posColumn = -1 + }, newPosition { + posFile = "removed for clarity", + posLine = -1, + posColumn = -1 + }))] + } + where + result = runIdentity $ + parseScript (mockedSystemInterface []) $ newParseSpec { + psFilename = "debug", + psScript = string + } + +testEnvironment = + Environment { + systemInterface = (mockedSystemInterface []), + checkSourced = False, + currentFilename = "myscript", + ignoreRC = False, + shellTypeOverride = Nothing + } + + +isOk p s = parsesCleanly p s == Just True -- The string parses with no warnings +isWarning p s = parsesCleanly p s == Just False -- The string parses with warnings +isNotOk p s = parsesCleanly p s == Nothing -- The string does not parse + +-- If the parser matches the string, return Right [ParseNotes+ParseProblems] +-- If it does not match the string, return Left [ParseProblems] +getParseOutput parser string = runIdentity $ do + (res, systemState) <- runParser testEnvironment + (parser >> eof >> getState) "-" string + return $ case res of + Right userState -> + Right $ parseNotes userState ++ parseProblems systemState + Left _ -> Left $ parseProblems systemState + +-- If the parser matches the string, return Just whether it was clean (without emitting suggestions) +-- Otherwise, Nothing +parsesCleanly parser string = + case getParseOutput parser string of + Right list -> Just $ null list + Left _ -> Nothing + +parseWithNotes parser = do + item <- parser + state <- getState + return (item, state) + +compareNotes (ParseNote pos1 pos1' level1 _ s1) (ParseNote pos2 pos2' level2 _ s2) = compare (pos1, pos1', level1) (pos2, pos2', level2) +sortNotes = sortBy compareNotes + + +makeErrorFor parsecError = + ParseNote pos pos ErrorC 1072 $ + getStringFromParsec $ errorMessages parsecError + where + pos = errorPos parsecError + +getStringFromParsec errors = + headOrDefault "" (mapMaybe f $ reverse errors) ++ + " Fix any mentioned problems and try again." + where + f err = + case err of + UnExpect s -> Nothing -- Due to not knowing Parsec, none of these + SysUnExpect s -> Nothing -- are actually helpful. has been hidden + Expect s -> Nothing -- and we only show explicit fail statements. + Message s -> if null s then Nothing else return $ s ++ "." + +runParser :: Monad m => + Environment m -> + SCParser m v -> + String -> + String -> + m (Either ParseError v, SystemState) + +runParser env p filename contents = + Ms.runStateT + (Mr.runReaderT + (runParserT p initialUserState filename contents) + env) + initialSystemState +system = lift . lift . lift + +parseShell env name contents = do + (result, state) <- runParser env (parseWithNotes readScript) name contents + case result of + Right (script, userstate) -> + return newParseResult { + prComments = map toPositionedComment $ nub $ parseNotes userstate ++ parseProblems state, + prTokenPositions = Map.map startEndPosToPos (positionMap userstate), + prRoot = Just script + } + Left err -> do + let context = contextStack state + return newParseResult { + prComments = + map toPositionedComment $ + (filter (not . isIgnored context) $ + notesForContext context + ++ [makeErrorFor err]) + ++ parseProblems state, + prTokenPositions = Map.empty, + prRoot = Nothing + } + where + -- A final pass for ignoring parse errors after failed parsing + isIgnored stack note = any (contextItemDisablesCode False (codeForParseNote note)) stack + +notesForContext list = zipWith ($) [first, second] [(pos, str) | ContextName pos str <- list] + where + first (pos, str) = ParseNote pos pos ErrorC 1073 $ + "Couldn't parse this " ++ str ++ ". Fix to allow more checks." + second (pos, str) = ParseNote pos pos InfoC 1009 $ + "The mentioned syntax error was in this " ++ str ++ "." + +-- Go over all T_UnparsedIndex and reparse them as either arithmetic or text +-- depending on declare -A statements. +reparseIndices root = process root + where + process = analyze blank blank f + associative = getAssociativeArrays root + isAssociative s = s `elem` associative + f (T_Assignment id mode name indices value) = do + newIndices <- mapM (fixAssignmentIndex name) indices + newValue <- case value of + (T_Array id2 words) -> do + newWords <- mapM (fixIndexElement name) words + return $ T_Array id2 newWords + x -> return x + return $ T_Assignment id mode name newIndices newValue + f (TA_Variable id name indices) = do + newIndices <- mapM (fixAssignmentIndex name) indices + return $ TA_Variable id name newIndices + f t = return t + + fixIndexElement name word = + case word of + T_IndexedElement id indices value -> do + new <- mapM (fixAssignmentIndex name) indices + return $ T_IndexedElement id new value + _ -> return word + + fixAssignmentIndex name word = + case word of + T_UnparsedIndex id pos src -> do + idx <- parsed name pos src + process idx -- Recursively parse for cases like x[y[z=1]]=1 + _ -> return word + + parsed name pos src = + if isAssociative name + then subParse pos (called "associative array index" $ readIndexSpan) src + else subParse pos (called "arithmetic array index expression" $ optional space >> readArithmeticContents) src + +reattachHereDocs root map = + doTransform f root + where + f t@(T_HereDoc id dash quote string []) = fromMaybe t $ do + list <- Map.lookup id map + return $ T_HereDoc id dash quote string list + f t = t + +toPositionedComment :: ParseNote -> PositionedComment +toPositionedComment (ParseNote start end severity code message) = + newPositionedComment { + pcStartPos = (posToPos start) + , pcEndPos = (posToPos end) + , pcComment = newComment { + cSeverity = severity + , cCode = code + , cMessage = message + } + } + +posToPos :: SourcePos -> Position +posToPos sp = newPosition { + posFile = sourceName sp, + posLine = fromIntegral $ sourceLine sp, + posColumn = fromIntegral $ sourceColumn sp +} + +startEndPosToPos :: (SourcePos, SourcePos) -> (Position, Position) +startEndPosToPos (s, e) = (posToPos s, posToPos e) + +-- TODO: Clean up crusty old code that this is layered on top of +parseScript :: Monad m => + SystemInterface m -> ParseSpec -> m ParseResult +parseScript sys spec = + parseShell env (psFilename spec) (psScript spec) + where + env = Environment { + systemInterface = sys, + checkSourced = psCheckSourced spec, + currentFilename = psFilename spec, + ignoreRC = psIgnoreRC spec, + shellTypeOverride = psShellTypeOverride spec + } + +-- Same as 'try' but emit syntax errors if the parse fails. +tryWithErrors :: Monad m => SCParser m v -> SCParser m v +tryWithErrors parser = do + userstate <- getState + oldContext <- getCurrentContexts + input <- getInput + pos <- getPosition + result <- lift $ runParserT (setPosition pos >> getResult parser) userstate (sourceName pos) input + case result of + Right (result, endPos, endInput, endState) -> do + -- 'many' objects if we don't consume anything at all, so read a dummy value + void anyChar <|> eof + putState endState + setPosition endPos + setInput endInput + return result + + Left err -> do + newContext <- getCurrentContexts + addParseProblem $ makeErrorFor err + mapM_ addParseProblem $ notesForContext newContext + setCurrentContexts oldContext + fail "" + where + getResult p = do + result <- p + endPos <- getPosition + endInput <- getInput + endState <- getState + return (result, endPos, endInput, endState) + +return [] +runTests = $quickCheckAll diff --git a/src/ShellCheck/Prelude.hs b/src/ShellCheck/Prelude.hs new file mode 100644 index 0000000..7610c46 --- /dev/null +++ b/src/ShellCheck/Prelude.hs @@ -0,0 +1,51 @@ +{- + Copyright 2022 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} + +-- Generic basic utility functions +module ShellCheck.Prelude where + +import Data.Semigroup + + +-- Get element 0 or a default. Like `head` but safe. +headOrDefault _ (a:_) = a +headOrDefault def _ = def + +-- Get the last element or a default. Like `last` but safe. +lastOrDefault def [] = def +lastOrDefault _ list = last list + +--- Get element n of a list, or Nothing. Like `!!` but safe. +(!!!) list i = + case drop i list of + [] -> Nothing + (r:_) -> Just r + + +-- Like mconcat but for Semigroups +sconcat1 :: (Semigroup t) => [t] -> t +sconcat1 [x] = x +sconcat1 (x:xs) = x <> sconcat1 xs + +sconcatOrDefault def [] = def +sconcatOrDefault _ list = sconcat1 list + +-- For more actionable "impossible" errors +pleaseReport str = "ShellCheck internal error, please report: " ++ str diff --git a/src/ShellCheck/Regex.hs b/src/ShellCheck/Regex.hs new file mode 100644 index 0000000..9367ee7 --- /dev/null +++ b/src/ShellCheck/Regex.hs @@ -0,0 +1,80 @@ +{- + Copyright 2012-2019 Vidar Holen + + This file is part of ShellCheck. + https://www.shellcheck.net + + ShellCheck is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ShellCheck is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +-} +{-# LANGUAGE FlexibleContexts #-} + +-- Basically Text.Regex based on regex-tdfa instead of the buggy regex-posix. +module ShellCheck.Regex where + +import Data.List +import Data.Maybe +import Control.Monad +import Text.Regex.TDFA + +-- Precompile the regex +mkRegex :: String -> Regex +mkRegex str = + let make :: String -> Regex + make = makeRegex + in + make str + +-- Does the regex match? +matches :: String -> Regex -> Bool +matches = flip match + +-- Get all subgroups of the first match +matchRegex :: Regex -> String -> Maybe [String] +matchRegex re str = do + (_, _, _, groups) <- matchM re str :: Maybe (String,String,String,[String]) + return groups + +-- Get all full matches +matchAllStrings :: Regex -> String -> [String] +matchAllStrings re = unfoldr f + where + f :: String -> Maybe (String, String) + f str = do + (_, match, rest, _) <- matchM re str :: Maybe (String, String, String, [String]) + return (match, rest) + +-- Get all subgroups from all matches +matchAllSubgroups :: Regex -> String -> [[String]] +matchAllSubgroups re = unfoldr f + where + f :: String -> Maybe ([String], String) + f str = do + (_, _, rest, groups) <- matchM re str :: Maybe (String, String, String, [String]) + return (groups, rest) + +-- Replace regex in input with string +subRegex :: Regex -> String -> String -> String +subRegex re input replacement = f input + where + f str = fromMaybe str $ do + (before, match, after) <- matchM re str :: Maybe (String, String, String) + when (null match) $ error ("Internal error: substituted empty in " ++ str) + return $ before ++ replacement ++ f after + +-- Split a string based on a regex. +splitOn :: String -> Regex -> [String] +splitOn input re = + case matchM re input :: Maybe (String, String, String) of + Just (before, match, after) -> before : after `splitOn` re + Nothing -> [input] diff --git a/stack.yaml b/stack.yaml new file mode 100644 index 0000000..4cf5c74 --- /dev/null +++ b/stack.yaml @@ -0,0 +1,35 @@ +# This file was automatically generated by stack init +# For more information, see: https://docs.haskellstack.org/en/stable/yaml_configuration/ + +# Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) +resolver: lts-18.15 + +# Local packages, usually specified by relative directory name +packages: +- '.' +# Packages to be pulled from upstream that are not in the resolver (e.g., acme-missiles-0.3) +extra-deps: [] + +# Override default flag values for local packages and extra-deps +flags: {} + +# Extra package databases containing global packages +extra-package-dbs: [] + +# Control whether we use the GHC we find on the path +# system-ghc: true + +# Require a specific version of stack, using version ranges +# require-stack-version: -any # Default +# require-stack-version: >= 1.0.0 + +# Override the architecture used by stack, especially useful on Windows +# arch: i386 +# arch: x86_64 + +# Extra directories used by stack for building +# extra-include-dirs: [/path/to/dir] +# extra-lib-dirs: [/path/to/dir] + +# Allow a newer minor version of GHC than the snapshot specifies +# compiler-check: newer-minor diff --git a/striptests b/striptests new file mode 100755 index 0000000..6e64607 --- /dev/null +++ b/striptests @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# This file strips all unit tests from ShellCheck, removing +# the dependency on QuickCheck and Template Haskell and +# reduces the binary size considerably. +set -o pipefail + +sponge() { + local data + data="$(cat)" + printf '%s\n' "$data" > "$1" +} + +modify() { + if ! "${@:2}" < "$1" | sponge "$1" + then + { + printf 'Failed to modify %s: ' "$1" + printf '%q ' "${@:2}" + printf '\n' + } >&2 + exit 1 + fi +} + +detestify() { + printf '%s\n' '-- AUTOGENERATED from ShellCheck by striptests. Do not modify.' + awk ' + BEGIN { + state = 0; + } + + /STRIP/ { next; } + /LANGUAGE TemplateHaskell/ { next; } + /^import.*Test\./ { next; } + + /^module/ { + sub(/,[^,)]*runTests/, ""); + } + + # Delete tests + /^prop_/ { state = 1; next; } + + # ..and any blank lines following them. + state == 1 && /^ / { next; } + + # Template Haskell marker + /^return / { + exit; + } + + { state = 0; print; } + ' +} + + + +if [[ ! -e 'ShellCheck.cabal' ]] +then + echo "Run me from the ShellCheck directory." >&2 + exit 1 +fi + +if [[ -d '.git' ]] && ! git diff --exit-code > /dev/null 2>&1 +then + echo "You have local changes! These may be overwritten." >&2 + exit 2 +fi + +modify 'ShellCheck.cabal' sed -e ' + /QuickCheck/d + /^test-suite/{ s/.*//; q; } + ' + +find . -name '.git' -prune -o -type f -name '*.hs' -print | + while IFS= read -r file + do + modify "$file" detestify + done diff --git a/test/buildtest b/test/buildtest new file mode 100755 index 0000000..3e0847e --- /dev/null +++ b/test/buildtest @@ -0,0 +1,57 @@ +#!/bin/bash +# This script configures, builds and runs tests. +# It's meant for automatic cross-distro testing. + +die() { echo "$*" >&2; exit 1; } + +[ -e "ShellCheck.cabal" ] || + die "ShellCheck.cabal not in current dir" +command -v cabal || + die "cabal is missing" + +cabal update || + die "can't update" + +if [ -e "cabal.project.freeze" ] +then + echo "Renaming cabal.project.freeze to .bak to avoid it interferring" >&2 + mv "cabal.project.freeze" "cabal.project.freeze.bak" || die "Couldn't rename" +fi + +if [ -e /etc/arch-release ] +then + # Arch has an unconventional packaging setup + flags=(--disable-library-vanilla --enable-shared --enable-executable-dynamic --ghc-options=-dynamic) +else + flags=() +fi + +cabal install --dependencies-only --enable-tests "${flags[@]}" || + cabal install --dependencies-only "${flags[@]}" || + cabal install --dependencies-only --max-backjumps -1 "${flags[@]}" || + die "can't install dependencies" +cabal configure --enable-tests "${flags[@]}" || + die "configure failed" +cabal build || + die "build failed" +cabal test || + die "test failed" +cabal haddock || + die "haddock failed" + +sc="$(find . -name shellcheck -type f -perm -111)" +[ -x "$sc" ] || die "Can't find executable" + +"$sc" - << 'EOF' || die "execution failed" +#!/bin/sh +echo "Hello World" +EOF + +"$sc" - << 'EOF' && die "negative execution failed" +#!/bin/sh +echo $1 +EOF + + +echo "Success" +exit 0 diff --git a/test/check_release b/test/check_release new file mode 100755 index 0000000..cbb741b --- /dev/null +++ b/test/check_release @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2257 + +failed=0 +fail() { + echo "$(tput setaf 1)$*$(tput sgr0)" + failed=1 +} + +i=1 j=1 +cat << EOF + +Manual Checklist + +$((i++)). Make sure README.md examples are up to date +$((i++)). Format and read over the manual for bad formatting and outdated info. +$((i++)). Run \`builders/build_builder build/*/\` to update all builder images. +$((i++)). \`builders/run_builder dist-newstyle/sdist/ShellCheck-*.tar.gz builders/*/\` to verify that they work. +$((i++)). \`for f in \$(cat build/*/tag); do docker push "\$f"; done\` to upload them. +$((i++)). Run test/distrotest to ensure that most distros can build OOTB. +$((i++)). Make sure GitHub Build currently passes: https://github.com/koalaman/shellcheck/actions +$((i++)). Make sure SnapCraft build currently works: https://snapcraft.io/shellcheck/builds +$((i++)). Make sure the Hackage package builds locally. +$((i++)). Make sure none of the automated checks below fail + +Release Steps + +$((j++)). \`cabal sdist\` to generate a Hackage package +$((j++)). \`git push --follow-tags\` to push commit +$((j++)). Wait for GitHub Actions to build. (v0.11.0 "Deploy" failed, but worked on retry) +$((j++)). Verify release: + a. Check that the new versions are uploaded: https://github.com/koalaman/shellcheck/tags + b. Check that the docker images have version tags: https://hub.docker.com/u/koalaman +$((j++)). If no disaster, upload to Hackage: http://hackage.haskell.org/upload +$((j++)). Run 'autoupdate' from https://github.com/koalaman/shellcheck-precommit +$((j++)). Release new snap versions on https://snapcraft.io/shellcheck/releases +$((j++)). Push a new commit that updates CHANGELOG.md + +Automated Checks + +EOF + +if git diff | grep -q "" +then + fail "There are uncommitted changes" +fi + +if [[ $(git log -1 --pretty=%B) != *"CHANGELOG"* ]] +then + fail "Expected git log message to contain CHANGELOG" +fi + +version=${current#v} +if ! grep "Version:" ShellCheck.cabal | grep -qFw "$version" +then + fail "The cabal file does not match tag version $version" +fi + +if ! grep -qF "## $current" CHANGELOG.md +then + fail "CHANGELOG.md does not contain '## $current'" +fi + +current=$(git tag --points-at) +if [[ -z "$current" ]] +then + fail "No git tag on the current commit" + echo "Create one with: git tag -a v0.0.0" +fi + +if [[ "$current" != v* ]] +then + fail "Bad tag format: expected v0.0.0" +fi + +if [[ "$(git cat-file -t "$current")" != "tag" ]] +then + fail "Current tag is not annotated (required for Snap)." +fi + +if [[ "$(git tag --points-at master)" != "$current" ]] +then + fail "You are not on master" +fi + +if [[ $(git log -1 --pretty=%B) != "Stable version "* ]] +then + fail "Expected git log message to be 'Stable version ...'" +fi +exit "$failed" diff --git a/test/distrotest b/test/distrotest new file mode 100755 index 0000000..f1d01c1 --- /dev/null +++ b/test/distrotest @@ -0,0 +1,94 @@ +#!/bin/bash +# This script runs 'buildtest' on each of several distros +# via Docker. +set -o pipefail + +exec 3>&1 4>&2 +die() { echo "$*" >&4; exit 1; } + +[ -e "ShellCheck.cabal" ] || die "ShellCheck.cabal not in this dir" + +if ( snap list | grep -q docker ) > /dev/null 2>&1 +then + # Snap docker can't mount /tmp in containers + echo "You appear to be using Docker from snap. Creating ~/tmp for temp files." >&2 + echo >&2 + export TMPDIR="$HOME/tmp" + mkdir -p "$TMPDIR" +fi + +[ "$1" = "--run" ] || { +cat << EOF +This script pulls multiple distros via Docker and compiles +ShellCheck and dependencies for each one. It takes hours, +and is still highly experimental. + +Make sure you're plugged in and have screen/tmux in place, +then re-run with $0 --run to continue. + +Also note that dist*/ and .stack-work/ will be deleted. +EOF +exit 0 +} + +echo "Deleting 'dist', 'dist-newstyle', and '.stack-work'..." +rm -rf dist dist-newstyle .stack-work + +execs=$(find . -name shellcheck) + +if [ -n "$execs" ] +then + die "Found unexpected executables. Remove and try again: $execs" +fi + +log=$(mktemp) || die "Can't create temp file" +date >> "$log" || die "Can't write to log" + +echo "Logging to $log" >&3 +exec >> "$log" 2>&1 + +final=0 +while read -r distro setup +do + [[ "$distro" = "#"* || -z "$distro" ]] && continue + printf '%s ' "$distro" >&3 + docker pull "$distro" || die "Can't pull $distro" + printf 'pulled. ' >&3 + + tmp=$(mktemp -d) || die "Can't make temp dir" + cp -r . "$tmp/" || die "Can't populate test dir" + printf 'Result: ' >&3 + < /dev/null docker run -v "$tmp:/mnt" "$distro" sh -c " + $setup + cd /mnt || exit 1 + test/buildtest + " + ret=$? + if [ "$ret" = 0 ] + then + echo "OK" >&3 + else + echo "FAIL with $ret. See $log" >&3 + final=1 + fi + rm -rf "$tmp" +done << EOF +# Docker tag Setup command +debian:stable apt-get update && apt-get install -y cabal-install +debian:testing apt-get update && apt-get install -y cabal-install +ubuntu:latest apt-get update && apt-get install -y cabal-install +haskell:latest true +opensuse/leap:latest zypper install -y cabal-install ghc +fedora:latest dnf install -y cabal-install ghc-template-haskell-devel findutils libstdc++-static gcc-c++ +archlinux:latest pacman -S -y --noconfirm cabal-install ghc-static base-devel + +# Ubuntu LTS +ubuntu:24.04 apt-get update && apt-get install -y cabal-install +ubuntu:22.04 apt-get update && apt-get install -y cabal-install +ubuntu:20.04 apt-get update && apt-get install -y cabal-install + +# Stack on Ubuntu LTS +ubuntu:24.04 set -e; apt-get update && apt-get install -y curl && curl -sSL https://get.haskellstack.org/ | sh -s - -f && cd /mnt && exec test/stacktest +EOF + +exit "$final" diff --git a/test/shellcheck.hs b/test/shellcheck.hs new file mode 100644 index 0000000..d5e056d --- /dev/null +++ b/test/shellcheck.hs @@ -0,0 +1,41 @@ +module Main where + +import Control.Monad +import System.Exit +import qualified ShellCheck.Analytics +import qualified ShellCheck.AnalyzerLib +import qualified ShellCheck.ASTLib +import qualified ShellCheck.CFG +import qualified ShellCheck.CFGAnalysis +import qualified ShellCheck.Checker +import qualified ShellCheck.Checks.Commands +import qualified ShellCheck.Checks.ControlFlow +import qualified ShellCheck.Checks.Custom +import qualified ShellCheck.Checks.ShellSupport +import qualified ShellCheck.Fixer +import qualified ShellCheck.Formatter.Diff +import qualified ShellCheck.Parser + +main = do + putStrLn "Running ShellCheck tests..." + failures <- filter (not . snd) <$> mapM sequenceA tests + if null failures then exitSuccess else do + putStrLn "Tests failed for the following module(s):" + mapM (putStrLn . ("- ShellCheck." ++) . fst) failures + exitFailure + where + tests = + [ ("Analytics" , ShellCheck.Analytics.runTests) + , ("AnalyzerLib" , ShellCheck.AnalyzerLib.runTests) + , ("ASTLib" , ShellCheck.ASTLib.runTests) + , ("CFG" , ShellCheck.CFG.runTests) + , ("CFGAnalysis" , ShellCheck.CFGAnalysis.runTests) + , ("Checker" , ShellCheck.Checker.runTests) + , ("Checks.Commands" , ShellCheck.Checks.Commands.runTests) + , ("Checks.ControlFlow" , ShellCheck.Checks.ControlFlow.runTests) + , ("Checks.Custom" , ShellCheck.Checks.Custom.runTests) + , ("Checks.ShellSupport", ShellCheck.Checks.ShellSupport.runTests) + , ("Fixer" , ShellCheck.Fixer.runTests) + , ("Formatter.Diff" , ShellCheck.Formatter.Diff.runTests) + , ("Parser" , ShellCheck.Parser.runTests) + ] diff --git a/test/stacktest b/test/stacktest new file mode 100755 index 0000000..b486c31 --- /dev/null +++ b/test/stacktest @@ -0,0 +1,28 @@ +#!/bin/bash +# This script builds ShellCheck through `stack` using +# various resolvers. It's run via distrotest. + +resolvers=( +# nightly-"$(date -d "3 days ago" +"%Y-%m-%d")" +) + +die() { echo "$*" >&2; exit 1; } + +[ -e "ShellCheck.cabal" ] || + die "ShellCheck.cabal not in current dir" +[ -e "stack.yaml" ] || + die "stack.yaml not in current dir" +command -v stack || + die "stack is missing" + +stack setup --allow-different-user || die "Failed to setup with default resolver" +stack build --test || die "Failed to build/test with default resolver" + +# Nice to haves, but not necessary +for resolver in "${resolvers[@]}" +do + stack --resolver="$resolver" setup || die "Failed to setup $resolver. This probably doesn't matter." + stack --resolver="$resolver" build --test || die "Failed build/test with $resolver! This probably doesn't matter." +done + +echo "Success"