#!/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
