Rusty-JavaC is an early-stage Java compiler written in Rust. Treat every change as compiler work: keep it small, test the generated class files, and leave the code easier to understand than you found it.
Required tools:
- Rust 1.85+ with Cargo
- Java 21+ for
javac,javap, and JVM verification - Git
Build and run the Rust tests:
cargo build --locked
cargo test --lockedFor bytecode, lowering, or type changes, also compile at least one Java fixture and inspect the generated class:
cargo run --locked --example compiler-example -- --output-dir target/test-output tests/java/HelloWorld.java
javap -v -c target/test-output/HelloWorld.class
java -Xverify:all -cp target/test-output HelloWorldThe project is a single Cargo package. Compiler stages are regular Rust modules under src/:
src/
|- ast # Syntax node wrappers over the parsed tree
|- lexer # Tokenization and Unicode escape handling
|- parser # Recursive-descent parser
|- hir # High-level IR, lowering, and expression inference
|- ty # Java type model, descriptors, and assignability
|- call_resolver # Class catalog and method/constructor lookup
|- bytecode # JVM bytecode generation and bytecode validation
|- classfile # .class reading and writing helpers
|- diagnostics # User-facing diagnostic rendering
`- compiler # Classpath, incremental state, and compile pipeline
The compile path is:
Java source
-> lexer
-> parser
-> ast
-> hir lowering
-> type and call resolution
-> bytecode generation
-> classfile writer
src/lib.rs should stay small. Add new implementation inside the stage that owns it, then expose only the API that other stages need.
Keep compiler behavior and structure separate:
- Parser code should describe syntax, not Java semantics.
- Lowering should translate syntax into HIR and preserve useful source spans.
- Type inference and call resolution should avoid hardcoded one-off cases when the class catalog can answer the question.
- Bytecode generation should consume typed/resolved HIR instead of re-parsing syntax decisions.
- Diagnostics should report the real source span and explain the fix when the compiler knows one.
Prefer small modules with clear ownership. If a file grows because it handles several independent concepts, split by concept rather than by arbitrary line count.
Do not add tests that only assert exact pretty-printed diagnostic text unless the output is intentionally stable. Prefer Java fixtures, JVM verification, javap inspection, or snapshot tests for rendered diagnostics.
Put integration fixtures under tests/java/. A good fixture:
- compiles with
javac - exercises one compiler capability or one known edge case
- can be verified with
java -Xverify:allwhen it has a runnablemain - avoids unrelated language features that make failures harder to diagnose
When changing bytecode output, compare against javac when practical. Exact bytecode does not need to match, but verifier behavior and runtime behavior should.
Run the narrowest useful checks first, then the full set before committing:
cargo fmt --all
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --lockedFor compiler changes, also compile the affected fixtures:
cargo run --locked --example compiler-example -- --output-dir target/java-fixtures-rusty tests/java/HelloWorld.java
javap -v -c target/java-fixtures-rusty/HelloWorld.classUse Conventional Commits:
type(scope): short description
Common types are feat, fix, refactor, docs, test, ci, and chore. Useful scopes are module names such as parser, hir, bytecode, compiler, diagnostics, and classfile.
Keep commits focused. If a feature needs parser, lowering, bytecode, and tests, split it into reviewable commits when possible.
Use TODO.md as the working backlog. If a PR finishes an item, update the checkbox in the same PR.
By contributing, you agree that your contributions are licensed under the MIT License.