All Downloads are FREE. Search and download functionalities are using the official Maven repository.

bsp.codegen.bsp4j.JavaRenderer.scala Maven / Gradle / Ivy

The newest version!
package bsp.codegen.bsp4j

import bsp.codegen._
import bsp.codegen.dsl.{block, empty, lines, newline}
import bsp.codegen.ir.Def._
import bsp.codegen.ir.EnumType.{IntEnum, StringEnum}
import bsp.codegen.ir.Hint._
import bsp.codegen.ir.JsonRPCMethodType.{Notification, Request}
import bsp.codegen.ir.Primitive._
import bsp.codegen.ir.Type._
import bsp.codegen.ir._
import cats.implicits.toFoldableOps
import os.RelPath
import software.amazon.smithy.model.shapes.ShapeId

class JavaRenderer(basepkg: String, definitions: List[Def], version: String) {
  import bsp.codegen.Settings.java

  val baseRelPath: RelPath = os.rel / basepkg.split('.')

  def render(): List[CodegenFile] = {
    definitions.flatMap(renderDef) ++ List(renderVersion(), copyPreconditions())
  }

  def copyPreconditions(): CodegenFile = {
    val preconditionsSourcePath =
      os.pwd / "codegen" / "src" / "main" / "resources" / "Preconditions.java"
    // TODO: dehardcode "codegen" path above
    val preconditionsContents = os.read(preconditionsSourcePath)
    val preconditionsPath = os.rel / "org" / "eclipse" / "lsp4j" / "util" / "Preconditions.java"
    // For some reason extend expects this file to be present in this specific location,
    // it can be removed once we stop using extend
    CodegenFile(preconditionsPath, preconditionsContents)
  }

  def renderVersion(): CodegenFile = {
    val contents = lines(
      s"package $basepkg;",
      newline,
      block("public class Bsp4j")(
        s"""public static final String PROTOCOL_VERSION = new String("$version");"""
      ),
      newline
    )

    CodegenFile(baseRelPath / "Bsp4j.java", contents.render)
  }

  def renderDef(definition: Def): Option[CodegenFile] = {
    definition match {
      case Alias(shapeId, tpe, _)               => None
      case Structure(shapeId, fields, hints, _) => Some(renderStructure(shapeId, fields, hints))
      case ClosedEnum(shapeId, enumType, values, hints) =>
        Some(renderClosedEnum(shapeId, enumType, values, hints))
      case OpenEnum(shapeId, enumType, values, hints) =>
        Some(renderOpenEnum(shapeId, enumType, values, hints))
      case Service(shapeId, operations, _) => Some(renderService(shapeId, operations))
    }
  }

  def renderStructure(shapeId: ShapeId, fields: List[Field], hints: List[Hint]): CodegenFile = {
    val requiredFields = fields.filter(_.required)
    val docsLines = renderDocs(hints)

    val allLines = lines(
      renderPkg(shapeId),
      newline,
      "import org.eclipse.lsp4j.generator.JsonRpcData",
      renderImports(fields),
      newline,
      docsLines,
      "@JsonRpcData",
      block(s"class ${shapeId.getName()}")(
        lines(fields.map(renderJavaField)),
        newline, {
          val params = requiredFields.map(renderParam).mkString(", ")
          val assignments = requiredFields.map(_.name).map(n => s"this.$n = $n")
          block(s"new($params)")(assignments)
        }
      ),
      newline
    )

    val fileName = shapeId.getName() + ".xtend"
    CodegenFile(baseRelPath / fileName, allLines.render)
  }

  def spreadEnumLines[A](enumType: EnumType[A], values: List[EnumValue[A]]): Lines = {
    val renderedValues = values.map(renderEnumValueDef(enumType))
    renderedValues.init.map(_ + ",") :+ (renderedValues.last + ";")
  }

  def renderDocs(hints: List[Hint]): Lines = {
    val isUnstable = hints.contains(Unstable)
    val unstableNote = if (isUnstable) {
      List("**Unstable** (may change in future versions)")
    } else {
      List.empty
    }
    val docs = unstableNote ++
      hints.collect { case Documentation(string) =>
        string.split(System.lineSeparator()).toList
      }.flatten
    docs match {
      case Nil => empty
      case _ =>
        lines(
          "/**",
          docs.map(line => s" * $line"),
          " */"
        )
    }
  }

  def renderClosedEnum[A](
      shapeId: ShapeId,
      enumType: EnumType[A],
      values: List[EnumValue[A]],
      hints: List[Hint]
  ): CodegenFile = {
    val evt = enumValueType(enumType)
    val tpe = shapeId.getName()
    val docsLines = renderDocs(hints)
    val allLines = lines(
      renderPkg(shapeId).map(_ + ";"),
      newline,
      "import com.google.gson.annotations.JsonAdapter;",
      "import org.eclipse.lsp4j.jsonrpc.json.adapters.EnumTypeAdapter;",
      newline,
      docsLines,
      "@JsonAdapter(EnumTypeAdapter.Factory.class)",
      block(s"public enum $tpe")(
        newline,
        spreadEnumLines(enumType, values),
        newline,
        s"private final $evt value;",
        newline,
        block(s"$tpe($evt value)") {
          "this.value = value;"
        },
        newline,
        block(s"public $evt getValue()") {
          "return value;"
        },
        newline,
        block(s"public static $tpe forValue($evt value)")(
          s"$tpe[] allValues = $tpe.values();",
          "if (value < 1 || value > allValues.length)",
          lines("""throw new IllegalArgumentException("Illegal enum value: " + value);""").indent,
          "return allValues[value - 1];"
        )
      ),
      newline
    )
    val fileName = shapeId.getName() + ".java"
    CodegenFile(baseRelPath / fileName, allLines.render)
  }

  def renderOpenEnum[A](
      shapeId: ShapeId,
      enumType: EnumType[A],
      values: List[EnumValue[A]],
      hints: List[Hint]
  ): CodegenFile = {
    val tpe = shapeId.getName()
    val docsLines = renderDocs(hints)
    val allLines = lines(
      renderPkg(shapeId).map(_ + ";"),
      newline,
      docsLines,
      block(s"public class $tpe") {
        values.map(renderStaticValue(enumType))
      },
      newline
    )
    val fileName = shapeId.getName() + ".java"
    CodegenFile(baseRelPath / fileName, allLines.render)
  }

  def renderService(shapeId: ShapeId, operations: List[Operation]): CodegenFile = {
    val allLines = lines(
      renderPkg(shapeId).map(_ + ";"),
      newline,
      "import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;",
      "import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;",
      newline,
      "import java.util.concurrent.CompletableFuture;",
      newline,
      block(s"public interface ${shapeId.getName()}")(
        operations.foldMap(renderOperation),
        newline
      ),
      newline
    )
    val fileName = shapeId.getName() + ".java"
    CodegenFile(baseRelPath / fileName, allLines.render)
  }

  def renderOperation(operation: Operation): Lines = {
    val output = (operation.jsonRPCMethodType, operation.outputType) match {
      case (Notification, _)                         => "void"
      case (Request, TPrimitive(Primitive.PUnit, _)) => s"CompletableFuture"
      case (Request, other)                          => s"CompletableFuture<${renderType(other)}>"
    }
    val input = operation.inputType match {
      case TPrimitive(Primitive.PUnit, _) => ""
      case other                          => s"${renderType(other)} params"
    }
    val rpcMethod = operation.jsonRPCMethod
    val rpcAnnotation = operation.jsonRPCMethodType match {
      case Notification => s"""@JsonNotification("$rpcMethod")"""
      case Request      => s"""@JsonRequest("$rpcMethod")"""
    }
    val maybeDeprecated = operation.hints.collectFirst({ case Deprecated(_) => "@Deprecated" })
    val docsLines = renderDocs(operation.hints)
    val method = operation.name.head.toLower + operation.name.tail
    lines(
      docsLines,
      maybeDeprecated,
      rpcAnnotation,
      s"$output $method($input);",
      newline
    )
  }

  def enumValueType[A](enumType: EnumType[A]): String = enumType match {
    case IntEnum    => "int"
    case StringEnum => "String"
  }

  def renderStaticValue[A](enumType: EnumType[A]): EnumValue[A] => String = {
    enumType match {
      case IntEnum =>
        (ev: EnumValue[Int]) => s"""public static final int ${ev.name} = ${ev.value};"""
      case StringEnum =>
        (ev: EnumValue[String]) => s"""public static final String ${ev.name} = "${ev.value}";"""
    }
  }

  def renderEnumValueDef[A](enumType: EnumType[A]): EnumValue[A] => String = {
    enumType match {
      case IntEnum    => (ev: EnumValue[Int]) => s"${ev.name}(${ev.value})"
      case StringEnum => (ev: EnumValue[String]) => s"""${ev.name}("${ev.value}")"""
    }
  }

  def renderPkg(shapeId: ShapeId): Lines = lines(
    s"package $basepkg"
  )

  def renderImports(fields: List[Field]): Lines =
    fields.foldMap(renderImport).distinct.sorted

  def renderImportFromType(tpe: Type): Lines = tpe match {
    case TRef(shapeId) => empty // assuming everything is generated in the same package
    case TMap(key, value) =>
      lines(s"import java.util.Map") ++ renderImportFromType(key) ++ renderImportFromType(value)
    case TCollection(member) =>
      lines(s"import java.util.List") ++ renderImportFromType(member)
    case TSet(member) =>
      lines(s"import java.util.Set") ++ renderImportFromType(member)
    case TUntaggedUnion(tpes) =>
      lines(s"import org.eclipse.lsp4j.jsonrpc.messages.Either") ++ tpes.foldMap(
        renderImportFromType
      )
    case TPrimitive(prim, _) => empty
  }

  def renderImport(field: Field): Lines = {
    val renameAnnotation = if (field.jsonRename.isDefined) {
      lines(s"import com.google.gson.annotations.SerializedName")
    } else empty

    val importType = renderImportFromType(field.tpe)

    val jsonAdapter = if (useJsonAdapter(field)) {
      lines(
        s"import com.google.gson.annotations.JsonAdapter",
        s"import org.eclipse.lsp4j.jsonrpc.json.adapters.JsonElementTypeAdapter"
      )
    } else empty

    val nonNull = if (field.required) {
      lines(s"import org.eclipse.lsp4j.jsonrpc.validation.NonNull")
    } else empty

    renameAnnotation ++ importType ++ jsonAdapter ++ nonNull
  }

  def useJsonAdapter(field: Field): Boolean = {
    field.tpe match {
      case Type.TPrimitive(Primitive.PDocument, _) => true
      case _                                       => false
    }
  }

  def renderJavaField(field: Field): Lines = {
    val maybeAdapter = if (useJsonAdapter(field)) {
      lines("@JsonAdapter(JsonElementTypeAdapter.Factory)")
    } else empty
    val maybeNonNull = if (field.required) lines("@NonNull") else empty
    val maybeRename =
      field.jsonRename.map(name => lines(s"""@SerializedName("$name")""")).getOrElse(empty)
    lines(
      maybeAdapter,
      maybeRename,
      maybeNonNull,
      renderFieldRaw(field)
    )
  }

  def renderParam(field: Field): String = {
    val decl = renderFieldRaw(field)
    if (field.required) {
      s"@NonNull $decl"
    } else decl
  }

  def renderFieldRaw(field: Field): String = {
    s"${renderType(field.tpe)} ${field.name}"
  }

  def renderType(tpe: Type): String = tpe match {
    case TRef(shapeId)        => shapeId.getName()
    case TPrimitive(prim, _)  => renderPrimitive(prim)
    case TMap(key, value)     => s"Map<${renderType(key)}, ${renderType(value)}>"
    case TCollection(member)  => s"List<${renderType(member)}>"
    case TSet(member)         => s"Set<${renderType(member)}>"
    case TUntaggedUnion(tpes) => renderUntaggedUnion(tpes)
  }

  private def renderUntaggedUnion(types: List[Type]): String = {
    if (types.size != 2)
      throw new Exception("Only unions of two types are supported")

    s"Either<${types.map(renderType).mkString(", ")}>"
  }

  def renderPrimitive(prim: Primitive): String = prim match {
    case PFloat     => "Float"
    case PDouble    => "Double"
    case PUnit      => "void"
    case PString    => "String"
    case PInt       => "Integer"
    case PDocument  => "Object"
    case PBool      => "Boolean"
    case PLong      => "Long"
    case PTimestamp => "Long"
  }

}