#!/usr/bin/env bash

# This file is part of the Symfony package.
#
# (c) Fabien Potencier <fabien@symfony.com>
#
# For the full copyright and license information, please view
# https://symfony.com/doc/current/contributing/code/license.html

# Tests the shell completion scripts against real shells.
#
# Usage: run.sh [bash|zsh|fish|all]

set -u

dir="$(cd "$(dirname "$0")" && pwd)"
shells="${1:-all}"
[ "$shells" = "all" ] && shells="bash zsh fish"

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# make the fixture application available as "app" in the PATH
mkdir -p "$tmp/bin"
ln -s "$dir/app" "$tmp/bin/app"
chmod +x "$dir/app"
export PATH="$tmp/bin:$PATH"

passed=0
failed=0
skipped=0

fail() {
    failed=$((failed + 1))
    echo "KO $1"
    [ -n "${GITHUB_ACTIONS:-}" ] && echo "::error::$1"
    shift
    printf '%s\n' "$@" | sed 's/^/    /'
}

pass() {
    passed=$((passed + 1))
    echo "OK $1"
}

normalize() {
    grep -v '^$' | LC_ALL=C sort -u
}

dump_script() {
    local shell="$1" script="$tmp/completion.$shell"

    if ! app completion "$shell" > "$script" 2> "$tmp/dump.err"; then
        fail "[$shell] dump the completion script" "$(cat "$tmp/dump.err")"
        return 1
    fi

    if grep -q '{{' "$script"; then
        fail "[$shell] all placeholders are replaced" "$(grep -n '{{' "$script")"
        return 1
    fi

    if ! grep -q '_sf_app' "$script"; then
        fail "[$shell] the command name is used in the script"
        return 1
    fi

    pass "[$shell] dump the completion script"
}

# Runs one case and compares the suggestions with the expectations
run_case() {
    local shell="$1" command_line="$2" expected="$3" name="[$shell] $2"
    local driver actual

    case "$shell" in
        bash) driver=(bash "$dir/drivers/bash.sh") ;;
        zsh) driver=(zsh "$dir/drivers/zsh.zsh") ;;
        fish) driver=(fish "$dir/drivers/fish.fish") ;;
    esac

    actual="$("${driver[@]}" "$tmp/completion.$shell" "$command_line" 2> "$tmp/case.err" | normalize)"
    expected="$(printf '%s' "$expected" | tr ';' '\n' | normalize)"

    if [ "$actual" = "$expected" ]; then
        pass "$name"

        return 0
    fi

    fail "$name" "expected:" "$expected" "actual:" "${actual:-<none>}" "$(cat "$tmp/case.err")"
}

# The completion must not run when the script was generated by another version of
# the API. Only bash reports the error, zsh and fish discard the error output.
test_api_version() {
    local script="$tmp/completion-outdated.bash" output

    sed 's/-a1/-a0.9/' "$tmp/completion.bash" > "$script"
    output="$(bash "$dir/drivers/bash.sh" "$script" 'app demo:' 2>&1)"

    case "$output" in
        *'Completion script version is not supported'*) pass "[bash] outdated completion script is detected" ;;
        *) fail "[bash] outdated completion script is detected" "$output" ;;
    esac
}

# bash-completion is not installed everywhere, for instance on macOS: the script
# must explain it instead of sending an incomplete request to the application
test_missing_bash_completion() {
    local output

    output="$(bash --noprofile --norc -c '
        source "$1"
        COMP_WORDS=(app demo:) COMP_CWORD=1 COMP_LINE="app demo:" COMP_POINT=9 COMPREPLY=()
        _sf_app
        printf "suggestions=%s\n" "${#COMPREPLY[@]}"
    ' _ "$tmp/completion.bash" 2>&1)"

    case "$output" in
        *'requires the "bash-completion" package'*'suggestions=0'*) pass "[bash] missing bash-completion is reported" ;;
        *) fail "[bash] missing bash-completion is reported" "$output" ;;
    esac
}

# The command may be reached through an alias, which can carry arguments. The
# completion is requested on "--ans" so that the expectation does not depend on
# the word breaks of the shell.
test_alias() {
    local shell="$1" name="$2" definition="$3" actual
    local driver

    case "$shell" in
        bash) driver=(bash "$dir/drivers/bash.sh") ;;
        zsh) driver=(zsh "$dir/drivers/zsh.zsh") ;;
        fish) driver=(fish "$dir/drivers/fish.fish") ;;
    esac

    actual="$("${driver[@]}" "$tmp/completion.$shell" "${definition%%=*} --ans" "$definition" 2> "$tmp/case.err" | normalize)"

    if [ "$actual" = '--ansi' ]; then
        pass "[$shell] completion through $name"
    else
        fail "[$shell] completion through $name" "expected:" "--ansi" "actual:" "${actual:-<none>}" "$(cat "$tmp/case.err")"
    fi
}

# The command may be reached through a path starting with a tilde. Only zsh expands
# it: the bash script looks the command up without expanding the tilde.
test_tilde_path() {
    local home="$tmp/home" actual

    mkdir -p "$home/bin"
    ln -sf "$dir/app" "$home/bin/app"

    actual="$(HOME="$home" zsh "$dir/drivers/zsh.zsh" "$tmp/completion.zsh" '~/bin/app --ans' 2> "$tmp/case.err" | normalize)"

    if [ "$actual" = '--ansi' ]; then
        pass "[zsh] completion through a tilde path"
    else
        fail "[zsh] completion through a tilde path" "expected:" "--ansi" "actual:" "${actual:-<none>}" "$(cat "$tmp/case.err")"
    fi
}

# The completion must not run the code the user is typing. The payload is put in a
# word the cursor is not on: zsh expands the word being completed on its own, for
# every command, so that one says nothing about the completion script.
test_no_command_execution() {
    local shell="$1" driver payload name
    local marker="$tmp/pwned"

    case "$shell" in
        bash) driver=(bash "$dir/drivers/bash.sh") ;;
        zsh) driver=(zsh "$dir/drivers/zsh.zsh") ;;
        fish) driver=(fish "$dir/drivers/fish.fish") ;;
    esac

    # a payload must not contain a space: the zsh script re-splits the words, which
    # breaks it apart and neutralises it by accident, turning the case into a no-op
    for payload in "\$(>$marker)" "\`>$marker\`"; do
        name="[$shell] a command substitution on the line is not executed: $payload"
        rm -f "$marker"

        "${driver[@]}" "$tmp/completion.$shell" "app demo:hello $payload --format=" > /dev/null 2>&1

        if [ -e "$marker" ]; then
            fail "$name" "the completion executed the command substitution"
        else
            pass "$name"
        fi
    done

    rm -f "$marker"
}

for shell in $shells; do
    if ! command -v "$shell" > /dev/null; then
        skipped=$((skipped + 1))
        echo "SKIP [$shell] $shell is not installed"

        continue
    fi

    echo "--- $shell ($("$shell" --version | head -n1))"

    dump_script "$shell" || continue

    while IFS='|' read -r case_shells command_line expected; do
        case "$case_shells" in
            ''|'#'*) continue ;;
        esac

        if [ "$case_shells" != "all" ] && [[ ",$case_shells," != *",$shell,"* ]]; then
            continue
        fi

        run_case "$shell" "${command_line//%app%/$dir/app}" "$expected"
    done < "$dir/cases.txt"

    test_alias "$shell" 'an alias' 'sfa=app'
    test_alias "$shell" 'an alias with arguments' 'sfb=app --no-ansi'
    test_no_command_execution "$shell"

    if [ "$shell" = "bash" ]; then
        test_api_version
        test_missing_bash_completion
    fi

    if [ "$shell" = "zsh" ]; then
        test_tilde_path
    fi
done

echo
echo "$passed passed, $failed failed, $skipped skipped"

[ "$failed" -eq 0 ]
