#!/bin/bash # * makem.sh --- Script to aid building and testing Emacs Lisp packages # https://github.com/alphapapa/makem.sh # * Commentary: # makem.sh is a script 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. # When desired, emacs-sandbox.sh can be used as a backend, which # allows package dependencies to be installed automatically into a # clean Emacs "sandbox" configuration without affecting the # developer's personal configuration. This is especially helpful when # upstream dependencies may have released new versions that differ # from those installed in the developer's personal configuration. See # . # * 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 . # * Functions # ** 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 <$file <$file <&1) fi output_file=$(mktemp) $emacs_command -Q --batch \ --load=$package_initialize_file \ -L "$load_path" \ "$@" \ &>$output_file exit=$? [[ $exit != 0 ]] && debug "Emacs exited non-zero: $exit" if [[ $verbose -gt 1 || $exit != 0 ]] then cat $output_file fi rm -f $output_file return $exit } # ** Compilation function batch-byte-compile { debug "batch-byte-compile: ERROR-ON-WARN:$compile_error_on_warn FILES:$@" [[ $compile_error_on_warn ]] && local error_on_warn=(--eval "(setq byte-compile-error-on-warn t)") run_emacs \ "${error_on_warn[@]}" \ --funcall batch-byte-compile \ "$@" } # ** Files function project-elisp-files { # Echo list of Elisp files in project. git ls-files 2>/dev/null | egrep "\.el$" | exclude-files } function project-source-files { # Echo list of Elisp files that are not tests. project-elisp-files | egrep -v "$test_files_regexp" | feature-files } function project-test-files { # Echo list of Elisp test files. project-elisp-files | egrep "$test_files_regexp" } function exclude-files { # Filter out paths (STDIN) which should be excluded by default. egrep -v "(/\.cask/|-autoloads.el|.dir-locals)" } function feature-files { # Read paths on STDIN and echo ones that (provide 'a-feature). while read path do debug "PATH: $path" egrep "^\\(provide '" "$path" &>/dev/null \ && echo "$path" done } function load-files-args { # For file in $@, echo "--load $file". for file in "$@" do printf -- '--load %q ' "$file" done } function files_args { # For file in STDIN, echo "$file". while read file do printf -- '%q ' "$file" done } function test-files-p { # Return 0 if $project_test_files is non-empty. [[ "${project_test_files[@]}" ]] } 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)" "${project_test_files[@]}" &>/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" "${project_test_files[@]}" &>/dev/null } function dependencies { # Echo list of package dependencies. # Search package headers. egrep '^;; Package-Requires: ' $(project-source-files) $(project-test-files) \ | 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 } # ** Utility function cleanup { # Remove temporary paths (${temp_paths[@]}). for path in "${temp_paths[@]}" 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_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=$1 shift echo_color $color "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=blue [[ $1 -ge 2 ]] && local color=cyan shift log_color $color "$@" >&2 fi } function ts { date "+%Y-%m-%d %H:%M:%S" } function usage { cat <. -s, --sandbox Run Emacs with emacs-sandbox.sh in a temporary directory (removing directory on exit). -S, --sandbox-dir DIR Use DIR for the sandbox directory (leaving it on exit). Implies -s. --auto-install Automatically install package dependencies. -i, --install PACKAGE Install PACKAGE before running rules. Source files are automatically discovered from git, or may be specified with options. Package dependencies are discovered from "Package-Requires" headers in source files and from a Cask file. EOF } # * Rules # These functions are intended to be called as rules, like a Makefile. function all { verbose 1 "Running all rules..." lint tests } function compile { [[ $compile ]] || return 0 unset compile # Only compile once. verbose 1 "Compiling..." debug "Byte-compile files: ${project_byte_compile_files[@]}" batch-byte-compile "${project_byte_compile_files[@]}" \ && success "Compiling finished without errors." \ || error "Compilation failed." } function lint { verbose 1 "Linting..." lint-checkdoc lint-compile lint-package } function lint-checkdoc { verbose 1 "Linting checkdoc..." local checkdoc_file="$(elisp-checkdoc-file)" temp_paths+=("$checkdoc_file") run_emacs \ --load="$checkdoc_file" \ "${project_source_files[@]}" \ && success "Linting checkdoc finished without errors." \ || error "Linting checkdoc failed." } function lint-compile { verbose 1 "Linting compilation..." compile_error_on_warn=true batch-byte-compile "${project_byte_compile_files[@]}" \ && success "Linting compilation finished without errors." \ || error "Linting compilation failed." unset compile_error_on_warn } function lint-package { verbose 1 "Linting package..." run_emacs \ --load package-lint \ --funcall package-lint-batch-and-exit \ "${project_source_files[@]}" \ && success "Linting package finished without errors." \ || error "Linting package failed." } function tests { verbose 1 "Running all tests..." test-ert test-buttercup } function test-buttercup { buttercup-tests-p || return 0 compile || die verbose 1 "Running Buttercup tests..." local buttercup_file="$(elisp-buttercup-file)" temp_paths+=("$buttercup_file") run_emacs \ --load buttercup \ --load "$buttercup_file" \ -f buttercup-run-discover \ && success "Buttercup tests finished without errors." \ || error "Buttercup tests failed." } function test-ert { ert-tests-p || return 0 compile || die verbose 1 "Running ERT tests..." debug "Test files: ${project_test_files[@]}" run_emacs \ $(load-files-args "${project_test_files[@]}") \ -f ert-run-tests-batch-and-exit \ && success "ERT tests finished without errors." \ || error "ERT tests failed." } # * Defaults test_files_regexp='^(tests?|t)/' emacs_command="emacs" errors=0 verbose=0 compile=true # 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. load_path="." # ** 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' # * Project files # MAYBE: Option to not byte-compile test files. (OTOH, byte-compiling reveals many # errors that would otherwise go unnoticed, so it's worth it to fix the warnings.) project_source_files=($(project-source-files)) project_test_files=($(project-test-files)) project_byte_compile_files=("${project_source_files[@]}" "${project_test_files[@]}") package_initialize_file="$(elisp-package-initialize-file)" temp_paths+=("$package_initialize_file") # * Args args=$(getopt -n "$0" \ -o dhi:sS:vf:C \ -l auto-install,debug,debug-load-path,help,install:,verbose,file:,no-color,no-compile,sandbox,sandbox-dir: \ -- "$@") \ || { usage; exit 1; } eval set -- "$args" while true do case "$1" in --auto-install) auto_install=true ;; -d|--debug) debug=true verbose=2 ;; --debug-load-path) debug_load_path=true ;; -h|--help) usage exit ;; -i|--install) shift sandbox_install_packages_args+=(--install "$1") ;; -s|--sandbox) sandbox=true ;; -S|--sandbox-dir) shift sandbox=true sandbox_dir="$1" ;; -v|--verbose) ((verbose++)) ;; -f|--file) shift project_source_files+=("$1") project_byte_compile_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[@]}" # * Main trap cleanup EXIT INT TERM if ! [[ ${project_source_files[@]} ]] then error "No files specified and not in a git repo." exit 1 fi if [[ $sandbox ]] then # Setup sandbox. type emacs-sandbox.sh &>/dev/null || die "emacs-sandbox.sh not found." if ! [[ $sandbox_dir ]] then # No sandbox dir specified: make temp dir and remove it on exit. sandbox_dir=$(mktemp -d) || die "Unable to make temp dir." temp_paths+=("$sandbox_dir") fi sandbox_basic_args=( -d "$sandbox_dir" ) [[ $debug ]] && sandbox_basic_args+=(--debug) if [[ $auto_install ]] then # Add dependencies to package install list. deps=($(dependencies)) debug "Installing dependencies: ${deps[@]}" for package in "${deps[@]}" do sandbox_install_packages_args+=(--install $package) done fi if [[ ${sandbox_install_packages_args[@]} ]] then # Initialize the sandbox (installs packages once rather than for every rule). emacs_command="emacs-sandbox.sh ${sandbox_basic_args[@]} ${sandbox_install_packages_args[@]} -- " debug "Initializing sandbox..." run_emacs || die "Unable to initialize sandbox." fi # After the sandbox is initialized and packages are installed, set the command # to prevent the package lists from being refreshed on each invocation. emacs_command="emacs-sandbox.sh ${sandbox_basic_args[@]} --no-refresh-packages -- " debug "Sandbox initialized." fi # Run rules. for rule in "${rest[@]}" do if type "$rule" 2>/dev/null | grep "$rule is a function" &>/dev/null then $rule 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 if [[ $errors -gt 0 ]] then log_color red "Finished with $errors errors." else success "Finished without errors." fi exit $errors