diff --git a/.shellspec b/.shellspec new file mode 100644 index 0000000..d567ecf --- /dev/null +++ b/.shellspec @@ -0,0 +1,12 @@ +--require spec_helper + +## Default kcov (coverage) options +# --kcov-options "--include-path=. --path-strip-level=1" +# --kcov-options "--include-pattern=.sh" +# --kcov-options "--exclude-pattern=/.shellspec,/spec/,/coverage/,/report/" + +## Example: Include script "myprog" with no extension +# --kcov-options "--include-pattern=.sh,myprog" + +## Example: Only specified files/directories +# --kcov-options "--include-pattern=myprog,/lib/" diff --git a/README.md b/README.md index 156e8d8..3164ebc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ -# altshfmt -AltSH (alternative shell script) formatter +# AltSH formatter (experimental) + +The **`altshfmt`** is an experimental tool for formatting AltSH (alternative shell script) with extended syntax that cannot be formatted correctly by [shfmt][shfmt]. This is implemented as a wrapper for `shfmt`. + +**Currently supported syntax**: [ShellSpec][shellspec], [shpec](shpec) + +[altshfmt-releases]: https://github.com/shellspec/altshfmt/releases +[shfmt]: https://github.com/mvdan/sh#shfmt +[shfmt-releases]: https://github.com/mvdan/sh/releases +[busybox-w32]: https://frippery.org/busybox +[shellspec]: https://github.com/shellspec/shellspec +[shpec]: https://github.com/rylnd/shpec +[shell-format]: https://marketplace.visualstudio.com/items?itemName=foxundermoon.shell-format + +## **Use it at your own risk** + +* It has not been enough tested. Please make sure to save and version control your files properly so that they can be restored even if they are corrupted. +* It depends on the behavior of `shfmt`, so it may not work depending on the combination of versions. +* Incompatible changes may be made. + +## Requirements + +* [shfmt][shfmt-releases] v3.2.1 +* Basic commands (`awk`, `cat`, `cmp`, `cp`, `diff`, `grep`, `ls`, `mktemp`, `rm`, `which`) + +## How to use + +Since `altshfmt` is implemented as compatible as possible with `shfmt`, You can use it instead of the `shfmt` command. + +### Linux or macOS + +* Download `altshfmt` archive from [here][altshfmt-releases] and extract it to a directory, or use `git clone`. +* Download `shfmt` binary from [here][shfmt-releases] and save it in the directory where `altshfmt` is located. + +### Windows + +WSL (Windows 10, version 1803 and later) or [busybox-w32][busybox-w32] is required. To run it from Windows (instead of from WSL), run `altshfmt.bat` instead of `altshfmt`. + +#### WSL + +* Download `altshfmt` archive from [here][altshfmt-releases] and extract it to a directory, or use `git clone`. +* Download `shfmt` binary from [here][shfmt-releases] and save it in the directory where `altshfmt` is located. +* The `shfmt` binary can be used for Windows or Linux. + +#### busybox-w32 + +* Download `altshfmt` archive from [here][altshfmt-releases] and extract it to a directory, or use `git clone`. +* Download `shfmt` binary from [here][shfmt-releases] and save it in the directory where `altshfmt` is located. +* Download `busybox` binary from [here][busybox-w32], rename it to `bash.exe` and save it in the directory where `altshfmt` is located. + +### Using with VSCode + +Use the [shell-format][shell-format] extension. + +* Install the shell-format extension and change `shellformat.path` in `settings.json` to the path to the `altshfmt` (or `altshfmt.bat` for windows). + +## About syntax detection + +The syntax is **automatically determined** from the beginning and ending pairs of the block of DSL used in the shell script. + +### shell directive + +If you have problems with the automatic detection, you can also use the shell directive. The shell directive is a comment that begins with "`shell:`". + +Example + +```sh +#!/bin/sh +# shell: sh altsh=shellspec + +Describe + ... +End +``` + +Usage: `shell: [] [altsh=]` + +* `shell`: `sh`, `bash`, `mksh` + * Unspecified: same as `auto` + * It is treated as the value of the `-ln` flag of the `shfmt` command + * `auto`: do not specify the `-ln` flag + * `sh`: implies `-ln posix` + * `bash`: implies `-ln bash` + * `mksh`: implies `-ln mksh` + * Others: implies `-ln bash` +* `syntax`: `shellspec`, `shpec` + * Unspecified: Treat it as a pure shell script + * Others: Treat it as a pure shell script + +## Limitation + +`altshfmt` is several times slower than `shfmt`. diff --git a/altshfmt b/altshfmt new file mode 100755 index 0000000..8f650e1 --- /dev/null +++ b/altshfmt @@ -0,0 +1,365 @@ +#!/bin/sh + +set -eu + +VERSION=0.1.0 URL="https://github.com/shellspec/altshfmt" +TMPBASE='' COLOR='' SHFMT='' +list='' diff='' write='' passthrough='' filename='' help='' hasflag='' + +SHELLSPEC_DSL_EXAMPLE_GROUP='([fx]?(ExampleGroup|Describe|Context))' +SHELLSPEC_DSL_EXAMPLE='([fx]?(Example|Specify|It))' +SHELLSPEC_DSL_DATA='(Data(:raw|:expand|))' +SHELLSPEC_DSL_ONLINE_DATA='(Data[[:space:]]+[[:alnum:]_<'\''"])' +SHELLSPEC_DSL_PARAMETERS='(Parameters(:block|:dynamic|:matrix|:value|))' +SHELLSPEC_DSL_MOCK='(Mock)' +SHELLSPEC_DSL_BEGIN="$SHELLSPEC_DSL_EXAMPLE_GROUP|$SHELLSPEC_DSL_EXAMPLE|$SHELLSPEC_DSL_DATA|$SHELLSPEC_DSL_PARAMETERS|$SHELLSPEC_DSL_MOCK" +SHELLSPEC_DSL_END='(End)' + +SHPEC_DSL_BEGIN='(describe|it)' +SHPEC_DSL_END='(end)' + +readlinkf() { + [ "${1:-}" ] || return 1 + max_symlinks=40 + CDPATH='' # to avoid changing to an unexpected directory + + target=$1 + [ -e "${target%/}" ] || target=${1%"${1##*[!/]}"} # trim trailing slashes + [ -d "${target:-/}" ] && target="$target/" + + cd -P . 2>/dev/null || return 1 + while [ "$max_symlinks" -ge 0 ] && max_symlinks=$((max_symlinks - 1)); do + if [ ! "$target" = "${target%/*}" ]; then + case $target in + /*) cd -P "${target%/*}/" 2>/dev/null || break ;; + *) cd -P "./${target%/*}" 2>/dev/null || break ;; + esac + target=${target##*/} + fi + + if [ ! -L "$target" ]; then + target="${PWD%/}${target:+/}${target}" + printf '%s\n' "${target:-/}" + return 0 + fi + + # `ls -dl` format: "%s %u %s %s %u %s %s -> %s\n", + # , , , , + # , , , + # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/ls.html + link=$(ls -dl -- "$target" 2>/dev/null) || break + target=${link#*" $target -> "} + done + return 1 +} + +self=$(readlinkf "$0") +basedir=${self%/*} +if [ "${ALTSHFMT_WINCWD:-}" ]; then + cd -- "${ALTSHFMT_WINCWD:-}" +fi + +is_wsl() { + IFS= read -r osrelease /dev/null; then + if [ -e "${basedir%/}/shfmt.exe" ]; then + SHFMT="${basedir%/}/shfmt.exe" + fi + fi + [ "$SHFMT" ] && return 0 + SHFMT=$(which shfmt) && return 0 + echo "shfmt: command not found" >&2 + exit 1 +} +detect_shfmt + +if [ "${FORCE_COLOR:-}" = "true" ]; then + COLOR=1 +elif [ "${TERM:-}" = "dumb" ]; then + : +elif [ -t 1 ]; then + COLOR=1 +fi + +shfmt() { + "$SHFMT" "$@" +} + +shell_directive() { + case $1 in (*[\#\$\&\(\)\*\;\<\>\?\[\\\]\`\{\|\}]*) + echo 'The following characters are not allowed' \ + 'in the shell directive: #$&()*;<>?[\]`{|}.' >&2 + exit 1 + esac + if ! ( eval "set -- $1" ) 2>/dev/null; then + printf '%s' "Invalid the shell directive: " >&2 + fi + eval "set -- $1" + shell=$1 altsh='' + shift + while [ $# -gt 0 ]; do + case $1 in + altsh=*) altsh=${1#*=} ;; + *) ;; # Unknown attributes MUST be ignored + esac + shift + done +} + +detect_syntax() { + awk ' + BEGIN { buf = ""; syntax = "auto"; directive = ""; altsh = ""; detect = "" } + NR == 1 && /^#!/ { + syntax = $1 + sub(/.*\//, "", syntax) + if (syntax == "env") syntax = $2 + } + { buf = buf $0 "\n" } + /^#[[:space:]]*shell:.*/ { + sub(/.*shell:[[:space:]]*/, "") + if (match($1, /.*=.*/)) { + directive = syntax " " $0 + } else { + directive = $0 + } + exit + } + /^[[:space:]]*('"$SHELLSPEC_DSL_BEGIN"')([[:space:]].*|$)/ { + detect = "shellspec" + } + /^[[:space:]]*('"$SHELLSPEC_DSL_END"')([[:space:]].*|$)/ { + if (detect == "shellspec") { altsh = detect; exit } + } + /^[[:space:]]*('"$SHPEC_DSL_BEGIN"')([[:space:]].*|$)/ { + detect = "shpec" + } + /^[[:space:]]*('"$SHPEC_DSL_END"')([[:space:]].*|$)/ { + if (detect == "shpec") { altsh = detect; exit } + } + END { + if (!directive) directive = syntax + if (altsh) directive = directive " altsh=" altsh + # print directive > "/dev/tty" + print directive + printf "%s", buf + while(getline > 0) print $0 + } + ' +} + +altshfmt() { + detect_syntax | { + read -r line + shell_directive "$line" + case $shell in + auto) ;; + sh) set -- -ln posix "$@" ;; + bash | mksh) set -- -ln "$shell" "$@" ;; + *) set -- -ln bash "$@" ;; + esac + # shellcheck disable=SC2016 + case $altsh in + shellspec) + code=' + /^[[:space:]]*('"$SHELLSPEC_DSL_END"')([[:space:]].*|$)/ { + print "} # @@altsh@@end" + } + { print } + /^[[:space:]]*('"$SHELLSPEC_DSL_ONLINE_DATA"').*/ { + next + } + /^[[:space:]]*('"$SHELLSPEC_DSL_BEGIN"')([[:space:]].*|$)/ { + while(match($0, /.*\\$/)) { getline; print } + print "{ # @@altsh@@begin" + } + ' + ;; + shpec) + code=' + /^[[:space:]]*('"$SHPEC_DSL_END"')([[:space:]].*|$)/ { + print "} # @@altsh@@end" + } + { print } + /^[[:space:]]*('"$SHPEC_DSL_BEGIN"')([[:space:]].*|$)/ { + while(match($0, /.*\\$/)) { getline; print } + print "{ # @@altsh@@begin" + } + ' + ;; + *) code="" ;; + esac + if [ "$code" ]; then + awk "$code" | shfmt "$@" | grep -v "# @@altsh@@" + else + shfmt "$@" + fi + } +} + +optparse() { + OPTARG='' OPTIND=1 i=$(($# + 1)) + while [ $# -gt 0 ]; do + n=1 + case $1 in (-*) hasflag=1; esac + case $1 in + -[!-]* | --[!-]*) + case ${1#"${1%%[!-]*}"} in + l) list=1 && shift && n=0 ;; + w) write=1 && shift && n=0 ;; + d) diff=1 && shift && n=0 ;; + filename) [ $# -gt 1 ] && filename=$2 && n=2 ;; + i | ln) [ $# -gt 1 ] && n=2 ;; + f | tojson) passthrough=1 ;; + h | help) help=1 ;; + esac + ;; + *) break ;; + esac + while [ "$n" -gt 0 ] && n=$((n - 1)); do + OPTARG="$OPTARG \"\${$((i - $#))}\"" + OPTIND=$((OPTIND + 1)) + shift + done + done + while [ $# -gt 0 ]; do + OPTARG="$OPTARG \"\${$((i - $#))}\"" + shift + done +} + +pretty() { + IFS= read -r line + IFS= read -r line + printf "%s\n" "--- ${1}.orig" + printf "%s\n" "+++ $1" + if [ "$COLOR" ]; then + range="\033[36m" deleted="\033[31m" added="\033[32m" reset="\033[m" + else + range="" deleted="" added="" reset="" + fi + while IFS= read -r line; do + case $line in + @@*) printf "${range}%s${reset}\n" "$line" ;; + -*) printf "${deleted}%s${reset}\n" "$line" ;; + +*) printf "${added}%s${reset}\n" "$line" ;; + *) printf "%s\n" "$line" ;; + esac + done + echo +} + +altshfmt_find() { + if [ -d "$1" ]; then + case $SHFMT in + *.exe) shfmt -f "$1" | sed 's|\\|/|g' ;; + *) shfmt -f "$1" ;; + esac + else + printf '%s\n' "$1" + fi +} + +check_syntax() { + shfmt < "$1" >/dev/null +} + +altshfmt_file() { + origfile=$1 && filename=$2 && shift 2 + newfile="$TMPBASE/new" changed='' + if [ -f "$origfile" ]; then + # The reason for doing the syntax check first is to output the correct line number. + check_syntax "$origfile" + altshfmt "$@" < "$origfile" > "$newfile" || return $? + else + altshfmt "$@" "$origfile" < /dev/null > "$newfile" || return $? + fi + + if [ "$list" ] || [ "$diff" ]; then + cmp -s "$origfile" "$newfile" || changed=1 + fi + + if [ "$list" ] && [ "$changed" ]; then + printf '%s\n' "$filename" + fi + + if [ "$diff" ] && [ "$changed" ]; then + diff -u "$origfile" "$newfile" | pretty "$filename" + fi + + if [ "$write" ]; then + if [ -s "$origfile" ] && [ ! -s "$newfile" ]; then + echo "Something's wrong (the output is empty)." >&2 + exit 1 + fi + cp "$newfile" "$origfile" + fi + + if [ "$diff" ] && [ "$changed" ]; then + return 1 + fi + + if [ ! "${list}${diff}${write}" ]; then + cat "$newfile" + fi +} + +altshfmt_files() { + cmd="" i=1 + while [ $i -lt "$1" ] && i=$((i + 1)); do + cmd="$cmd \"\${$i}\"" + done + ret=0 + while [ $i -lt $# ] && i=$((i + 1)); do + eval "origfile=\"\${$i}\"" + altshfmt_find "$origfile" | ( + ret=0 + while IFS= read -r origfile; do + altshfmt_files_ "$@" || ret=$? + done + exit "$ret" + ) || ret=$? + done + return "$ret" +} +altshfmt_files_() { + eval "set -- $cmd" + altshfmt_file "$origfile" "$origfile" "$@" +} + +cleanup() { + if [ "$TMPBASE" ] && [ -d "$TMPBASE" ]; then + rm -rf "$TMPBASE" + fi +} + +${__SOURCED__:+return} + +trap cleanup INT TERM EXIT +TMPBASE=$(mktemp -d) + +optparse "$@" +eval "set -- $OPTARG" + +if [ "$passthrough" ]; then + shfmt "$@" +elif [ "$OPTIND" -le $# ]; then + altshfmt_files "$OPTIND" "$@" +elif [ -t 0 ] && [ "$hasflag" ]; then + shfmt "$@" + if [ "$help" ]; then + echo "" + echo "Integrated with altshfmt v$VERSION." + echo "For more information, see $URL." + fi +else + origfile="$TMPBASE/orig" + cat > "$origfile" + altshfmt_file "$origfile" "${filename:-""}" "$@" +fi diff --git a/altshfmt.bat b/altshfmt.bat new file mode 100755 index 0000000..a2ba1fe --- /dev/null +++ b/altshfmt.bat @@ -0,0 +1,6 @@ +@echo off +setlocal +set ALTSHFMT_WINCWD=%CD% +set WSLENV=ALTSHFMT_WINCWD/pu:%WSLENV% +cd "%~dp0" +bash altshfmt %* diff --git a/spec/altshfmt_spec.sh b/spec/altshfmt_spec.sh new file mode 100644 index 0000000..d7c7985 --- /dev/null +++ b/spec/altshfmt_spec.sh @@ -0,0 +1,182 @@ +Describe "altshfmt" + Include ./altshfmt + + Describe "detect_syntax()" + Context "when shell directive defined" + Context "and not specified shebang" + Data + #|# shell: altsh=shellspec + #|script + End + + Specify "The default shell is auto" + When call detect_syntax + The line 1 should eq "auto altsh=shellspec" + The lines of output should eq 3 + End + End + + Context "and specified shebang and shell" + Data + #|#!/bin/sh + #|# shell: bash altsh=shellspec + #|script + End + + Specify "The directive take precedence over shebang" + When call detect_syntax + The line 1 should eq "bash altsh=shellspec" + The lines of output should eq 4 + End + End + + Context "and specified shebang only" + Data + #|#!/usr/bin/bash aa + #|# shell: altsh=shellspec + #|script + End + + Specify "Use shebang as the default shell" + When call detect_syntax + The line 1 should eq "bash altsh=shellspec" + The lines of output should eq 4 + End + + Describe "using env" + Data + #|#!/usr/bin/env bash arg + #|# shell: altsh=shellspec + #|script + End + + Specify "Use shebang as the default shell" + When call detect_syntax + The line 1 should eq "bash altsh=shellspec" + The lines of output should eq 4 + End + End + End + End + + Context "when shell directive not defined" + Context "and not specified shebang" + Data + #|script + End + + Specify "The default shell is auto" + When call detect_syntax + The line 1 should eq "auto" + The lines of output should eq 2 + End + End + + Context "and specified shebang" + Data + #|#!/usr/bin/env bash arg + #|script + End + + Specify "Use shebang as the default shell" + When call detect_syntax + The line 1 should eq "bash" + The lines of output should eq 3 + End + End + + Context "if shellspec DSL is found" + Data + #|#!/usr/bin/env bash arg + #|script + #|Describe + #|End + End + + Specify "The altsh is shellspec" + When call detect_syntax + The line 1 should eq "bash altsh=shellspec" + The lines of output should eq 5 + End + End + End + End + + Describe "optparse()" + _optparse() { + optparse "$@" + eval "set -- $OPTARG" + echo "$OPTIND:" "$@" + } + + It "supports the -l flag" + When call _optparse -l -x arg1 arg2 + The variable list should eq 1 + The output should eq "2: -x arg1 arg2" + End + + It "supports the -w flag" + When call _optparse -w -x arg1 arg2 + The variable write should eq 1 + The output should eq "2: -x arg1 arg2" + End + + It "supports the -d flag" + When call _optparse -d -x arg1 arg2 + The variable diff should eq 1 + The output should eq "2: -x arg1 arg2" + End + + It "supports the -filename flag" + When call _optparse -filename "path" -x arg1 arg2 + The variable filename should eq "path" + The output should eq "4: -filename path -x arg1 arg2" + End + + It "supports the -i flag" + When call _optparse -i 2 -x arg1 arg2 + The output should eq "4: -i 2 -x arg1 arg2" + End + + It "supports the -ln flag" + When call _optparse -ln posix -x arg1 arg2 + The output should eq "4: -ln posix -x arg1 arg2" + End + + It "supports the -f flag" + When call _optparse -f -x arg1 arg2 + The variable passthrough should eq 1 + The output should eq "3: -f -x arg1 arg2" + End + + It "supports the -tojson flag" + When call _optparse -tojson -x arg1 arg2 + The variable passthrough should eq 1 + The output should eq "3: -tojson -x arg1 arg2" + End + + It "supports unknown flags" + When call _optparse -unknown -x arg1 arg2 + The status should be success + The output should eq "3: -unknown -x arg1 arg2" + End + + It "determines that there are paths" + When call _optparse -p file -x arg1 arg2 + The status should be success + The output should eq "2: -p file -x arg1 arg2" + End + + It "can handle flags starting with --" + When call _optparse --l -x arg1 arg2 + The variable list should eq 1 + The output should eq "2: -x arg1 arg2" + End + + It "does not treat after PATH as a flag." + When call _optparse -l -l arg1 arg2 -l + The variable list should eq 1 + The output should eq "1: arg1 arg2 -l" + End + End +End diff --git a/spec/spec_helper.sh b/spec/spec_helper.sh new file mode 100644 index 0000000..93f1908 --- /dev/null +++ b/spec/spec_helper.sh @@ -0,0 +1,24 @@ +# shellcheck shell=sh + +# Defining variables and functions here will affect all specfiles. +# Change shell options inside a function may cause different behavior, +# so it is better to set them here. +# set -eu + +# This callback function will be invoked only once before loading specfiles. +spec_helper_precheck() { + # Available functions: info, warn, error, abort, setenv, unsetenv + # Available variables: VERSION, SHELL_TYPE, SHELL_VERSION + : minimum_version "0.28.1" +} + +# This callback function will be invoked after a specfile has been loaded. +spec_helper_loaded() { + : +} + +# This callback function will be invoked after core modules has been loaded. +spec_helper_configure() { + # Available functions: import, before_each, after_each, before_all, after_all + : import 'support/custom_matcher' +}