Skip to content

Accept an escaped ! as negation in [ ] test expressions - #3517

Open
Eljees wants to merge 1 commit into
koalaman:masterfrom
Eljees:fix/3475-escaped-bang-in-test
Open

Accept an escaped ! as negation in [ ] test expressions#3517
Eljees wants to merge 1 commit into
koalaman:masterfrom
Eljees:fix/3475-escaped-bang-in-test

Conversation

@Eljees

@Eljees Eljees commented Aug 9, 2026

Copy link
Copy Markdown

Fixes #3475.

Symptom

#!/bin/sh
if [ \! \( -x "$a" -a -e "$b" \) ]; then echo hi; fi
SC1073 (error): Couldn't parse this test expression.
SC1072 (error): Expected comparison operator

The script is valid and runs correctly. A probe isolates the trigger to the escaped !, not to the parentheses or the quoting:

expression before
[ \! \( -x "$a" -a -e "$b" \) ] SC1072 / SC1073
[ ! \( -x "$a" -a -e "$b" \) ] fine
[ \( -x "$a" -a -e "$b" \) ] fine
[ \! -x "$a" ] parses, but bogus SC2057 Unknown binary operator

The second row matters: \( and \) are already accepted — and SC1028 actively tells users to write them that way — while \! is not.

Cause

readCondGroup reads its parentheses through the escape-tolerant helper:

lparen <- try $ readRegularOrEscaped (string "(")
...
rparen <- readRegularOrEscaped (string ")")

readCondNot matches a bare character instead:

readCondNot = do
    start <- startSpan
    char '!'

So an escaped \! never becomes TC_Unary "!". On its own it degrades into a word, which then looks like the left operand of a binary test and yields the spurious SC2057; combined with a group it derails the parse entirely.

Why the fix is conditional on single

[ ] and [[ ]] genuinely differ here, so I checked both against real shells rather than assuming:

expression bash dash
[ ! -x /nonexistent ] negates negates
[ \! -x /nonexistent ] negates negates
[[ ! -x /nonexistent ]] negates n/a
[[ \! -x /nonexistent ]] syntax error: conditional binary operator expected n/a

In [ ] the backslash is only shell quoting and test still receives the argument !. In [[ ]] bash rejects the escaped form outright, so ShellCheck's current complaint there is correct. The escaped form is therefore accepted only when parsing [ ]; [[ ]] is untouched and still reports SC2057 for [[ \! -e foo ]].

Tests

prop_readCondition30    = isOk       readCondition       "[ \\! \\( -e foo -a -e bar \\) ]"
prop_checkValidCondOps5 = verifyNot  checkValidCondOps   "[ \\! -e foo ]"

One per symptom: the parse failure and the spurious SC2057. Both fail on master with the tests alone (*** Failed! Falsified (after 1 test) for exactly these two and nothing else) and pass with the change; the full cabal test suite is green on GHC 9.8.4.

End-to-end, per CLAUDE.md:

  • the reporter's script no longer errors — only the pre-existing SC2166 advice about -a remains;
  • [ \! -x "$a" ] is now silent;
  • [ ! -x "$a" ] is unchanged;
  • [[ \! -x "$a" ]] still reports SC2057.

One behaviour change worth flagging

[ \! ] — a one-argument test of the literal string !, which every shell evaluates as true — used to parse as a nullary test and now fails to parse. That is because it takes exactly the same path as the bare [ ! ], which already failed to parse before this change. I left that alone: making the negation parser backtrack would also change long-standing behaviour for the unescaped form, which seemed out of scope here. Happy to address it separately if you'd like the two of them fixed together.

AI usage

I used Claude to help locate the parser path and draft the change and the tests, following this repository's .claude/CLAUDE.md. I reviewed every line, established the cause with the probe above rather than by assumption, verified the [ ] versus [[ ]] difference against bash and dash before choosing the conditional form, ran the new tests against unpatched master first to confirm they fail, and ran the full test suite and the end-to-end checks myself.

readCondGroup already reads its parentheses with
`readRegularOrEscaped (string "(")`, so `[ \( x \) ]` parses, and SC1028 tells
users to write them that way. readCondNot, however, matched a bare `char '!'`,
so the equally valid `[ \! x ]` was not recognised as a negation: on its own it
produced a bogus SC2057 "Unknown binary operator", and combined with escaped
parentheses it failed to parse at all (SC1072/SC1073).

Both bash and dash treat `!`, `\!` and `"!"` alike in `[ ]`, because test
simply receives the argument `!`. Inside `[[ ]]` they do not: bash rejects
`[[ \! -e foo ]]` with "conditional binary operator expected". The escaped form
is therefore accepted only when parsing `[ ]`, leaving `[[ ]]` as it was.

Fixes koalaman#3475
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected shellcheck parse error for code that runs correctly and looks correct

1 participant