![JAR search and dependency download from the Maven repository](/logo.png)
pl.touk.nussknacker.engine.spel.SpelExpressionSuggester.scala Maven / Gradle / Ivy
package pl.touk.nussknacker.engine.spel
import com.typesafe.scalalogging.LazyLogging
import io.circe.generic.JsonCodec
import org.springframework.expression.common.TemplateParserContext
import org.springframework.expression.spel.ast._
import org.springframework.expression.spel.standard.{SpelExpression => SpringSpelExpression}
import org.springframework.expression.spel.{SpelNode, SpelParserConfiguration}
import pl.touk.nussknacker.engine.api.context.ValidationContext
import pl.touk.nussknacker.engine.api.dict.UiDictServices
import pl.touk.nussknacker.engine.api.typed.typing._
import pl.touk.nussknacker.engine.api.validation.Validations.isVariableNameValid
import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinition, ClassDefinitionSet}
import pl.touk.nussknacker.engine.definition.globalvariables.ExpressionConfigDefinition
import pl.touk.nussknacker.engine.dict.LabelsDictTyper
import pl.touk.nussknacker.engine.extension.CastOrConversionExt
import pl.touk.nussknacker.engine.graph.expression.Expression
import pl.touk.nussknacker.engine.graph.expression.Expression.Language
import pl.touk.nussknacker.engine.spel.Typer.TypingResultWithContext
import pl.touk.nussknacker.engine.spel.ast.SpelAst.SpelNodeId
import pl.touk.nussknacker.engine.spel.parser.NuTemplateAwareExpressionParser
import pl.touk.nussknacker.engine.util.CaretPosition2d
import scala.collection.compat.immutable.LazyList
import cats.implicits._
import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap
import pl.touk.nussknacker.engine.util.classes.Extensions.{ClassExtensions, ClassesExtensions}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Try}
class SpelExpressionSuggester(
expressionConfig: ExpressionConfigDefinition,
clssDefinitions: ClassDefinitionSet,
uiDictServices: UiDictServices,
classLoader: ClassLoader
) {
private val successfulNil = Future.successful[List[ExpressionSuggestion]](Nil)
private val typer =
Typer.default(classLoader, expressionConfig, new LabelsDictTyper(uiDictServices.dictRegistry), clssDefinitions)
private val nuSpelNodeParser = new NuSpelNodeParser(typer)
private val dictQueryService = uiDictServices.dictQueryService
def expressionSuggestions(
expression: Expression,
caretPosition2d: CaretPosition2d,
validationContext: ValidationContext
)(implicit ec: ExecutionContext): Future[List[ExpressionSuggestion]] = {
val futureSuggestionsOption =
expressionSuggestionsAux(
expression,
caretPosition2d.normalizedCaretPosition(expression.expression),
validationContext
)
lazy val newExpressionForSpELVariable: Option[Expression] =
truncateExpressionToCorrespondingSpELVariable(expression, caretPosition2d)
lazy val futureSuggestionsAfterTruncatingExpressionToCorrespondingSpELVariableOption =
newExpressionForSpELVariable.flatMap(e =>
expressionSuggestionsAux(
e,
e.expression.length,
validationContext
)
)
val lazyListOfFutureSuggestions = LazyList(
futureSuggestionsOption,
futureSuggestionsAfterTruncatingExpressionToCorrespondingSpELVariableOption
)
val firstNonEmptySuggestionFuture = firstNonEmptyFuture[ExpressionSuggestion](lazyListOfFutureSuggestions)
firstNonEmptySuggestionFuture.map(_.toList.sortBy(_.methodName)).map(_.toList)
}
private def firstNonEmptyFuture[A](
options: LazyList[Option[Future[Iterable[A]]]]
)(implicit ec: ExecutionContext): Future[Iterable[A]] = {
def processOption(optFuture: Option[Future[Iterable[A]]]): Future[Iterable[A]] = {
optFuture match {
case Some(future) =>
future.flatMap {
case iterable if iterable.isEmpty => Future.failed(new Exception("Empty iterable"))
case nonEmptyIterable => Future.successful(nonEmptyIterable)
}
case None => Future.failed(new Exception("None should be skipped"))
}
}
def processOptionsRecursively(opts: LazyList[Option[Future[Iterable[A]]]]): Future[Iterable[A]] = {
opts match {
case LazyList() => Future.successful(Iterable.empty)
case ll =>
processOption(ll.head).recoverWith { case _: Exception =>
processOptionsRecursively(ll.tail)
}
}
}
processOptionsRecursively(options)
}
private def expressionSuggestionsAux(
expression: Expression,
normalizedCaretPosition: Int,
validationContext: ValidationContext
)(
implicit ec: ExecutionContext
): Option[Future[Iterable[ExpressionSuggestion]]] = {
val spelExpression = expression.expression
if (normalizedCaretPosition == 0) {
return Some(successfulNil)
}
val previousChar = spelExpression.substring(normalizedCaretPosition - 1, normalizedCaretPosition)
val shouldInsertDummyVariable =
(previousChar == "#" || previousChar == ".") && (normalizedCaretPosition == spelExpression.length || !spelExpression
.charAt(normalizedCaretPosition)
.isLetter)
val input = if (shouldInsertDummyVariable) {
insertDummyVariable(spelExpression, normalizedCaretPosition)
} else {
spelExpression
}
def filterMapByName[V](map: Map[String, V], name: String): Map[String, V] = {
if (shouldInsertDummyVariable) {
map
} else {
map.filter { case (key, _) => key.toLowerCase.contains(name.toLowerCase) }
}
}
def suggestionsForPropertyOrFieldReference(
nodeInPosition: NuSpelNode,
p: PropertyOrFieldReference
): Future[Iterable[ExpressionSuggestion]] = {
val nuSpelNodeParentOpt = nodeInPosition.parent.map(_.node)
val parentIsIndexer = nuSpelNodeParentOpt.exists(_.spelNode.isInstanceOf[Indexer])
val typedNode = nuSpelNodeParentOpt.flatMap {
case nuSpelNodeParent if parentIsIndexer =>
nuSpelNodeParent.prevNode().flatMap(_.typingResultWithContext)
case _ =>
nodeInPosition.prevNode().flatMap(_.typingResultWithContext)
}
typedNode
.collect {
case TypingResultWithContext(tc: TypedClass, staticContext) =>
Future.successful(
clssDefinitions.get(tc.klass).map(c => filterClassMethods(c, p.getName, staticContext)).getOrElse(Nil)
)
case TypingResultWithContext(to: TypedObjectWithValue, staticContext) =>
Future.successful(
clssDefinitions
.get(to.underlying.klass)
.map(c => filterClassMethods(c, p.getName, staticContext))
.getOrElse(Nil)
)
case TypingResultWithContext(to: TypedObjectTypingResult, _) =>
def filterIllegalIdentifierAfterDot(name: String) = {
isVariableNameValid(name)
}
val collectSuggestionsFromClass = parentIsIndexer
val suggestionsFromFields = filterMapByName(to.fields, p.getName).toList
.filter { case (fieldName, _) =>
// TODO: signal to user that some values have been filtered
filterIllegalIdentifierAfterDot(fieldName)
}
.map { case (methodName, clazzRef) =>
ExpressionSuggestion(methodName, clazzRef, fromClass = false, None, Nil)
}
val suggestionsFromClass = clssDefinitions
.get(to.runtimeObjType.klass)
.map(c => filterClassMethods(c, p.getName, staticContext = false, fromClass = true))
.getOrElse(Nil)
val applicableSuggestions = if (collectSuggestionsFromClass) {
suggestionsFromFields
} else {
suggestionsFromFields ++ suggestionsFromClass
}
Future.successful(applicableSuggestions)
case TypingResultWithContext(tu: TypedUnion, staticContext) =>
Future.successful(
tu.possibleTypes
.map(_.runtimeObjType.klass)
.toList
.flatMap(klass =>
clssDefinitions.get(klass).map(c => filterClassMethods(c, p.getName, staticContext)).getOrElse(Nil)
)
.distinct
)
case TypingResultWithContext(td: TypedDict, _) =>
dictQueryService
.queryEntriesByLabel(td.dictId, if (shouldInsertDummyVariable) "" else p.getName)
.map(_.map(list => list.map(e => ExpressionSuggestion(e.label, td, fromClass = false, None, Nil))))
.getOrElse(successfulNil)
case TypingResultWithContext(Unknown, staticContext) =>
Future.successful(
clssDefinitions.unknown
.map(c => filterClassMethods(c, p.getName, staticContext))
.getOrElse(Nil)
)
}
.getOrElse(successfulNil)
}
def filterClassMethods(
classDefinition: ClassDefinition,
name: String,
staticContext: Boolean,
fromClass: Boolean = false
): List[ExpressionSuggestion] = {
val methods = filterMapByName(if (staticContext) classDefinition.staticMethods else classDefinition.methods, name)
methods.values.flatten.map { method =>
// TODO: present all overloaded methods, not only one with most parameters.
val signature = method.signatures.toList.maxBy(_.parametersToList.length)
ExpressionSuggestion(
method.name,
signature.result,
fromClass = fromClass,
method.description,
(signature.noVarArgs ::: signature.varArg.toList).map(p => Parameter(p.name, p.refClazz))
)
}.toList
}
val suggestions = for {
(parsedSpelNode, adjustedPosition) <- nuSpelNodeParser
.parse(input, expression.language, normalizedCaretPosition, validationContext)
.toOption
.flatten
nodeInPosition <- parsedSpelNode.findNodeInPosition(adjustedPosition)
} yield {
nodeInPosition.spelNode match {
// variable is typed (#foo), so we need to return filtered list of all variables that match currently typed name
case v: VariableReference =>
// if the caret is inside projection or selection (eg #list.?[#]) we add `this` to list of variables
val thisTypingResult = for {
parent <- nodeInPosition.parent.map(_.node)
prevNode <- parent.prevNode().flatMap(_.typingResultWithContext)
} yield {
parent.spelNode match {
case _: Selection | _: Projection => Some(determineIterableElementTypingResult(prevNode.typingResult))
case _ => None
}
}
val filteredVariables = filterMapByName(
thisTypingResult.flatten.map("this" -> _).toMap ++ validationContext.variables,
v.toStringAST.stripPrefix("#")
)
Future.successful(filteredVariables.map { case (variable, clazzRef) =>
ExpressionSuggestion(s"#$variable", clazzRef, fromClass = false, None, Nil)
})
// property is typed (#foo.bar), so we need to return filtered list of all methods and fields from previous spel node type
case p: PropertyOrFieldReference =>
suggestionsForPropertyOrFieldReference(nodeInPosition, p)
// suggestions:
// 1. for dictionary with indexer notation - #dict['Foo']
// 1. caret is inside string
// 2. parent node is Indexer - []
// 3. parent's prev node is dictionary
// 2. for MethodReference and Cast methods - e.g. #variable.castTo('')
case s: StringLiteral =>
val y = for {
parent <- nodeInPosition.parent.map(_.node)
parentPrevNode <- parent.prevNode()
parentPrevNodeTyping <- parentPrevNode.typingResultWithContext.map(_.typingResult)
} yield {
parent.spelNode match {
case _: Indexer =>
parentPrevNodeTyping match {
case td: TypedDict =>
dictQueryService
.queryEntriesByLabel(td.dictId, s.getLiteralValue.getValue.toString)
.map(
_.map(list =>
list.map(e => ExpressionSuggestion(e.label, td.valueType, fromClass = false, None, Nil))
)
)
.getOrElse(successfulNil)
case TypedObjectTypingResult(fields, _, _) =>
Future.successful(fields.map(f => ExpressionSuggestion(f._1, f._2, fromClass = false, None, Nil)))
case _ => successfulNil
}
case m: MethodReference if CastOrConversionExt.isCastOrConversionMethod(m.getName) =>
parentPrevNodeTyping.withoutValue match {
case t @ Unknown =>
castOrConversionMethodsSuggestions(classOf[Object], t)
case t @ TypedClass(klass, _) =>
castOrConversionMethodsSuggestions(klass, t)
case _ => successfulNil
}
case _ => successfulNil
}
}
y.getOrElse(successfulNil)
// suggestions for full class name inside TypeReference, eg T(java.time.Duration)
case _: Identifier =>
val r = for {
parentNode <- nodeInPosition.parent
grandparentNode <- parentNode.node.parent
} yield {
(parentNode.node.spelNode, grandparentNode.node.spelNode) match {
case (q: QualifiedIdentifier, _: TypeReference) =>
val name = if (shouldInsertDummyVariable) {
q.toStringAST.stripSuffix("x")
} else {
q.toStringAST
}
clssDefinitions.classDefinitionsMap.keys
.filter { klass =>
klass.getName.startsWith(name)
}
.flatMap { klass =>
klass.getName
.stripPrefix(q.toStringAST.split('.').dropRight(1).mkString("."))
.stripPrefix(".")
.split('.')
.headOption
}
.toSet
.map {
ExpressionSuggestion(_, Unknown, fromClass = false, None, Nil)
}
case _ => Nil
}
}
Future.successful(r.getOrElse(Nil))
case _ => successfulNil
}
}
suggestions
}
private def castOrConversionMethodsSuggestions(
invocationTargetClass: Class[_],
invocationTargetTyping: TypingResult,
)(implicit ec: ExecutionContext): Future[Iterable[ExpressionSuggestion]] =
Future {
val allowedClassesForCastParameter = invocationTargetClass
.findAllowedClassesForCastParameter(clssDefinitions)
.mapValuesNow(_.clazzName)
val castSuggestions = allowedClassesForCastParameter.keySet
.classesBySimpleNamesRegardingClashes()
.map { case (name, clazz) =>
ExpressionSuggestion(name, allowedClassesForCastParameter.getOrElse(clazz, Unknown), false, None, Nil)
}
val conversionSuggestions = CastOrConversionExt
.allowedConversions(invocationTargetClass)
.map(c =>
ExpressionSuggestion(
c.resultTypeClass.simpleName(),
c.typingFunction(invocationTargetTyping).getOrElse(c.typingResult),
false,
None,
Nil
)
)
(castSuggestions ++ conversionSuggestions).toSet
}
private def expressionContainOddNumberOfQuotesOrOddNumberOfDoubleQuotes(plainExpression: String): Boolean =
plainExpression.count(
_ == '\''
) % 2 == 1 || plainExpression.count(_ == '\"') % 2 == 1
private def truncateExpressionToCorrespondingSpELVariable(
expression: Expression,
caretPosition2d: CaretPosition2d
): Option[Expression] = {
val truncatedPlainExpression = truncatePlainExpression(expression, caretPosition2d)
truncatedPlainExpression match {
case expr
if expr.isEmpty || !expr
.contains('#') || expr.last == ' ' || expressionContainOddNumberOfQuotesOrOddNumberOfDoubleQuotes(expr) =>
None
case expr =>
expr.split('#').toList.reverse.headOption match {
case Some(alignedSpELVariableName) => Some(expression.copy(expression = s"#$alignedSpELVariableName"))
case None => None
}
}
}
private def truncatePlainExpression(expression: Expression, caretPosition2d: CaretPosition2d) = {
val transformedPlainExpression = expression.expression
.split("\n")
.toList
.zipWithIndex
.filter(_._2 <= caretPosition2d.row)
.map {
case (s, caretPosition2d.row) => s.take(caretPosition2d.column)
case (s, _) => s
}
.mkString("\n")
transformedPlainExpression
}
private def insertDummyVariable(s: String, index: Int): String = {
val (start, end) = s.splitAt(index)
start + "x" + end
}
private def determineIterableElementTypingResult(parent: TypingResult): TypingResult = {
parent match {
case tc: SingleTypingResult if tc.runtimeObjType.canBeSubclassOf(Typed[java.util.Collection[_]]) =>
tc.runtimeObjType.params.headOption.getOrElse(Unknown)
case tc: SingleTypingResult if tc.runtimeObjType.canBeSubclassOf(Typed[java.util.Map[_, _]]) =>
Typed.record(
Map(
"key" -> tc.runtimeObjType.params.headOption.getOrElse(Unknown),
"value" -> tc.runtimeObjType.params.drop(1).headOption.getOrElse(Unknown)
)
)
case _ => Unknown
}
}
}
private class NuSpelNodeParser(typer: Typer) extends LazyLogging {
private val parser = new NuTemplateAwareExpressionParser(new SpelParserConfiguration)
def parse(
input: String,
language: Language,
position: Int,
validationContext: ValidationContext
): Try[Option[(NuSpelNode, Int)]] = {
val rawExpression = language match {
case Language.Spel => Try(parser.parseExpression(input, null))
case Language.SpelTemplate => Try(parser.parseExpression(input, new TemplateParserContext()))
case Language.DictKeyWithLabel | Language.TabularDataDefinition =>
Failure(new IllegalArgumentException(s"Language $language is not supported"))
}
rawExpression
.map { parsedExpressions =>
parsedExpressions.find(e => e.start <= position && position <= e.end).flatMap { e =>
e.expression match {
case s: SpringSpelExpression =>
val collectedTypingResult =
typer.doTypeExpression(s, validationContext)._2
Some((new NuSpelNode(s.getAST, collectedTypingResult), position - e.start))
case _ => None
}
}
}
.recoverWith { case e =>
logger.debug(s"Failed to parse $language expression: $input, error: ${e.getMessage}")
Failure(e)
}
}
}
private class NuSpelNode(
val spelNode: SpelNode,
collectedTypingResult: CollectedTypingResult,
val parent: Option[NuSpelNodeParent] = None
) {
val children: List[NuSpelNode] = (0 until spelNode.getChildCount).map { i =>
new NuSpelNode(spelNode.getChild(i), collectedTypingResult, Some(NuSpelNodeParent(this, i)))
}.toList
val typingResultWithContext: Option[Typer.TypingResultWithContext] =
collectedTypingResult.intermediateResults.get(SpelNodeId(spelNode))
def findNodeInPosition(position: Int): Option[NuSpelNode] = {
val allInPosition = (this :: children.flatMap(c => c.findNodeInPosition(position)))
.filter(e => e.isInPosition(position))
for {
// scala 2.12 is missing minOption and findLast
shortest <- minOption(allInPosition.map(e => e.positionLength))
last <- allInPosition.reverse.find(e => e.positionLength == shortest)
} yield last
}
def prevNode(): Option[NuSpelNode] = {
parent.filter(_.nodeIndex > 0).map(p => p.node.children(p.nodeIndex - 1))
}
private def minOption(seq: Seq[Int]): Option[Int] = if (seq.isEmpty) {
None
} else {
Some(seq.min)
}
private def isInPosition(position: Int): Boolean =
spelNode.getStartPosition <= position && position <= spelNode.getEndPosition
private def positionLength: Int = spelNode.getEndPosition - spelNode.getStartPosition
}
private case class NuSpelNodeParent(node: NuSpelNode, nodeIndex: Int)
// TODO: fromClass is used to calculate suggestion score - to show fields first, then class methods. Maybe we should
// return score from BE?
@JsonCodec(encodeOnly = true)
case class ExpressionSuggestion(
methodName: String,
refClazz: TypingResult,
fromClass: Boolean,
description: Option[String],
parameters: List[Parameter]
)
@JsonCodec(encodeOnly = true)
case class Parameter(name: String, refClazz: TypingResult)
© 2015 - 2025 Weber Informatics LLC | Privacy Policy