The --chain-lint option detects broken &&-chains by forcing the test to
exit early (as the very first step) with a sentinel value. If that
sentinel is the test's overall exit code, then the &&-chain is intact;
if not, then the chain is broken. Unfortunately, this detection does not
extend to &&-chains within subshells even when the subshell itself is
properly linked into the outer &&-chain.

Address this shortcoming by eliminating the subshell during the
"linting" phase and incorporating its body directly into the surrounding
&&-chain. To keep this transformation cheap, no attempt is made at
properly parsing shell code. Instead, the manipulations are purely
textual. For example:

    statement1 &&
    (
        statement2 &&
        statement3
    ) &&
    statement4

is transformed to:

    statement1 &&
        statement2 &&
        statement3 &&
    statement4

Notice how "statement3" gains the "&&" which dwelt originally on the
closing ") &&" line. Since this manipulation is purely textual (in fact,
line-by-line), special care is taken to ensure that the "&&" is moved to
the final _statement_ before the closing ")", not necessarily the final
_line_ of text within the subshell. For example, with a here-doc, the
"&&" needs to be added to the opening "<<EOF" line, not to the "EOF"
line which closes it.

In addition to modern subshell formatting shown above, old-style
formatting is also recognized:

    statement1 &&
    (statement2 &&
     statement3) &&
    statement4

Heuristics are employed to properly identify the extent of a subshell
formatted in the old-style since a number of legitimate constructs may
superficially appear to close the subshell even though they don't. For
instance, the ")" in "x=$(command) &&" and "case $x in *)" is specially
recognized to avoid being falsely considered the end of a subshell.

Due to the complexities of line-by-line detection (and limitations of
the tool, 'sed'), only subshells one level deep are handled, as well as
one-liner subshells one level below that. Subshells deeper than that or
multi-line subshells at level two are passed through as-is, thus
&&-chains in their bodies are not checked.

Signed-off-by: Eric Sunshine <sunsh...@sunshineco.com>
---
 t/test-lib.sh | 245 +++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 244 insertions(+), 1 deletion(-)

diff --git a/t/test-lib.sh b/t/test-lib.sh
index 28315706be..ade5066fff 100644
--- a/t/test-lib.sh
+++ b/t/test-lib.sh
@@ -664,6 +664,248 @@ test_eval_ () {
        return $test_eval_ret_
 }
 
+test_subshell_sed_='
+# incomplete line -- slurp up next line
+/[^\\]\\$/ {
+      N
+      s/\\\n//
+}
+
+# here-doc -- swallow it to avoid false hits within its body (but keep the
+# command to which it was attached)
+/<<[   ]*[-\\]*EOF[    ]*&&[   ]*$/ {
+       s/<<[   ]*[-\\]*EOF//
+       h
+       :hereslurp
+       N
+       s/.*\n//
+       /^[     ]*EOF[  ]*$/!bhereslurp
+       x
+       }
+
+# one-liner "(... || git ...)" or "(... || test ...)" -- short-hand for
+# "if ... then : else ...", so leave untouched; contrast with "(... || true)"
+# which ought to be replaced with "test_might_fail ..." to avoid breaking
+# &&-chain
+/^[    ]*(..*||[       ]*git[  ]..*)[  ]*&&[   ]*$/b
+/^[    ]*(..*||[       ]*git[  ]..*)[  ]*$/b
+/^[    ]*(..*||[       ]*test..*)[     ]*&&[   ]*$/b
+/^[    ]*(..*||[       ]*test..*)[     ]*$/b
+
+# one-liner "(... &) &&" backgrounder -- needs to remain in subshell to avoid
+# becoming syntactically invalid "... & &&"
+/^[    ]*(..*&[        ]*)[    ]*&&[   ]*$/b
+
+# one-liner "(...) &&" -- strip "(" and ")"
+/^[    ]*(..*)[        ]*&&[   ]*$/ {
+       s/(//
+       s/)[    ]*&&[   ]*$/ \&\&/
+       b
+}
+
+# same as above but without trailing "&&"
+/^[    ]*(..*)[        ]*$/ {
+       s/(//
+       s/)[    ]*$//
+       b
+}
+
+# one-liner "(...) >x" (or "2>x" or "<x" or "|x" or "&" -- strip "(" and ")"
+/^[    ]*(..*)[        ]*[0-9]*[<>|&]/ {
+       s/(//
+       s/)[    ]*\([0-9]*[<>|&]\)/\1/
+       b
+}
+
+# multi-line "(..." -- strip "(" and pass-thru enclosed lines until ")"
+/^[    ]*(/ {
+       # strip "(" and stash for later printing
+       s/(//
+       h
+
+       :discard
+       N
+       s/.*\n//
+
+       # loop: slurp enclosed lines
+       :slurp
+       # end-of-file
+       $beof
+       # incomplete line
+       /[^\\]\\$/bincomplete
+       # here-doc
+       /<<[    ]*[-\\]*EOF/bheredoc
+       # comment or empty line -- discard since closing ") &&" will need to
+       # add "&&" to final non-comment, non-empty subshell line
+       /^[     ]*#/bdiscard
+       /^[     ]*$/bdiscard
+       # one-liner "case ... esac"
+       /^[     ]*case[         ]*..*esac/bpassthru
+       # multi-line "case ... esac"
+       /^[     ]*case[         ]..*[   ]in/bcase
+       # nested one-liner "(...) &&"
+       /^[     ]*(.*)[         ]*&&[   ]*$/boneline
+       # nested one-liner "(...)"
+       /^[     ]*(.*)[         ]*$/boneline
+       # nested one-liner "(...) >x" (or "2>x" or "<x" or "|x")
+       /^[     ]*(.*)[         ]*[0-9]*[<>|]/bonelineredir
+       # nested multi-line "(...\n...)"
+       /^[     ]*(/bnest
+       # closing ") &&" on own line
+       /^[     ]*)[    ]*&&[   ]*$/bcloseamp
+       # closing ")" on own line
+       /^[     ]*)[    ]*$/bclose
+       # closing ") >x" (or "2>x" or "<x" or "|x") on own line
+       /^[     ]*)[    ]*[0-9]*[<>|]/bcloseredir
+       # "$((...))" -- not closing ")"
+       /\$(([^)][^)]*))[^)]*$/bpassthru
+       # "$(...)" -- not closing ")"
+       /\$([^)][^)]*)[^)]*$/bpassthru
+       # "=(...)" -- Bash array assignment; not closing ")"
+       /=(/bpassthru
+       # closing "...) &&"
+       /)[     ]*&&[   ]*$/bcloseampx
+       # closing "...)"
+       /)[     ]*$/bclosex
+       # closing "...) >x" (or "2>x" or "<x" or "|x")
+       /)[     ]*[<>|]/bcloseredirx
+       :passthru
+       # retrieve and print previous line
+       x
+       n
+       bslurp
+
+       # end-of-file -- must be closing "...)" line or empty line; if empty,
+       # strip ")" from previous line, else strip ")" from this line
+       :eof
+       /^[     ]*$/bempty
+       x
+       p
+       :empty
+       x
+       /)[     ]*[<>|]/s/[<>|]..*$//
+       s/)[    ]*$//
+       b
+
+       # found "...\" -- slurp up next line
+       :incomplete
+       N
+       s/\\\n//
+       bslurp
+
+       # found here-doc inside subshell: when a subshell ends, we append
+       # "&&" to the final subshell line to chain it with lines outside the
+       # subshell, however, we cannot append "&&" to the ending EOF of a
+       # here-doc since "&&" belongs on the "<<EOF" opening line, so just
+       # discard the here-doc altogether (but keep the command to which it
+       # was attached)
+       :heredoc
+       s/<<[   ]*[-\\]*EOF//
+       x
+       p
+       :hereslurpsub
+       N
+       s/.*\n//
+       /^[     ]*EOF[  ]*$/bdiscard
+       bhereslurpsub
+
+       # found "case ... in" -- pass-thru untouched to avoid "...)" arms
+       # being misidentified as subshell closing ")"
+       :case
+       x
+       p
+       x
+       :caseslurp
+       n
+       /^[     ]*esac/besac
+       bcaseslurp
+       :esac
+       x
+       bdiscard
+
+       # found one-liner "(...) &&" or "(...)" -- strip "(" and ")"
+       :oneline
+       s/(//
+       s/)[    ]*\(&&\)*[      ]*$/ \1/
+       bpassthru
+
+       # found one-liner "(...) >x" (or "2>x" or "<x" or "|x") -- strip
+       # "(" and ")"
+       :onelineredir
+       s/(//
+       s/)[    ]*\([0-9]*[<>|]\)/\1/
+       bpassthru
+
+       # found nested multi-line "(...\n...)" -- pass-thru untouched
+       :nest
+       x
+       p
+       x
+       :nslurp
+       n
+       # closing ")" on own line -- stop nested slurp
+       /^[     ]*)/bnclose
+       # "$((...))" -- not closing ")"
+       /\$(([^)][^)]*))[^)]*$/bnslurp
+       # "$(...)" -- not closing ")"
+       /\$([^)][^)]*)[^)]*$/bnslurp
+       # closing "...)" -- stop nested slurp
+       /)/bnclose
+       bnslurp
+       :nclose
+       # stash ")" (or ") &&", etc.) line for later printing and drop
+       # leftover gunk from hold area
+       x
+       bdiscard
+
+       # found ") &&" -- drop ") &&" and add "&&" to final subshell line
+       :closeamp
+       x
+       s/$/ \&\&/
+       b
+
+       # found ")" -- drop it and print final subshell line
+       :close
+       x
+       b
+
+       # found ") >x" (or "2>x" or "<x" or "|x" or "|") -- replace ")" with
+       # ":" to keep ")|\n" syntactically valid, and add "&&" to final
+       # subshell line
+       :closeredir
+       x
+       s/$/ \&\&/
+       p
+       x
+       s/)/:/
+       b
+
+       # found "...) &&" -- drop ")" but keep "..."
+       :closeampx
+       x
+       p
+       x
+       s/)[    ]*&&[   ]*$/ \&\&/
+       b
+
+       # found "...)" -- drop ")" but keep "..."
+       :closex
+       x
+       p
+       x
+       s/)[    ]*$//
+       b
+
+       # found "...) >x" (or "2>x" or "<x" or "|x") -- drop ")" but keep "..."
+       :closeredirx
+       x
+       p
+       x
+       s/)[    ]*\([<>|]\)/ \&\& : \1/
+       b
+}
+'
+
 test_run_ () {
        test_cleanup=:
        expecting_failure=$2
@@ -675,7 +917,8 @@ test_run_ () {
                trace=
                # 117 is magic because it is unlikely to match the exit
                # code of other programs
-               if test "OK-117" != "$(test_eval_ "(exit 117) && 
$1${LF}${LF}echo OK-\$?" 3>&1)"
+               test_linter=$(printf '%s\n' "$1" | sed -e "$test_subshell_sed_")
+               if test "OK-117" != "$(test_eval_ "(exit 117) && 
${test_linter}${LF}${LF}echo OK-\$?" 3>&1)"
                then
                        error "bug in the test script: broken &&-chain or 
run-away HERE-DOC: $1"
                fi
-- 
2.18.0.419.gfe4b301394

Reply via email to