Skip to content

Commit

Permalink
feat: further improve output
Browse files Browse the repository at this point in the history
  • Loading branch information
simonseyock committed Dec 20, 2024
1 parent bff2282 commit 10f9150
Show file tree
Hide file tree
Showing 24 changed files with 487 additions and 203 deletions.
66 changes: 32 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ class A {
the java class to convert
}
```
```
```typescript
class A {
the typescript code that should be generated
}
```
````

Expand All @@ -55,7 +58,7 @@ if you want to debug a fixture, you can add the line `options: debug` right unde
options: debug
```

This will cause the `FixtureSpec` test to only run this single fixture and allways fail (so it won't pass the ci). You can then debug the `FixtureSpec` test and set a breakpoint anywhere.
This will cause the `FixtureSpec` test to only run this single fixture and always fail (so it won't pass the ci). You can then debug the `FixtureSpec` test and set a breakpoint anywhere.

### Run program

Expand All @@ -79,41 +82,36 @@ If you want to see how the typescript AST looks, you can use https://ts-ast-view
* Find enum `SyntaxKind` in `typescript.d.ts` (`node_modules/@ts-morph/common/lib/typescript.d.ts`)
* Convert enum to scala in `java2typescript/ast/SyntaxKind.scala`

## TODOs
## How does it work?

* add a first parsing step that only determines exports and maps them
* add imports for classes from the same package
* add different imports for nested classes
* add different imports for static methods on enums
* add package.json
* add tsconfig
* create drop in replacements for java builtins
The program runs in multiple steps:

parseExports
1. It reads a config file that contains context information about the code that should be translated. An example lives in `config/j2ts-config.json`.
2. It gathers all configured files and reads them into memory. It uses regex replacements from the config file to replace some java constructs with other constructs.
3. It analyzes all exports. Later in the code types that are used are checked against this list via `context.addImportIfNeeded`.
4. It creates a project context that contains this information
5. It parses all files one-by-one and creates a typescript ast out of it
6. It takes the ast for a file and starts a javascript program using the typescript compiler to convert the file into typescript code.
7. It writes the generated typescript code into the target files

export -> import
class XY -> import {XY} from "filename"
in code
new XY or XY.staticMethod (same)

class XY { class AB }
->
class XY {}
class XY_AB {}
->
import {XY, AB} from "filename"

in code
new XY.AB
->
new XY_AB
## TODOs

enum XY { static something() }
->
enum XY {}
XY_something()
* CURRENT: Point.java / Geometry.java

* Improve documentation
* methods from inherited classes need to be prefixed by this
* At the moment it works like this: for a given name the program checks if the name is a parameter of a method or a property of the class, if yes it prepends this.
* this is not sufficient for names from parent class. if we would gather all local names (names in the class context, parameters and any local variables) we could determine this correctly.
* Find a clever way to transform constructs that work in Java into equivalent TypeScript structures
* super is called
* create drop in replacements for java builtins
* List, ArrayList, Collection -> array
* Automatically filter files that contain un-translatable structures
* Disabled at the moment
* Is done on class level at the moment -> move down to method level
* replace by error throwing constructs
* What to do with System.getProperty?
* Check if enum construct is sufficient
* Improve performance of the typescript code generation step
* Use const instead of let if possible

-> import {XY, something} from "filename"
in code
XY.something -> something
4 changes: 4 additions & 0 deletions config/j2ts-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
{
"pattern": "public Object clone\\(\\)(\\s+\\{\\s*return copy\\(\\);\\s*}|;)",
"replacement": ""
},
{
"pattern": "private static final long serialVersionUID = \\d+L;",
"replacement": ""
}
],
"skipFiles": [
Expand Down
4 changes: 2 additions & 2 deletions config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
"strictNullChecks": false, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"strictPropertyInitialization": false, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
Expand Down
23 changes: 22 additions & 1 deletion src/main/scala/java2typescript/main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import java.io.File
import java.nio.file.{Files, Path, Paths}
import scala.io.Source
import scala.jdk.CollectionConverters.*
import scala.util.Using
import scala.util.matching.Regex

@main def main(configFile: String, files: String*): Unit =
Expand All @@ -35,15 +36,20 @@ import scala.util.matching.Regex
val contents = readFiles(config, gatheredFiles)

println("analyse exports")
val analyseProgress = Progress(contents.length)
val importMappings = contents.flatMap {
(file, content) => analyseExports(content)
(file, content) =>
analyseProgress.step()
analyseExports(content)
}

val context = ProjectContext(config, importMappings)

println("parse files")
val parseProgress = Progress(contents.length)
val parseResults = contents.flatMap {
(file, content) =>
parseProgress.step()
try Some(file, parser.parse(context, content)) catch
case err: Throwable =>
println(s"error occured in $file")
Expand All @@ -52,8 +58,10 @@ import scala.util.matching.Regex
}

println("create typescript code")
val tsProgress = Progress(parseResults.length)
val tsContents = parseResults.flatMap {
(file, parseResult) =>
tsProgress.step()
try Some(file, writer.write(parseResult)) catch
case err: Throwable =>
println(s"error occured in $file")
Expand All @@ -62,8 +70,10 @@ import scala.util.matching.Regex
}

println("write files")
val writeProgress = Progress(tsContents.length)
tsContents.foreach {
(file, tsContent) => {
writeProgress.step()
val target = changeFileExtension(targetPath.resolve(sourcePath.relativize(file)), "ts")
val fileWriter = new java.io.PrintWriter(target.toFile)
try
Expand Down Expand Up @@ -140,3 +150,14 @@ def readFiles(config: Config, files: List[Path]): List[(Path, String)] =
file =>
(file, replace(readFile(file.toFile), config.replacements))
}

class Progress (val total: Int, val barLength: Int = 50) {
var count = 0
def step(): Unit = {
val filled = (count.toFloat / total * barLength).toInt
print(s"\r[${"=".repeat(filled)}>${" ".repeat(barLength - filled - 1)}]")
count += 1
if (count == total)
println("")
}
}
61 changes: 61 additions & 0 deletions src/main/scala/java2typescript/transformer/builtins.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package java2typescript.transformer

import com.github.javaparser.ast.expr.{ObjectCreationExpr, SimpleName}
import java2typescript.ast

import scala.jdk.CollectionConverters.*

def transformObjectCreationExpression(context: ParameterContext, expr: ObjectCreationExpr): ast.Expression =
if (isExceptionType(expr))
transformExceptionCreationExpression(context, expr)
else if (isArrayType(expr))
transformArrayExpression(context, expr)
else
context.addImportIfNeeded(getTypeScope(expr.getType), getTypeName(expr.getType))
ast.NewExpression(
ast.Identifier(expr.getType.getName.getIdentifier),
transformArguments(context, expr.getArguments),
transformTypeArguments(context, expr.getTypeArguments)
)

def isExceptionType(expr: ObjectCreationExpr): Boolean =
getTypeName(expr.getType).endsWith("Exception") || getTypeName(expr.getType).endsWith("Error")

def transformExceptionCreationExpression(context: ParameterContext, expr: ObjectCreationExpr): ast.Expression =
val name = expr.getType.getName
if (name.asString() == "Error")
return ast.NewExpression(
ast.Identifier("Error"),
transformArguments(context, expr.getArguments),
transformTypeArguments(context, expr.getTypeArguments)
)
val args = expr.getArguments.asScala
if (args.length > 1)
println(s"WARN: ${expr.getType.getName}: all but the first error argument are dropped.")

ast.NewExpression(
ast.Identifier("Error"),
List(
if (args.isEmpty)
ast.StringLiteral(name.toString)
else
ast.BinaryExpression(
ast.StringLiteral(s"$name: "),
transformExpression(context, args.head),
ast.PlusToken()
)
)
)

def isArrayType(expr: ObjectCreationExpr): Boolean =
val typeName = getTypeName(expr.getType)
typeName.endsWith("List") || typeName == "Collection"

def transformArrayExpression(context: ParameterContext, expr: ObjectCreationExpr) =
ast.ArrayLiteralExpression(List())

def isBuiltInType(name: SimpleName): Boolean =
List("Math", "System").contains(name.asString)

def isDroppableInterface(name: SimpleName): Boolean =
List("Cloneable", "Comparable", "Serializable").contains(name.asString)
24 changes: 15 additions & 9 deletions src/main/scala/java2typescript/transformer/classes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@ import com.github.javaparser.ast.{Modifier, NodeList}
import com.github.javaparser.ast.`type`.ClassOrInterfaceType
import com.github.javaparser.ast.body.{BodyDeclaration, ClassOrInterfaceDeclaration, ConstructorDeclaration, EnumDeclaration, FieldDeclaration, InitializerDeclaration, MethodDeclaration, Parameter}
import java2typescript.ast
import java2typescript.ast.SyntaxKind

import scala.collection.mutable.ListBuffer
import scala.jdk.CollectionConverters.*
import scala.jdk.OptionConverters.*

def transformHeritage(context: FileContext, nodes: NodeList[ClassOrInterfaceType], token: ast.SyntaxKind): Option[ast.HeritageClause] =
val implementedTypes = nodes.asScala.toList
if (implementedTypes.nonEmpty)
def transformHeritage(context: FileContext, ancestors: List[ClassOrInterfaceType], token: ast.SyntaxKind): Option[ast.HeritageClause] =
if (ancestors.nonEmpty)
Some(
ast.HeritageClause(
implementedTypes.map {
ancestors.map {
t =>
context.addImportIfNeeded(None, t.getName.asString)
ast.ExpressionWithTypeArguments(
transformName(t.getName),
t.getTypeArguments.toScala.toList
Expand Down Expand Up @@ -56,6 +57,7 @@ def transformClassOrInterfaceDeclaration(
val classContext = context match
case c: FileContext => ClassContext(c, Some(decl))
case c: ClassContext => ClassContext(c, Some(decl), Option(c))

val members = decl.getMembers.asScala
.flatMap(transformMember.curried(classContext))

Expand All @@ -70,8 +72,8 @@ def transformClassOrInterfaceDeclaration(
transformName(decl.getName),
members = members.toList,
modifiers = modifiersVal ::: List(ast.AbstractKeyword()),
heritageClauses = transformHeritage(context, decl.getExtendedTypes, ast.SyntaxKind.ExtendsKeyword).toList
::: transformHeritage(context, decl.getImplementedTypes, ast.SyntaxKind.ImplementsKeyword).toList
heritageClauses = transformHeritage(context, decl.getExtendedTypes.asScala.toList, ast.SyntaxKind.ExtendsKeyword).toList
::: transformHeritage(context, decl.getImplementedTypes.asScala.filter(t => !isDroppableInterface(t.getName)).toList, ast.SyntaxKind.ImplementsKeyword).toList
)
::
extractedStatements
Expand Down Expand Up @@ -110,8 +112,8 @@ def transformClassOrInterfaceDeclaration(
transformName(decl.getName),
members = properties ::: constructorsWithOverloads ::: methodsWithOverloads,
modifiers = modifiersVal,
heritageClauses = transformHeritage(context, decl.getExtendedTypes, ast.SyntaxKind.ExtendsKeyword).toList
::: transformHeritage(context, decl.getImplementedTypes, ast.SyntaxKind.ImplementsKeyword).toList
heritageClauses = transformHeritage(context, decl.getExtendedTypes.asScala.toList, ast.SyntaxKind.ExtendsKeyword).toList
::: transformHeritage(context, decl.getImplementedTypes.asScala.filter(t => !isDroppableInterface(t.getName)).toList, ast.SyntaxKind.ImplementsKeyword).toList
)
::
extractedStatements
Expand Down Expand Up @@ -209,7 +211,11 @@ def transformMethodDeclaration(context: ClassContext, decl: MethodDeclaration) =
val methodBody = decl.getBody.toScala.map(body =>
ast.Block(body.getStatements.asScala.map(transformStatement.curried(methodContext)).toList)
)
val methodModifiers = decl.getModifiers.asScala.flatMap(transformModifier).toList
val originalMethodModifiers = decl.getModifiers.asScala.flatMap(transformModifier).toList
val methodModifiers = if (originalMethodModifiers.isEmpty)
List(ast.PublicKeyword(), ast.AbstractKeyword())
else
originalMethodModifiers

ast.MethodDeclaration(
transformName(decl.getName),
Expand Down
33 changes: 30 additions & 3 deletions src/main/scala/java2typescript/transformer/contexts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ class FileContext(
val projectContext: ProjectContext,
val packageName: Option[String],
val extractedExport: mutable.Buffer[Import] = ListBuffer(),
val neededImports: mutable.Buffer[Import] = ListBuffer()
val neededImports: mutable.Buffer[Import] = ListBuffer(),
val localIdentifiers: mutable.Buffer[ast.Identifier] = ListBuffer()
) extends ProjectContext(projectContext.config, projectContext.importMappings) {
def addLocalName(identifier: ast.Identifier): Unit =
localIdentifiers += identifier

def isLocal(name: SimpleName): Boolean =
localIdentifiers.exists(i => i.escapedText == name.getIdentifier)

def addExtractedExport(imp: Import): Unit =
extractedExport += imp

def addImportIfNeeded(scope: Option[String], name: String): Unit =
if (isImportable(scope, name))
val exists = neededImports.exists {
Expand All @@ -45,18 +53,21 @@ class FileContext(
else
addImportIfNeeded(Some(splitted.dropRight(1).mkString(".")), splitted(-1))
}

def getImport(scope: Option[String], name: String): Option[Import] =
importMappings.find {
im => im.javaScope == scope && im.javaName == name
}
def isImportedName(name: SimpleName): Boolean =
neededImports.exists(p => p.javaName == name.asString)
}

class ClassContext(
val fileContext: FileContext,
val classOrInterface: Option[ClassOrInterfaceDeclaration|EnumDeclaration],
val parentClassContext: Option[ClassContext] = Option.empty,
val extractedStatements: mutable.Buffer[ast.Statement] = ListBuffer(),
) extends FileContext(fileContext.projectContext, fileContext.packageName, fileContext.extractedExport, fileContext.neededImports) {
) extends FileContext(fileContext.projectContext, fileContext.packageName, fileContext.extractedExport, fileContext.neededImports, fileContext.localIdentifiers) {
def addExtractedStatements(sts: List[ast.Statement]): Unit =
extractedStatements.appendAll(sts)

Expand All @@ -65,12 +76,28 @@ class ClassContext(
case None => classOrInterface.exists(c => c.getName.asString != name)
case scopeVal: Some[String] => classOrInterface.exists(c => c.getName.asString != scopeVal.get)
})

def isClassName(name: SimpleName): Boolean =
classOrInterface.exists(c => c.getName == name) || parentClassContext.exists(c => c.isClassName(name))

def isInterface: Boolean =
classOrInterface.exists(c => {
c match
case c: ClassOrInterfaceDeclaration => c.isInterface
case _ => false
}) || parentClassContext.exists(c => c.isInterface)
}

class ParameterContext(
val classContext: ClassContext,
val parameters: mutable.Buffer[ast.Parameter]
) extends ClassContext(classContext.fileContext, classContext.classOrInterface, classContext.parentClassContext, classContext.extractedStatements) {

override def isLocal(name: SimpleName): Boolean =
super.isLocal(name)
||
parameters.exists(p => p.name.escapedText == name.getIdentifier)

def isNonStaticMember(name: SimpleName): Boolean =
classOrInterface.exists {
_.getMembers.asScala.exists {
Expand All @@ -87,7 +114,7 @@ class ParameterContext(
def isStaticMember(name: SimpleName): Boolean =
classOrInterface.exists {
p => p match {
// we trat enum constants as static members
// we treat enum constants as static members
case e: EnumDeclaration => e.getEntries.asScala.exists {
entry => entry.getName == name
}
Expand Down
Loading

0 comments on commit 10f9150

Please sign in to comment.