#!/usr/bin/env zsh
# This file is double-licensed under GPLv3 and MIT (see LICENSE file)

local -A colors
autoload colors
colors 2>/dev/null

setopt extendedglob typesetsilent

usage() {
    print "ZSD INTERNAL SCRIPT"
    print "$fg[green]Usage:$reset_color zsd-transform [-h/--help] [-v/--verbose] [-q/--quiet] [-n/--noansi] $fg_bold[magenta]{file}$reset_color"
    print "The $fg_bold[magenta]file$reset_color will be converted into extracted data: functions, script body."
    print "Supported are Bash and Zsh script files."
    print
    print "$fg[green]Options:$reset_color"
    print -- "$fg[magenta]-h/--help$reset_color      Usage information"
    print -- "$fg[magenta]-v/--verbose$reset_color   More verbose operation-status output"
    print -- "$fg[magenta]-q/--quiet$reset_color     No status messages"
    print -- "$fg[magenta]-n/--noansi$reset_color    No colors in terminal output"
}

zsd-process-buffer() {
# This file is double-licensed under GPLv3 and MIT (see LICENSE file)

# Input:
# $1 - optional buffer to process (default is $BUFFER)
# $2 - optional parameter containing cursor (default is $CURSOR)
#
# Output:
# ZSD_PB_WORDS - split of "$1" into shell words; array
# ZSD_PB_WORDS_BEGINNINGS - indexes of first letters of corresponding words in ZSD_PB_WORDS
# ZSD_PB_SPACES - white spaces before corresponding words in ZSD_PB_WORDS
# ZSD_PB_SELECTED_WORD - index in ZSD_PB_WORDS pointing to word activated by cursor position
# ZSD_PB_LEFT - left part of active word
# ZSD_PB_RIGHT - right part of active word
#

emulate -LR zsh
setopt typesetsilent extendedglob noshortloops

local MBEGIN MEND MATCH mbegin mend match

local buf="${1:-$BUFFER}"
local cursor="${2:-$CURSOR}"

# All output variables are either overwritten or cleared
ZSD_PB_WORDS=( "${(Z+cn+)buf}" )
ZSD_PB_SPACES=( )
ZSD_PB_WORDS_BEGINNINGS=( )
ZSD_PB_SELECTED_WORD="-1"
ZSD_PB_LEFT=""
ZSD_PB_RIGHT=""

# (Z+n+) will return 1 element for buf that is empty or only whitespace
if [[ "$buf" = ( |$'\t')# ]]; then
    ZSD_PB_WORDS=( )
    integer nwords=0
else
    integer nwords="${#ZSD_PB_WORDS}"
fi

# Remove ZSD_PB_WORDS one by one, counting characters,
# computing beginning of each word, to find
# place to break the word into 2 halves (for
# complete_in_word option)

local i word wordlen tword
integer char_count=0

# (Z) handles spaces nicely, but we need them for the user
# Also compute words beginnings and the selected word
for (( i=1; i<=nwords; i++ )); do
    # Remove spurious space generated by Z-flag when
    # input is an unbound '$(' (happens with zsh < 5.1)
    # and also real spaces gathered by an unbound '$(',
    # to handle them in a way normal to this loop
    ZSD_PB_WORDS[i]="${ZSD_PB_WORDS[i]%% ##}"
    word="${ZSD_PB_WORDS[i]}"
    wordlen="${#word}"

    # In general, $buf can start with white spaces
    # We will not search for them, but instead for
    # leading character of current shell word,
    # negated. This is an ambition to completely
    # avoid character classes

    # Remove white spaces
    buf="${buf##(#m)[^$word[1]]#}"
    # Count them
    char_count=char_count+"${#MATCH}"
    # This is the beginning of current word
    ZSD_PB_WORDS_BEGINNINGS[i]=$(( char_count + 1 ))
    # Remember the spaces
    ZSD_PB_SPACES[i]="$MATCH"

    tword="${buf[1,wordlen]}"

    # Remove the word
    [[ "$tword" != "$word" && ${#word} != ${#tword} ]] && { echo "BUG in processing ${buf[1,wordlen]} vs. $word"; return 1; }
    buf="${buf[wordlen+1,-1]}"

    # Spaces point to previous shell word
    # Visual cursor right after spaces (-ge) -> not enough to select previous word (-gt required)
    [[ "$ZSD_PB_SELECTED_WORD" -eq "-1" && "$char_count" -gt "$cursor" ]] && ZSD_PB_SELECTED_WORD=$(( i-1 ))

    # Actual characters point to current shell word
    # Visual cursor right after letters (-ge) -> enough to select current word
    char_count=char_count+"$#word"
    [[ "$ZSD_PB_SELECTED_WORD" -eq "-1" && "$char_count" -ge "$cursor" ]] && ZSD_PB_SELECTED_WORD="$i"
done 

# What's left in $buf can be only white spaces
char_count=char_count+"$#buf"
ZSD_PB_SPACES[i]="$buf"

# Visual cursor right after spaces (-ge) -> enough to select last word
[[ "$ZSD_PB_SELECTED_WORD" -eq "-1" && "$char_count" -ge "$cursor" ]] && ZSD_PB_SELECTED_WORD=$(( i-1 ))

# Divide active word into two halves
integer diff=$(( cursor - ZSD_PB_WORDS_BEGINNINGS[ZSD_PB_SELECTED_WORD] + 1 ))
word="${ZSD_PB_WORDS[ZSD_PB_SELECTED_WORD]}"
ZSD_PB_LEFT="${word[1,diff]}"
ZSD_PB_RIGHT="${word[diff+1,-1]}"

# This function should be tested
return 0

# vim:ft=zsh
}

zsd-trim-indent() {
# vim:ft=zsh
# This file is double-licensed under GPLv3 and MIT (see LICENSE file)

local blob="$1"
local -a lines new_lines match mbegin mend
lines=( "${(@f)blob}" )

# Measure every line
local line spaces
integer indent=-1
for line in "${lines[@]}"; do
    if [[ "$line" = (#b)([[:space:]]#)* && "$line" != [[:space:]]# ]]; then
        spaces="${match[1]}"
        if [[ "${#spaces}" -lt "$indent" || "$indent" = "-1" ]]; then
            indent="${#spaces}"
        fi
    elif [[ "$line" = [[:space:]]# ]]; then
        :
    else
        print -u 2 "Bug in Zsh, pattern didn't match"
    fi
done

integer top_spaces=1
for line in "${lines[@]}"; do
    if [[ "$line" != [[:space:]]# ]]; then
        top_spaces=0
    fi
    if (( top_spaces == 0 )); then
        line[1,indent]=""
        new_lines+=( "$line" )
    fi
done

REPLY="${(F)new_lines}"
}

# vim:ft=zsh

typeset -gA TOKEN_TYPES

TOKEN_TYPES=(

  # Precommand

  'builtin'     1
  'command'     1
  'exec'        1
  'nocorrect'   1
  'noglob'      1
  'pkexec'      1 # immune to #121 because it's usually not passed --option flags

  # Control flow
  # Tokens that at "command position" are followed by a command position.
  # All of these are reserved words.

  $'\x7b'   2 # block
  $'\x28'   2 # subshell
  '()'      2 # anonymous function
  'while'   2
  'until'   2
  'if'      2
  'then'    2
  'elif'    2
  'else'    2
  'do'      2
  'time'    2
  'coproc'  2
  '!'       2 # reserved word; unrelated to $histchars[1]

  # Command separators

  '|'   3
  '||'  3
  ';'   3
  '&'   3
  '&&'  3
  '|&'  3
  '&!'  3
  '&|'  3

  # ### 'case' syntax, but followed by a pattern, not by a command
  # ';;' ';&' ';|'
)

# vim:ft=zsh
# This file is double-licensed under GPLv3 and MIT (see LICENSE file)

### Options ###

local OPT_HELP OPT_VERBOSE OPT_QUIET OPT_NOANSI
local -A opthash
zparseopts -E -D -A opthash h -help v -verbose q -quiet n -noansi || { echo "Improper options given, see help (-h/--help)"; return 1; }

(( ${+opthash[-h]} + ${+opthash[--help]} ))     && OPT_HELP="-h"
(( ${+opthash[-v]} + ${+opthash[--verbose]} ))  && OPT_VERBOSE="-v"
(( ${+opthash[-q]} + ${+opthash[--quiet]} ))    && OPT_QUIET="-q"
(( ${+opthash[-n]} + ${+opthash[--noansi]} ))   && OPT_NOANSI="-n"

[[ -n "$OPT_NOANSI" ]] && { colors=(); fg=(); bg=(); fg_bold=(); bg_bold=(); reset_color=""; }

[[ -z "$OPT_QUIET" ]] && print "$fg[cyan]== zsd-transform starting for file \`$fg_bold[yellow]$1$fg_no_bold[cyan]' (1-st pass)$reset_color"

if [[ -n "$OPT_HELP" ]]; then
    usage
    return 0
fi

if [[ "$#" -le 0 || "$*" = [[:space:]]## ]]; then
    print "Argument needed, see help (-h/--help)"
    return 1
fi

if [[ ! -f "$1" ]]; then
    [[ -z "$OPT_QUIET" || -n "$OPT_VERBOSE" ]] && print "$fg[magenta]File \`$1' doesn't exist, skipping it (see help, with -h/--help option)$reset_color"
    return 1
fi

if [[ ! -r "$1" ]]; then
    [[ -z "$OPT_QUIET" || -n "$OPT_VERBOSE" ]] && print "$fg[magenta]File \`$1' is unreadable, skipping it (see help, with -h/--help option)$reset_color"
    return 1
fi

### Code ###

local name="${1:t}"
local doc="$(<$name)" token prev_token="" spaces prev_spaces="" next_token next_spaces

local preamble="" fun_name=""
local -A funs auto_funs
local -a arr

# Function extraction
integer at_command=1 in_autoload=0
integer next_fun=0 cur_fun=0 prev_fun=0
integer depth=0 prev_depth=0 fun_depth=-1 anon_depth=-1 descentff=0 descentfa=0

# Nested functions tracking
integer nested_fun=0 next_nested_fun=0 prev_nested_fun=0
local -a fun_stack_depths

line_count()
{
    local -a list
    list=( "${(@f)1}" )
    local count=${#list}
    [[ "$1" = *$'\n' ]] && (( -- count ))
    print -r -- "${(l:3:: :)count}"
}


zsd-process-buffer "$doc" 1
integer i size="${#ZSD_PB_WORDS}"

for (( i=1; i<=size; ++ i )); do
    token="${ZSD_PB_WORDS[i]}"
    spaces="${ZSD_PB_SPACES[i]}"
    next_token="${ZSD_PB_WORDS[i+1]}"
    next_spaces="${ZSD_PB_SPACES[i+1]}"
    cur_fun=0 prev_fun=0 descentff=0 descentfa=0
    nested_fun=0 prev_nested_fun=0

    (( next_fun )) && { next_fun=0 cur_fun=1 prev_fun=0 anon_depth=-1; }
    (( next_nested_fun )) && { next_nested_fun=0 nested_fun=1 prev_nested_fun=0; }

    # Explicit future function
    if [[ "$token" = "function" && ( "$fun_depth" -lt 0 ) && ( $anon_depth -lt 0 ) ]]; then
        next_fun=1 cur_fun=0 prev_fun=0 anon_depth=-1
    # Detect function if not already in function
    elif [[ "$token" = "()" && ( "$fun_depth" -lt 0 ) && ( $anon_depth -lt 0 ) ]]; then
        if [[ "$spaces" = *$'\n'* || -z "$prev_token" || "${TOKEN_TYPES[$prev_token]}" = [123] ]]; then
            next_fun=0 cur_fun=0 prev_fun=0 anon_depth=$depth
        else
            next_fun=0 cur_fun=0 prev_fun=1 anon_depth=-1
        fi
    elif [[ "$token" = "function" ]]; then
        next_nested_fun=1 nested_fun=0 prev_nested_fun=0
    elif [[ "$token" = "()" && "$nested_fun" -eq 0 && "$depth" -gt "$fun_stack_depths[-1]" ]]; then
        if [[ "$spaces" != *$'\n'* && -n "$prev_token" && "${TOKEN_TYPES[$prev_token]}" != [123] ]]; then
            next_nested_fun=0 nested_fun=0 prev_nested_fun=1
        fi
    elif [[ "$token" = "{" ]]; then
        (( ++ depth ))
    elif [[ "$token" = "}" ]]; then
        (( -- depth ))
    fi

    if (( cur_fun )); then
        fun_name="$token"
        fun_depth="$depth"
        fun_stack_depths+=( "$depth" )
    elif (( prev_fun )); then
        fun_name="$prev_token"
        fun_depth="$depth"
        fun_stack_depths+=( "$depth" )
    fi

    if (( nested_fun + prev_nested_fun )); then
        fun_stack_depths+=( "$depth" )
    fi

    # Ascent to function - skip '{'
    if (( fun_depth >= 0 && depth == (fun_depth + 1) )) && [[ "$token" = "{" ]]; then
        :
    # In-function
    elif (( fun_depth >= 0 && depth > fun_depth )); then
        if [[ "$token" != [[:space:]]#\#* ]]; then
            funs[$fun_name]+="${spaces}${token}"
        fi
        if (( ${#fun_stack_depths} > 0 && depth == fun_stack_depths[-1] && prev_depth == fun_stack_depths[-1] + 1 )); then
            fun_stack_depths[-1]=()
        fi
    elif (( anon_depth >= 0 && depth > anon_depth )); then
        if (( ${#fun_stack_depths} > 0 && depth == fun_stack_depths[-1] && prev_depth == fun_stack_depths[-1] + 1 )); then
            fun_stack_depths[-1]=()
        fi
    # Descent from function - skip '}'
    elif (( fun_depth >= 0 && depth == fun_depth && prev_depth == fun_depth + 1 )); then
        descentff=1
    # Descent from anon
    elif (( anon_depth >= 0 && depth == anon_depth && prev_depth == anon_depth + 1 )); then
        descentfa=1
    fi

    # Anon function in top-level
    if (( anon_depth >= 0 && fun_depth < 0 )); then
        [[ "$token" != [[:space:]]#\#* ]] && preamble+="${spaces}${token}"
    fi

    # Check for introduction of autoload function
    if [[ "$spaces" = *$'\n'* || -z "$prev_token" || "${TOKEN_TYPES[$prev_token]}" = [123] ]]; then
        at_command=1
        in_autoload=0
    fi
    if (( at_command )); then
        at_command=0
        if [[ "$cur_fun" -eq 0 && "$next_token" != "()" && "$fun_stack_depths[-1]" -le "0" ]]; then
            if [[ "$token" = "autoload" ]]; then
                in_autoload=1
            fi
        fi
    elif (( in_autoload )); then
        if [[ "$token" = "--" ]]; then
            in_autoload=2
        elif [[ "$token" != -* || "$in_autoload" -eq "2" ]]; then
            arr=( $^fpath/$token(N) )
            if [[ "${#arr}" -ge 1 ]]; then
                auto_funs[$token]="$(<${arr[1]})"
            #else
            #    auto_funs[$token]="Autoload-function source not found"
            fi
        fi
    fi

    # Late disable of anonymous function
    if (( descentfa )); then
        anon_depth=-1
    # Late disable of normal function
    elif (( descentff )); then
        fun_name=""
        fun_depth=-1
        fun_stack_depths[-1]=()
    # No-function text gathering
    elif (( next_fun == 0 && cur_fun == 0 && prev_fun == 0 && anon_depth < 0 && fun_depth < 0 )); then
        if [[ "$next_token" != "()" || "$next_spaces" = *$'\n'* || "${TOKEN_TYPES[$token]}" = 3 ]]; then
            [[ "$token" != [[:space:]]#\#* ]] && preamble+="${spaces}${token}"
        fi
    fi

    prev_depth="$depth"
    prev_token="$token"
    prev_spaces="$spaces"
done

### OUTPUT: FUNCTIONS

command mkdir -p zsdoc/data/functions/"$name"

for fun_name in "${(ko@)funs}"; do
    [[ -z "$OPT_QUIET" ]] && print "Extracted `line_count ${funs[$fun_name]}`-line function \`$fg[green]${fun_name}$reset_color'..."
    {
        zsd-trim-indent "${funs[$fun_name]}"
        print -r -- "$REPLY"
    } >| "zsdoc/data/functions/$name/$fun_name"
done

### OUTPUT: EXTENDED BODY

command mkdir -p zsdoc/data/extended
command rm -f zsdoc/data/extended/"$name"
echo "$doc" >| zsdoc/data/extended/"$name"

### OUTPUT: AUTOLOAD FUNCTIONS AND EXTENDED BODY

command mkdir -p zsdoc/data/autoload/"$name"

for fun_name in "${(ko@)auto_funs}"; do
    [[ -z "$OPT_QUIET" ]] && print "Extracted `line_count ${auto_funs[$fun_name]}`-line $fg[red]AUTOLOAD$reset_color function \`$fg[green]${fun_name}$reset_color'..."
    # Autoload function
    zsd-trim-indent "${auto_funs[$fun_name]}"
    print -r -- "$REPLY" >| "zsdoc/data/autoload/$name/$fun_name"

    # Appendix of the function to extended body
    print >>| "zsdoc/data/extended/$name"
    print -r -- "${fun_name}() {" >>| "zsdoc/data/extended/$name"
    print -r -- "$REPLY" >>| "zsdoc/data/extended/$name"
    print -r -- "}" >>| "zsdoc/data/extended/$name"
done

### OUTPUT: SCRIPT BODIES

command mkdir -p zsdoc/data/bodies

print -r -- "$preamble" >| zsdoc/data/bodies/${name}
[[ -z "$OPT_QUIET" ]] && print "$fg[yellow]Generated body of script \`$name\'$reset_color (`line_count $preamble` lines)"

return 0
