branch: externals/el-job commit 18b039e51d2db8af1bd816b54c06fd13d148d37a Author: Martin Edström <meedstro...@gmail.com> Commit: Martin Edström <meedstro...@gmail.com>
Add linter --- .github/workflows/test.yml | 79 +++ makem.sh | 1349 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1428 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..932ae6e4c9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +# * test.yml --- Test Emacs packages using makem.sh on GitHub Actions + +# URL: https://github.com/alphapapa/makem.sh +# Version: 0.8-pre + +# * Commentary: + +# Based on Steve Purcell's examples at +# <https://github.com/purcell/setup-emacs/blob/master/.github/workflows/test.yml>, +# <https://github.com/purcell/package-lint/blob/master/.github/workflows/test.yml>. + +# * License: + +# 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 <https://www.gnu.org/licenses/>. + +# * Code: + +name: "CI" +on: + pull_request: + push: + # Comment out this section to enable testing of all branches. + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + emacs_version: + - 26.3 + - 27.1 + - snapshot + steps: + - uses: purcell/setup-emacs@master + with: + version: ${{ matrix.emacs_version }} + + - uses: actions/checkout@v2 + + # - name: Install Ispell + # run: | + # sudo apt-get install ispell + + - name: Initialize sandbox + run: | + SANDBOX_DIR=$(mktemp -d) || exit 1 + echo "SANDBOX_DIR=$SANDBOX_DIR" >> $GITHUB_ENV + ./makem.sh -vv --sandbox=$SANDBOX_DIR --install-deps --install-linters + + # The "all" rule is not used, because it treats compilation warnings + # as failures, so linting and testing are run as separate steps. + + - name: Lint + # NOTE: Uncomment this line to treat lint failures as passing + # so the job doesn't show failure. + # continue-on-error: true + run: ./makem.sh -vv --sandbox=$SANDBOX_DIR lint + + - name: Test + if: always() # Run test even if linting fails. + run: ./makem.sh -vv --sandbox=$SANDBOX_DIR test + +# Local Variables: +# eval: (outline-minor-mode) +# End: diff --git a/makem.sh b/makem.sh new file mode 100755 index 0000000000..5a71b1b90b --- /dev/null +++ b/makem.sh @@ -0,0 +1,1349 @@ +#!/usr/bin/env bash + +# * makem.sh --- Script to aid building and testing Emacs Lisp packages + +# URL: https://github.com/alphapapa/makem.sh +# Version: 0.8-pre + +# * Commentary: + +# makem.sh is a script that helps to build, lint, and test Emacs Lisp +# packages. It aims to make linting and testing as simple as possible +# without requiring per-package configuration. + +# It works similarly to a Makefile in that "rules" are called to +# perform actions such as byte-compiling, linting, testing, etc. + +# Source and test files are discovered automatically from the +# project's Git repo, and package dependencies within them are parsed +# automatically. + +# Output is simple: by default, there is no output unless errors +# occur. With increasing verbosity levels, more detail gives positive +# feedback. Output is colored by default to make reading easy. + +# The script can run Emacs with the developer's local Emacs +# configuration, or with a clean, "sandbox" configuration that can be +# optionally removed afterward. This is especially helpful when +# upstream dependencies may have released new versions that differ +# from those installed in the developer's personal configuration. + +# * License: + +# 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 <https://www.gnu.org/licenses/>. + +# * Functions + +function usage { + cat <<EOF +$0 [OPTIONS] RULES... + +Linter- and test-specific rules will error when their linters or tests +are not found. With -vv, rules that run multiple rules will show a +message for unavailable linters or tests. + +Rules: + all Run all lints and tests. + compile Byte-compile source files. + + lint Run all linters, ignoring unavailable ones. + lint-checkdoc Run checkdoc. + lint-compile Byte-compile source files with warnings as errors. + lint-declare Run check-declare. + lint-elsa Run Elsa (not included in "lint" rule). + lint-indent Lint indentation. + lint-package Run package-lint. + lint-regexps Run relint. + + test, tests Run all tests, ignoring missing test types. + test-buttercup Run Buttercup tests. + test-ert Run ERT tests. + test-ert-interactive Run ERT tests interactively. + + batch Run Emacs in batch mode, loading project source and test files + automatically, with remaining args (after "--") passed to Emacs. + interactive Run Emacs interactively, loading project source and test files + automatically, with remaining args (after "--") passed to Emacs. + +Options: + -d, --debug Print debug info. + -h, --help I need somebody! + -v, --verbose Increase verbosity, up to -vvv. + --no-color Disable color output. + + --debug-load-path Print load-path from inside Emacs. + + -E, --emacs PATH Run Emacs at PATH. + + -e, --exclude FILE Exclude FILE from linting and testing. + -f, --file FILE Check FILE in addition to discovered files. + + -c, --compile-batch Batch-compile files (instead of separately; quicker, but + may hide problems). + -C, --no-compile Don't compile files automatically. + +Sandbox options: + -s[DIR], --sandbox[=DIR] Run Emacs with an empty config in a sandbox DIR. + If DIR does not exist, make it. If DIR is not + specified, use a temporary sandbox directory and + delete it afterward, implying --install-deps and + --install-linters. + --install-deps Automatically install package dependencies. + --install-linters Automatically install linters. + -i, --install PACKAGE Install PACKAGE before running rules. + + An Emacs version-specific subdirectory is automatically made inside + the sandbox, allowing testing with multiple Emacs versions. When + specifying a sandbox directory, use options --install-deps and + --install-linters on first-run and omit them afterward to save time. + +Source files are automatically discovered from git, or may be +specified with options. Package dependencies are discovered from +"Package-Requires" headers in source files, from -pkg.el files, and +from a Cask file. + +Checkdoc's spell checker may not recognize some words, causing the +\`lint-checkdoc' rule to fail. Custom words can be added in file-local +or directory-local variables using the variable +\`ispell-buffer-session-localwords', which should be set to a list of +strings. +EOF +} + +# ** Elisp + +# These functions return a path to an elisp file which can be loaded +# by Emacs on the command line with -l or --load. + +function elisp-buttercup-file { + # The function buttercup-run, which is called by buttercup-run-discover, + # signals an error if it can't find any Buttercup test suites. We don't + # want that to be an error, so we define advice which ignores that error. + if ! [[ $elisp_buttercup_file ]] + then + elisp_buttercup_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-buttercup-file-XXX") + cat >"$elisp_buttercup_file" <<EOF +(defun makem-buttercup-run (oldfun &rest r) + "Call buttercup-run only if \`buttercup-suites' is non-nil." + (when buttercup-suites + (apply oldfun r))) + +(advice-add #'buttercup-run :around #'makem-buttercup-run) +EOF + fi + echo "$elisp_buttercup_file" +} + +function elisp-elint-file { + if ! [[ $elisp_elint_file ]] + then + elisp_elint_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-elint-file-XXX") + cat >"$elisp_elint_file" <<EOF +(require 'cl-lib) +(require 'elint) +(defun makem-elint-file (file) + (let ((errors 0)) + (cl-letf (((symbol-function 'orig-message) (symbol-function 'message)) + ((symbol-function 'message) (symbol-function 'ignore)) + ((symbol-function 'elint-output) + (lambda (string) + (cl-incf errors) + (orig-message "%s" string)))) + (elint-file file) + ;; NOTE: \`errors' is not actually the number of errors, because + ;; it's incremented for non-error header strings as well. + (kill-emacs errors)))) +EOF + fi + echo "$elisp_elint_file" +} + +function elisp-checkdoc-file { + # Since checkdoc doesn't have a batch function that exits non-zero + # when errors are found, we make one. + if ! [[ $elisp_checkdoc_file ]] + then + elisp_checkdoc_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-checkdoc-file-XXX") + cat >"$elisp_checkdoc_file" <<EOF +(defvar makem-checkdoc-errors-p nil) + +(defun makem-checkdoc-files-and-exit () + "Run checkdoc-file on files remaining on command line, exiting non-zero if there are warnings." + (let* ((files (mapcar #'expand-file-name command-line-args-left)) + (checkdoc-create-error-function + (lambda (text start end &optional unfixable) + (let ((msg (concat (checkdoc-buffer-label) ":" + (int-to-string (count-lines (point-min) (or start (point-min)))) + ": " text))) + (message msg) + (setq makem-checkdoc-errors-p t) + ;; Return nil because we *are* generating a buffered list of errors. + nil)))) + (put 'ispell-buffer-session-localwords 'safe-local-variable #'list-of-strings-p) + (mapcar #'checkdoc-file files) + (when makem-checkdoc-errors-p + (kill-emacs 1)))) + +(setq checkdoc-spellcheck-documentation-flag t) +(makem-checkdoc-files-and-exit) +EOF + fi + echo "$elisp_checkdoc_file" +} + +function elisp-byte-compile-file { + # This seems to be the only way to make byte-compilation signal + # errors for warnings AND display all warnings rather than only + # the first one. + if ! [[ $elisp_byte_compile_file ]] + then + elisp_byte_compile_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-byte-compile-file-XXX") + # TODO: Add file to $paths_temp in other elisp- functions. + cat >"$elisp_byte_compile_file" <<EOF +(defun makem-batch-byte-compile (&rest args) + "" + (let ((num-errors 0) + (num-warnings 0)) + ;; NOTE: Only accepts files as args, not directories. + (dolist (file command-line-args-left) + (pcase-let ((\`(,errors ,warnings) (makem-byte-compile-file file))) + (cl-incf num-errors errors) + (cl-incf num-warnings warnings))) + (zerop num-errors))) + +(defun makem-byte-compile-file (filename &optional load) + "Call \`byte-compile-warn', returning the number of errors and the number of warnings." + (let ((num-warnings 0) + (num-errors 0)) + (let ((byte-compile-log-warning-function + (lambda (string position fill level) + (pcase-exhaustive level + (:warning (cl-incf num-warnings)) + (:error (cl-incf num-errors))) + (byte-compile--log-warning-for-byte-compile string position fill level)))) + (byte-compile-file filename load)) + (list num-errors num-warnings))) +EOF + fi + echo "$elisp_byte_compile_file" +} + +function elisp-check-declare-file { + # Since check-declare doesn't have a batch function that exits + # non-zero when errors are found, we make one. + if ! [[ $elisp_check_declare_file ]] + then + elisp_check_declare_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-check-declare-file-XXX") + cat >"$elisp_check_declare_file" <<EOF +(require 'check-declare) + +(defun makem-check-declare-files-and-exit () + "Run check-declare-files on files remaining on command line, exiting non-zero if there are warnings." + (let* ((files (mapcar #'expand-file-name command-line-args-left)) + (errors (apply #'check-declare-files files))) + (when errors + (with-current-buffer check-declare-warning-buffer + (print (buffer-string))) + (kill-emacs 1)))) +EOF + fi + echo "$elisp_check_declare_file" +} + +function elisp-lint-indent-file { + # This function prints warnings for indentation errors and exits + # non-zero when errors are found. + if ! [[ $elisp_lint_indent_file ]] + then + elisp_lint_indent_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-lint-indent-file-XXX") + cat >"$elisp_lint_indent_file" <<EOF +(require 'cl-lib) + +(defun makem-lint-indent-batch-and-exit () + "Print warnings for files which are not indented properly, then exit. +Exits non-zero if mis-indented lines are found. Checks files in +'command-line-args-left'." + (let ((errors-p)) + (cl-labels ((lint-file (file) + (find-file file) + (let ((inhibit-message t)) + (indent-region (point-min) (point-max))) + (when buffer-undo-list + ;; Indentation changed: warn for each line. + (dolist (line (undo-lines buffer-undo-list)) + (message "%s:%s: Indentation mismatch" (buffer-name) line)) + (setf errors-p t))) + (undo-pos (entry) + (cl-typecase (car entry) + (number (car entry)) + (string (abs (cdr entry))))) + (undo-lines (undo-list) + ;; Return list of lines changed in UNDO-LIST. + (nreverse (cl-loop for elt in undo-list + for pos = (undo-pos elt) + when pos + collect (line-number-at-pos pos))))) + (mapc #'lint-file (mapcar #'expand-file-name command-line-args-left)) + (when errors-p + (kill-emacs 1))))) +EOF + fi + echo "$elisp_lint_indent_file" +} + +function elisp-package-initialize-file { + if ! [[ $elisp_package_initialize_file ]] + then + elisp_package_initialize_file=$(mktemp --tmpdir --suffix=".el" "makem-elisp-package-initialize-file-XXX") + cat >"$elisp_package_initialize_file" <<EOF +(require 'package) +(setq package-archives (list (cons "gnu" "https://elpa.gnu.org/packages/") + (cons "melpa" "https://melpa.org/packages/") + (cons "melpa-stable" "https://stable.melpa.org/packages/"))) +(package-initialize) +EOF + fi + echo "$elisp_package_initialize_file" +} + +# ** Emacs + +function run_emacs { + # NOTE: The sandbox args need to come before the package + # initialization so Emacs will use the sandbox's packages. + local emacs_command=( + "${emacs_command[@]}" + -Q + --eval "(setq load-prefer-newer t)" + "${args_debug[@]}" + "${args_sandbox[@]}" + $arg_batch + "${args_load_paths[@]}" + ) + + # Show debug message with load-path from inside Emacs. + [[ $debug_load_path ]] \ + && debug $("${emacs_command[@]}" \ + --batch \ + --eval "(message \"LOAD-PATH: %s\" load-path)" \ + 2>&1) + + # Set output file. + output_file=$(mktemp --tmpdir --suffix=".txt" "makem-emacs-output-XXX") || die "Unable to make output file." + paths_temp+=("$output_file") + + # Run Emacs. + debug "run_emacs: ${emacs_command[@]} $@ &>\"$output_file\"" + "${emacs_command[@]}" "$@" &>"$output_file" + + # Check exit code and output. + exit=$? + [[ $exit != 0 ]] \ + && debug "Emacs exited non-zero: $exit" + + [[ $verbose -gt 1 || $exit != 0 ]] \ + && cat $output_file + + return $exit +} + +# ** Compilation + +function batch-byte-compile { + debug "batch-byte-compile: ERROR-ON-WARN:$compile_error_on_warn" + + [[ $compile_error_on_warn ]] && local error_on_warn=(--eval "(setq byte-compile-error-on-warn t)") + + run_emacs \ + --load "$elisp_byte_compile_file" \ + "${error_on_warn[@]}" \ + --eval "(unless (makem-batch-byte-compile) (kill-emacs 1))" \ + "$@" +} + +function byte-compile-file { + debug "byte-compile: ERROR-ON-WARN:$compile_error_on_warn" + local file="$1" + + [[ $compile_error_on_warn ]] && local error_on_warn=(--eval "(setq byte-compile-error-on-warn t)") + + # FIXME: Why is the line starting with "&& verbose 3" not indented properly? Emacs insists on indenting it back a level. + run_emacs \ + --load "$elisp_byte_compile_file" \ + "${error_on_warn[@]}" \ + --eval "(pcase-let ((\`(,num-errors ,num-warnings) (makem-byte-compile-file \"$file\"))) (when (or (and byte-compile-error-on-warn (not (zerop num-warnings))) (not (zerop num-errors))) (kill-emacs 1)))" \ + && verbose 3 "Compiling $file finished without errors." \ + || { verbose 3 "Compiling file failed: $file"; return 1; } +} + +# ** Files + +function submodules { + # Echo a list of submodules's paths relative to the repo root. + # TODO: Parse with bash regexp instead of cut. + git submodule status | awk '{print $2}' +} + +function project-root { + # Echo the root of the project (or superproject, if running from + # within a submodule). + root_dir=$(git rev-parse --show-superproject-working-tree) + [[ $root_dir ]] || root_dir=$(git rev-parse --show-toplevel) + [[ $root_dir ]] || error "Can't find repo root." + + echo "$root_dir" +} + +function files-project { + # Echo a list of files in project; or with $1, files in it + # matching that pattern with "git ls-files". Excludes submodules. + [[ $1 ]] && pattern="/$1" || pattern="." + + local excludes=() + for submodule in $(submodules) + do + excludes+=(":!:$submodule") + done + + git ls-files -- "$pattern" "${excludes[@]}" +} + +function dirs-project { + # Echo list of directories to be used in load path. + files-project-feature | dirnames + files-project-test | dirnames +} + +function files-project-elisp { + # Echo list of Elisp files in project. + files-project 2>/dev/null \ + | grep -E "\.el$" \ + | filter-files-exclude-default \ + | filter-files-exclude-args +} + +function files-project-feature { + # Echo list of Elisp files that are not tests and provide a feature. + files-project-elisp \ + | grep -E -v "$test_files_regexp" \ + | filter-files-feature +} + +function files-project-test { + # Echo list of Elisp test files. + files-project-elisp | grep -E "$test_files_regexp" +} + +function dirnames { + # Echo directory names for files on STDIN. + while read file + do + dirname "$file" + done +} + +function filter-files-exclude-default { + # Filter out paths (STDIN) which should be excluded by default. + grep -E -v "(/\.cask/|-autoloads\.el|\.dir-locals)" +} + +function filter-files-exclude-args { + # Filter out paths (STDIN) which are excluded with --exclude. + if [[ ${files_exclude[@]} ]] + then + ( + # We use a subshell to set IFS temporarily so we can send + # the list of files to grep -F. This is ugly but more + # correct than replacing spaces with line breaks. Note + # that, for some reason, using IFS="\n" or IFS='\n' doesn't + # work, and a literal line break seems to be required. + IFS=" +" + grep -Fv "${files_exclude[*]}" + ) + else + cat + fi +} + +function filter-files-feature { + # Read paths on STDIN and echo ones that (provide 'a-feature). + while read path + do + grep -E "^\\(provide '" "$path" &>/dev/null \ + && echo "$path" + done +} + +function args-load-files { + # For file in $@, echo "--load $file". + for file in "$@" + do + sans_extension=${file%%.el} + printf -- '--load %q ' "$sans_extension" + done +} + +function args-load-path { + # Echo load-path arguments. + for path in $(dirs-project | sort -u) + do + printf -- '-L %q ' "$path" + done +} + +function test-files-p { + # Return 0 if $files_project_test is non-empty. + [[ "${files_project_test[@]}" ]] +} + +function buttercup-tests-p { + # Return 0 if Buttercup tests are found. + test-files-p || die "No tests found." + debug "Checking for Buttercup tests..." + + grep "(require 'buttercup)" "${files_project_test[@]}" &>/dev/null +} + +function ert-tests-p { + # Return 0 if ERT tests are found. + test-files-p || die "No tests found." + debug "Checking for ERT tests..." + + # We check for this rather than "(require 'ert)", because ERT may + # already be loaded in Emacs and might not be loaded with + # "require" in a test file. + grep "(ert-deftest" "${files_project_test[@]}" &>/dev/null +} + +function package-main-file { + # Echo the package's main file. + file_pkg=$(files-project "*-pkg.el" 2>/dev/null) + + if [[ $file_pkg ]] + then + # Use *-pkg.el file if it exists. + echo "$file_pkg" + else + # Use shortest filename (a sloppy heuristic that will do for now). + for file in "${files_project_feature[@]}" + do + echo ${#file} "$file" + done \ + | sort -h \ + | head -n1 \ + | sed -r 's/^[[:digit:]]+ //' + fi +} + +function dependencies { + # Echo list of package dependencies. + + # Search package headers. Use -a so grep won't think that an Elisp file containing + # control characters (rare, but sometimes necessary) is binary and refuse to search it. + grep -E -a -i '^;; Package-Requires: ' $(files-project-feature) $(files-project-test) \ + | grep -E -o '\([^([:space:]][^)]*\)' \ + | grep -E -o '^[^[:space:])]+' \ + | sed -r 's/\(//g' \ + | grep -E -v '^emacs$' # Ignore Emacs version requirement. + + # Search Cask file. + if [[ -r Cask ]] + then + grep -E '\(depends-on "[^"]+"' Cask \ + | sed -r -e 's/\(depends-on "([^"]+)".*/\1/g' + fi + + # Search -pkg.el file. + if [[ $(files-project "*-pkg.el" 2>/dev/null) ]] + then + sed -nr 's/.*\(([-[:alnum:]]+)[[:blank:]]+"[.[:digit:]]+"\).*/\1/p' $(files-project- -- -pkg.el 2>/dev/null) + fi +} + +# ** Sandbox + +function sandbox { + verbose 2 "Initializing sandbox..." + + # *** Sandbox arguments + + # MAYBE: Optionally use branch-specific sandbox? + + # Check or make user-emacs-directory. + if [[ $sandbox_dir ]] + then + # Directory given as argument: ensure it exists. + if ! [[ -d $sandbox_dir ]] + then + debug "Making sandbox directory: $sandbox_dir" + mkdir -p "$sandbox_dir" || die "Unable to make sandbox dir." + fi + + # Add Emacs version-specific subdirectory, creating if necessary. + sandbox_dir="$sandbox_dir/$(emacs-version)" + if ! [[ -d $sandbox_dir ]] + then + mkdir "$sandbox_dir" || die "Unable to make sandbox subdir: $sandbox_dir" + fi + else + # Not given: make temp directory, and delete it on exit. + local sandbox_dir=$(mktemp --tmpdir -d "makem-emacs-sandbox-dir-XXX") || die "Unable to make sandbox dir." + paths_temp+=("$sandbox_dir") + fi + + # Make argument to load init file if it exists. + init_file="$sandbox_dir/init.el" + + # Set sandbox args. This is a global variable used by the run_emacs function. + args_sandbox=( + --title "makem.sh: $(basename $(pwd)) (sandbox: $sandbox_dir)" + --eval "(setq user-emacs-directory (file-truename \"$sandbox_dir\"))" + --eval "(setq package-user-dir (expand-file-name \"elpa\" user-emacs-directory))" + --eval "(setq user-init-file (file-truename \"$init_file\"))" + ) + + # Add package-install arguments for dependencies. + if [[ $install_deps ]] + then + local deps=($(dependencies)) + debug "Installing dependencies: ${deps[@]}" + + # Ensure built-in packages get upgraded to newer versions from ELPA. + args_sandbox_package_install+=(--eval "(setq package-install-upgrade-built-in t)") + + for package in "${deps[@]}" + do + args_sandbox_package_install+=(--eval "(package-install '$package)") + done + fi + + # Add package-install arguments for linters. + if [[ $install_linters ]] + then + debug "Installing linters: package-lint relint" + + args_sandbox_package_install+=( + --eval "(package-install 'elsa)" + --eval "(package-install 'package-lint)" + --eval "(package-install 'relint)") + fi + + # *** Install packages into sandbox + + if [[ ${args_sandbox_package_install[@]} ]] + then + # Initialize the sandbox (installs packages once rather than for every rule). + verbose 1 "Installing packages into sandbox..." + + run_emacs \ + --eval "(setq package-user-dir (expand-file-name \"elpa\" user-emacs-directory))" \ + -l "$elisp_package_initialize_file" \ + --eval "(package-refresh-contents)" \ + "${args_sandbox_package_install[@]}" \ + && success "Packages installed." \ + || die "Unable to initialize sandbox." + fi + + verbose 2 "Sandbox initialized." +} + +function args-load-path-sandbox { + # Echo list of Emacs arguments to add paths of packages installed + # in sandbox to load-path. + if ! [[ -d "$sandbox_dir/elpa" ]] + then + warn "Sandbox's \"elpa/\" directory not found: no packages installed." + else + for path in $(find "$sandbox_dir/elpa" -maxdepth 1 -type d -not -name "archives" -print \ + | tail -n+2) + do + printf -- '-L %q ' "$path" + done + fi +} + +# ** Utility + +function cleanup { + # Remove temporary paths (${paths_temp[@]}). + for path in "${paths_temp[@]}" + do + if [[ $debug ]] + then + debug "Debugging enabled: not deleting temporary path: $path" + elif [[ -r $path ]] + then + rm -rf "$path" + else + debug "Temporary path doesn't exist, not deleting: $path" + fi + done +} + +function echo-unset-p { + # Echo 0 if $1 is set, otherwise 1. IOW, this returns the exit + # code of [[ $1 ]] as STDOUT. + [[ $1 ]] + echo $? +} + +function ensure-package-available { + # If package $1 is available, return 0. Otherwise, return 1, and + # if $2 is set, give error otherwise verbose. Outputting messages + # here avoids repetition in callers. + local package=$1 + local direct_p=$2 + + if ! run_emacs --load $package &>/dev/null + then + if [[ $direct_p ]] + then + error "$package not available." + else + verbose 2 "$package not available." + fi + return 1 + fi +} + +function ensure-tests-available { + # If tests of type $1 (like "ERT") are available, return 0. Otherwise, if + # $2 is set, give an error and return 1; otherwise give verbose message. $1 + # should have a corresponding predicate command, like ert-tests-p for ERT. + local test_name=$1 + local test_command="${test_name,,}-tests-p" # Converts name to lowercase. + local direct_p=$2 + + if ! $test_command + then + if [[ $direct_p ]] + then + error "$test_name tests not found." + else + verbose 2 "$test_name tests not found." + fi + return 1 + fi +} + +function echo_color { + # This allows bold, italic, etc. without needing a function for + # each variation. + local color_code="COLOR_$1" + shift + + if [[ $color ]] + then + echo -e "${!color_code}${@}${COLOR_off}" + else + echo "$@" + fi +} +function debug { + if [[ $debug ]] + then + function debug { + echo_color yellow "DEBUG ($(ts)): $@" >&2 + } + debug "$@" + else + function debug { + true + } + fi +} +function error { + echo_color red "ERROR ($(ts)): $@" >&2 + ((errors++)) + return 1 +} +function die { + [[ $@ ]] && error "$@" + exit $errors +} +function warn { + echo_color yellow "WARNING ($(ts)): $@" >&2 + ((warnings++)) +} +function log { + echo "LOG ($(ts)): $@" >&2 +} +function log_color { + local color_name=$1 + shift + echo_color $color_name "LOG ($(ts)): $@" >&2 +} +function success { + if [[ $verbose -ge 2 ]] + then + log_color green "$@" >&2 + fi +} +function verbose { + # $1 is the verbosity level, rest are echoed when appropriate. + if [[ $verbose -ge $1 ]] + then + [[ $1 -eq 1 ]] && local color_name=blue + [[ $1 -eq 2 ]] && local color_name=cyan + [[ $1 -ge 3 ]] && local color_name=white + + shift + log_color $color_name "$@" >&2 + fi +} + +function ts { + date "+%Y-%m-%d %H:%M:%S" +} + +function emacs-version { + # Echo Emacs version number. + + # Don't use run_emacs function, which does more than we need. + "${emacs_command[@]}" -Q --batch --eval "(princ emacs-version)" \ + || die "Unable to get Emacs version." +} + +function rule-p { + # Return 0 if $1 is a rule. + [[ $1 =~ ^(lint-?|tests?)$ ]] \ + || [[ $1 =~ ^(batch|interactive)$ ]] \ + || [[ $(type -t "$2" 2>/dev/null) =~ function ]] +} + +# * Rules + +# These functions are intended to be called as rules, like a Makefile. +# Some rules test $1 to determine whether the rule is being called +# directly or from a meta-rule; if directly, an error is given if the +# rule can't be run, otherwise it's skipped. + +function all { + verbose 1 "Running all rules..." + + lint + tests +} + +function compile-batch { + [[ $compile ]] || return 0 + unset compile # Only compile once. + + verbose 1 "Compiling..." + verbose 2 "Batch-compiling files..." + debug "Byte-compile files: ${files_project_byte_compile[@]}" + + batch-byte-compile "${files_project_byte_compile[@]}" +} + +function compile-each { + [[ $compile ]] || return 0 + unset compile # Only compile once. + + verbose 1 "Compiling..." + debug "Byte-compile files: ${files_project_byte_compile[@]}" + + local compile_errors + for file in "${files_project_byte_compile[@]}" + do + verbose 2 "Compiling file: $file..." + byte-compile-file "$file" \ + || compile_errors=t + done + + [[ ! $compile_errors ]] +} + +function compile { + if [[ $compile = batch ]] + then + compile-batch "$@" + else + compile-each "$@" + fi + local status=$? + + if [[ $compile_error_on_warn ]] + then + # Linting: just return status code, because lint rule will print messages. + [[ $status = 0 ]] + else + # Not linting: print messages here. + [[ $status = 0 ]] \ + && success "Compiling finished without errors." \ + || error "Compiling failed." + fi +} + +function batch { + # Run Emacs in batch mode with ${args_batch_interactive[@]} and + # with project source and test files loaded. + verbose 1 "Executing Emacs with arguments: ${args_batch_interactive[@]}" + + run_emacs \ + $(args-load-files "${files_project_feature[@]}" "${files_project_test[@]}") \ + "${args_batch_interactive[@]}" +} + +function interactive { + # Run Emacs interactively. Most useful with --sandbox and --install-deps. + local load_file_args=$(args-load-files "${files_project_feature[@]}" "${files_project_test[@]}") + verbose 1 "Running Emacs interactively..." + verbose 2 "Loading files: ${load_file_args//--load /}" + + [[ $compile ]] && compile + + unset arg_batch + run_emacs \ + $load_file_args \ + --eval "(load user-init-file)" \ + "${args_batch_interactive[@]}" + arg_batch="--batch" +} + +function lint { + verbose 1 "Linting..." + + lint-checkdoc + lint-compile + lint-declare + # NOTE: Elint doesn't seem very useful at the moment. See comment + # in lint-elint function. + # lint-elint + lint-indent + lint-package + lint-regexps +} + +function lint-checkdoc { + verbose 1 "Linting checkdoc..." + + run_emacs \ + --load="$elisp_checkdoc_file" \ + "${files_project_feature[@]}" \ + && success "Linting checkdoc finished without errors." \ + || error "Linting checkdoc failed." +} + +function lint-compile { + verbose 1 "Linting compilation..." + + compile_error_on_warn=true + compile "${files_project_byte_compile[@]}" \ + && success "Linting compilation finished without errors." \ + || error "Linting compilation failed." + unset compile_error_on_warn +} + +function lint-declare { + verbose 1 "Linting declarations..." + + run_emacs \ + --load "$elisp_check_declare_file" \ + -f makem-check-declare-files-and-exit \ + "${files_project_feature[@]}" \ + && success "Linting declarations finished without errors." \ + || error "Linting declarations failed." +} + +function lint-elsa { + verbose 1 "Linting with Elsa..." + + # MAYBE: Install Elsa here rather than in sandbox init, to avoid installing + # it when not needed. However, we should be careful to be clear about when + # packages are installed, because installing them does execute code. + run_emacs \ + --load elsa \ + -f elsa-run-files-and-exit \ + "${files_project_feature[@]}" \ + && success "Linting with Elsa finished without errors." \ + || error "Linting with Elsa failed." +} + +function lint-elint { + # NOTE: Elint gives a lot of spurious warnings, apparently because it doesn't load files + # that are `require'd, so its output isn't very useful. But in case it's improved in + # the future, and since this wrapper code already works, we might as well leave it in. + verbose 1 "Linting with Elint..." + + local errors=0 + for file in "${files_project_feature[@]}" + do + verbose 2 "Linting with Elint: $file..." + run_emacs \ + --load "$elisp_elint_file" \ + --eval "(makem-elint-file \"$file\")" \ + && verbose 3 "Linting with Elint found no errors." \ + || { error "Linting with Elint failed: $file"; ((errors++)) ; } + done + + [[ $errors = 0 ]] \ + && success "Linting with Elint finished without errors." \ + || error "Linting with Elint failed." +} + +function lint-indent { + verbose 1 "Linting indentation..." + + # We load project source files as well, because they may contain + # macros with (declare (indent)) rules which must be loaded to set + # indentation. + + run_emacs \ + --load "$(elisp-lint-indent-file)" \ + $(args-load-files "${files_project_feature[@]}" "${files_project_test[@]}") \ + --funcall makem-lint-indent-batch-and-exit \ + "${files_project_feature[@]}" "${files_project_test[@]}" \ + && success "Linting indentation finished without errors." \ + || error "Linting indentation failed." +} + +function lint-package { + ensure-package-available package-lint $1 || return $(echo-unset-p $1) + + verbose 1 "Linting package..." + + run_emacs \ + --load package-lint \ + --eval "(setq package-lint-main-file \"$(package-main-file)\")" \ + --funcall package-lint-batch-and-exit \ + "${files_project_feature[@]}" \ + && success "Linting package finished without errors." \ + || error "Linting package failed." +} + +function lint-regexps { + ensure-package-available relint $1 || return $(echo-unset-p $1) + + verbose 1 "Linting regexps..." + + run_emacs \ + --load relint \ + --funcall relint-batch \ + "${files_project_source[@]}" \ + && success "Linting regexps finished without errors." \ + || error "Linting regexps failed." +} + +function tests { + verbose 1 "Running all tests..." + + test-ert + test-buttercup +} + +function test-ert-interactive { + verbose 1 "Running ERT tests interactively..." + + unset arg_batch + run_emacs \ + $(args-load-files "${files_project_test[@]}") \ + --eval "(ert-run-tests-interactively t)" + arg_batch="--batch" +} + +function test-buttercup { + ensure-tests-available Buttercup $1 || return $(echo-unset-p $1) + compile || die + + verbose 1 "Running Buttercup tests..." + + run_emacs \ + $(args-load-files "${files_project_test[@]}") \ + --load "$elisp_buttercup_file" \ + --eval "(progn (setq backtrace-on-error-noninteractive nil) (buttercup-run))" \ + && success "Buttercup tests finished without errors." \ + || error "Buttercup tests failed." +} + +function test-ert { + ensure-tests-available ERT $1 || return $(echo-unset-p $1) + compile || die + + verbose 1 "Running ERT tests..." + debug "Test files: ${files_project_test[@]}" + + run_emacs \ + $(args-load-files "${files_project_test[@]}") \ + -f ert-run-tests-batch-and-exit \ + && success "ERT tests finished without errors." \ + || error "ERT tests failed." +} + +# * Defaults + +test_files_regexp='^((tests?|t)/)|-tests?.el$|^test-' + +emacs_command=("emacs") +errors=0 +# TODO: Do something with number of warnings? +warnings=0 +verbose=0 +compile=true +arg_batch="--batch" +compile=each + +# MAYBE: Disable color if not outputting to a terminal. (OTOH, the +# colorized output is helpful in CI logs, and I don't know if, +# e.g. GitHub Actions logging pretends to be a terminal.) +color=true + +# TODO: Using the current directory (i.e. a package's repo root directory) in +# load-path can cause weird errors in case of--you guessed it--stale .ELC files, +# the zombie problem that just won't die. It's incredible how many different ways +# this problem presents itself. In this latest example, an old .ELC file, for a +# .EL file that had since been renamed, was present on my local system, which meant +# that an example .EL file that hadn't been updated was able to "require" that .ELC +# file's feature without error. But on another system (in this case, trying to +# setup CI using GitHub Actions), the old .ELC was not present, so the example .EL +# file was not able to load the feature, which caused a byte-compilation error. + +# In this case, I will prevent such example files from being compiled. But in +# general, this can cause weird problems that are tedious to debug. I guess +# the best way to fix it would be to actually install the repo's code as a +# package into the sandbox, but doing that would require additional tooling, +# pulling in something like Quelpa or package-build--and if the default recipe +# weren't being used, the actual recipe would have to be fetched off MELPA or +# something, which seems like getting too smart for our own good. + +# TODO: Emit a warning if .ELC files that don't match any .EL files are detected. + +# ** Colors + +COLOR_off='\e[0m' +COLOR_black='\e[0;30m' +COLOR_red='\e[0;31m' +COLOR_green='\e[0;32m' +COLOR_yellow='\e[0;33m' +COLOR_blue='\e[0;34m' +COLOR_purple='\e[0;35m' +COLOR_cyan='\e[0;36m' +COLOR_white='\e[0;37m' + +# ** Package system args + +args_package_archives=( + --eval "(add-to-list 'package-archives '(\"gnu\" . \"https://elpa.gnu.org/packages/\") t)" + --eval "(add-to-list 'package-archives '(\"melpa\" . \"https://melpa.org/packages/\") t)" +) + +args_package_init=( + --eval "(package-initialize)" +) + +# * Args + +args=$(getopt -n "$0" \ + -o dhce:E:i:s::vf:C \ + -l compile-batch,exclude:,emacs:,install-deps,install-linters,debug,debug-load-path,help,install:,verbose,file:,no-color,no-compile,sandbox:: \ + -- "$@") \ + || { usage; exit 1; } +eval set -- "$args" + +while true +do + case "$1" in + --install-deps) + install_deps=true + ;; + --install-linters) + install_linters=true + ;; + -d|--debug) + debug=true + verbose=2 + args_debug=(--eval "(setq init-file-debug t)" + --eval "(setq debug-on-error t)") + ;; + --debug-load-path) + debug_load_path=true + ;; + -h|--help) + usage + exit + ;; + -c|--compile-batch) + debug "Compiling files in batch mode" + compile=batch + ;; + -E|--emacs) + shift + emacs_command=($1) + ;; + -i|--install) + shift + args_sandbox_package_install+=(--eval "(package-install '$1)") + ;; + -s|--sandbox) + sandbox=true + shift + sandbox_dir="$1" + + if ! [[ $sandbox_dir ]] + then + debug "No sandbox dir: installing dependencies." + install_deps=true + else + debug "Sandbox dir: $1" + fi + ;; + -v|--verbose) + ((verbose++)) + ;; + -e|--exclude) + shift + debug "Excluding file: $1" + files_exclude+=("$1") + ;; + -f|--file) + shift + args_files+=("$1") + ;; + --no-color) + unset color + ;; + -C|--no-compile) + unset compile + ;; + --) + # Remaining args (required; do not remove) + shift + rest=("$@") + break + ;; + esac + + shift +done + +debug "ARGS: $args" +debug "Remaining args: ${rest[@]}" + +# Elisp load files (output these once only). +elisp_buttercup_file=$(elisp-buttercup-file) +elisp_elint_file=$(elisp-elint-file) +elisp_checkdoc_file=$(elisp-checkdoc-file) +elisp_byte_compile_file=$(elisp-byte-compile-file) +elisp_check_declare_file=$(elisp-check-declare-file) +elisp_lint_indent_file=$(elisp-lint-indent-file) +elisp_package_initialize_file=$(elisp-package-initialize-file) + +# Since those variables' values come from functions called in +# subshells, those functions can't assign to the global value of +# paths_temp, so we add their values in this loop (this is a bit +# messy, but Bash scripting makes this awkward). +variable_names=(elisp_buttercup_file elisp_elint_file + elisp_checkdoc_file elisp_byte_compile_file + elisp_check_declare_file elisp_lint_indent_file + elisp_package_initialize_file) +for var in "${variable_names[@]}" +do + paths_temp+=("${!var}") +done + +# * Main + +trap cleanup EXIT INT TERM + +# Change to project root directory first. +cd "$(project-root)" + +# Discover project files. +files_project_feature=($(files-project-feature)) +files_project_test=($(files-project-test)) +files_project_byte_compile=("${files_project_feature[@]}" "${files_project_test[@]}") + +if [[ ${args_files[@]} ]] +then + # Add specified files. + files_project_feature+=("${args_files[@]}") + files_project_byte_compile+=("${args_files[@]}") +fi + +debug "EXCLUDING FILES: ${files_exclude[@]}" +debug "FEATURE FILES: ${files_project_feature[@]}" +debug "TEST FILES: ${files_project_test[@]}" +debug "BYTE-COMPILE FILES: ${files_project_byte_compile[@]}" +debug "PACKAGE-MAIN-FILE: $(package-main-file)" + +if ! [[ ${files_project_feature[@]} ]] +then + error "No files specified and not in a git repo." + exit 1 +fi + +# Set load path. +args_load_paths=($(args-load-path)) + +# If rules include linters and sandbox-dir is unspecified, install +# linters automatically. +if [[ $sandbox && ! $sandbox_dir ]] && [[ "${rest[@]}" =~ lint ]] +then + debug "Installing linters automatically." + install_linters=true +fi + +# Initialize sandbox. +[[ $sandbox ]] && { + sandbox + args_load_paths+=($(args-load-path-sandbox)) +} + +debug "LOAD PATH ARGS: ${args_load_paths[@]}" + +# Run rules. +for rule in "${rest[@]}" +do + if [[ $batch || $interactive ]] + then + debug "Adding batch/interactive argument: $rule" + args_batch_interactive+=("$rule") + + elif [[ $rule = batch ]] + then + # Remaining arguments are passed to Emacs. + batch=true + elif [[ $rule = interactive ]] + then + # Remaining arguments are passed to Emacs. + interactive=true + + elif type -t "$rule" 2>/dev/null | grep function &>/dev/null + then + # Pass called-directly as $1 to indicate that the rule is + # being called directly rather than from a meta-rule. + $rule called-directly + elif [[ $rule = test ]] + then + # Allow the "tests" rule to be called as "test". Since "test" + # is a shell builtin, this workaround is required. + tests + else + error "Invalid rule: $rule" + fi +done + +# Batch/interactive rules. +[[ $batch ]] && batch +[[ $interactive ]] && interactive + +if [[ $errors -gt 0 ]] +then + log_color red "Finished with $errors errors." +else + success "Finished without errors." +fi + +exit $errors