• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TouK / nussknacker / 5976637142

25 Aug 2023 01:43PM UTC coverage: 81.47% (+0.03%) from 81.438%
5976637142

push

github

Filemon279
Fix migration in 1.11

25 of 25 new or added lines in 2 files covered. (100.0%)

14865 of 18246 relevant lines covered (81.47%)

5.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.81
/interpreter/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSuggester.scala
1
package pl.touk.nussknacker.engine.spel
2

3
import com.typesafe.scalalogging.LazyLogging
4
import io.circe.generic.JsonCodec
5
import org.springframework.expression.common.TemplateParserContext
6
import org.springframework.expression.spel.ast._
7
import org.springframework.expression.spel.standard.{SpelExpression => SpringSpelExpression}
8
import org.springframework.expression.spel.{SpelNode, SpelParserConfiguration}
9
import pl.touk.nussknacker.engine.TypeDefinitionSet
10
import pl.touk.nussknacker.engine.api.context.ValidationContext
11
import pl.touk.nussknacker.engine.api.dict.UiDictServices
12
import pl.touk.nussknacker.engine.api.typed.typing._
13
import pl.touk.nussknacker.engine.definition.ProcessDefinitionExtractor.ExpressionDefinition
14
import pl.touk.nussknacker.engine.definition.TypeInfos.ClazzDefinition
15
import pl.touk.nussknacker.engine.dict.LabelsDictTyper
16
import pl.touk.nussknacker.engine.graph.expression.Expression
17
import pl.touk.nussknacker.engine.spel.Typer.TypingResultWithContext
18
import pl.touk.nussknacker.engine.spel.ast.SpelAst.SpelNodeId
19
import pl.touk.nussknacker.engine.spel.parser.NuTemplateAwareExpressionParser
20

21
import scala.concurrent.{ExecutionContext, Future}
22
import scala.util.{Failure, Try}
23

24
class SpelExpressionSuggester(expressionConfig: ExpressionDefinition[_], typeDefinitions: TypeDefinitionSet, uiDictServices: UiDictServices, classLoader: ClassLoader) {
25
  private val successfulNil = Future.successful[List[ExpressionSuggestion]](Nil)
2✔
26
  private val typer = Typer.default(classLoader, expressionConfig, new LabelsDictTyper(uiDictServices.dictRegistry), typeDefinitions)
2✔
27
  private val nuSpelNodeParser = new NuSpelNodeParser(typer)
2✔
28
  private val dictQueryService = uiDictServices.dictQueryService
2✔
29

30
  def expressionSuggestions(expression: Expression, normalizedCaretPosition: Int, variables: Map[String, TypingResult])(implicit ec: ExecutionContext): Future[List[ExpressionSuggestion]] = {
31
    val spelExpression = expression.expression
2✔
32
    if (normalizedCaretPosition == 0) {
2✔
33
      return successfulNil
2✔
34
    }
35
    val previousChar = spelExpression.substring(normalizedCaretPosition - 1, normalizedCaretPosition)
2✔
36
    val shouldInsertDummyVariable = (previousChar == "#" || previousChar == ".") && (normalizedCaretPosition == spelExpression.length || !spelExpression.charAt(normalizedCaretPosition).isLetter)
2✔
37
    val input = if (shouldInsertDummyVariable) {
38
      insertDummyVariable(spelExpression, normalizedCaretPosition)
2✔
39
    } else {
40
      spelExpression
2✔
41
    }
42

43
    def filterMapByName[V](map: Map[String, V], name: String): Map[String, V] = {
44
      if (shouldInsertDummyVariable) {
45
        map
2✔
46
      } else {
47
        map.filter { case (key, _) => key.toLowerCase.contains(name.toLowerCase) }
2✔
48
      }
49
    }
50

51
    def suggestionsForPropertyOrFieldReference(nodeInPosition: NuSpelNode, p: PropertyOrFieldReference): Future[Iterable[ExpressionSuggestion]] = {
52
      val typedPrevNode = nodeInPosition.prevNode().flatMap(_.typingResultWithContext)
2✔
53
      typedPrevNode.collect {
2✔
54
        case TypingResultWithContext(tc: TypedClass, staticContext) => Future.successful(typeDefinitions.get(tc.klass).map(c => filterClassMethods(c, p.getName, staticContext)).getOrElse(Nil))
2✔
55
        case TypingResultWithContext(to: TypedObjectWithValue, staticContext) => Future.successful(typeDefinitions.get(to.underlying.klass).map(c => filterClassMethods(c, p.getName, staticContext)).getOrElse(Nil))
2✔
56
        case TypingResultWithContext(to: TypedObjectTypingResult, _) =>
2✔
57
          val suggestionsFromFields = filterMapByName(to.fields, p.getName).toList.map { case (methodName, clazzRef) => ExpressionSuggestion(methodName, clazzRef, fromClass = false, None, Nil) }
2✔
58
          val suggestionsFromClass = typeDefinitions.get(to.objType.klass).map(c => filterClassMethods(c, p.getName, staticContext = false, fromClass = suggestionsFromFields.nonEmpty)).getOrElse(Nil)
2✔
59
          Future.successful(suggestionsFromFields ++ suggestionsFromClass)
2✔
60
        case TypingResultWithContext(tu: TypedUnion, staticContext) => Future.successful(tu.possibleTypes.map(_.objType.klass).flatMap(klass => typeDefinitions.get(klass).map(c => filterClassMethods(c, p.getName, staticContext)).getOrElse(Nil)))
2✔
61
        case TypingResultWithContext(td: TypedDict, _) => dictQueryService.queryEntriesByLabel(td.dictId, if (shouldInsertDummyVariable) "" else p.getName)
1✔
62
          .map(_.map(list => list.map(e => ExpressionSuggestion(e.label, td, fromClass = false, None, Nil)))).getOrElse(successfulNil)
2✔
63
      }.getOrElse(successfulNil)
2✔
64
    }
65

66
    def filterClassMethods(classDefinition: ClazzDefinition, name: String, staticContext: Boolean, fromClass: Boolean = false): List[ExpressionSuggestion] = {
67
      val methods = filterMapByName(if (staticContext) classDefinition.staticMethods else classDefinition.methods, name)
2✔
68

69
      methods.values.flatten
2✔
70
        .map { method =>
1✔
71
          // TODO: present all overloaded methods, not only one with most parameters.
72
          //  Current logic here is the same as in UIProcessObjectsFactory
73
          val signature = method.signatures.toList.maxBy(_.parametersToList.length)
2✔
74
          ExpressionSuggestion(method.name, signature.result, fromClass = fromClass, method.description, (signature.noVarArgs ::: signature.varArg.toList).map(p => Parameter(p.name, p.refClazz)))
2✔
75
        }
76
        .toList
2✔
77
    }
78

79
    val suggestions = for {
80
      (parsedSpelNode, adjustedPosition) <- nuSpelNodeParser.parse(input, expression.language, normalizedCaretPosition, variables).toOption.flatten
2✔
81
      nodeInPosition <- parsedSpelNode.findNodeInPosition(adjustedPosition)
2✔
82
    } yield {
83
      nodeInPosition.spelNode match {
2✔
84
        // variable is typed (#foo), so we need to return filtered list of all variables that match currently typed name
85
        case v: VariableReference =>
2✔
86
          // if the caret is inside projection or selection (eg #list.?[#<HERE>]) we add `this` to list of variables
87
          val thisTypingResult = for {
88
            parent <- nodeInPosition.parent.map(_.node)
2✔
89
            prevNode <- parent.prevNode().flatMap(_.typingResultWithContext)
2✔
90
          } yield {
91
            parent.spelNode match {
2✔
92
              case _: Selection | _: Projection => Some(determineIterableElementTypingResult(prevNode.typingResult))
2✔
93
              case _ => None
2✔
94
            }
95
          }
96
          val filteredVariables = filterMapByName(thisTypingResult.flatten.map("this" -> _).toMap ++ variables, v.toStringAST.stripPrefix("#"))
2✔
97
          Future.successful(filteredVariables.map { case (variable, clazzRef) => ExpressionSuggestion(s"#$variable", clazzRef, fromClass = false, None, Nil) })
2✔
98
        // property is typed (#foo.bar), so we need to return filtered list of all methods and fields from previous spel node type
99
        case p: PropertyOrFieldReference =>
100
          suggestionsForPropertyOrFieldReference(nodeInPosition, p)
2✔
101
        // suggestions for dictionary with indexer notation - #dict['Foo']
102
        // 1. caret is inside string
103
        // 2. parent node is Indexer - []
104
        // 3. parent's prev node is dictionary
105
        case s: StringLiteral =>
2✔
106
          val y = for {
107
            parent <- nodeInPosition.parent.map(_.node)
2✔
108
            parentPrevNode <- parent.prevNode()
2✔
109
            parentPrevNodeTyping <- parentPrevNode.typingResultWithContext.map(_.typingResult)
2✔
110
          } yield {
111
            parent.spelNode match {
2✔
112
              case _: Indexer => parentPrevNodeTyping match {
2✔
113
                case td: TypedDict => dictQueryService.queryEntriesByLabel(td.dictId, s.getLiteralValue.getValue.toString)
2✔
114
                  .map(_.map(list => list.map(e => ExpressionSuggestion(e.label, td, fromClass = false, None, Nil)))).getOrElse(successfulNil)
2✔
115
                case _ => successfulNil
×
116
              }
117
              case _ => successfulNil
×
118
            }
119
          }
120
          y.getOrElse(successfulNil)
×
121
        // suggestions for full class name inside TypeReference, eg T(java.time.Duration)
122
        case _: Identifier =>
2✔
123
          val r = for {
124
            parentNode <- nodeInPosition.parent
2✔
125
            grandparentNode <- parentNode.node.parent
2✔
126
          } yield {
127
            (parentNode.node.spelNode, grandparentNode.node.spelNode) match {
128
              case (q: QualifiedIdentifier, _: TypeReference) =>
129
                val name = if (shouldInsertDummyVariable) {
130
                  q.toStringAST.stripSuffix("x")
2✔
131
                } else {
132
                  q.toStringAST
2✔
133
                }
134
                typeDefinitions.typeDefinitions.keys.filter {
135
                  klass => klass.getName.startsWith(name)
2✔
136
                }.flatMap {
1✔
137
                  klass => klass.getName.stripPrefix(q.toStringAST.split('.').dropRight(1).mkString(".")).stripPrefix(".").split('.').headOption
2✔
138
                }.toSet.map {
2✔
139
                  ExpressionSuggestion(_, Unknown, fromClass = false, None, Nil)
2✔
140
                }
141
              case _ => Nil
×
142
            }
143
          }
144
          Future.successful(r.getOrElse(Nil))
1✔
145
        case _ => successfulNil
×
146
      }
147
    }
148
    suggestions.getOrElse(successfulNil).map(_.toList.sortBy(_.methodName))
2✔
149

150
  }
151

152
  private def insertDummyVariable(s: String, index: Int): String = {
153
    val (start, end) = s.splitAt(index)
2✔
154
    start + "x" + end
2✔
155
  }
156

157
  private def determineIterableElementTypingResult(parent: TypingResult): TypingResult = {
158
    parent match {
159
      case tc: SingleTypingResult if tc.objType.canBeSubclassOf(Typed[java.util.Collection[_]]) =>
2✔
160
        tc.objType.params.headOption.getOrElse(Unknown)
1✔
161
      case tc: SingleTypingResult if tc.objType.canBeSubclassOf(Typed[java.util.Map[_, _]]) =>
2✔
162
        TypedObjectTypingResult(List(
2✔
163
          ("key", tc.objType.params.headOption.getOrElse(Unknown)),
1✔
164
          ("value", tc.objType.params.drop(1).headOption.getOrElse(Unknown))))
2✔
165
      case tc: SingleTypingResult if tc.objType.klass.isArray =>
×
166
        tc.objType.params.headOption.getOrElse(Unknown)
×
167
      case _ => Unknown
×
168
    }
169
  }
170
}
171

172
private class NuSpelNodeParser(typer: Typer) extends LazyLogging {
173
  private val parser = new NuTemplateAwareExpressionParser(new SpelParserConfiguration)
2✔
174

175
  def parse(input: String, language: String, position: Int, variables: Map[String, TypingResult]): Try[Option[(NuSpelNode, Int)]] = {
176
    val rawExpression = language match {
177
      case Expression.Language.Spel => Try(parser.parseExpression(input, null))
2✔
178
      case Expression.Language.SpelTemplate => Try(parser.parseExpression(input, new TemplateParserContext()))
2✔
179
      case _ => Failure(new IllegalArgumentException(s"Language $language is not supported"))
×
180
    }
181
    rawExpression.map { parsedExpressions =>
182
      parsedExpressions.find(e => e.start <= position && position <= e.end).flatMap { e =>
2✔
183
        e.expression match {
2✔
184
          case s: SpringSpelExpression =>
2✔
185
            val collectedTypingResult = typer.doTypeExpression(s, ValidationContext(variables))._2
2✔
186
            Some((new NuSpelNode(s.getAST, collectedTypingResult), position - e.start))
2✔
187
          case _ => None
2✔
188
        }
189
      }
190
    }.recoverWith {
2✔
191
      case e =>
2✔
192
        logger.debug(s"Failed to parse $language expression: $input, error: ${e.getMessage}")
193
        Failure(e)
2✔
194
    }
195
  }
196
}
197

198
private class NuSpelNode(val spelNode: SpelNode, collectedTypingResult: CollectedTypingResult, val parent: Option[NuSpelNodeParent] = None) {
199
  val children: List[NuSpelNode] = (0 until spelNode.getChildCount).map { i =>
2✔
200
    new NuSpelNode(spelNode.getChild(i), collectedTypingResult, Some(NuSpelNodeParent(this, i)))
2✔
201
  }.toList
2✔
202
  val typingResultWithContext: Option[Typer.TypingResultWithContext] = collectedTypingResult.intermediateResults.get(SpelNodeId(spelNode))
2✔
203

204
  def findNodeInPosition(position: Int): Option[NuSpelNode] = {
205
    val allInPosition = (this :: children.flatMap(c => c.findNodeInPosition(position)))
2✔
206
      .filter(e => e.isInPosition(position))
2✔
207
    for {
208
      // scala 2.12 is missing minOption and findLast
209
      shortest <- minOption(allInPosition.map(e => e.positionLength))
2✔
210
      last <- allInPosition.reverse.find(e => e.positionLength == shortest)
2✔
211
    } yield last
212
  }
213

214
  def prevNode(): Option[NuSpelNode] = {
215
    parent.filter(_.nodeIndex > 0).map(p => p.node.children(p.nodeIndex - 1))
2✔
216
  }
217

218
  private def minOption(seq: Seq[Int]): Option[Int] = if (seq.isEmpty) {
2✔
219
    None
2✔
220
  } else {
221
    Some(seq.min)
2✔
222
  }
223

224
  private def isInPosition(position: Int): Boolean = spelNode.getStartPosition <= position && position <= spelNode.getEndPosition
2✔
225

226
  private def positionLength: Int = spelNode.getEndPosition - spelNode.getStartPosition
2✔
227

228
}
229

230
private case class NuSpelNodeParent(node: NuSpelNode, nodeIndex: Int)
231

232
// TODO: fromClass is used to calculate suggestion score - to show fields first, then class methods. Maybe we should
233
//  return score from BE?
234
@JsonCodec(encodeOnly = true)
×
235
case class ExpressionSuggestion(methodName: String, refClazz: TypingResult, fromClass: Boolean, description: Option[String], parameters: List[Parameter])
236

237
@JsonCodec(encodeOnly = true)
×
238
case class Parameter(name: String, refClazz: TypingResult)
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc