From 8cb4b9b118cbbb80701b4864faf892c8c2d4e610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:42:13 +0000 Subject: [PATCH] Add CaseElseBranch AST node for Ruby case else branches --- .../2026-06-15-case-else-branch.md | 4 ++ ruby/ql/lib/codeql/ruby/ast/Control.qll | 30 +++++++- ruby/ql/lib/codeql/ruby/ast/internal/AST.qll | 24 ++++--- .../lib/codeql/ruby/ast/internal/Control.qll | 16 ++++- .../codeql/ruby/ast/internal/Synthesis.qll | 69 ++++++++++++++++++- .../internal/ControlFlowGraphImpl.qll | 10 +++ .../library-tests/ast/control/CaseExpr.ql | 2 +- 7 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 ruby/ql/lib/change-notes/2026-06-15-case-else-branch.md diff --git a/ruby/ql/lib/change-notes/2026-06-15-case-else-branch.md b/ruby/ql/lib/change-notes/2026-06-15-case-else-branch.md new file mode 100644 index 00000000000..a927f1e2c28 --- /dev/null +++ b/ruby/ql/lib/change-notes/2026-06-15-case-else-branch.md @@ -0,0 +1,4 @@ +--- +category: breaking +--- +* The `else` branch of a `case` expression is no longer represented as a `StmtSequence` directly. Instead, a new `CaseElseBranch` AST node wraps the body (a `StmtSequence`). `CaseExpr.getElseBranch()` now returns a `CaseElseBranch`, and the body of the else branch can be accessed via `CaseElseBranch.getBody()`. diff --git a/ruby/ql/lib/codeql/ruby/ast/Control.qll b/ruby/ql/lib/codeql/ruby/ast/Control.qll index 5d83e7a62fd..cf7920968c8 100644 --- a/ruby/ql/lib/codeql/ruby/ast/Control.qll +++ b/ruby/ql/lib/codeql/ruby/ast/Control.qll @@ -377,18 +377,18 @@ class CaseExpr extends ControlExpr instanceof CaseExprImpl { /** * Gets the `n`th branch of this case expression, either a `WhenClause`, an - * `InClause`, or a `StmtSequence`. + * `InClause`, or a `CaseElseBranch`. */ final AstNode getBranch(int n) { result = super.getBranch(n) } /** * Gets a branch of this case expression, either a `WhenClause`, an - * `InClause`, or a `StmtSequence`. + * `InClause`, or a `CaseElseBranch`. */ final AstNode getABranch() { result = this.getBranch(_) } /** Gets the `else` branch of this case expression, if any. */ - final StmtSequence getElseBranch() { result = this.getABranch() } + final CaseElseBranch getElseBranch() { result = this.getABranch() } /** * Gets the number of branches of this case expression. @@ -533,6 +533,30 @@ class InClause extends AstNode instanceof InClauseImpl { } } +/** + * An `else` branch of a `case` expression. + * ```rb + * case foo + * when 1 then puts "one" + * else puts "other" + * end + * ``` + */ +class CaseElseBranch extends AstNode instanceof CaseElseBranchImpl { + final override string getAPrimaryQlClass() { result = "CaseElseBranch" } + + /** Gets the body of this else branch. */ + final Stmt getBody() { result = super.getBody() } + + final override string toString() { result = "else ..." } + + final override AstNode getAChild(string pred) { + result = AstNode.super.getAChild(pred) + or + pred = "getBody" and result = this.getBody() + } +} + /** * A one-line pattern match using the `=>` operator. For example: * ```rb diff --git a/ruby/ql/lib/codeql/ruby/ast/internal/AST.qll b/ruby/ql/lib/codeql/ruby/ast/internal/AST.qll index 17d4a6bb8b6..4b3535c490d 100644 --- a/ruby/ql/lib/codeql/ruby/ast/internal/AST.qll +++ b/ruby/ql/lib/codeql/ruby/ast/internal/AST.qll @@ -113,6 +113,9 @@ private module Cached { TBraceBlockSynth(Ast::AstNode parent, int i) { mkSynthChild(BraceBlockKind(), parent, i) } or TBraceBlockReal(Ruby::Block g) { not g.getParent() instanceof Ruby::Lambda } or TBreakStmt(Ruby::Break g) or + TCaseElseBranchSynth(Ast::AstNode parent, int i) { + mkSynthChild(CaseElseBranchKind(), parent, i) + } or TCaseEqExpr(Ruby::Binary g) { g instanceof @ruby_binary_equalequalequal } or TCaseExpr(Ruby::Case g) or TCaseMatchReal(Ruby::CaseMatch g) or @@ -400,14 +403,15 @@ private module Cached { class TAstNodeSynth = TAddExprSynth or TAssignExprSynth or TBitwiseAndExprSynth or TBitwiseOrExprSynth or TBitwiseXorExprSynth or TBraceBlockSynth or TBodyStmtSynth or TBooleanLiteralSynth or - TCaseMatchSynth or TClassVariableAccessSynth or TConstantReadAccessSynth or - TConstantWriteAccessSynth or TDivExprSynth or TElseSynth or TExponentExprSynth or - TGlobalVariableAccessSynth or TIfSynth or TInClauseSynth or TInstanceVariableAccessSynth or - TIntegerLiteralSynth or TLShiftExprSynth or TLocalVariableAccessSynth or - TLogicalAndExprSynth or TLogicalOrExprSynth or TMethodCallSynth or TModuloExprSynth or - TMulExprSynth or TNilLiteralSynth or TRShiftExprSynth or TRangeLiteralSynth or TSelfSynth or - TSimpleParameterSynth or TSplatExprSynth or THashSplatExprSynth or TStmtSequenceSynth or - TSubExprSynth or TPairSynth or TSimpleSymbolLiteralSynth; + TCaseElseBranchSynth or TCaseMatchSynth or TClassVariableAccessSynth or + TConstantReadAccessSynth or TConstantWriteAccessSynth or TDivExprSynth or TElseSynth or + TExponentExprSynth or TGlobalVariableAccessSynth or TIfSynth or TInClauseSynth or + TInstanceVariableAccessSynth or TIntegerLiteralSynth or TLShiftExprSynth or + TLocalVariableAccessSynth or TLogicalAndExprSynth or TLogicalOrExprSynth or + TMethodCallSynth or TModuloExprSynth or TMulExprSynth or TNilLiteralSynth or + TRShiftExprSynth or TRangeLiteralSynth or TSelfSynth or TSimpleParameterSynth or + TSplatExprSynth or THashSplatExprSynth or TStmtSequenceSynth or TSubExprSynth or + TPairSynth or TSimpleSymbolLiteralSynth; /** * Gets the underlying TreeSitter entity for a given AST node. This does not @@ -598,6 +602,8 @@ private module Cached { or result = TBraceBlockSynth(parent, i) or + result = TCaseElseBranchSynth(parent, i) + or result = TCaseMatchSynth(parent, i) or result = TClassVariableAccessSynth(parent, i, _) @@ -718,6 +724,8 @@ TAstNodeReal fromGenerated(Ruby::AstNode n) { n = toGenerated(result) } class TCall = TMethodCall or TYieldCall; +class TCaseElseBranch = TCaseElseBranchSynth; + class TCaseMatch = TCaseMatchReal or TCaseMatchSynth; class TCase = TCaseExpr or TCaseMatch; diff --git a/ruby/ql/lib/codeql/ruby/ast/internal/Control.qll b/ruby/ql/lib/codeql/ruby/ast/internal/Control.qll index dd57a0d197d..095fb85de6f 100644 --- a/ruby/ql/lib/codeql/ruby/ast/internal/Control.qll +++ b/ruby/ql/lib/codeql/ruby/ast/internal/Control.qll @@ -19,8 +19,11 @@ class CaseWhenClause extends CaseExprImpl, TCaseExpr { final override Expr getValue() { toGenerated(result) = g.getValue() } final override AstNode getBranch(int n) { - toGenerated(result) = g.getChild(n) or - toGenerated(result) = g.getChild(n) + // When branches map directly to WhenClause nodes + toGenerated(result) = g.getChild(n) and not g.getChild(n) instanceof Ruby::Else + or + // The else branch is wrapped in a synthesized CaseElseBranch node + g.getChild(n) instanceof Ruby::Else and result = getSynthChild(this, n) } } @@ -34,7 +37,8 @@ class CaseMatch extends CaseExprImpl, TCaseMatchReal { final override AstNode getBranch(int n) { toGenerated(result) = g.getClauses(n) or - n = count(g.getClauses(_)) and toGenerated(result) = g.getElse() + // The else branch is wrapped in a synthesized CaseElseBranch node + n = count(g.getClauses(_)) and exists(g.getElse()) and result = getSynthChild(this, n) } } @@ -87,3 +91,9 @@ class InClauseSynth extends InClauseImpl, TInClauseSynth { final override predicate hasUnlessCondition() { none() } } + +class CaseElseBranchImpl extends AstNode, TCaseElseBranch { + CaseElseBranchImpl() { this = TCaseElseBranchSynth(_, _) } + + final Stmt getBody() { synthChild(this, 0, result) } +} diff --git a/ruby/ql/lib/codeql/ruby/ast/internal/Synthesis.qll b/ruby/ql/lib/codeql/ruby/ast/internal/Synthesis.qll index ca40a4160d2..081cbd01a38 100644 --- a/ruby/ql/lib/codeql/ruby/ast/internal/Synthesis.qll +++ b/ruby/ql/lib/codeql/ruby/ast/internal/Synthesis.qll @@ -22,6 +22,7 @@ newtype TSynthKind = BodyStmtKind() or BooleanLiteralKind(boolean value) { value = true or value = false } or BraceBlockKind() or + CaseElseBranchKind() or CaseMatchKind() or ClassVariableAccessKind(ClassVariable v) or DefinedExprKind() or @@ -80,6 +81,8 @@ class SynthKind extends TSynthKind { or this = BraceBlockKind() and result = "BraceBlockKind" or + this = CaseElseBranchKind() and result = "CaseElseBranchKind" + or this = CaseMatchKind() and result = "CaseMatchKind" or this = ClassVariableAccessKind(_) and result = "ClassVariableAccessKind" @@ -1840,7 +1843,7 @@ private module TestPatternDesugar { or child = SynthChild(InClauseKind()) and i = 1 or - child = SynthChild(ElseKind()) and i = 2 + child = SynthChild(CaseElseBranchKind()) and i = 2 ) or parent = TInClauseSynth(case, 1) and @@ -1851,7 +1854,11 @@ private module TestPatternDesugar { child = SynthChild(BooleanLiteralKind(true)) and i = 1 ) or - parent = TElseSynth(case, 2) and + parent = TCaseElseBranchSynth(case, 2) and + child = SynthChild(ElseKind()) and + i = 0 + or + parent = TElseSynth(TCaseElseBranchSynth(case, 2), 0) and child = SynthChild(BooleanLiteralKind(false)) and i = 0 ) @@ -1994,3 +2001,61 @@ private module CallableBodySynthesis { } } } + +private module CaseElseBranchSynthesis { + pragma[nomagic] + private predicate caseElseBranchSynthesis(AstNode parent, int i, Child child) { + // Wrap the else branch of a real `case`/`when` expression + exists(Ruby::Case g, Ruby::Else elseNode, int elseIndex | + elseNode = g.getChild(elseIndex) and + ( + // Create the CaseElseBranch wrapper node at the else index + parent = TCaseExpr(g) and + child = SynthChild(CaseElseBranchKind()) and + i = elseIndex + or + // The body of the CaseElseBranch is the Else node + parent = TCaseElseBranchSynth(TCaseExpr(g), elseIndex) and + child = RealChildRef(TElseReal(elseNode)) and + i = 0 + ) + ) + or + // Wrap the else branch of a real `case`/`in` expression + exists(Ruby::CaseMatch g, Ruby::Else elseNode, int elseIndex | + elseNode = g.getElse() and + elseIndex = count(g.getClauses(_)) and + ( + // Create the CaseElseBranch wrapper node at the else index + parent = TCaseMatchReal(g) and + child = SynthChild(CaseElseBranchKind()) and + i = elseIndex + or + // The body of the CaseElseBranch is the Else node + parent = TCaseElseBranchSynth(TCaseMatchReal(g), elseIndex) and + child = RealChildRef(TElseReal(elseNode)) and + i = 0 + ) + ) + } + + private class CaseElseBranchSynthesisImpl extends Synthesis { + final override predicate child(AstNode parent, int i, Child child) { + caseElseBranchSynthesis(parent, i, child) + } + + final override predicate location(AstNode n, Location l) { + // Give the CaseElseBranch the location of the underlying Else node + exists(Ruby::Case g, int elseIndex | + n = TCaseElseBranchSynth(TCaseExpr(g), elseIndex) and + l = g.getChild(elseIndex).getLocation() + ) + or + exists(Ruby::CaseMatch g, int elseIndex | + elseIndex = count(g.getClauses(_)) and + n = TCaseElseBranchSynth(TCaseMatchReal(g), elseIndex) and + l = g.getElse().getLocation() + ) + } + } +} diff --git a/ruby/ql/lib/codeql/ruby/controlflow/internal/ControlFlowGraphImpl.qll b/ruby/ql/lib/codeql/ruby/controlflow/internal/ControlFlowGraphImpl.qll index 9658c51d673..e66e8bad003 100644 --- a/ruby/ql/lib/codeql/ruby/controlflow/internal/ControlFlowGraphImpl.qll +++ b/ruby/ql/lib/codeql/ruby/controlflow/internal/ControlFlowGraphImpl.qll @@ -498,6 +498,16 @@ module Trees { } } + private class CaseElseBranchTree extends ControlFlowTree instanceof CaseElseBranch { + final override predicate propagatesAbnormal(AstNode child) { child = super.getBody() } + + final override predicate first(AstNode first) { first(super.getBody(), first) } + + final override predicate last(AstNode last, Completion c) { last(super.getBody(), last, c) } + + final override predicate succ(AstNode pred, AstNode succ, Completion c) { none() } + } + private class PatternVariableAccessTree extends LocalVariableAccessTree instanceof LocalVariableWriteAccess, CasePattern { diff --git a/ruby/ql/test/library-tests/ast/control/CaseExpr.ql b/ruby/ql/test/library-tests/ast/control/CaseExpr.ql index 09a64e767e7..f03bdd1e534 100644 --- a/ruby/ql/test/library-tests/ast/control/CaseExpr.ql +++ b/ruby/ql/test/library-tests/ast/control/CaseExpr.ql @@ -4,7 +4,7 @@ query predicate caseValues(CaseExpr c, Expr value) { value = c.getValue() } query predicate caseNoValues(CaseExpr c) { not exists(c.getValue()) } -query predicate caseElseBranches(CaseExpr c, StmtSequence elseBranch) { +query predicate caseElseBranches(CaseExpr c, CaseElseBranch elseBranch) { elseBranch = c.getElseBranch() }