Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/ShellCheck/Analytics.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5049,6 +5049,12 @@ prop_checkCommandIsUnreachable2 = verify checkCommandIsUnreachable "die() { exit
prop_checkCommandIsUnreachable3 = verifyNot checkCommandIsUnreachable "foo; bar || exit; baz"
prop_checkCommandIsUnreachable4 = verifyNot checkCommandIsUnreachable "f() { foo; }; # Maybe sourced"
prop_checkCommandIsUnreachable5 = verify checkCommandIsUnreachable "f() { foo; }; exit # Not sourced"
prop_checkCommandIsUnreachable6 = verifyNot checkCommandIsUnreachable "return || true; echo 'reachable'"
prop_checkCommandIsUnreachable7 = verifyNot checkCommandIsUnreachable "return 2>/dev/null ||:"
prop_checkCommandIsUnreachable8 = verifyNot checkCommandIsUnreachable "return; echo 'reachable when not in function'"
prop_checkCommandIsUnreachable9 = verify checkCommandIsUnreachable "f() { return; echo unreachable; }"
prop_checkCommandIsUnreachable10 = verifyNot checkCommandIsUnreachable "PS4func() { :; }; PS4='$(PS4func)'; exit"
prop_checkCommandIsUnreachable11 = verifyNot checkCommandIsUnreachable "f() { :; }; var=`f`; exit"
checkCommandIsUnreachable params t =
case t of
T_Pipeline {} -> sequence_ $ do
Expand All @@ -5058,10 +5064,11 @@ checkCommandIsUnreachable params t =
guard . not $ isSourced params t
guard . not $ any (\t -> isUnreachable t || isUnreachableFunction t) $ NE.drop 1 $ getPath (parentMap params) t
return $ info (getId t) 2317 "Command appears to be unreachable. Check usage (or ignore if invoked indirectly)."
T_Function id _ _ _ _ ->
T_Function id _ _ name _ ->
when (isUnreachableFunction t
&& (not . any isUnreachableFunction . NE.drop 1 $ getPath (parentMap params) t)
&& (not $ isSourced params t)) $
&& (not $ isSourced params t)
&& (not $ isFunctionReferencedInCommandSubstitution name)) $
info id 2329 "This function is never invoked. Check usage (or ignored if invoked indirectly)."
_ -> return ()
where
Expand All @@ -5074,6 +5081,44 @@ checkCommandIsUnreachable params t =
cfga <- cfgAnalysis params
state <- CF.getIncomingState cfga (getId t)
return . not $ CF.stateIsReachable state

-- Check if a function is referenced in command substitution or prompt variables
isFunctionReferencedInCommandSubstitution :: String -> Bool
isFunctionReferencedInCommandSubstitution name =
not . null $ analyse findReferences (rootNode params)
where
findReferences token =
case token of
-- Check in $() command substitutions
T_DollarExpansion _ _ ->
when (hasFunctionCall token) $ modify (token:)
-- Check in backtick command substitutions
T_Backticked _ _ ->
when (hasFunctionCall token) $ modify (token:)
-- Check in assignments to prompt variables (PS1, PS2, PS3, PS4, PROMPT_COMMAND)
-- These variables are evaluated by bash even in single quotes
T_Assignment _ _ varname _ value ->
when (varname `elem` promptVars && hasFunctionReference value) $ modify (token:)
_ -> return ()
promptVars = ["PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND"]
hasFunctionCall token =
not . null $ analyse findFunctionCalls token
hasFunctionReference token =
-- Check if function name appears in the value
-- For prompt variables, they can contain:
-- 1. Direct command names (PROMPT_COMMAND='funcname')
-- 2. Command substitutions with $(...) or `...`
case getLiteralString token of
Just str ->
name == str -- Direct function name
|| ("$(" ++ name) `isInfixOf` str -- $(funcname)
|| ("`" ++ name) `isInfixOf` str -- `funcname`
Nothing -> hasFunctionCall token
findFunctionCalls token =
case token of
T_SimpleCommand _ _ (cmd:_) ->
when (getUnquotedLiteral cmd == Just name) $ modify (token:)
_ -> return ()


prop_checkOverwrittenExitCode1 = verify checkOverwrittenExitCode "x; [ $? -eq 1 ] || [ $? -eq 2 ]"
Expand Down
13 changes: 10 additions & 3 deletions src/ShellCheck/CFG.hs
Original file line number Diff line number Diff line change
Expand Up @@ -983,13 +983,20 @@ handleCommand cmd vars args literalCmd = do

handleReturn = do
returnTarget <- reader cfReturnTarget
case returnTarget of
Nothing -> error $ pleaseReport "missing return target"
Just target -> do
isFunction <- reader cfIsFunction
case (returnTarget, isFunction) of
(Nothing, _) -> error $ pleaseReport "missing return target"
(Just target, True) -> do
-- In a function: return actually returns, making subsequent code unreachable
ret <- newNode CFStructuralNode
link ret target CFEFlow
unreachable <- newNode CFUnreachable
return $ Range ret unreachable
(Just _, False) -> do
-- Not in a function: return will fail and execution continues
-- Treat it like a regular command that sets exit code
ret <- newNodeRange $ CFSetExitCode (getId cmd)
return ret

handleUnset (cmd NE.:| args) = do
case () of
Expand Down