mirror of
https://github.com/vale981/ement.el
synced 2025-03-04 08:51:39 -05:00
1265 lines
38 KiB
Bash
Executable file
1265 lines
38 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
|
|
# * makem.sh --- Script to aid building and testing Emacs Lisp packages
|
|
|
|
# URL: https://github.com/alphapapa/makem.sh
|
|
# Version: 0.6-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.
|
|
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.
|
|
local file=$(mktemp)
|
|
cat >$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
|
|
echo $file
|
|
}
|
|
|
|
function elisp-elint-file {
|
|
local file=$(mktemp)
|
|
cat >$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
|
|
echo "$file"
|
|
}
|
|
|
|
function elisp-checkdoc-file {
|
|
# Since checkdoc doesn't have a batch function that exits non-zero
|
|
# when errors are found, we make one.
|
|
local file=$(mktemp)
|
|
|
|
cat >$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))))
|
|
(mapcar #'checkdoc-file files)
|
|
(when makem-checkdoc-errors-p
|
|
(kill-emacs 1))))
|
|
|
|
(setq checkdoc-spellcheck-documentation-flag t)
|
|
(makem-checkdoc-files-and-exit)
|
|
EOF
|
|
echo $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.
|
|
local file=$(mktemp)
|
|
# TODO: Add file to $paths_temp in other elisp- functions.
|
|
paths_temp+=("$file")
|
|
|
|
cat >"$file" <<EOF
|
|
(defun makem-batch-byte-compile (&rest args)
|
|
""
|
|
(let ((num-errors 0))
|
|
;; NOTE: Only accepts files as args, not directories.
|
|
(dolist (file command-line-args-left)
|
|
(unless (makem-byte-compile-file file)
|
|
(cl-incf num-errors)))
|
|
(zerop num-errors)))
|
|
|
|
(defun makem-byte-compile-file (filename &optional load)
|
|
"Call \`byte-compile-warn', returning nil if there are any warnings."
|
|
(let ((num-warnings 0))
|
|
(cl-letf (((symbol-function 'byte-compile-warn)
|
|
(lambda (format &rest args)
|
|
;; Copied from \`byte-compile-warn'.
|
|
(cl-incf num-warnings)
|
|
(setq format (apply #'format-message format args))
|
|
(byte-compile-log-warning format t :warning))))
|
|
(byte-compile-file filename load))
|
|
(zerop num-warnings)))
|
|
EOF
|
|
echo "$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.
|
|
local file=$(mktemp)
|
|
|
|
cat >$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
|
|
echo $file
|
|
}
|
|
|
|
function elisp-lint-indent-file {
|
|
# This function prints warnings for indentation errors and exits
|
|
# non-zero when errors are found.
|
|
local file=$(mktemp)
|
|
|
|
cat >"$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
|
|
|
|
echo "$file"
|
|
}
|
|
|
|
function elisp-package-initialize-file {
|
|
local file=$(mktemp)
|
|
|
|
cat >$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/")))
|
|
$elisp_org_package_archive
|
|
(package-initialize)
|
|
EOF
|
|
echo $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[@]}"
|
|
-l $package_initialize_file
|
|
$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) || 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 "(unless (makem-byte-compile-file \"$file\") (kill-emacs 1))" \
|
|
&& verbose 3 "Compiling $file finished without errors." \
|
|
|| { verbose 3 "Compiling file failed: $file"; return 1; }
|
|
}
|
|
|
|
# ** Files
|
|
|
|
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.
|
|
git ls-files 2>/dev/null \
|
|
| egrep "\.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 \
|
|
| egrep -v "$test_files_regexp" \
|
|
| filter-files-feature
|
|
}
|
|
|
|
function files-project-test {
|
|
# Echo list of Elisp test files.
|
|
files-project-elisp | egrep "$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.
|
|
egrep -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
|
|
egrep "^\\(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=$(git ls-files ./*-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.
|
|
egrep -a -i '^;; Package-Requires: ' $(files-project-feature) $(files-project-test) \
|
|
| egrep -o '\([^([:space:]][^)]*\)' \
|
|
| egrep -o '^[^[:space:])]+' \
|
|
| sed -r 's/\(//g' \
|
|
| egrep -v '^emacs$' # Ignore Emacs version requirement.
|
|
|
|
# Search Cask file.
|
|
if [[ -r Cask ]]
|
|
then
|
|
egrep '\(depends-on "[^"]+"' Cask \
|
|
| sed -r -e 's/\(depends-on "([^"]+)".*/\1/g'
|
|
fi
|
|
|
|
# Search -pkg.el file.
|
|
if [[ $(git ls-files ./*-pkg.el 2>/dev/null) ]]
|
|
then
|
|
sed -nr 's/.*\(([-[:alnum:]]+)[[:blank:]]+"[.[:digit:]]+"\).*/\1/p' $(git ls-files ./*-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 -d) || 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\"))"
|
|
--load package
|
|
--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[@]}"
|
|
|
|
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 "(package-refresh-contents)" \
|
|
"${args_sandbox_package_install[@]}" \
|
|
&& success "Packages installed." \
|
|
|| die "Unable to initialize sandbox."
|
|
fi
|
|
|
|
verbose 2 "Sandbox initialized."
|
|
}
|
|
|
|
# ** 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 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..."
|
|
|
|
local checkdoc_file="$(elisp-checkdoc-file)"
|
|
paths_temp+=("$checkdoc_file")
|
|
|
|
run_emacs \
|
|
--load="$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..."
|
|
|
|
local check_declare_file="$(elisp-check-declare-file)"
|
|
paths_temp+=("$check_declare_file")
|
|
|
|
run_emacs \
|
|
--load "$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..."
|
|
|
|
local buttercup_file="$(elisp-buttercup-file)"
|
|
paths_temp+=("$buttercup_file")
|
|
|
|
run_emacs \
|
|
$(args-load-files "${files_project_test[@]}") \
|
|
-f 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
|
|
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_org_package_archives=(
|
|
--eval "(add-to-list 'package-archives '(\"org\" . \"https://orgmode.org/elpa/\") t)"
|
|
)
|
|
|
|
args_package_init=(
|
|
--eval "(package-initialize)"
|
|
)
|
|
|
|
elisp_org_package_archive="(add-to-list 'package-archives '(\"org\" . \"https://orgmode.org/elpa/\") t)"
|
|
|
|
# * Args
|
|
|
|
args=$(getopt -n "$0" \
|
|
-o dhce:E:i:s::vf:CO \
|
|
-l compile-batch,exclude:,emacs:,install-deps,install-linters,debug,debug-load-path,help,install:,verbose,file:,no-color,no-compile,no-org-repo,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")
|
|
;;
|
|
-O|--no-org-repo)
|
|
unset elisp_org_package_archive
|
|
;;
|
|
--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[@]}"
|
|
|
|
# Set package elisp (which depends on --no-org-repo arg).
|
|
package_initialize_file="$(elisp-package-initialize-file)"
|
|
paths_temp+=("$package_initialize_file")
|
|
|
|
# * Main
|
|
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# 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))
|
|
debug "LOAD PATH ARGS: ${args_load_paths[@]}"
|
|
|
|
# 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
|
|
|
|
# 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
|