#!/bin/sh # projectdo – universal project commands. # Copyright (C) 2019-present Simon Friis Vindum # 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 . VERSION="0.2.0" # Global mutable variables. QUIET=false DRY_RUN=false PROJECT_ROOT="${PROJECT_ROOT:-}" ACTION="" TOOL_ARGS="" # Arguments to pass along to the tool IS_TOOL=false # True if ACTION is 'tool' as this action is somewhat special. has_command() { command -v "$1" >/dev/null 2>&1 } # When running the appropriate external tool this function is used which # evaluates the given command while respecting $QUIET and $DRY_RUN. execute() { if [ $QUIET = false ]; then echo "$1" $TOOL_ARGS fi if [ $DRY_RUN = false ]; then if [ $QUIET = false ]; then eval "$1" $TOOL_ARGS else eval "$1" $TOOL_ARGS > /dev/null fi fi } # Takes the name of a tool and an action. If the action is `tool` it is # ignored. It handles the common case where the tool is invoked as `tool # action`. execute_command() { if [ "$2" = tool ]; then execute "$1" else execute "$1 $2" fi exit 0 } # Every supported tool requires a function `try_tool` where `tool` is a name # indicating what tool the function tries to detect. The function should: # # * Return if it does not detect that the tool is appropriate. # * Read the variables `ACTION` and `IS_TOOL` to determine the correct action # to run. # * When running an action is should use the `execute` function and _exit_ when # it is done. # Start list of tools # JavaScript + NodeJS try_nodejs() { if [ ! -f package.json ]; then return 1 fi if [ -f yarn.lock ]; then tool=yarn else tool=npm fi if ! has_command $tool; then echo "Found a package.json file but '$tool' is not installed." exit 1 fi local node_action="$ACTION" # Only the "run" action need translation, the others match 1-to-1 if [ "$ACTION" = run ]; then node_action=start fi # We check if the package.json file contains an appropriate script. We use # grep for this. The check is not 100% bulletproof, but it's very close. We # could've used `npm run` to get the authorative list of the scripts but # invoking `npm` is two orders of magnitude slower which leads to a # noticeable delay. if ! $IS_TOOL && ! grep -q "^[[:space:]]*\"${node_action}\":" package.json; then return 0 fi execute_command "$tool" "$node_action" } # Rust + Cargo try_cargo() { if [ -f Cargo.toml ]; then execute_command cargo "$ACTION" fi } # CMake try_cmake() { if [ -f CMakeLists.txt ] && [ "$ACTION" = test ]; then if [ -f build ]; then execute "cmake --build build --target test" else execute "cmake --build . --target test" fi exit fi } # Haskell + Stack try_stack() { if [ -f package.yaml ] && [ -f stack.yaml ]; then execute_command stack "$ACTION" fi } # Haskell + Cabal try_cabal() { cabal_file="$(find ./ -maxdepth 1 -name "*.cabal" 2> /dev/null | wc -l)" if [ "$cabal_file" -gt 0 ] && [ ! -f stack.yml ]; then execute_command cabal "$ACTION" fi } # Maven try_maven() { if [ -f pom.xml ]; then case $ACTION in build) execute "mvn compile" exit ;; test) execute "mvn test" exit ;; run) echo "projectdo does not know how to run a project with Maven." exit esac fi } # Clojure + Leiningen try_lein() { if [ -f project.clj ] && [ "$ACTION" = test ]; then execute "lein test" fi } # Makefile has_make_target() { target="${1?}" output=$(make -n "${target}" 2>&1) exit_code=$? if [ $exit_code -ne 0 ]; then return $exit_code fi # If there is a file with the name of the target we're looking for but no # actual target with that name, make will exit successfully with that # message. We need to consider that case as a "target not found". Note that # the way the target is quoted in the output (`test' vs 'test') can differ # across OSes so we only check a prefix up to the problematic quotes. if expr "$output" : "make: Nothing to be done for" 1> /dev/null; then return 1 fi return 0 } try_makefile() { if [ -f Makefile ]; then if ! has_command "make"; then echo "Found a Makefile but 'make' is not installed." exit 1 fi if $IS_TOOL || [ "$ACTION" = build ]; then # For make "build" is the default action when running `make` execute "make" exit elif [ "$ACTION" = test ]; then # Let's see if the makefile contains a test or check target if has_make_target "test"; then execute "make test" exit elif has_make_target "check"; then execute "make check" exit fi fi fi return 1 } # Python try_python() { if [ -f pyproject.toml ]; then if grep -q -m 1 "^\[tool.poetry\]$" pyproject.toml; then case $ACTION in build) execute "poetry build" exit ;; test) # TODO: There are other Python test frameworks, it would be nice to # detect and run the right one. execute "poetry run pytest" exit ;; run) echo "projectdo does not know how to run a project with Poetry." exit esac else echo "Found a pyproject.toml file but projectdo is not sure how to run it." exit fi return 1 fi } # Go try_go() { if [ -f go.mod ] && [ "$ACTION" = test ]; then # We detect Makefiles before we detect Go, so here we know that the Go # project is _not_ tested by a Makefile. # Check if the project uses Mage. A magefile could in theory have any name, # but `magefile.go` seems to be the convention. if grep -q -m 1 '^func Check(' magefile.go; then execute "mage check" exit elif grep -q -m 1 '^func Test(' magefile.go; then execute "mage test" exit fi execute "go test" exit fi } # LaTeX try_latex() { if [ -f Tectonic.toml ] && [ "$ACTION" = build ]; then execute "tectonic -X build" exit fi } # End of list of tools detect_and_run() { try_nodejs try_cargo try_stack try_cabal try_cmake try_maven try_lein try_makefile try_python try_go try_latex } set_project_root() { if [ -n "$PROJECT_ROOT" ]; then return fi if has_command git; then # Find the root of the git repository if we're inside one. If we're in a # git submodule then the root of the outer git repo is used. If we're not # in a git repo the git command will not output anything and $PROJECT_ROOT # is set to the empty string which is fine. PROJECT_ROOT=$(git rev-parse --show-superproject-working-tree --show-toplevel 2> /dev/null | head -n 1) fi } nothing_found() { echo "No way to $ACTION found :'(" exit 1 } display_version() { echo "$(basename "$0") version $VERSION" } display_help() { echo "Usage: $(basename "$0") [options] [action] [tool-arguments] Options: -h, --help Display this help. -n, -d, --dry-run Do not execute any commands with side-effects. -q, --quiet Do not print commands as they are about to be executed. -v, --version Display the version of the program (which is $VERSION). Actions: build, run, test Build, run, or test the current project. tool Invoke the guessed tool for the current project. Tool arguments: Any arguments following [action] are passed along to the invoked tool." } # Main execution starts here while getopts dhnqv-: c do case $c in d) DRY_RUN=true ;; h) display_help; exit 0 ;; n) DRY_RUN=true ;; q) QUIET=true ;; v) display_version; exit 0 ;; -) case $OPTARG in help) display_help; exit 0 ;; dry-run) DRY_RUN=true ;; quiet) QUIET=true ;; version) display_version; exit 0 ;; '' ) break ;; # "--" should terminate argument processing * ) echo "Illegal option --$OPTARG" >&2; exit 1 ;; esac ;; \?) exit 1 ;; esac done shift $((OPTIND-1)) # Shift away the parsed option arguments if [ "$1" = test ] || [ "$1" = run ] || [ "$1" = build ] || [ "$1" = tool ]; then ACTION=$1 if [ "$1" = tool ]; then IS_TOOL=true fi shift 1 # Remove the action from the arguments TOOL_ARGS=$@ else if [ -z "$1" ]; then echo "No action specified." else echo "$1 is not a valid action." fi echo "" echo "The valid actions are: build, run, test, tool" echo "" echo "Example: projectdo test" exit 1 fi set_project_root while : do # We don't want to do anything if we are in the home or root directory if [ "$PWD" = "$HOME" ] || [ "$PWD" = / ]; then nothing_found fi detect_and_run # If we didn't detect a tool to run in this directory we go up one directory # while ensuring that we don't leave the project root if [ "$PWD" = "$PROJECT_ROOT" ]; then nothing_found fi cd .. done