Skip to content

Commit 3aa646b

Browse files
AndreiDreyerTebogo Selahle
andauthored
[ruby] Add Support for ERB files (#5447)
* [ruby] Add support for basic ERB files (#5404) * Added tests for ERB processing * update test expectation * Add isLineFeed func * removed erb config file test * Fixed new line issues with ERB files, updated test expectations * [ruby] rework erb support. Added templateOutRaw and templateOutEscape operators. Included explicit return for ERB file processing * [ruby] Add .html.erb files as config files for ruby * review comments * Fixed ERB lowering implementation. Updated test expectations * Updated comments * remove test * upgrade ruby-ast-gen version * upgrade ruby-ast-gen version, remove unnecessary whitespace * [ruby] update code string and tests for joern__buffer changes to self.joern__buffer * [ruby] update ERB tests to reflect no line numbers being set from ruby_ast_gen * [ruby] remove self parameter from lambda, capture from outside instead * [ruby] Add empty expression for if statement with no expressions in body specifically in ERB files * [ruby] Only look for the closest self in the outerscope in closure body * [ruby] capture self variable * fix: resolve multiple self identifier refOuts bug * chore: add return type * refactor: scalafmt run * fix: resolve incorrect closure binding refOut bug This also fixes a bug where a lambda param IDENTIFIER was not referencing the param * feat: use joern__buffer_append * chore: scalafmt * fix: recognise joern_inner_buffer appends as erb calls * fix: retain joern__buffer_append args' receiver ast * improvement: turn lookupSelfInOuterScope into wrapper * improvement: override span instead of using 2 parameter lists * refactor: remove debug lines in test * improvement: move typeFullName to outer scope * improvement: use buffer_append constant * improvement: use Option.when * improvement: remove case for self * improvement: remove ERB call special case * improvement: use camelCase for ERB buffer symbols and methods * refactor: scalafmt * cleanup: remove unused variable * improvement: rename isErbCall * improvement: avoid unnecessary call * refactor: refactor block * fix: don't generate receiver AST if isStatic * fix: add tmp assignments to tests * Revert "fix: add tmp assignments to tests" This reverts commit f0fdf28. * Revert "fix: don't generate receiver AST if isStatic" This reverts commit 39edae8. * chore: use Option.unless --------- Co-authored-by: Tebogo Selahle <[email protected]>
1 parent 8676e7b commit 3aa646b

File tree

16 files changed

+793
-98
lines changed

16 files changed

+793
-98
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
rubysrc2cpg {
2-
ruby_ast_gen_version: "0.33.0"
2+
ruby_ast_gen_version: "0.58.0"
33
joern_type_stubs_version: "0.6.0"
4-
}
4+
}

joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/RubySrc2Cpg.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class RubySrc2Cpg extends X2CpgFrontend {
6767
}
6868
.filter { x =>
6969
if x.fileContent.isBlank then logger.info(s"File content empty, skipping - ${x.fileName}")
70-
!x.fileContent.isBlank
70+
!x.fileContent.isBlank || x.fileName.endsWith(".html.erb")
7171
}
7272

7373
val internalProgramSummary = ConcurrentTaskUtil
@@ -122,7 +122,7 @@ object RubySrc2Cpg {
122122
): Iterator[() => AstCreator] = {
123123
astFiles.map { fileName => () =>
124124
val parserResult = RubyJsonParser.readFile(Paths.get(fileName))
125-
val rubyProgram = new RubyJsonToNodeCreator().visitProgram(parserResult.json)
125+
val rubyProgram = new RubyJsonToNodeCreator(fileName = fileName).visitProgram(parserResult.json)
126126
val sourceFileName = parserResult.fullPath
127127
val fileContent = new String(Files.readAllBytes(Paths.get(sourceFileName)), Charset.defaultCharset())
128128
new AstCreator(

joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstCreatorHelper.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import io.joern.rubysrc2cpg.astcreation.RubyIntermediateAst.{
2121
}
2222
import io.joern.rubysrc2cpg.datastructures.{BlockScope, FieldDecl}
2323
import io.joern.rubysrc2cpg.passes.Defines
24+
import io.joern.rubysrc2cpg.passes.Defines.RubyOperators
2425
import io.joern.rubysrc2cpg.passes.GlobalTypes
2526
import io.joern.rubysrc2cpg.passes.GlobalTypes.{kernelFunctions, kernelPrefix}
27+
import io.joern.x2cpg.frontendspecific.rubysrc2cpg.Constants
2628
import io.joern.x2cpg.{Ast, ValidationMode}
2729
import io.shiftleft.codepropertygraph.generated.nodes.*
2830
import io.shiftleft.codepropertygraph.generated.{DispatchTypes, EdgeTypes, Operators}
@@ -252,6 +254,14 @@ trait AstCreatorHelper(implicit withSchemaValidation: ValidationMode) { this: As
252254
StatementList(tmpAssignment :: ifStmt :: Nil)(originSpan)
253255
}
254256

257+
protected def isErbCall(callName: String): Boolean = ErbTemplateCallNames.contains(callName)
258+
259+
protected val ErbTemplateCallNames: Map[String, String] = Map(
260+
Constants.joernErbTemplateOutRawName -> RubyOperators.templateOutRaw,
261+
Constants.joernErbTemplateOutEscapeName -> RubyOperators.templateOutEscape,
262+
Constants.joernErbBufferAppend -> RubyOperators.bufferAppend
263+
)
264+
255265
protected val UnaryOperatorNames: Map[String, String] = Map(
256266
"!" -> Operators.logicalNot,
257267
"not" -> Operators.logicalNot,

joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForExpressionsCreator.scala

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,12 @@ package io.joern.rubysrc2cpg.astcreation
22

33
import io.joern.rubysrc2cpg.astcreation.RubyIntermediateAst.{Unknown, Block as RubyBlock, *}
44
import io.joern.rubysrc2cpg.datastructures.BlockScope
5-
import io.joern.rubysrc2cpg.parser.RubyJsonHelpers
6-
import io.joern.rubysrc2cpg.passes.Defines
7-
import io.joern.rubysrc2cpg.passes.GlobalTypes
8-
import io.joern.rubysrc2cpg.passes.Defines.{RubyOperators, prefixAsKernelDefined}
5+
import io.joern.rubysrc2cpg.passes.Defines.RubyOperators
6+
import io.joern.rubysrc2cpg.passes.{Defines, GlobalTypes}
7+
import io.joern.x2cpg.frontendspecific.rubysrc2cpg.Constants
98
import io.joern.x2cpg.{Ast, ValidationMode, Defines as XDefines}
109
import io.shiftleft.codepropertygraph.generated.nodes.*
11-
import io.shiftleft.codepropertygraph.generated.{
12-
ControlStructureTypes,
13-
DispatchTypes,
14-
EdgeTypes,
15-
NodeTypes,
16-
Operators,
17-
PropertyNames
18-
}
10+
import io.shiftleft.codepropertygraph.generated.{ControlStructureTypes, DispatchTypes, EdgeTypes, NodeTypes, Operators}
1911

2012
import scala.collection.mutable
2113

@@ -42,6 +34,7 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
4234
case node: TypeIdentifier => astForTypeIdentifier(node)
4335
case node: RubyIdentifier => astForSimpleIdentifier(node)
4436
case node: SimpleCall => astForSimpleCall(node)
37+
case node: ErbTemplateCall => astForErbTemplateCall(node)
4538
case node: RequireCall => astForRequireCall(node)
4639
case node: IncludeCall => astForIncludeCall(node)
4740
case node: RaiseCall => astForRaiseCall(node)
@@ -64,6 +57,7 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
6457
case node: ArrayPattern => astForArrayPattern(node)
6558
case node: DummyNode => Ast(node.node)
6659
case node: DummyAst => node.ast
60+
case node: EmptyExpression => Ast()
6761
case node: Unknown => astForUnknown(node)
6862
case x =>
6963
logger.warn(s"Unhandled expression of type ${x.getClass.getSimpleName}")
@@ -162,7 +156,12 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
162156
case None => x
163157
}
164158
astForFieldAccess(node.copy(target = newTarget)(node.span))
165-
case _ => astForFieldAccess(node)
159+
case _ =>
160+
if (Constants.joernErbBuffers.contains(node.memberName)) {
161+
astForFieldAccess(node, typeFullName = Constants.stringPrefix)
162+
} else {
163+
astForFieldAccess(node)
164+
}
166165
}
167166
}
168167

@@ -191,26 +190,35 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
191190
protected def astForMemberCall(node: RubyCallWithBase, isStatic: Boolean = false): Ast = {
192191

193192
def createMemberCall(n: RubyCallWithBase): Ast = {
194-
val receiverAst = astForFieldAccess(MemberAccess(n.target, ".", n.methodName)(n.span), stripLeadingAt = true)
193+
val isErbBufferAppendCall = n.methodName == Constants.joernErbBufferAppend
194+
195+
val receiverAstOpt = Option.unless(isErbBufferAppendCall)(
196+
astForFieldAccess(MemberAccess(n.target, ".", n.methodName)(n.span), stripLeadingAt = true)
197+
)
198+
195199
val (baseAst, baseCode) = astForMemberAccessTarget(n.target)
196200
val builtinType = n.target match {
197201
case MemberAccess(_: SelfIdentifier, _, memberName) if isBundledClass(memberName) =>
198202
Option(prefixAsCoreType(memberName))
199203
case x: TypeIdentifier if x.isBuiltin => Option(x.typeFullName)
200204
case _ => None
201205
}
202-
val methodFullName = receiverAst.nodes
203-
.collectFirst {
204-
case _ if builtinType.isDefined => s"${builtinType.get}.${n.methodName}"
205-
case x: NewMethodRef => x.methodFullName
206-
case _ =>
207-
(n.target match {
208-
case ma: MemberAccess => scope.tryResolveTypeReference(ma.memberName).map(_.name)
209-
case _ => typeFromCallTarget(n.target)
210-
}).map(x => s"$x.${n.methodName}")
211-
.getOrElse(XDefines.DynamicCallUnknownFullName)
206+
val methodFullName = if (isErbBufferAppendCall) {
207+
RubyOperators.bufferAppend
208+
} else {
209+
val fullNameOpt = receiverAstOpt.flatMap { ast =>
210+
ast.nodes.headOption.flatMap {
211+
case _ if builtinType.isDefined => builtinType.map(t => s"$t.${n.methodName}")
212+
case x: NewMethodRef => Some(x.methodFullName)
213+
case _ =>
214+
(n.target match {
215+
case ma: MemberAccess => scope.tryResolveTypeReference(ma.memberName).map(_.name)
216+
case _ => typeFromCallTarget(n.target)
217+
}).map(x => s"$x.${n.methodName}")
218+
}
212219
}
213-
.getOrElse(XDefines.DynamicCallUnknownFullName)
220+
fullNameOpt.getOrElse(XDefines.DynamicCallUnknownFullName)
221+
}
214222
val argumentAsts = n.arguments.map(astForMethodCallArgument)
215223
val dispatchType = if (isStatic) DispatchTypes.STATIC_DISPATCH else DispatchTypes.DYNAMIC_DISPATCH
216224

@@ -223,12 +231,21 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
223231
code(n)
224232
}
225233

226-
val call = callNode(n, callCode, n.methodName, XDefines.DynamicCallUnknownFullName, dispatchType)
227-
if methodFullName != XDefines.DynamicCallUnknownFullName then call.possibleTypes(Seq(methodFullName))
228-
if (isStatic) {
229-
callAst(call, argumentAsts, base = Option(baseAst)).copy(receiverEdges = Nil)
234+
val call = if (isErbBufferAppendCall) {
235+
operatorCallNode(n, callCode, methodFullName, Some(Constants.stringPrefix))
236+
} else {
237+
val call = callNode(n, callCode, n.methodName, XDefines.DynamicCallUnknownFullName, dispatchType)
238+
if methodFullName != XDefines.DynamicCallUnknownFullName then call.possibleTypes(Seq(methodFullName))
239+
call
240+
}
241+
if (isErbBufferAppendCall) {
242+
callAst(call, argumentAsts)
230243
} else {
231-
callAst(call, argumentAsts, base = Option(baseAst), receiver = Option(receiverAst))
244+
if (isStatic) {
245+
callAst(call, argumentAsts, base = Option(baseAst)).copy(receiverEdges = Nil)
246+
} else {
247+
callAst(call, argumentAsts, base = Option(baseAst), receiver = receiverAstOpt)
248+
}
232249
}
233250
}
234251

@@ -278,7 +295,11 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
278295
}
279296
}
280297

281-
protected def astForFieldAccess(node: MemberAccess, stripLeadingAt: Boolean = false): Ast = {
298+
protected def astForFieldAccess(
299+
node: MemberAccess,
300+
stripLeadingAt: Boolean = false,
301+
typeFullName: String = Defines.Any
302+
): Ast = {
282303
val (memberName, memberCode) = node.target match {
283304
case _ if node.memberName == Defines.Initialize => Defines.Initialize -> Defines.Initialize
284305
case _ if stripLeadingAt => node.memberName -> node.memberName.stripPrefix("@")
@@ -307,7 +328,7 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
307328
Operators.fieldAccess,
308329
DispatchTypes.STATIC_DISPATCH,
309330
signature = None,
310-
typeFullName = Option(Defines.Any)
331+
typeFullName = Option(typeFullName)
311332
).possibleTypes(IndexedSeq(memberType.get))
312333
callAst(fieldAccess, Seq(targetAst, fieldIdentifierAst))
313334
}
@@ -662,6 +683,13 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
662683
astForUnknown(targetNode)
663684
}
664685

686+
protected def astForErbTemplateCall(node: ErbTemplateCall): Ast = {
687+
val argAsts = node.arguments.map(astForExpression)
688+
val opName = ErbTemplateCallNames(node.target.text)
689+
val opNode = callNode(node, node.span.text, opName, opName, DispatchTypes.STATIC_DISPATCH)
690+
callAst(opNode, argAsts)
691+
}
692+
665693
protected def astForRequireCall(node: RequireCall): Ast = {
666694
val pathOpt = node.argument match {
667695
case arg: StaticLiteral if arg.isString => Option(arg.innerText)
@@ -970,7 +998,8 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
970998
}
971999

9721000
private def astForMethodCallWithoutBlock(node: SimpleCall, methodIdentifier: SimpleIdentifier): Ast = {
973-
val methodName = methodIdentifier.text
1001+
val methodName =
1002+
if (isErbCall(methodIdentifier.text)) ErbTemplateCallNames(methodIdentifier.text) else methodIdentifier.text
9741003
lazy val defaultResult = Defines.Any -> XDefines.DynamicCallUnknownFullName
9751004

9761005
val (receiverType, methodFullNameHint) =
@@ -1087,4 +1116,6 @@ trait AstForExpressionsCreator(implicit withSchemaValidation: ValidationMode) {
10871116
private def getUnaryOperatorName(op: String): Option[String] = UnaryOperatorNames.get(op)
10881117

10891118
private def getAssignmentOperatorName(op: String): Option[String] = AssignmentOperatorNames.get(op)
1119+
1120+
private def isLineFeed(text: String): Boolean = text == "\n" || text == "\r" || text == "\r\n"
10901121
}

joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/AstForFunctionsCreator.scala

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,26 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
6868
if (isConstructor) scope.pushNewScope(ConstructorScope(fullName, scope.getNewProcParam))
6969
else scope.pushNewScope(MethodScope(fullName, scope.getNewProcParam))
7070

71-
val thisParameterNode = parameterInNode(
72-
node,
73-
name = Defines.Self,
74-
code = Defines.Self,
75-
index = 0,
76-
isVariadic = false,
77-
typeFullName = Option(scope.surroundingTypeFullName.getOrElse(Defines.Any)),
78-
evaluationStrategy = EvaluationStrategies.BY_SHARING
79-
)
80-
val thisParameterAst = Ast(thisParameterNode)
81-
scope.addToScope(Defines.Self, thisParameterNode)
82-
val parameterAsts = thisParameterAst :: astForParameters(node.parameters)
71+
val parameterAsts =
72+
if (isClosure) {
73+
astForParameters(node.parameters)
74+
} else {
75+
val thisParameterNode = parameterInNode(
76+
node,
77+
name = Defines.Self,
78+
code = Defines.Self,
79+
index = 0,
80+
isVariadic = false,
81+
typeFullName = Option(scope.surroundingTypeFullName.getOrElse(Defines.Any)),
82+
evaluationStrategy = EvaluationStrategies.BY_SHARING
83+
)
8384

84-
val optionalStatementList = statementListForOptionalParams(node.parameters)
85+
val thisParameterAst = Ast(thisParameterNode)
86+
scope.addToScope(Defines.Self, thisParameterNode)
87+
thisParameterAst :: astForParameters(node.parameters)
88+
}
8589

86-
val methodReturn = methodReturnNode(node, Defines.Any)
90+
val optionalStatementList = statementListForOptionalParams(node.parameters)
8791

8892
val refs = {
8993
val typeRef =
@@ -92,8 +96,21 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
9296
List(typeRef, methodRefNode(node, methodName, fullName, fullName)).map(Ast.apply)
9397
}
9498

99+
val methodReturn = methodReturnNode(node, Defines.Any)
100+
95101
// Consider which variables are captured from the outer scope
96102
val stmtBlockAst = if (isClosure || isSingletonObjectMethod) {
103+
// create closure `self` local used for capturing
104+
scope.lookupSelfInOuterScope
105+
.collect {
106+
case local: NewLocal => local.name
107+
case param: NewMethodParameterIn => param.name
108+
}
109+
.foreach { name =>
110+
val capturingLocal =
111+
localNode(node.body, name, name, Defines.Any, closureBindingId = Option(s"$fullName.$name"))
112+
scope.addToScope(capturingLocal.name, capturingLocal)
113+
}
97114
val baseStmtBlockAst = astForMethodBody(node.body, optionalStatementList)
98115
transformAsClosureBody(node.body, refs, baseStmtBlockAst)
99116
} else {
@@ -205,22 +222,27 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
205222
methodAst
206223
}
207224

208-
private def transformAsClosureBody(originNode: RubyExpression, refs: List[Ast], baseStmtBlockAst: Ast) = {
225+
private def transformAsClosureBody(originNode: RubyExpression, refs: List[Ast], baseStmtBlockAst: Ast): Ast = {
209226
// Determine which locals are captured
210227
val capturedLocalNodes = baseStmtBlockAst.nodes
211-
.collect { case x: NewIdentifier if x.name != Defines.Self => x } // Self identifiers are handled separately
228+
.collect { case x: NewIdentifier if x.name != Defines.Self => x }
212229
.distinctBy(_.name)
213230
.map(i => scope.lookupVariableInOuterScope(i.name))
214-
.filter(_.nonEmpty)
231+
.filter(_.iterator.nonEmpty)
215232
.flatten
216233
.toSet
217234

235+
val selfLocal = scope.lookupSelfInOuterScope.toSet
236+
val capturedNodes = capturedLocalNodes ++ selfLocal
237+
218238
val capturedIdentifiers = baseStmtBlockAst.nodes.collect {
219-
case i: NewIdentifier if capturedLocalNodes.map(_.name).contains(i.name) => i
239+
case i: NewIdentifier if capturedNodes.map(_.name).contains(i.name) => i
220240
}
221241
// Copy AST block detaching the REF nodes between parent locals/params and identifiers, with the closures' one
222242
val capturedBlockAst = baseStmtBlockAst.copy(refEdges = baseStmtBlockAst.refEdges.filterNot {
223-
case AstEdge(_: NewIdentifier, dst: DeclarationNew) => capturedLocalNodes.contains(dst)
243+
case AstEdge(_: NewIdentifier, dst: NewLocal) if dst.name == Defines.Self =>
244+
capturedNodes.map(_.name).contains(dst.name)
245+
case AstEdge(_: NewIdentifier, dst: DeclarationNew) => capturedNodes.contains(dst)
224246
case _ => false
225247
})
226248

@@ -229,18 +251,13 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
229251
val astChildren = mutable.Buffer.empty[NewNode]
230252
val refEdges = mutable.Buffer.empty[(NewNode, NewNode)]
231253
val captureEdges = mutable.Buffer.empty[(NewNode, NewNode)]
232-
capturedLocalNodes
233-
.collect {
234-
case local: NewLocal =>
235-
val closureBindingId = scope.variableScopeFullName(local.name).map(x => s"$x.${local.name}")
236-
(local, local.name, local.code, closureBindingId)
237-
case param: NewMethodParameterIn =>
238-
val closureBindingId = scope.variableScopeFullName(param.name).map(x => s"$x.${param.name}")
239-
(param, param.name, param.code, closureBindingId)
240-
}
254+
255+
createClosureBindingInformation(capturedNodes)
241256
.collect { case (capturedLocal, name, code, Some(closureBindingId)) =>
242-
val capturingLocal =
257+
val selfCapturingLocal = Option.when(name == Defines.Self)(scope.lookupSelfInCurrentScope).flatten
258+
val capturingLocal = selfCapturingLocal.getOrElse(
243259
localNode(originNode, name, name, Defines.Any, closureBindingId = Option(closureBindingId))
260+
)
244261

245262
val closureBinding = closureBindingNode(
246263
closureBindingId = closureBindingId,
@@ -614,4 +631,18 @@ trait AstForFunctionsCreator(implicit withSchemaValidation: ValidationMode) { th
614631
case _ => false
615632
}
616633
}
634+
635+
private def createClosureBindingInformation(
636+
capturedNodes: Set[DeclarationNew]
637+
): Set[(DeclarationNew, String, String, Option[String])] = {
638+
capturedNodes
639+
.collect {
640+
case local: NewLocal =>
641+
val closureBindingId = scope.variableScopeFullName(local.name).map(x => s"$x.${local.name}")
642+
(local, local.name, local.code, closureBindingId)
643+
case param: NewMethodParameterIn =>
644+
val closureBindingId = scope.variableScopeFullName(param.name).map(x => s"$x.${param.name}")
645+
(param, param.name, param.code, closureBindingId)
646+
}
647+
}
617648
}

joern-cli/frontends/rubysrc2cpg/src/main/scala/io/joern/rubysrc2cpg/astcreation/RubyIntermediateAst.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,10 @@ object RubyIntermediateAst {
508508
extends RubyExpression(span)
509509
with RubyCall
510510

511+
final case class ErbTemplateCall(target: RubyExpression, arguments: List[RubyExpression])(span: TextSpan)
512+
extends RubyExpression(span)
513+
with RubyCall
514+
511515
sealed trait AccessModifier extends AllowedTypeDeclarationChild {
512516
def toSimpleIdentifier: SimpleIdentifier
513517
}
@@ -685,4 +689,5 @@ object RubyIntermediateAst {
685689
extends RubyExpression(span)
686690
with AllowedTypeDeclarationChild
687691

692+
final case class EmptyExpression(override val span: TextSpan) extends RubyExpression(span)
688693
}

0 commit comments

Comments
 (0)