#!/usr/bin/env bash
set -euf -o pipefail

usage="
Usage: xyz [options]

Publish a new version of the npm package in the current working directory.
This involves updating the version number in package.json, committing this
change (along with any staged changes), tagging the commit, pushing to the
remote git repository, and finally publishing to the public npm registry.

Options:

-b --branch <name>
        Specify the branch from which new versions must be published.
        xyz aborts if run from any other branch to prevent accidental
        publication of feature branches. 'main' is assumed if this
        option is omitted.

-e --edit
        Allow the commit message to be edited before the commit is made.

-i --increment <level>
        Specify the level of the current version number to increment.
        Valid levels: 'major', 'minor', 'patch', 'premajor', 'preminor',
        'prepatch', and 'prerelease'. 'patch' is assumed if this option
        is omitted. Choosing one of the pre-releases causes the npm dist-tag
        to be set according to --prerelease-label.

-m --message <template>
        Specify the format of the commit (and tag) message.
        'X.Y.Z' acts as a placeholder for the version number.
        'Version X.Y.Z' is assumed if this option is omitted.

   --prerelease-label <label>
        Specify the label to be used in the version number when publishing
        a pre-release version (e.g. 'beta' is the label in '2.0.0-beta.0').
        'rc' is assumed if this option is omitted. If the release is a
        pre-release, as indicated by --increment, the --prerelease-label will
        be used to create an npm dist-tag for the release.

   --publish-command <command>
        Specify the command to be run to publish the package. It may refer
        to the VERSION and PREVIOUS_VERSION environment variables. A no-op
        command (':' or 'true') prevents the package from being published
        to a registry. 'npm publish' is assumed if this option is omitted.
        If this option is provided, the --prerelease-label will not be used
        to create an npm dist-tag for the release.

-r --repo <repository>
        Specify the remote repository to which to 'git push'.
        The value must be either a URL or the name of a remote.
        The latter is not recommended: it relies on local state.
        'origin' is assumed if this option is omitted.

-s --script <path>
        Specify a script to be run after the confirmation prompt.
        It is passed VERSION and PREVIOUS_VERSION as environment
        variables. xyz aborts if the script's exit code is not 0.

-t --tag <template>
        Specify the format of the tag name. As with --message,
        'X.Y.Z' acts as a placeholder for the version number.
        'vX.Y.Z' is assumed if this option is omitted.

   --dry-run
        Print the commands without evaluating them.

-v --version
        Print xyz's version number and exit.
"

abort() {
  printf '%s\n' "$@" >&2
  exit 1
}

inc() {
  local prerelease_label="$1" increment="$2" version="$3"
  local number='0|[1-9][0-9]*'
  local part="$number|[-0-9A-Za-z]+"
  local pattern="^($number)[.]($number)[.]($number)(-(($part)([.]($part))*))?$"
  if ! [[ $version =~ $pattern ]] ; then
    printf 'Invalid version: %s\n' "$version" >&2
    return 1
  fi
  local -i x=${BASH_REMATCH[1]}
  local -i y=${BASH_REMATCH[2]}
  local -i z=${BASH_REMATCH[3]}
  local qual=${BASH_REMATCH[5]}

  local -a parts=("$prerelease_label" 0)
  case $increment in
    major)  (( y + z == 0 )) && [[ -n $qual ]] || x+=1 ; y=0 ; z=0 ;;
    minor)      (( z == 0 )) && [[ -n $qual ]] || y+=1 ; z=0 ;;
    patch)                      [[ -n $qual ]] || z+=1 ;;
    premajor)                                     x+=1 ; y=0 ; z=0 ;;
    preminor)                                     y+=1 ; z=0 ;;
    prepatch)                                     z+=1 ;;
    prerelease)
      case ${BASH_REMATCH[6]} in
        '') z+=1 ;;
        "$prerelease_label")
          local idx
          local -a xs
          IFS=. read -r -a xs <<<"$qual"
          for (( idx = ${#xs[@]} - 1 ; idx > 0 ; idx -= 1 )) ; do
            pattern="^($number)$"
            if [[ ${xs[idx]} =~ $pattern ]] ; then
              parts=("${xs[@]}")
              (( parts[idx] += 1 ))
              break
            fi
          done
      esac
      ;;
    *)
      echo "Invalid --increment" >&2
      return 1
  esac

  version="$x.$y.$z"
  if [[ $increment =~ ^pre ]] ; then
    join() { local IFS=. ; echo "$*" ; }
    version+="-$(join "${parts[@]}")"
  fi
  printf %s "$version"
}

if type tput &>/dev/null ; then
  bold=$(tput bold)
  smul=$(tput smul)
  rmul=$(tput rmul)
  reset=$(tput sgr0)
fi

check_bash_version() {
  if (( BASH_VERSINFO[0] < 4 )) ; then
    local xyz
    xyz="$(IFS=.; echo "${BASH_VERSINFO[*]:0:3}")"
    abort \
      "Inadequate Bash version: ${bold}${xyz}${reset} < ${bold}4.0.0${reset}" \
      "${smul}https://github.com/davidchambers/xyz/issues/51${rmul}"
  fi
}
check_bash_version

declare -A options=(
  [branch]=main
  [dry-run]=false
  [edit]=false
  [increment]=patch
  [message]='Version X.Y.Z'
  [prerelease-label]=rc
  [repo]=origin
  [tag]=vX.Y.Z
)
declare -a scripts

while (( $# > 0 )) ; do
  option="$1" ; shift

  case $option in
    -b) option=--branch ;;
    -e) option=--edit ;;
    -h) option=--help ;;
    -i) option=--increment ;;
    -m) option=--message ;;
    -r) option=--repo ;;
    -s) option=--script ;;
    -t) option=--tag ;;
    -v) option=--version ;;
  esac

  case $option in
    --help)
      echo "$usage" ; exit ;;
    --version)
      node -p 'require("xyz/package.json").version' ; exit ;;
    --script)
      scripts+=("$1") ; shift ;;
    --dry-run|--edit)
      options[${option:2}]=true ;;
    --publish-command)
      options[publish-command]="$1" ; shift ;;
    *)
      if [[ $option =~ ^--(.+)$ && -n ${options[${BASH_REMATCH[1]}]+x} ]] ; then
        options[${BASH_REMATCH[1]}]="$1" ; shift
      else
        abort "Unrecognized option $option"
      fi
  esac
done

if [[ ${options[publish-command]+x} != x ]] ; then
  if [[ "${options[increment]}" == pre* ]] ; then
    options[publish-command]="npm publish --tag ${options[prerelease-label]}"
  else
    options[publish-command]="npm publish"
  fi
fi

[[ $(git rev-parse --abbrev-ref HEAD) == "${options[branch]}" ]] ||
  abort "Current branch does not match specified --branch"

git diff --quiet ||
  abort "Working directory contains unstaged changes"

name=$(node -p "require('./package.json').name" 2>/dev/null) ||
  abort "Cannot read package name"

version=$(node -p "require('./package.json').version" 2>/dev/null) ||
  abort "Cannot read package version"

next_version=$(inc "${options[prerelease-label]}" \
                   "${options[increment]}" \
                   "$version")

message="${options[message]//X.Y.Z/$next_version}"
tag="${options[tag]//X.Y.Z/$next_version}"

printf "Current version is %s. Press [enter] to publish %s." \
       "${bold}${version}${reset}" \
       "${bold}${name}@${next_version}${reset}"
read -r -s  # suppress user input
echo        # output \n since [enter] output was suppressed

run() {
  local arg
  for arg ; do
    if [[ $(printf "%q" "$arg") == "$arg" ]] ; then
      printf "%s " "$arg"
    else
      printf "'%s' " "${arg//"'"/"'"'"'"'"'"'"'"}"
    fi
  done
  echo
  if [[ ${options[dry-run]} == false ]] ; then
    "$@"
  fi
}

# Prune before running tests to catch dependencies that have been
# installed but not specified in the project's `package.json` file.

run npm prune
run npm test

for script in "${scripts[@]}" ; do
  [[ $script =~ ^/ ]] || script="$(pwd)/$script"
  run env VERSION="$next_version" PREVIOUS_VERSION="$version" "$script"
done

run env VERSION="$next_version" node -e '
  var pkg = require("./package.json");
  pkg.version = process.env.VERSION;
  fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
run git add package.json

declare -a commit_options=(--message "$message")
[[ ${options[edit]} == true ]] && commit_options+=(--edit)
run git commit "${commit_options[@]}"
run git tag --annotate "$tag" --message "$message"
run git push --atomic "${options[repo]}" \
  "refs/heads/${options[branch]}" \
  "refs/tags/$tag"

run env VERSION="$next_version" PREVIOUS_VERSION="$version" \
        bash -c "${options[publish-command]}"
