#!/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 <https://www.gnu.org/licenses/>.

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
