#!/bin/bash
#
# lint - static-check the shell scripts in this repo
#
# Usage:
#   tools/lint              # check everything
#   tools/lint -l           # just list what would be checked
#   tools/lint FILE...      # check only these
#
# Exits non-zero if either checker reports anything, so it is usable from CI.
#
# Scripts are found by extension (.sh/.bash/.ksh/.zsh) or, for extensionless files, by
# reading the shebang -- the repo has a dozen-plus extensionless bash scripts in home-bin/,
# sys-setup/, and the root, and one Perl one, so "has a shebang" is not a sufficient test.
#
# Two checkers, because shellcheck has no zsh dialect: it parses zsh as bash and reports
# artifacts of that rather than real problems (an anonymous `() { ... }` function trips
# SC1073). So bash/sh goes to shellcheck, and zsh gets `zsh -n`, which is syntax-only but
# is the best available.
#
# bash/sh files also get a grep pass for constructs that need a bash newer than the 3.2
# macOS ships. It's a blacklist and knows only what's in its table, so a clean run proves
# nothing on its own -- see run_bash3_compat_check for why nothing better exists.

set -o errexit
set -o nounset
set -o pipefail

PROGRAM_PATH="$0"
PROGRAM_NAME=$(basename "$0")
readonly PROGRAM_PATH PROGRAM_NAME

function info()  { emit "$*"; }
function error() { emit "ERROR: $*"; }
function warn()  { emit "WARNING: $*"; }
function die()   { error "$*"; exit 1; }
function emit()  { echo >&2 "${PROGRAM_NAME}: $*"; }

function usage() {
  cat <<EOF
$PROGRAM_NAME - static-check the shell scripts in this repo

Usage:
  $PROGRAM_NAME [--list] [FILE...]
  $PROGRAM_NAME --help

Options:
    -l, --list    list the files that would be checked, and exit
    -h, --help    display help

bash/sh files go to shellcheck; zsh files get 'zsh -n', since shellcheck has no zsh
dialect. bash/sh files are also scanned for constructs requiring a bash newer than 3.2;
that scan is a best-effort blacklist, not a guarantee.

Suppress a bash-3.2 finding with a '# jx-lint-ok: bash4' comment on the line, or
'# jx-lint-ok-file: bash4' anywhere in a file to exempt the whole file.
EOF
}

FILE_ARGS=()
OPT_LIST=0

function main() {
  local -a sh_targets=() zsh_targets=()
  local file rc=0

  cd "$(dirname "$PROGRAM_PATH")/.."

  if [[ ${#FILE_ARGS[@]} -gt 0 ]]; then
    # An explicitly named file gets checked whatever it is; the classifier only picks
    # which checker to use, defaulting to shellcheck.
    for file in "${FILE_ARGS[@]}"; do
      if [[ "$(script_kind "$file")" == zsh ]]; then
        zsh_targets+=("$file")
      else
        sh_targets+=("$file")
      fi
    done
  else
    while IFS= read -r -d '' file; do sh_targets+=("$file");  done < <(collect_targets sh)
    while IFS= read -r -d '' file; do zsh_targets+=("$file"); done < <(collect_targets zsh)
  fi

  if [[ ${#sh_targets[@]} -eq 0 && ${#zsh_targets[@]} -eq 0 ]]; then
    die "no shell scripts found"
  fi

  if [[ $OPT_LIST == 1 ]]; then
    if [[ ${#sh_targets[@]}  -gt 0 ]]; then printf '%s\n' "${sh_targets[@]}";  fi
    if [[ ${#zsh_targets[@]} -gt 0 ]]; then printf '%s\n' "${zsh_targets[@]}"; fi
    return 0
  fi

  if [[ ${#sh_targets[@]} -gt 0 ]]; then
    run_shellcheck "${sh_targets[@]}" || rc=1
    run_bash3_compat_check "${sh_targets[@]}" || rc=1
  fi
  if [[ ${#zsh_targets[@]} -gt 0 ]]; then
    run_zsh_syntax_check "${zsh_targets[@]}" || rc=1
  fi
  return $rc
}

function parse_cli() {
  local arg

  while [[ $# -ge 1 ]]; do
    arg="$1"; shift
    case "$arg" in
      --list | -l)                  OPT_LIST=1 ;;
      --help | -help | -h | '-?')   usage; exit 0 ;;
      --)                           break ;;
      -*)
        die "Unexpected option: ${arg}. See '$PROGRAM_NAME --help' for usage" ;;
      *)                            FILE_ARGS+=("$arg") ;;
    esac
  done
  # Whatever followed a `--` is a file name, however option-like it looks.
  FILE_ARGS+=("$@")

  readonly OPT_LIST FILE_ARGS
}

function run_shellcheck() {
  local rc=0
  shellcheck "$@" || rc=$?
  if [[ $rc == 0 ]]; then
    info "shellcheck: clean ($# files)"
  fi
  return $rc
}

function run_zsh_syntax_check() {
  # zsh -n parses without executing. Syntax only: no style or quoting analysis, so this
  # is not a shellcheck equivalent. There isn't one -- shellcheck has no zsh dialect --
  # and this at least catches what breaks shell startup. Less is lost than it looks:
  # most of shellcheck's value is word-splitting warnings, and zsh doesn't word-split
  # unquoted expansions in the first place.
  local file rc=0
  if ! command -v zsh >/dev/null 2>&1; then
    warn "zsh not found; skipped $# zsh file(s)"
    return 0
  fi
  for file in "$@"; do
    zsh -n "$file" || rc=1
  done
  if [[ $rc == 0 ]]; then
    info "zsh -n: clean ($# files)"
  fi
  return $rc
}

function run_bash3_compat_check() {
  # Flag constructs needing a bash newer than the 3.2 macOS ships as /bin/bash.
  #
  # Nothing else catches these. They are *runtime* failures, not parse errors -- bash 3.2
  # parses the newer forms happily and only objects when the line executes -- and so
  # `bash -n` under 3.2 exits 0 on all of them. shellcheck has no version targeting:
  # --shell takes only sh/bash/dash/ksh/busybox, and --shell=sh is no proxy, since it
  # rejects plain arrays and [[ ]] too.
  #
  # A blacklist: it knows what's in BASH4_CONSTRUCTS and nothing else, so a clean run is
  # not a portability guarantee. Add entries as new traps turn up.
  local i pat descr file line raw hits skipped rc=0 grep_rc nl=$'\n'
  local -a targets=()

  if (( ${#BASH4_CONSTRUCTS[@]} % 2 != 0 )); then
    die "BASH4_CONSTRUCTS is malformed: odd entry count (want pattern/description pairs)"
  fi

  # Unlike the pattern loop below, a failure here needs no check: an empty skip list just
  # means everything gets checked, so the worst case is a spurious finding, not a miss.
  skipped=$(grep -lE "$BASH4_SKIP_FILE_RE" -- "$@") || true
  for file in "$@"; do
    case "$nl$skipped$nl" in
      *"$nl$file$nl"*) continue ;;
    esac
    targets+=("$file")
  done
  if [[ ${#targets[@]} -eq 0 ]]; then
    info "bash 3.2 compat: every file opted out ($# files)"
    return 0
  fi

  for ((i = 0; i < ${#BASH4_CONSTRUCTS[@]}; i += 2)); do
    pat="${BASH4_CONSTRUCTS[i]}"
    descr="${BASH4_CONSTRUCTS[i+1]}"
    grep_rc=0
    raw=$(grep -nHE -- "$pat" "${targets[@]}") || grep_rc=$?
    # 1 is "no match"; anything higher is a real failure and must not read as clean.
    if [[ $grep_rc -gt 1 ]]; then
      die "grep exited $grep_rc on pattern: $pat"
    fi
    [[ -n "$raw" ]] || continue
    hits=""
    while IFS= read -r line; do
      if [[ "$line" =~ $BASH4_SKIP_LINE_RE ]]; then continue; fi
      hits="${hits:+$hits$nl}    $line"
    done <<< "$raw"
    if [[ -n "$hits" ]]; then
      printf '%s\n%s\n' "$descr" "$hits"
      rc=1
    fi
  done

  if [[ $rc == 0 ]]; then
    info "bash 3.2 compat: clean (${#targets[@]} files)"
  fi
  return $rc
}

# Pattern/description pairs, walked with a stride of 2, since bash 3.2 has no associative
# arrays. Interleaved rather than two parallel arrays so a pattern and its description
# can't drift apart: with two arrays, adding an entry to one and not the other misaligns
# every description after it and mislabels findings forever without failing.
#
# Some patterns bracket a character ([s]hopt, [&]>>) purely so the pattern text doesn't
# match itself when the linter checks this file. Where that wasn't practical, the
# description carries a jx-lint-ok marker instead.
#
# shellcheck disable=SC2016  # the ${...} in the descriptions is prose, not an expansion
BASH4_CONSTRUCTS=(
  '(^|[^[:alnum:]_./-])(declare|local|typeset)[[:space:]]+-[a-zA-Z]{0,3}A([[:space:]]|$)'
      'bash 4.0: associative arrays (declare -A)'
  '(^|[^[:alnum:]_./-])(mapfile|readarray)([[:space:]]|$)'
      'bash 4.0: mapfile / readarray'   # jx-lint-ok: bash4
  '\$\{[!#]?[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?(,|\^)'
      'bash 4.0: case modification ${var,,} and ${var^^}'   # jx-lint-ok: bash4
  '\|[&]'
      'bash 4.0: |& pipe of stdout and stderr (use 2>&1 |)'   # jx-lint-ok: bash4
  '[&]>>'
      'bash 4.0: &>> append of stdout and stderr (use >>file 2>&1)'   # jx-lint-ok: bash4
  '(^|[^[:alnum:]_])coproc([[:space:]]|$)'
      'bash 4.0: coproc'
  '[s]hopt[^#]*(globstar|lastpipe|autocd|checkjobs|dirspell)'
      'bash 4.0/4.2: shell options globstar, lastpipe, autocd, checkjobs, dirspell'
  ';;?[&]'
      'bash 4.0: case fallthrough ;& and ;;&'   # jx-lint-ok: bash4
  '(^|[^[:alnum:]_./-])(declare|typeset)[[:space:]]+-[a-zA-Z]{0,3}g([[:space:]]|$)'
      'bash 4.2: declare -g'
  '(^|[^[:alnum:]_./-])(\[\[|\[|test)[[:space:]]+-v[[:space:]]'
      'bash 4.2: [[ -v var ]] (use [[ -n ${var+x} ]])'   # jx-lint-ok: bash4
  'printf[^#]*%\([^)]*\)T'
      'bash 4.2: printf %()T time formatting'   # jx-lint-ok: bash4
  '(^|[^[:alnum:]_./-])(declare|local|typeset)[[:space:]]+-[a-zA-Z]{0,3}n([[:space:]]|$)'
      'bash 4.3: namerefs (declare -n)'
  '(^|[^[:alnum:]_])wait[[:space:]]+-n([[:space:]]|$)'
      'bash 4.3: wait -n'
  '\$\{[!#]?[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?@[a-zA-Z]\}'
      'bash 4.4: parameter transformations ${var@Q}'   # jx-lint-ok: bash4
  '\$\{?(EPOCHSECONDS|EPOCHREALTIME|SRANDOM|BASH_ARGV0)([^A-Za-z0-9_]|$)'
      'bash 5.0: EPOCHSECONDS, EPOCHREALTIME, SRANDOM, BASH_ARGV0'
)

# The colon is part of the name here, so the -file form doesn't also register as the
# per-line form -- it contains the per-line name as a prefix. Naming the check (bash4)
# keeps room for a second one later.
#
# The file form is anchored to a line holding nothing but the marker, because an
# unanchored match exempts any file that so much as documents the directive -- this one
# did exactly that, silently skipping itself, until the anchors went in.
BASH4_SKIP_LINE_RE='jx-lint-ok:[[:space:]]*bash4'
BASH4_SKIP_FILE_RE='^[[:space:]]*#[[:space:]]*jx-lint-ok-file:[[:space:]]*bash4$'

function collect_targets() {
  # Emits NUL-delimited paths of the given kind, sorted. Kind is "sh" or "zsh".
  # Pre: cwd is the repo root.
  local want="$1" file
  # /local and /zz-local are gitignored scratch dirs, not part of the project.
  find . \( -name .git -o -name zz-local -o -name local \) -prune -o -type f -print0 \
    | while IFS= read -r -d '' file; do
        if [[ "$(script_kind "$file")" == "$want" ]]; then
          printf '%s\0' "$file"
        fi
      done \
    | sort -z
}

function script_kind() {
  # Echoes "sh" for anything shellcheck can parse, "zsh" for zsh, nothing otherwise.
  local file="$1" base interp
  base=${file##*/}
  case "$base" in
    *.sh | *.bash | *.ksh) echo sh   ; return ;;
    *.zsh)                 echo zsh  ; return ;;
    *.*)                               return ;;   # some other extension; not ours
  esac
  interp=$(shebang_interp "$file")
  case "$interp" in
    sh | bash | ksh | dash | ash) echo sh  ;;
    zsh)                          echo zsh ;;
  esac
}

function shebang_interp() {
  # Echoes the interpreter's basename from the file's shebang, or nothing.
  local file="$1"
  local first interp
  local -a words
  IFS= read -r first < "$file" || return 0
  case "$first" in '#!'*) ;; *) return 0 ;; esac
  # shellcheck disable=SC2206  # splitting the shebang line into words is the point
  words=(${first#\#!})
  [[ ${#words[@]} -gt 0 ]] || return 0
  interp=${words[0]##*/}
  # `#!/usr/bin/env bash`: the real interpreter is the next word.
  if [[ "$interp" == env && ${#words[@]} -gt 1 ]]; then
    interp=${words[1]##*/}
  fi
  printf '%s\n' "$interp"
}

# ========== Main script ==========

parse_cli "$@"
main
exit $?
