|
| 1 | +-- | Dead code analysis for PHP |
| 2 | +-- Detects unused variables and unreachable code |
| 3 | +-- SPDX-License-Identifier: AGPL-3.0-or-later |
| 4 | +module Sanctify.Analysis.DeadCode |
| 5 | + ( -- * Main analysis |
| 6 | + analyzeDeadCode |
| 7 | + , DeadCodeIssue(..) |
| 8 | + , DeadCodeType(..) |
| 9 | + |
| 10 | + -- * Specific checks |
| 11 | + , findUnusedVariables |
| 12 | + , findUnreachableCode |
| 13 | + ) where |
| 14 | + |
| 15 | +import Data.Text (Text) |
| 16 | +import qualified Data.Text as T |
| 17 | +import Data.Set (Set) |
| 18 | +import qualified Data.Set as Set |
| 19 | +import Data.Map.Strict (Map) |
| 20 | +import qualified Data.Map.Strict as Map |
| 21 | +import Control.Monad.State.Strict |
| 22 | +import GHC.Generics (Generic) |
| 23 | +import Data.Aeson (ToJSON) |
| 24 | + |
| 25 | +import Sanctify.AST |
| 26 | + |
| 27 | +-- | Types of dead code issues |
| 28 | +data DeadCodeType |
| 29 | + = UnusedVariable -- ^ Variable declared but never used |
| 30 | + | UnreachableCode -- ^ Code after return/throw/exit |
| 31 | + | UnusedParameter -- ^ Function parameter never used |
| 32 | + | UnusedImport -- ^ Use statement never referenced |
| 33 | + deriving stock (Eq, Show, Generic) |
| 34 | + deriving anyclass (ToJSON) |
| 35 | + |
| 36 | +-- | A detected dead code issue |
| 37 | +data DeadCodeIssue = DeadCodeIssue |
| 38 | + { dcType :: DeadCodeType |
| 39 | + , dcLocation :: SourcePos |
| 40 | + , dcDescription :: Text |
| 41 | + , dcIdentifier :: Text -- The name of the unused variable/etc. |
| 42 | + } |
| 43 | + deriving stock (Eq, Show, Generic) |
| 44 | + deriving anyclass (ToJSON) |
| 45 | + |
| 46 | +-- | Analysis state for tracking variable usage |
| 47 | +data AnalysisState = AnalysisState |
| 48 | + { asDeclared :: Map Text SourcePos -- ^ Variables that have been declared/assigned |
| 49 | + , asUsed :: Set Text -- ^ Variables that have been read |
| 50 | + , asIssues :: [DeadCodeIssue] -- ^ Accumulated issues |
| 51 | + } |
| 52 | + deriving stock (Eq, Show) |
| 53 | + |
| 54 | +type AnalysisM = State AnalysisState |
| 55 | + |
| 56 | +-- | Initial analysis state |
| 57 | +initialState :: AnalysisState |
| 58 | +initialState = AnalysisState Map.empty Set.empty [] |
| 59 | + |
| 60 | +-- | Analyze a PHP file for dead code issues |
| 61 | +analyzeDeadCode :: PhpFile -> [DeadCodeIssue] |
| 62 | +analyzeDeadCode file = |
| 63 | + let finalState = execState (analyzeStatements (phpStatements file)) initialState |
| 64 | + unusedVars = findUnusedFromState finalState |
| 65 | + in unusedVars ++ asIssues finalState |
| 66 | + |
| 67 | +-- | Find unused variables from the final state |
| 68 | +findUnusedFromState :: AnalysisState -> [DeadCodeIssue] |
| 69 | +findUnusedFromState state = |
| 70 | + let declared = asDeclared state |
| 71 | + used = asUsed state |
| 72 | + unused = Map.filterWithKey (\k _ -> not (Set.member k used)) declared |
| 73 | + in map mkUnusedIssue (Map.toList unused) |
| 74 | + where |
| 75 | + mkUnusedIssue (name, pos) = DeadCodeIssue |
| 76 | + { dcType = UnusedVariable |
| 77 | + , dcLocation = pos |
| 78 | + , dcDescription = "Variable '$" <> name <> "' is assigned but never used" |
| 79 | + , dcIdentifier = name |
| 80 | + } |
| 81 | + |
| 82 | +-- | Analyze a list of statements, detecting unreachable code |
| 83 | +analyzeStatements :: [Located Statement] -> AnalysisM () |
| 84 | +analyzeStatements [] = pure () |
| 85 | +analyzeStatements (stmt:rest) = do |
| 86 | + analyzeStatement stmt |
| 87 | + -- Check if current statement is a terminator |
| 88 | + when (isTerminator (locNode stmt)) $ do |
| 89 | + -- Mark remaining statements as unreachable |
| 90 | + forM_ rest $ \(Located pos s) -> |
| 91 | + unless (isNoop s) $ |
| 92 | + addIssue DeadCodeIssue |
| 93 | + { dcType = UnreachableCode |
| 94 | + , dcLocation = pos |
| 95 | + , dcDescription = "Unreachable code after " <> terminatorName (locNode stmt) |
| 96 | + , dcIdentifier = "" |
| 97 | + } |
| 98 | + -- Continue analyzing rest only if not a terminator |
| 99 | + unless (isTerminator (locNode stmt)) $ |
| 100 | + analyzeStatements rest |
| 101 | + |
| 102 | +-- | Check if a statement is a control flow terminator |
| 103 | +isTerminator :: Statement -> Bool |
| 104 | +isTerminator = \case |
| 105 | + StmtReturn _ -> True |
| 106 | + StmtThrow _ -> True |
| 107 | + StmtBreak _ -> True |
| 108 | + StmtContinue _ -> True |
| 109 | + _ -> False |
| 110 | + |
| 111 | +-- | Get the name of the terminator for error messages |
| 112 | +terminatorName :: Statement -> Text |
| 113 | +terminatorName = \case |
| 114 | + StmtReturn _ -> "return statement" |
| 115 | + StmtThrow _ -> "throw statement" |
| 116 | + StmtBreak _ -> "break statement" |
| 117 | + StmtContinue _ -> "continue statement" |
| 118 | + _ -> "terminating statement" |
| 119 | + |
| 120 | +-- | Check if a statement is a no-op (empty statement) |
| 121 | +isNoop :: Statement -> Bool |
| 122 | +isNoop StmtNoop = True |
| 123 | +isNoop _ = False |
| 124 | + |
| 125 | +-- | Add an issue to the state |
| 126 | +addIssue :: DeadCodeIssue -> AnalysisM () |
| 127 | +addIssue issue = modify' $ \s -> s { asIssues = issue : asIssues s } |
| 128 | + |
| 129 | +-- | Record that a variable was declared/assigned |
| 130 | +declareVar :: Text -> SourcePos -> AnalysisM () |
| 131 | +declareVar name pos = modify' $ \s -> |
| 132 | + s { asDeclared = Map.insert name pos (asDeclared s) } |
| 133 | + |
| 134 | +-- | Record that a variable was used/read |
| 135 | +useVar :: Text -> AnalysisM () |
| 136 | +useVar name = modify' $ \s -> |
| 137 | + s { asUsed = Set.insert name (asUsed s) } |
| 138 | + |
| 139 | +-- | Analyze a single statement |
| 140 | +analyzeStatement :: Located Statement -> AnalysisM () |
| 141 | +analyzeStatement (Located pos stmt) = case stmt of |
| 142 | + StmtExpr expr -> analyzeExpr expr |
| 143 | + |
| 144 | + StmtIf cond thenStmts elseStmts -> do |
| 145 | + analyzeExpr cond |
| 146 | + -- Analyze branches in isolated scopes for unreachable code |
| 147 | + analyzeStatements thenStmts |
| 148 | + maybe (pure ()) analyzeStatements elseStmts |
| 149 | + |
| 150 | + StmtWhile cond body -> do |
| 151 | + analyzeExpr cond |
| 152 | + analyzeStatements body |
| 153 | + |
| 154 | + StmtFor init cond update body -> do |
| 155 | + maybe (pure ()) analyzeExpr init |
| 156 | + maybe (pure ()) analyzeExpr cond |
| 157 | + maybe (pure ()) analyzeExpr update |
| 158 | + analyzeStatements body |
| 159 | + |
| 160 | + StmtForeach expr keyVar valVar body -> do |
| 161 | + analyzeExpr expr |
| 162 | + -- The foreach variables are declared |
| 163 | + declareVar (varName keyVar) pos |
| 164 | + maybe (pure ()) (\v -> declareVar (varName v) pos) valVar |
| 165 | + analyzeStatements body |
| 166 | + |
| 167 | + StmtSwitch expr cases -> do |
| 168 | + analyzeExpr expr |
| 169 | + forM_ cases $ \c -> do |
| 170 | + maybe (pure ()) analyzeExpr (caseExpr c) |
| 171 | + analyzeStatements (caseBody c) |
| 172 | + |
| 173 | + StmtMatch expr arms -> do |
| 174 | + analyzeExpr expr |
| 175 | + forM_ arms $ \arm -> do |
| 176 | + mapM_ analyzeExpr (matchConditions arm) |
| 177 | + analyzeExpr (matchResult arm) |
| 178 | + |
| 179 | + StmtTry tryBody catches finally -> do |
| 180 | + analyzeStatements tryBody |
| 181 | + forM_ catches $ \c -> do |
| 182 | + -- Catch variable is declared |
| 183 | + maybe (pure ()) (\v -> declareVar (varName v) pos) (catchVar c) |
| 184 | + analyzeStatements (catchBody c) |
| 185 | + maybe (pure ()) analyzeStatements finally |
| 186 | + |
| 187 | + StmtReturn mexpr -> maybe (pure ()) analyzeExpr mexpr |
| 188 | + |
| 189 | + StmtThrow expr -> analyzeExpr expr |
| 190 | + |
| 191 | + StmtEcho exprs -> mapM_ analyzeExpr exprs |
| 192 | + |
| 193 | + StmtGlobal vars -> forM_ vars $ \v -> useVar (varName v) |
| 194 | + |
| 195 | + StmtStatic pairs -> forM_ pairs $ \(v, mexpr) -> do |
| 196 | + declareVar (varName v) pos |
| 197 | + maybe (pure ()) analyzeExpr mexpr |
| 198 | + |
| 199 | + StmtUnset exprs -> mapM_ analyzeExpr exprs |
| 200 | + |
| 201 | + StmtDecl decl -> analyzeDeclaration pos decl |
| 202 | + |
| 203 | + StmtDeclare _ body -> analyzeStatements body |
| 204 | + |
| 205 | + _ -> pure () |
| 206 | + |
| 207 | +-- | Analyze a declaration |
| 208 | +analyzeDeclaration :: SourcePos -> Declaration -> AnalysisM () |
| 209 | +analyzeDeclaration pos decl = case decl of |
| 210 | + DeclFunction{fnParams = params, fnBody = body} -> do |
| 211 | + -- Track parameters |
| 212 | + let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params |
| 213 | + -- Analyze body with fresh state for unused parameter detection |
| 214 | + oldState <- get |
| 215 | + put $ initialState { asDeclared = paramState } |
| 216 | + analyzeStatements body |
| 217 | + newState <- get |
| 218 | + -- Report unused parameters |
| 219 | + let unusedParams = Map.filterWithKey |
| 220 | + (\k _ -> not (Set.member k (asUsed newState))) |
| 221 | + paramState |
| 222 | + forM_ (Map.toList unusedParams) $ \(name, ppos) -> |
| 223 | + addIssue DeadCodeIssue |
| 224 | + { dcType = UnusedParameter |
| 225 | + , dcLocation = ppos |
| 226 | + , dcDescription = "Parameter '$" <> name <> "' is never used" |
| 227 | + , dcIdentifier = name |
| 228 | + } |
| 229 | + -- Restore outer state, keeping issues |
| 230 | + put $ oldState { asIssues = asIssues newState ++ asIssues oldState } |
| 231 | + |
| 232 | + DeclClass{clsMembers = members} -> |
| 233 | + forM_ members (analyzeClassMember pos) |
| 234 | + |
| 235 | + _ -> pure () |
| 236 | + |
| 237 | +-- | Analyze class members |
| 238 | +analyzeClassMember :: SourcePos -> ClassMember -> AnalysisM () |
| 239 | +analyzeClassMember pos member = case member of |
| 240 | + MemberMethod{methParams = params, methBody = Just body} -> do |
| 241 | + let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params |
| 242 | + oldState <- get |
| 243 | + put $ initialState { asDeclared = paramState } |
| 244 | + analyzeStatements body |
| 245 | + newState <- get |
| 246 | + -- Report unused parameters |
| 247 | + let unusedParams = Map.filterWithKey |
| 248 | + (\k _ -> not (Set.member k (asUsed newState))) |
| 249 | + paramState |
| 250 | + forM_ (Map.toList unusedParams) $ \(name, ppos) -> |
| 251 | + addIssue DeadCodeIssue |
| 252 | + { dcType = UnusedParameter |
| 253 | + , dcLocation = ppos |
| 254 | + , dcDescription = "Parameter '$" <> name <> "' is never used" |
| 255 | + , dcIdentifier = name |
| 256 | + } |
| 257 | + put $ oldState { asIssues = asIssues newState ++ asIssues oldState } |
| 258 | + |
| 259 | + MemberProperty{propDefault = Just expr} -> analyzeExpr expr |
| 260 | + |
| 261 | + _ -> pure () |
| 262 | + |
| 263 | +-- | Analyze an expression, tracking variable usage |
| 264 | +analyzeExpr :: Located Expr -> AnalysisM () |
| 265 | +analyzeExpr (Located pos expr) = case expr of |
| 266 | + ExprVariable (Variable name) -> |
| 267 | + -- This is a variable read |
| 268 | + useVar name |
| 269 | + |
| 270 | + ExprAssign target value -> do |
| 271 | + -- Check if target is a simple variable assignment |
| 272 | + case locNode target of |
| 273 | + ExprVariable (Variable name) -> declareVar name pos |
| 274 | + _ -> analyzeExpr target |
| 275 | + analyzeExpr value |
| 276 | + |
| 277 | + ExprAssignOp _ target value -> do |
| 278 | + -- Compound assignment both reads and writes |
| 279 | + analyzeExpr target |
| 280 | + analyzeExpr value |
| 281 | + |
| 282 | + ExprBinary _ left right -> do |
| 283 | + analyzeExpr left |
| 284 | + analyzeExpr right |
| 285 | + |
| 286 | + ExprUnary _ operand -> analyzeExpr operand |
| 287 | + |
| 288 | + ExprTernary cond mtrue false -> do |
| 289 | + analyzeExpr cond |
| 290 | + maybe (pure ()) analyzeExpr mtrue |
| 291 | + analyzeExpr false |
| 292 | + |
| 293 | + ExprCall callee args -> do |
| 294 | + analyzeExpr callee |
| 295 | + mapM_ (analyzeExpr . argValue) args |
| 296 | + |
| 297 | + ExprMethodCall obj _ args -> do |
| 298 | + analyzeExpr obj |
| 299 | + mapM_ (analyzeExpr . argValue) args |
| 300 | + |
| 301 | + ExprStaticCall _ _ args -> |
| 302 | + mapM_ (analyzeExpr . argValue) args |
| 303 | + |
| 304 | + ExprNullsafeMethodCall obj _ args -> do |
| 305 | + analyzeExpr obj |
| 306 | + mapM_ (analyzeExpr . argValue) args |
| 307 | + |
| 308 | + ExprPropertyAccess obj _ -> analyzeExpr obj |
| 309 | + |
| 310 | + ExprNullsafePropertyAccess obj _ -> analyzeExpr obj |
| 311 | + |
| 312 | + ExprArrayAccess base mindex -> do |
| 313 | + analyzeExpr base |
| 314 | + maybe (pure ()) analyzeExpr mindex |
| 315 | + |
| 316 | + ExprNew _ args -> |
| 317 | + mapM_ (analyzeExpr . argValue) args |
| 318 | + |
| 319 | + ExprClosure{closureUses = uses, closureBody = body} -> do |
| 320 | + -- Variables in use() clause are used in outer scope |
| 321 | + forM_ uses $ \(v, _) -> useVar (varName v) |
| 322 | + analyzeStatements body |
| 323 | + |
| 324 | + ExprArrowFunction{arrowExpr = e} -> analyzeExpr e |
| 325 | + |
| 326 | + ExprCast _ e -> analyzeExpr e |
| 327 | + |
| 328 | + ExprIsset exprs -> mapM_ analyzeExpr exprs |
| 329 | + |
| 330 | + ExprEmpty e -> analyzeExpr e |
| 331 | + |
| 332 | + ExprEval e -> analyzeExpr e |
| 333 | + |
| 334 | + ExprInclude _ e -> analyzeExpr e |
| 335 | + |
| 336 | + ExprYield mkey mval -> do |
| 337 | + maybe (pure ()) analyzeExpr mkey |
| 338 | + maybe (pure ()) analyzeExpr mval |
| 339 | + |
| 340 | + ExprYieldFrom e -> analyzeExpr e |
| 341 | + |
| 342 | + ExprThrow e -> analyzeExpr e |
| 343 | + |
| 344 | + ExprLiteral (LitArray pairs) -> |
| 345 | + forM_ pairs $ \(mkey, val) -> do |
| 346 | + maybe (pure ()) analyzeExpr mkey |
| 347 | + analyzeExpr val |
| 348 | + |
| 349 | + ExprList exprs -> |
| 350 | + forM_ exprs $ maybe (pure ()) analyzeExpr |
| 351 | + |
| 352 | + _ -> pure () |
| 353 | + |
| 354 | +-- | Find unused variables in a PHP file (convenience function) |
| 355 | +findUnusedVariables :: PhpFile -> [DeadCodeIssue] |
| 356 | +findUnusedVariables = filter isUnusedVar . analyzeDeadCode |
| 357 | + where |
| 358 | + isUnusedVar issue = dcType issue `elem` [UnusedVariable, UnusedParameter] |
| 359 | + |
| 360 | +-- | Find unreachable code in a PHP file (convenience function) |
| 361 | +findUnreachableCode :: PhpFile -> [DeadCodeIssue] |
| 362 | +findUnreachableCode = filter isUnreachable . analyzeDeadCode |
| 363 | + where |
| 364 | + isUnreachable issue = dcType issue == UnreachableCode |
0 commit comments