diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index be739cd4b..2c8b940a9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -59,7 +59,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] # moonbit removed from language matrix for now - causing CI failures - lang: [c, rust, teavm-java, go, csharp] + lang: [c, rust, teavm-java, go, csharp, scalajs-jco] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -112,12 +112,22 @@ jobs: with: tinygo-version: 0.31.0 + - uses: actions/setup-java@v4 + if: matrix.lang == 'scalajs-jco' + with: + distribution: 'temurin' + java-version: 17 + cache: 'sbt' + - uses: sbt/setup-sbt@v1 + if: matrix.lang == 'scalajs-jco' + - run: | cargo test \ -p wit-bindgen-cli \ -p wit-bindgen-${{ matrix.lang }} \ --no-default-features \ --features ${{ matrix.lang }} + if: ${{ (! (matrix.os == 'windows-latest' && matrix.lang == 'scalajs-jco')) && (! (matrix.os == 'macos-latest' && matrix.lang == 'scalajs-jco')) }} # setup-scala does not make sbt available on Windows, and the scalajs tests are very slow on macos test_unit: name: Crate Unit Tests @@ -161,6 +171,7 @@ jobs: - run: cargo build --no-default-features --features csharp - run: cargo build --no-default-features --features markdown - run: cargo build --no-default-features --features moonbit + - run: cargo build --no-default-features --features scalajs-jco # Feature combos of the `wit-bindgen` crate - run: cargo build --target wasm32-wasip1 -p wit-bindgen --no-default-features diff --git a/Cargo.lock b/Cargo.lock index d7f36dbf1..3630e9321 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2561,6 +2561,7 @@ dependencies = [ "wit-bindgen-markdown", "wit-bindgen-moonbit", "wit-bindgen-rust", + "wit-bindgen-scalajs-jco", "wit-bindgen-teavm-java", "wit-component", "wit-parser 0.226.0", @@ -2669,6 +2670,17 @@ dependencies = [ "wit-bindgen-rust", ] +[[package]] +name = "wit-bindgen-scalajs-jco" +version = "0.39.0" +dependencies = [ + "anyhow", + "clap", + "heck 0.5.0", + "test-helpers", + "wit-bindgen-core", +] + [[package]] name = "wit-bindgen-teavm-java" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index a04f33b8b..c24640484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ wit-bindgen-go = { path = 'crates/go', version = '0.39.0' } wit-bindgen-csharp = { path = 'crates/csharp', version = '0.39.0' } wit-bindgen-markdown = { path = 'crates/markdown', version = '0.39.0' } wit-bindgen-moonbit = { path = 'crates/moonbit', version = '0.39.0' } +wit-bindgen-scalajs-jco = { path = 'crates/scalajs-jco', version = '0.39.0' } wit-bindgen = { path = 'crates/guest-rust', version = '0.39.0', default-features = false } [[bin]] @@ -63,6 +64,7 @@ wit-bindgen-moonbit = { workspace = true, features = ['clap'], optional = true } wit-bindgen-teavm-java = { workspace = true, features = ['clap'], optional = true } wit-bindgen-go = { workspace = true, features = ['clap'], optional = true } wit-bindgen-csharp = { workspace = true, features = ['clap'], optional = true } +wit-bindgen-scalajs-jco = { workspace = true, features = ['clap'], optional = true } wit-component = { workspace = true } wasm-encoder = { workspace = true } @@ -75,6 +77,7 @@ default = [ 'go', 'csharp', 'moonbit', + 'scalajs-jco', 'async', ] c = ['dep:wit-bindgen-c'] @@ -85,6 +88,7 @@ go = ['dep:wit-bindgen-go'] csharp = ['dep:wit-bindgen-csharp'] csharp-mono = ['csharp'] moonbit = ['dep:wit-bindgen-moonbit'] +scalajs-jco = ['dep:wit-bindgen-scalajs-jco'] async = [] [dev-dependencies] diff --git a/crates/scalajs-jco/Cargo.toml b/crates/scalajs-jco/Cargo.toml new file mode 100644 index 000000000..6d340307e --- /dev/null +++ b/crates/scalajs-jco/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wit-bindgen-scalajs-jco" +authors = ["Daniel Vigovszky "] +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +homepage = 'https://github.com/bytecodealliance/wit-bindgen' +description = """ +Scala.js bindings generator for WIT and the component model, typically used +through the `wit-bindgen-cli` crate. +""" + +[dependencies] +anyhow = { workspace = true } +wit-bindgen-core = { workspace = true } +heck = { workspace = true } +clap = { workspace = true, optional = true } + +[dev-dependencies] +test-helpers = { path = '../test-helpers' } diff --git a/crates/scalajs-jco/scala/build.properties b/crates/scalajs-jco/scala/build.properties new file mode 100644 index 000000000..fe69360b7 --- /dev/null +++ b/crates/scalajs-jco/scala/build.properties @@ -0,0 +1 @@ +sbt.version = 1.10.7 diff --git a/crates/scalajs-jco/scala/build.sbt b/crates/scalajs-jco/scala/build.sbt new file mode 100644 index 000000000..43a03c607 --- /dev/null +++ b/crates/scalajs-jco/scala/build.sbt @@ -0,0 +1,7 @@ +lazy val root = project + .in(file(".")) + .settings( + name := "wit-bindgen-scalajs-test", + scalaVersion := "2.13.16", + ) + .enablePlugins(ScalaJSPlugin) diff --git a/crates/scalajs-jco/scala/plugins.sbt b/crates/scalajs-jco/scala/plugins.sbt new file mode 100644 index 000000000..0242aded2 --- /dev/null +++ b/crates/scalajs-jco/scala/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.18.2") \ No newline at end of file diff --git a/crates/scalajs-jco/scala/wit.scala b/crates/scalajs-jco/scala/wit.scala new file mode 100644 index 000000000..385fe6ea5 --- /dev/null +++ b/crates/scalajs-jco/scala/wit.scala @@ -0,0 +1,138 @@ +package object wit { + + import scala.scalajs.js + import scala.scalajs.js.JSConverters._ + import scala.scalajs.js.| + import scala.scalajs.js.annotation.JSName + + sealed trait Nullable[+A] extends js.Any + + object Nullable { + def some[A](value: A): Nullable[A] = value.asInstanceOf[Nullable[A]] + + val none: Nullable[Nothing] = null.asInstanceOf[Nullable[Nothing]] + + def fromOption[A](option: Option[A]): Nullable[A] = + option match { + case Some(value) => some(value) + case None => none + } + } + + implicit class NullableOps[A](private val self: Nullable[A]) extends AnyVal { + def toOption: Option[A] = Option(self.asInstanceOf[A]) + } + + sealed trait WitOption[A] extends js.Object { + val tag: String + val `val`: js.UndefOr[A] + } + + object WitOption { + def some[A](value: A): WitOption[A] = new WitOption[A] { + val tag: String = "some" + val `val`: js.UndefOr[A] = value + } + + def none[A]: WitOption[A] = new WitOption[A] { + val tag: String = "none" + val `val`: js.UndefOr[A] = js.undefined + } + + def fromOption[A](option: Option[A]): WitOption[A] = + option match { + case Some(value) => some(value) + case None => none + } + } + + implicit class WitOptionOps[A](private val self: WitOption[A]) extends AnyVal { + def toOption: Option[A] = self.tag match { + case "some" => Some(self.`val`.get) + case _ => None + } + } + + sealed trait WitResult[Ok, Err] extends js.Object { + val tag: String + val `val`: js.UndefOr[Ok | Err] + } + + object WitResult { + def ok[Ok, Err](value: Ok): WitResult[Ok, Err] = new WitResult[Ok, Err] { + val tag: String = "ok" + val `val`: js.UndefOr[Ok | Err] = value + } + + def err[Ok, Err](value: Err): WitResult[Ok, Err] = new WitResult[Ok, Err] { + val tag: String = "err" + val `val`: js.UndefOr[Ok | Err] = value + } + + def fromEither[E, A](either: Either[E, A]): WitResult[A, E] = + either match { + case Right(value) => ok(value) + case Left(value) => err(value) + } + } + + implicit class WitResultOps[Ok, Err](private val self: WitResult[Ok, Err]) extends AnyVal { + def toEither: Either[Err, Ok] = self.tag match { + case "ok" => Right(self.`val`.get.asInstanceOf[Ok]) + case _ => Left(self.`val`.get.asInstanceOf[Err]) + } + } + + type WitList[A] = js.Array[A] + + object WitList { + def fromList[A](list: List[A]): WitList[A] = list.toJSArray + } + + sealed trait WitTuple0 extends js.Object { + } + + object WitTuple0 { + def apply(): WitTuple0 = js.Array().asInstanceOf[WitTuple0] + + def unapply(tuple: WitTuple0): Some[Unit] = Some(()) + + implicit def fromScalaTuple0(tuple: Unit): WitTuple0 = WitTuple0() + + implicit def toScalaTuple0(tuple: WitTuple0): Unit = () + } + + sealed trait WitTuple1[T1] extends js.Object { + @JSName("0") val _1: T1 + } + + object WitTuple1 { + def apply[T1](_1: T1): WitTuple1[T1] = js.Array(_1).asInstanceOf[WitTuple1[T1]] + + def unapply[T1](tuple: WitTuple1[T1]): Some[(T1)] = Some(tuple) + + implicit def toScalaTuple1[T1](tuple: WitTuple1[T1]): (T1) = (tuple._1) + } + + type WitTuple2[T1, T2] = js.Tuple2[T1, T2] + type WitTuple3[T1, T2, T3] = js.Tuple3[T1, T2, T3] + type WitTuple4[T1, T2, T3, T4] = js.Tuple4[T1, T2, T3, T4] + type WitTuple5[T1, T2, T3, T4, T5] = js.Tuple5[T1, T2, T3, T4, T5] + type WitTuple6[T1, T2, T3, T4, T5, T6] = js.Tuple6[T1, T2, T3, T4, T5, T6] + type WitTuple7[T1, T2, T3, T4, T5, T6, T7] = js.Tuple7[T1, T2, T3, T4, T5, T6, T7] + type WitTuple8[T1, T2, T3, T4, T5, T6, T7, T8] = js.Tuple8[T1, T2, T3, T4, T5, T6, T7, T8] + type WitTuple9[T1, T2, T3, T4, T5, T6, T7, T8, T9] = js.Tuple9[T1, T2, T3, T4, T5, T6, T7, T8, T9] + type WitTuple10[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10] = js.Tuple10[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10] + type WitTuple11[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11] = js.Tuple11[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11] + type WitTuple12[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12] = js.Tuple12[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12] + type WitTuple13[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13] = js.Tuple13[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13] + type WitTuple14[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14] = js.Tuple14[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14] + type WitTuple15[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15] = js.Tuple15[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15] + type WitTuple16[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16] = js.Tuple16[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16] + type WitTuple17[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17] = js.Tuple17[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17] + type WitTuple18[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18] = js.Tuple18[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18] + type WitTuple19[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19] = js.Tuple19[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19] + type WitTuple20[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20] = js.Tuple20[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20] + type WitTuple21[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20, T21] = js.Tuple21[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20, T21] + type WitTuple22[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20, T21, T22] = js.Tuple22[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T20, T21, T22] +} \ No newline at end of file diff --git a/crates/scalajs-jco/src/context.rs b/crates/scalajs-jco/src/context.rs new file mode 100644 index 000000000..6db5b79a3 --- /dev/null +++ b/crates/scalajs-jco/src/context.rs @@ -0,0 +1,811 @@ +use crate::interface::ScalaJsInterface; +use crate::jco::{maybe_null, to_js_identifier}; +use crate::skeleton::ScalaJsInterfaceSkeleton; +use crate::{Opts, ScalaDialect, ScalaJs}; +use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase}; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use wit_bindgen_core::wit_parser::{ + Docs, Handle, InterfaceId, PackageName, Resolve, Tuple, Type, TypeDef, TypeDefKind, TypeId, + TypeOwner, WorldItem, +}; +use wit_bindgen_core::Direction::{Export, Import}; +use wit_bindgen_core::{uwrite, uwriteln, Direction}; + +pub struct ScalaKeywords { + keywords: HashSet, + pub base_methods: HashSet, +} + +impl ScalaKeywords { + pub fn new(dialect: &ScalaDialect) -> Self { + let mut keywords = HashSet::new(); + keywords.insert("abstract".to_string()); + keywords.insert("case".to_string()); + keywords.insert("do".to_string()); + keywords.insert("else".to_string()); + keywords.insert("finally".to_string()); + keywords.insert("for".to_string()); + keywords.insert("import".to_string()); + keywords.insert("lazy".to_string()); + keywords.insert("object".to_string()); + keywords.insert("override".to_string()); + keywords.insert("return".to_string()); + keywords.insert("sealed".to_string()); + keywords.insert("trait".to_string()); + keywords.insert("try".to_string()); + keywords.insert("var".to_string()); + keywords.insert("while".to_string()); + keywords.insert("catch".to_string()); + keywords.insert("class".to_string()); + keywords.insert("extends".to_string()); + keywords.insert("false".to_string()); + keywords.insert("forSome".to_string()); + keywords.insert("if".to_string()); + keywords.insert("macro".to_string()); + keywords.insert("match".to_string()); + keywords.insert("new".to_string()); + keywords.insert("package".to_string()); + keywords.insert("private".to_string()); + keywords.insert("super".to_string()); + keywords.insert("this".to_string()); + keywords.insert("true".to_string()); + keywords.insert("type".to_string()); + keywords.insert("with".to_string()); + keywords.insert("yield".to_string()); + keywords.insert("def".to_string()); + keywords.insert("final".to_string()); + keywords.insert("implicit".to_string()); + keywords.insert("null".to_string()); + keywords.insert("protected".to_string()); + keywords.insert("throw".to_string()); + keywords.insert("val".to_string()); + keywords.insert("_".to_string()); + keywords.insert(":".to_string()); + keywords.insert("=".to_string()); + keywords.insert("=>".to_string()); + keywords.insert("<-".to_string()); + keywords.insert("<:".to_string()); + keywords.insert("<%".to_string()); + keywords.insert("=>>".to_string()); + keywords.insert(">:".to_string()); + keywords.insert("#".to_string()); + keywords.insert("@".to_string()); + keywords.insert("\u{21D2}".to_string()); + keywords.insert("\u{2190}".to_string()); + + let mut base_methods = HashSet::new(); + base_methods.insert("equals".to_string()); + base_methods.insert("hashCode".to_string()); + base_methods.insert("toString".to_string()); + base_methods.insert("clone".to_string()); + base_methods.insert("finalize".to_string()); + base_methods.insert("getClass".to_string()); + base_methods.insert("notify".to_string()); + base_methods.insert("notifyAll".to_string()); + base_methods.insert("wait".to_string()); + base_methods.insert("isInstanceOf".to_string()); + base_methods.insert("asInstanceOf".to_string()); + base_methods.insert("synchronized".to_string()); + base_methods.insert("ne".to_string()); + base_methods.insert("eq".to_string()); + base_methods.insert("hasOwnProperty".to_string()); + base_methods.insert("isPrototypeOf".to_string()); + base_methods.insert("propertyIsEnumerable".to_string()); + base_methods.insert("toLocaleString".to_string()); + base_methods.insert("valueOf".to_string()); + + match dialect { + ScalaDialect::Scala2 => {} + ScalaDialect::Scala3 => { + keywords.insert("enum".to_string()); + keywords.insert("export".to_string()); + keywords.insert("given".to_string()); + keywords.insert("?=>".to_string()); + keywords.insert("then".to_string()); + } + } + + Self { + keywords, + base_methods, + } + } + + pub(crate) fn escape(&self, ident: impl AsRef) -> String { + if self.keywords.contains(ident.as_ref()) { + format!("`{}`", ident.as_ref()) + } else { + ident.as_ref().to_string() + } + } +} + +pub trait OwnerContext { + fn is_local_import( + &self, + id: &InterfaceId, + is_resource: bool, + direction: Direction, + ) -> Option>; +} + +impl OwnerContext for ScalaJs { + fn is_local_import( + &self, + _id: &InterfaceId, + _is_resource: bool, + _direction: Direction, + ) -> Option> { + None + } +} + +impl OwnerContext for ScalaJsContext { + fn is_local_import( + &self, + _id: &InterfaceId, + _is_resource: bool, + _direction: Direction, + ) -> Option> { + None + } +} + +impl<'a> OwnerContext for ScalaJsInterface<'a> { + fn is_local_import( + &self, + id: &InterfaceId, + is_resource: bool, + direction: Direction, + ) -> Option> { + if id == &self.interface_id { + if is_resource && self.direction == Import { + Some(Some(self.name.clone())) // Using a local type within 'name' + } else { + if self.direction == Export && direction == Import { + None // Using the fully qualified import package + } else { + Some(None) // Using the current export package + } + } + } else { + None // Using the fully qualified package + } + } +} + +impl<'a> OwnerContext for ScalaJsInterfaceSkeleton<'a> { + fn is_local_import( + &self, + id: &InterfaceId, + is_resource: bool, + direction: Direction, + ) -> Option> { + if is_resource && &self.interface_id == id { + let iface = &self.resolve.interfaces[*id]; + if iface.name.is_some() { + None // use the fully qualified name of the interface + } else { + // Otherwise we must refer to the supertype directly from the world export here + for (_world_id, world) in self.resolve.worlds.iter() { + let world_name = + world + .exports + .iter() + .find_map(|(world_key, item)| match item { + WorldItem::Interface { id: iface_id, .. } if iface_id == id => { + Some(self.resolve.name_world_key(world_key)) + } + _ => None, + }); + + if let Some(world_name) = world_name { + let package_id = iface.package.expect("Interface must have a package"); + let package = &self.resolve.packages[package_id]; + + let mut segments = package_name_to_segments( + &self.generator.context.opts, + &package.name, + &direction, + &self.generator.context.keywords, + false, + ); + segments.push( + self.generator + .context + .keywords + .escape(world_name.to_snake_case()), + ); + return Some(Some(segments.join("."))); + } + } + + panic!("Anonymous interface was not found in any worlds"); + } + } else { + None + } + } +} + +pub struct ScalaJsContext { + pub opts: Opts, + pub keywords: ScalaKeywords, + pub overrides: HashMap, + pub imports: HashSet, + pub exports: HashSet, +} + +impl ScalaJsContext { + pub fn encode_name(&self, name: impl AsRef) -> EncodedName { + let name = name.as_ref(); + let scala_name = self.keywords.escape(name); + let js_name = to_js_identifier(name); + + let rename_attribute = if scala_name != js_name && scala_name != format!("`{js_name}`") { + format!("@JSName(\"{js_name}\")") + } else { + "".to_string() + }; + EncodedName { + scala: scala_name, + js: js_name, + rename_attribute, + } + } + + pub fn render_args<'b>( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + params: impl Iterator, + ) -> String { + let mut args = Vec::new(); + for (param_name, param_typ) in params { + let param_typ = self.render_type_reference(owner_context, resolve, param_typ); + let param_name = self.encode_name(param_name.to_lower_camel_case()); + args.push(format!("{}: {param_typ}", param_name.scala)); + } + args.join(", ") + } + + pub fn render_return_type( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + results: &Option, + ) -> (String, Option) { + match results { + None => ("Unit".to_string(), None), + Some(result) => { + if let Some((ok, err)) = throws(result, resolve) { + let throws = if let Some(err) = err { + Some(self.render_type_reference(owner_context, resolve, err)) + } else { + Some("Unit".to_string()) + }; + if let Some(ok) = ok { + ( + self.render_type_reference(owner_context, resolve, ok), + throws, + ) + } else { + ("Unit".to_string(), throws) + } + } else { + ( + self.render_type_reference(owner_context, resolve, result), + None, + ) + } + } + } + } + + fn render_type_reference( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + typ: &Type, + ) -> String { + match typ { + Type::Bool => "Boolean".to_string(), + Type::U8 => "Byte".to_string(), + Type::U16 => "Short".to_string(), + Type::U32 => "Int".to_string(), + Type::U64 => "Long".to_string(), + Type::S8 => "Byte".to_string(), + Type::S16 => "Short".to_string(), + Type::S32 => "Int".to_string(), + Type::S64 => "Long".to_string(), + Type::F32 => "Float".to_string(), + Type::F64 => "Double".to_string(), + Type::Char => "Char".to_string(), + Type::String => "String".to_string(), + Type::Id(id) => { + let typ = &resolve.types[*id]; + self.render_typedef_reference(owner_context, resolve, typ) + } + } + } + + fn render_typedef_reference( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + typ: &TypeDef, + ) -> String { + match &typ.kind { + TypeDefKind::Record(_) + | TypeDefKind::Resource + | TypeDefKind::Flags(_) + | TypeDefKind::Enum(_) + | TypeDefKind::Type(_) + | TypeDefKind::Variant(_) => { + let prefix = match self.render_owner( + owner_context, + resolve, + &typ.owner, + &typ.kind == &TypeDefKind::Resource, + ) { + Some(owner) => format!("{owner}."), + None => "".to_string(), + }; + format!( + "{}{}", + prefix, + self.keywords.escape( + typ.name + .clone() + .expect("Anonymous types are not supported") + .to_pascal_case() + ) + ) + } + TypeDefKind::Handle(handle) => { + let id = match handle { + Handle::Own(id) => id, + Handle::Borrow(id) => id, + }; + let typ = &resolve.types[*id]; + self.render_typedef_reference(owner_context, resolve, typ) + } + TypeDefKind::Tuple(tuple) => self.render_tuple(owner_context, resolve, tuple), + TypeDefKind::Option(option) => { + if !maybe_null(resolve, option) { + format!( + "Nullable[{}]", + self.render_type_reference(owner_context, resolve, option) + ) + } else { + format!( + "WitOption[{}]", + self.render_type_reference(owner_context, resolve, option) + ) + } + } + TypeDefKind::Result(result) => { + let ok = result + .ok + .map(|ok| self.render_type_reference(owner_context, resolve, &ok)) + .unwrap_or("Unit".to_string()); + let err = result + .err + .map(|err| self.render_type_reference(owner_context, resolve, &err)) + .unwrap_or("Unit".to_string()); + format!("WitResult[{ok}, {err}]") + } + TypeDefKind::List(list) => { + if matches!(list, Type::U8) { + "js.typedarray.Uint8Array".to_string() + } else { + format!( + "WitList[{}]", + self.render_type_reference(owner_context, resolve, list) + ) + } + } + TypeDefKind::Future(_) => panic!("Futures not supported yet"), + TypeDefKind::Stream(_) => panic!("Streams not supported yet"), + TypeDefKind::ErrorContext => panic!("ErrorContext not supported yet"), + TypeDefKind::Unknown => panic!("Unknown type"), + } + } + + fn render_tuple( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + tuple: &Tuple, + ) -> String { + let arity = tuple.types.len(); + + let mut parts = Vec::new(); + for part in &tuple.types { + parts.push(self.render_type_reference(owner_context, resolve, part)); + } + format!("WitTuple{arity}[{}]", parts.join(", ")) + } + + fn render_owner( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + owner: &TypeOwner, + is_resource: bool, + ) -> Option { + match owner { + TypeOwner::World(id) => { + let world = &resolve.worlds[*id]; + + let name = world.name.clone().to_snake_case(); + + let package_name = resolve.packages + [world.package.expect("missing package for world")] + .name + .clone(); + + let mut package = package_name_to_segments( + &self.opts, + &package_name, + &Import, + &self.keywords, + false, + ); + + package.push(self.keywords.escape(name)); + + Some(package.join(".")) + } + TypeOwner::Interface(id) => { + let direction = self.interface_direction(id); + + match owner_context.is_local_import(id, is_resource, direction) { + Some(Some(name)) => Some(name), + Some(None) => None, + None => { + let iface = &resolve.interfaces[*id]; + let name = iface.name.clone().expect("Interface must have a name"); + let package_id = iface.package.expect("Interface must have a package"); + + let package = &resolve.packages[package_id]; + + let mut segments = package_name_to_segments( + &self.opts, + &package.name, + &direction, + &self.keywords, + false, + ); + segments.push(self.keywords.escape(name.to_snake_case())); + + if is_resource && direction == Import { + segments.push(self.keywords.escape(name.to_pascal_case())); + } + + Some(segments.join(".")) + } + } + } + TypeOwner::None => None, + } + } + + pub(crate) fn render_typedef( + &self, + owner_context: &impl OwnerContext, + resolve: &Resolve, + name: &str, + typ: &TypeDef, + ) -> Option { + let encoded_name = self.encode_name(name.to_pascal_case()); + let scala_name = encoded_name.scala; + + let mut source = String::new(); + match &typ.kind { + TypeDefKind::Record(record) => { + let mut fields = Vec::new(); + for field in &record.fields { + let typ = self.render_type_reference(owner_context, resolve, &field.ty); + let field_name = self.encode_name(field.name.to_lower_camel_case()); + let field_name0 = self + .keywords + .escape(format!("{}0", field.name.to_lower_camel_case())); + fields.push((field_name, field_name0, typ, &field.docs)); + } + + write_doc_comment(&mut source, " ", &typ.docs); + uwriteln!(source, " sealed trait {scala_name} extends js.Object {{"); + for (field_name, _, typ, docs) in &fields { + write_doc_comment(&mut source, " ", &docs); + field_name.write_rename_attribute(&mut source, " "); + uwriteln!(source, " val {}: {typ}", field_name.scala); + } + uwriteln!(source, " }}"); + uwriteln!(source, ""); + uwriteln!(source, " case object {scala_name} {{"); + uwriteln!(source, " def apply("); + for (_, field_name0, typ, _) in &fields { + uwriteln!(source, " {field_name0}: {typ},"); + } + uwriteln!(source, " ): {scala_name} = {{"); + uwriteln!(source, " new {scala_name} {{"); + for (field_name, field_name0, typ, _) in &fields { + field_name.write_rename_attribute(&mut source, " "); + uwriteln!( + source, + " val {}: {typ} = {field_name0}", + field_name.scala + ); + } + uwriteln!(source, " }}"); + uwriteln!(source, " }}"); + uwriteln!(source, " }}"); + } + TypeDefKind::Resource => { + // resource wrappers are generated separately + } + TypeDefKind::Handle(_) => { + panic!("Unexpected top-level handle type"); + } + TypeDefKind::Flags(flags) => { + let mut fields = Vec::new(); + for flag in &flags.flags { + let typ = "Boolean".to_string(); + let field_name = self.encode_name(flag.name.to_lower_camel_case()); + let field_name0 = self + .keywords + .escape(format!("{}0", flag.name.to_lower_camel_case())); + fields.push((field_name, field_name0, typ, &flag.docs)); + } + + write_doc_comment(&mut source, " ", &typ.docs); + uwriteln!(source, " sealed trait {scala_name} extends js.Object {{"); + for (field_name, _, typ, docs) in &fields { + write_doc_comment(&mut source, " ", docs); + field_name.write_rename_attribute(&mut source, " "); + uwriteln!(source, " val {}: {typ}", field_name.scala); + } + uwriteln!(source, " }}"); + uwriteln!(source, ""); + uwriteln!(source, " case object {scala_name} {{"); + uwriteln!(source, " def apply("); + for (_, field_name0, typ, _) in &fields { + uwriteln!(source, " {field_name0}: {typ},"); + } + uwriteln!(source, " ): {scala_name} = {{"); + uwriteln!(source, " new {scala_name} {{"); + for (field_name, field_name0, typ, _) in &fields { + field_name.write_rename_attribute(&mut source, " "); + uwriteln!( + source, + " val {}: {typ} = {field_name0}", + field_name.scala + ); + } + uwriteln!(source, " }}"); + uwriteln!(source, " }}"); + uwriteln!(source, " }}"); + } + TypeDefKind::Tuple(tuple) => { + let arity = tuple.types.len(); + write_doc_comment(&mut source, " ", &typ.docs); + uwrite!(source, " type {scala_name} = WitTuple{arity}["); + for (idx, part) in tuple.types.iter().enumerate() { + let part = self.render_type_reference(owner_context, resolve, part); + uwrite!(source, "{part}"); + if idx < tuple.types.len() - 1 { + uwrite!(source, ", "); + } + } + uwriteln!(source, "]"); + } + TypeDefKind::Variant(variant) => { + write_doc_comment(&mut source, " ", &typ.docs); + uwriteln!(source, " sealed trait {scala_name} extends js.Object {{"); + uwriteln!(source, " type Type"); + uwriteln!(source, " val tag: String"); + uwriteln!(source, " val `val`: js.UndefOr[Type]"); + uwriteln!(source, " }}"); + uwriteln!(source, ""); + uwriteln!(source, " object {scala_name} {{"); + for case in &variant.cases { + let case_name = &case.name; + let scala_case_name = self.keywords.escape(case_name.to_lower_camel_case()); + match &case.ty { + Some(ty) => { + let typ = self.render_type_reference(owner_context, resolve, ty); + write_doc_comment(&mut source, " ", &case.docs); + uwriteln!(source, " def {scala_case_name}(value: {typ}): {scala_name} = new {scala_name} {{"); + uwriteln!(source, " type Type = {typ}"); + uwriteln!(source, " val tag: String = \"{case_name}\""); + uwriteln!(source, " val `val`: js.UndefOr[Type] = value"); + uwriteln!(source, " }}"); + } + None => { + write_doc_comment(&mut source, " ", &case.docs); + uwriteln!( + source, + " def {scala_case_name}(): {scala_name} = new {scala_name} {{" + ); + uwriteln!(source, " type Type = Unit"); + uwriteln!(source, " val tag: String = \"{case_name}\""); + uwriteln!(source, " val `val`: js.UndefOr[Type] = ()"); + uwriteln!(source, " }}"); + } + } + } + uwriteln!(source, " }}"); + } + TypeDefKind::Enum(enm) => { + write_doc_comment(&mut source, " ", &typ.docs); + uwriteln!(source, " sealed trait {scala_name} extends js.Object"); + uwriteln!(source, ""); + uwriteln!(source, " object {scala_name} {{"); + for case in &enm.cases { + let case_name = &case.name; + let scala_case_name = self.keywords.escape(case_name.to_lower_camel_case()); + write_doc_comment(&mut source, " ", &case.docs); + uwriteln!( + source, + " def {scala_case_name}: {scala_name} = \"{case_name}\".asInstanceOf[{scala_name}]", + ); + } + uwriteln!(source, " }}"); + } + TypeDefKind::Option(option) => { + write_doc_comment(&mut source, " ", &typ.docs); + let typ = self.render_type_reference(owner_context, resolve, option); + if !maybe_null(resolve, option) { + uwriteln!(source, " type {scala_name} = Nullable[{typ}]"); + } else { + uwriteln!(source, " type {scala_name} = WitOption[{typ}]"); + } + } + TypeDefKind::Result(result) => { + write_doc_comment(&mut source, " ", &typ.docs); + let ok = result + .ok + .map(|ok| self.render_type_reference(owner_context, resolve, &ok)) + .unwrap_or("Unit".to_string()); + let err = result + .err + .map(|err| self.render_type_reference(owner_context, resolve, &err)) + .unwrap_or("Unit".to_string()); + uwriteln!(source, " type {scala_name} = WitResult[{ok}, {err}]"); + } + TypeDefKind::List(list) => { + write_doc_comment(&mut source, " ", &typ.docs); + if matches!(list, Type::U8) { + uwriteln!(source, " type {scala_name} = js.typedarray.Uint8Array") + } else { + let typ = self.render_type_reference(owner_context, resolve, list); + uwriteln!(source, " type {scala_name} = WitList[{typ}]"); + } + } + TypeDefKind::Future(_) => { + panic!("Futures are not supported yet"); + } + TypeDefKind::Stream(_) => { + panic!("Streams are not supported yet"); + } + TypeDefKind::ErrorContext => { + panic!("ErrorContext is not supported yet"); + } + TypeDefKind::Type(reftyp) => { + write_doc_comment(&mut source, " ", &typ.docs); + let typ = self.render_type_reference(owner_context, resolve, reftyp); + uwriteln!(source, " type {scala_name} = {typ}"); + } + TypeDefKind::Unknown => { + panic!("Unknown type"); + } + } + + if source.len() > 0 { + Some(source) + } else { + None + } + } + + pub fn interface_direction(&self, id: &InterfaceId) -> Direction { + if self.imports.contains(id) { + Import + } else if self.exports.contains(id) { + Export + } else { + // Have not seen it yet, so it must be also an export + Export + } + } +} + +pub struct ScalaJsFile { + pub package: Vec, + pub name: String, + pub source: String, +} + +impl ScalaJsFile { + pub fn path(&self, optional_root: &Option) -> String { + // TODO: use PathBuf + match optional_root { + Some(root) => format!("{}/{}/{}.scala", root, self.package.join("/"), self.name), + None => format!("{}/{}.scala", self.package.join("/"), self.name), + } + } +} + +pub fn package_name_to_segments( + opts: &Opts, + package_name: &PackageName, + direction: &Direction, + keywords: &ScalaKeywords, + is_skeleton: bool, +) -> Vec { + let mut segments = if is_skeleton { + opts.base_skeleton_package_segments() + } else { + opts.base_package_segments() + }; + + if direction == &Export { + segments.push("exports".to_string()); + } + + segments.push(package_name.namespace.to_snake_case()); + segments.push(package_name.name.to_snake_case()); + if let Some(version) = &package_name.version { + segments.push(format!("v{}", version.to_string().to_snake_case())); + } + segments.into_iter().map(|s| keywords.escape(s)).collect() +} + +pub fn write_doc_comment(source: &mut impl Write, indent: &str, docs: &Docs) { + // TODO: rewrite types in `` blocks? + if !docs.is_empty() { + uwriteln!(source, "{}/**", indent); + for line in docs.contents.as_ref().unwrap().lines() { + uwriteln!(source, "{} * {}", indent, line); + } + uwriteln!(source, "{} */", indent); + } +} + +pub fn throws<'a>( + typ: &Type, + resolve: &'a Resolve, +) -> Option<(Option<&'a Type>, Option<&'a Type>)> { + match typ { + Type::Id(id) => match &resolve.types[*id].kind { + TypeDefKind::Result(r) => Some((r.ok.as_ref(), r.err.as_ref())), + _ => None, + }, + _ => None, + } +} + +#[allow(dead_code)] +pub struct EncodedName { + pub scala: String, + pub js: String, + pub rename_attribute: String, +} + +impl EncodedName { + pub fn write_rename_attribute(&self, target: &mut impl Write, ident: &str) { + if self.rename_attribute.len() > 0 { + uwriteln!(target, "{}{}", ident, self.rename_attribute); + } + } + + pub fn write_export_attribute(&self, target: &mut impl Write, ident: &str) { + uwriteln!(target, "{}@JSExport(\"{}\")", ident, self.js); + } + + pub fn write_static_export_attribute(&self, target: &mut impl Write, ident: &str) { + uwriteln!(target, "{}@JSExportStatic(\"{}\")", ident, self.js); + } +} diff --git a/crates/scalajs-jco/src/interface.rs b/crates/scalajs-jco/src/interface.rs new file mode 100644 index 000000000..94b0ce1c5 --- /dev/null +++ b/crates/scalajs-jco/src/interface.rs @@ -0,0 +1,339 @@ +use crate::context::{package_name_to_segments, write_doc_comment, ScalaJsFile}; +use crate::resource::{ScalaJsExportedResource, ScalaJsImportedResource}; +use crate::ScalaJs; +use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase}; +use std::collections::HashMap; +use std::fmt::Write; +use wit_bindgen_core::wit_parser::{ + FunctionKind, Interface, InterfaceId, Resolve, TypeDefKind, TypeId, +}; +use wit_bindgen_core::Direction::{Export, Import}; +use wit_bindgen_core::{uwriteln, Direction}; + +pub struct ScalaJsInterface<'a> { + wit_name: String, + pub name: String, + package_object_name: String, + source: String, + package: Vec, + pub resolve: &'a Resolve, + interface: &'a Interface, + pub interface_id: InterfaceId, + pub direction: Direction, + pub generator: &'a mut ScalaJs, +} + +impl<'a> ScalaJsInterface<'a> { + pub fn new( + wit_name: String, + resolve: &'a Resolve, + interface_id: InterfaceId, + direction: Direction, + generator: &'a mut ScalaJs, + ) -> Self { + let interface = &resolve.interfaces[interface_id]; + let name = interface + .name + .clone() + .unwrap_or(wit_name.clone()) + .to_pascal_case(); + + let package_name = resolve.packages + [interface.package.expect("missing package for interface")] + .name + .clone(); + + let package = package_name_to_segments( + &generator.context.opts, + &package_name, + &direction, + &generator.context.keywords, + false, + ); + + let package_object_name = generator.context.keywords.escape(name.to_snake_case()); + + Self { + wit_name, + name, + package_object_name, + source: "".to_string(), + package, + resolve, + interface, + interface_id, + direction, + generator, + } + } + + pub fn generate(&mut self) { + match self.direction { + Import => self.generate_import(), + Export => self.generate_export(), + } + } + + pub fn generate_import(&mut self) { + let mut source = String::new(); + self.generate_package_header(&mut source); + + let types = self.collect_type_definition_snippets(); + let imported_resources = self.collect_imported_resources(); + let functions = self.collect_function_snippets(); + + for typ in types { + uwriteln!(source, "{}", typ); + uwriteln!(source, ""); + } + + write_doc_comment(&mut source, " ", &self.interface.docs); + uwriteln!(source, " @js.native"); + uwriteln!(source, " trait {} extends js.Object {{", self.name); + + for (_, resource) in imported_resources { + uwriteln!(source, "{}", resource.finalize()); + uwriteln!(source, ""); + } + + for function in functions { + uwriteln!(source, "{function}"); + } + + uwriteln!(source, " }}"); + + uwriteln!(source, ""); + uwriteln!(source, " @js.native"); + uwriteln!( + source, + " @JSImport(\"{}\", JSImport.Namespace)", + self.wit_name + ); + uwriteln!(source, " object {} extends {}", self.name, self.name); + + uwriteln!(source, "}}"); + self.source = source; + } + + pub fn generate_export(&mut self) { + let mut source = String::new(); + self.generate_package_header(&mut source); + + let also_imported = self + .generator + .context + .interface_direction(&self.interface_id) + == Import; + + let types = if also_imported { + Vec::new() + } else { + self.collect_type_definition_snippets() + }; + let exported_resources = self.collect_exported_resources(); + let functions = self.collect_function_snippets(); + + for typ in types { + uwriteln!(source, "{}", typ); + uwriteln!(source, ""); + } + + for (_, resource) in exported_resources { + uwriteln!(source, "{}", resource.finalize()); + uwriteln!(source, ""); + } + + write_doc_comment(&mut source, " ", &self.interface.docs); + uwriteln!(source, " trait {} {{", self.name); + for function in functions { + uwriteln!(source, "{function}"); + } + + uwriteln!(source, " }}"); + + uwriteln!(source, "}}"); + self.source = source; + } + + fn generate_package_header(&mut self, source: &mut String) { + uwriteln!(source, "package {}", self.package.join(".")); + uwriteln!(source, ""); + uwriteln!(source, "import scala.scalajs.js"); + uwriteln!(source, "import scala.scalajs.js.annotation._"); + uwriteln!( + source, + "import {}wit._", + self.generator.context.opts.base_package_prefix() + ); + uwriteln!(source, ""); + + uwriteln!(source, "package object {} {{", self.package_object_name); + } + + fn collect_type_definition_snippets(&mut self) -> Vec { + let mut types = Vec::new(); + + for (type_name, type_id) in &self.interface.types { + let type_def = &self.resolve.types[*type_id]; + + let type_name = self + .generator + .context + .overrides + .get(type_id) + .unwrap_or(type_name); + let type_name = if type_name.eq_ignore_ascii_case(&self.name) { + let overridden_type_name = format!("{}Type", type_name); + self.generator + .context + .overrides + .insert(*type_id, overridden_type_name.clone()); + overridden_type_name + } else { + type_name.clone() + }; + + if let Some(typ) = + self.generator + .context + .render_typedef(self, &self.resolve, &type_name, type_def) + { + types.push(typ); + } + } + + types + } + + fn collect_imported_resources(&self) -> HashMap { + let mut imported_resources = HashMap::new(); + for (_, type_id) in &self.interface.types { + let type_def = &self.resolve.types[*type_id]; + if let TypeDefKind::Resource = &type_def.kind { + imported_resources.entry(*type_id).or_insert_with(|| { + ScalaJsImportedResource::new( + &self.generator.context, + self.resolve, + *type_id, + " ", + ) + }); + } + } + + for (func_name, func) in &self.interface.functions { + match func.kind { + FunctionKind::Method(resource_type) + | FunctionKind::Static(resource_type) + | FunctionKind::Constructor(resource_type) => { + let resource = imported_resources.entry(resource_type).or_insert_with(|| { + ScalaJsImportedResource::new( + &self.generator.context, + self.resolve, + resource_type, + " ", + ) + }); + resource.add_function( + &self.generator.context, + self.resolve, + self, + func_name, + func, + ); + } + FunctionKind::Freestanding => {} + } + } + + imported_resources + } + + fn collect_exported_resources(&self) -> HashMap { + let mut exported_resources = HashMap::new(); + + for (_, type_id) in &self.interface.types { + let type_def = &self.resolve.types[*type_id]; + if let TypeDefKind::Resource = &type_def.kind { + exported_resources + .entry(*type_id) + .or_insert_with(|| ScalaJsExportedResource::new(self, *type_id)); + } + } + + for (func_name, func) in &self.interface.functions { + match func.kind { + FunctionKind::Method(resource_type) + | FunctionKind::Static(resource_type) + | FunctionKind::Constructor(resource_type) => { + let resource = exported_resources + .entry(resource_type) + .or_insert_with(|| ScalaJsExportedResource::new(self, resource_type)); + resource.add_function(func_name, func); + } + FunctionKind::Freestanding => {} + } + } + + exported_resources + } + + fn collect_function_snippets(&self) -> Vec { + let mut functions = Vec::new(); + + for (func_name, func) in &self.interface.functions { + let func_name = self + .generator + .context + .encode_name(func_name.to_lower_camel_case()); + + match func.kind { + FunctionKind::Freestanding => { + let args = + self.generator + .context + .render_args(self, self.resolve, func.params.iter()); + let (ret, throws) = + self.generator + .context + .render_return_type(self, self.resolve, &func.result); + + let mut function = String::new(); + write_doc_comment(&mut function, " ", &func.docs); + + let mut postfix = match self.direction { + Import => " = js.native".to_string(), + Export => "".to_string(), + }; + + if let Some(throws) = throws { + postfix.push_str(&format!(" // throws {throws}")); + } + + if self.direction == Import { + func_name.write_rename_attribute(&mut function, " "); + } + uwriteln!( + function, + " def {}({args}): {ret}{postfix}", + func_name.scala + ); + functions.push(function); + } + FunctionKind::Method(_) + | FunctionKind::Static(_) + | FunctionKind::Constructor(_) => {} + } + } + + functions + } + + pub fn finalize(self) -> ScalaJsFile { + ScalaJsFile { + package: self.package, + name: self.package_object_name, + source: self.source, + } + } +} diff --git a/crates/scalajs-jco/src/jco.rs b/crates/scalajs-jco/src/jco.rs new file mode 100644 index 000000000..a934b0d15 --- /dev/null +++ b/crates/scalajs-jco/src/jco.rs @@ -0,0 +1,168 @@ +// Code copied from jco + +use heck::ToLowerCamelCase; +use wit_bindgen_core::wit_parser::{Resolve, Type, TypeDefKind}; + +/// Tests whether `ty` can be represented with `null`, and if it can then +/// the "other type" is returned. If `Some` is returned that means that `ty` +/// is `null | `. If `None` is returned that means that `null` can't +/// be used to represent `ty`. +pub fn as_nullable<'a>(resolve: &'a Resolve, ty: &'a Type) -> Option<&'a Type> { + let id = match ty { + Type::Id(id) => *id, + _ => return None, + }; + match &resolve.types[id].kind { + // If `ty` points to an `option`, then `ty` can be represented + // with `null` if `t` itself can't be represented with null. For + // example `option>` can't be represented with `null` + // since that's ambiguous if it's `none` or `some(none)`. + // + // Note, oddly enough, that `option>>` can be + // represented as `null` since: + // + // * `null` => `none` + // * `{ tag: "none" }` => `some(none)` + // * `{ tag: "some", val: null }` => `some(some(none))` + // * `{ tag: "some", val: 1 }` => `some(some(some(1)))` + // + // It's doubtful anyone would actually rely on that though due to + // how confusing it is. + TypeDefKind::Option(t) => { + if !maybe_null(resolve, t) { + Some(t) + } else { + None + } + } + TypeDefKind::Type(t) => as_nullable(resolve, t), + _ => None, + } +} + +pub fn maybe_null(resolve: &Resolve, ty: &Type) -> bool { + as_nullable(resolve, ty).is_some() +} + +// Convert an arbitrary string to a similar close js identifier +pub fn to_js_identifier(goal_name: &str) -> String { + if is_js_identifier(goal_name) { + goal_name.to_string() + } else { + let goal = goal_name.to_lower_camel_case(); + let mut identifier = String::new(); + for char in goal.chars() { + let valid_char = if identifier.is_empty() { + is_js_identifier_start(char) + } else { + is_js_identifier_char(char) + }; + if valid_char { + identifier.push(char); + } else { + identifier.push(match char { + '.' => '_', + _ => '$', + }); + } + } + if !is_js_identifier(&identifier) { + identifier = format!("_{identifier}"); + if !is_js_identifier(&identifier) { + panic!("Unable to generate valid identifier {identifier} for '{goal_name}'"); + } + } + identifier + } +} + +pub fn is_js_identifier(s: &str) -> bool { + let mut chars = s.chars(); + if let Some(char) = chars.next() { + if !is_js_identifier_start(char) { + return false; + } + } else { + return false; + } + for char in chars { + if !is_js_identifier_char(char) { + return false; + } + } + !is_js_reserved_word(&s) +} + +pub fn is_js_reserved_word(s: &str) -> bool { + RESERVED_KEYWORDS.binary_search(&s).is_ok() +} + +// https://tc39.es/ecma262/#prod-IdentifierStartChar +// Unicode ID_Start | "$" | "_" +fn is_js_identifier_start(code: char) -> bool { + match code { + 'A'..='Z' | 'a'..='z' | '$' | '_' => true, + // leaving out non-ascii for now... + _ => false, + } +} + +// https://tc39.es/ecma262/#prod-IdentifierPartChar +// Unicode ID_Continue | "$" | U+200C | U+200D +fn is_js_identifier_char(code: char) -> bool { + match code { + '0'..='9' | 'A'..='Z' | 'a'..='z' | '$' | '_' => true, + // leaving out non-ascii for now... + _ => false, + } +} + +pub(crate) const RESERVED_KEYWORDS: &[&str] = &[ + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "eval", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]; diff --git a/crates/scalajs-jco/src/lib.rs b/crates/scalajs-jco/src/lib.rs new file mode 100644 index 000000000..d69f762b5 --- /dev/null +++ b/crates/scalajs-jco/src/lib.rs @@ -0,0 +1,337 @@ +pub mod context; +pub mod interface; +pub mod jco; +pub mod resource; +pub mod rt; +mod skeleton; +pub mod world; + +use crate::context::{ScalaJsContext, ScalaJsFile, ScalaKeywords}; +use crate::interface::ScalaJsInterface; +use crate::rt::render_runtime_module; +use crate::skeleton::{ScalaJsInterfaceSkeleton, ScalaJsWorldSkeleton}; +use crate::world::ScalaJsWorld; +use std::collections::{HashMap, HashSet}; +use std::fmt::Display; +use std::str::FromStr; +use wit_bindgen_core::wit_parser::{Function, InterfaceId, Resolve, TypeId, WorldId, WorldKey}; +use wit_bindgen_core::Direction::{Export, Import}; +use wit_bindgen_core::{Files, WorldGenerator}; + +#[derive(Debug, Clone)] +pub enum ScalaDialect { + Scala2, + Scala3, +} + +impl Default for ScalaDialect { + fn default() -> Self { + ScalaDialect::Scala2 + } +} + +impl Display for ScalaDialect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ScalaDialect::Scala2 => write!(f, "scala2"), + ScalaDialect::Scala3 => write!(f, "scala3"), + } + } +} + +impl FromStr for ScalaDialect { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "scala2" => Ok(ScalaDialect::Scala2), + "scala3" => Ok(ScalaDialect::Scala3), + _ => Err("Invalid Scala dialect".to_string()), + } + } +} + +#[derive(Default, Debug, Clone)] +#[cfg_attr(feature = "clap", derive(clap::Args))] +pub struct Opts { + #[cfg_attr( + feature = "clap", + clap(long, help = "Base package for generated Scala.js code") + )] + pub base_package: Option, + + #[cfg_attr( + feature = "clap", + clap(long, help = "Base package for generated Scala.js skeleton code") + )] + pub skeleton_base_package: Option, + + #[cfg_attr( + feature = "clap", + clap( + long, + help = "Scala dialect to generate code for", + default_value = "scala2" + ) + )] + pub scala_dialect: ScalaDialect, + #[cfg_attr( + feature = "clap", + clap(long, help = "Generate a skeleton for implementing all the exports",) + )] + pub generate_skeleton: bool, + #[cfg_attr( + feature = "clap", + clap( + long, + help = "Relative root directory for placing the skeleton sources", + ) + )] + pub skeleton_root: Option, + #[cfg_attr( + feature = "clap", + clap(long, help = "Relative root directory for placing the binding sources",) + )] + pub binding_root: Option, +} + +impl Opts { + pub fn build(&self) -> Box { + Box::new(ScalaJs::new(self.clone())) + } + + pub fn base_package_segments(&self) -> Vec { + self.base_package + .clone() + .map(|pkg| pkg.split('.').map(|s| s.to_string()).collect::>()) + .unwrap_or_default() + } + + pub fn base_skeleton_package_segments(&self) -> Vec { + self.skeleton_base_package + .clone() + .or(self.base_package.clone()) + .map(|pkg| pkg.split('.').map(|s| s.to_string()).collect::>()) + .unwrap_or_default() + } + + pub fn base_package_prefix(&self) -> String { + match &self.base_package { + Some(pkg) => format!("{pkg}."), + None => "".to_string(), + } + } +} + +pub struct ScalaJs { + context: ScalaJsContext, + generated_files: Vec, + generated_skeleton_files: Vec, + world_defs: HashMap, + world_skeletons: HashMap, +} + +impl WorldGenerator for ScalaJs { + fn import_interface( + &mut self, + resolve: &Resolve, + name: &WorldKey, + iface: InterfaceId, + _files: &mut Files, + ) -> anyhow::Result<()> { + let key = name; + let wit_name = resolve.name_world_key(key); + + self.context.imports.insert(iface); + let mut scalajs_iface = + ScalaJsInterface::new(wit_name.clone(), resolve, iface, Import, self); + scalajs_iface.generate(); + + let file = scalajs_iface.finalize(); + self.generated_files.push(file); + + Ok(()) + } + + fn export_interface( + &mut self, + resolve: &Resolve, + name: &WorldKey, + iface: InterfaceId, + _files: &mut Files, + ) -> anyhow::Result<()> { + let key = name; + let wit_name = resolve.name_world_key(key); + + self.context.exports.insert(iface); + let mut scalajs_iface = + ScalaJsInterface::new(wit_name.clone(), resolve, iface, Export, self); + scalajs_iface.generate(); + let binding_file = scalajs_iface.finalize(); + self.generated_files.push(binding_file); + + if self.context.opts.generate_skeleton { + let mut interface_skeleton = + ScalaJsInterfaceSkeleton::new(wit_name.clone(), resolve, iface, self); + interface_skeleton.generate(); + let skeleton_file = interface_skeleton.finalize(); + self.generated_skeleton_files.push(skeleton_file); + } + + Ok(()) + } + + fn import_funcs( + &mut self, + resolve: &Resolve, + world_id: WorldId, + funcs: &[(&str, &Function)], + _files: &mut Files, + ) { + let world = &resolve.worlds[world_id]; + + if !self.world_defs.contains_key(&world_id) { + let world_def = ScalaJsWorld::new(&self.context, resolve, world_id, world); + self.world_defs.insert(world_id, world_def); + } + + for (func_name, func) in funcs { + self.world_defs + .get_mut(&world_id) + .unwrap() + .add_imported_function(&self.context, resolve, func_name, func); + } + } + + fn export_funcs( + &mut self, + resolve: &Resolve, + world_id: WorldId, + funcs: &[(&str, &Function)], + _files: &mut Files, + ) -> anyhow::Result<()> { + let world = &resolve.worlds[world_id]; + + if !self.world_defs.contains_key(&world_id) { + let world_def = ScalaJsWorld::new(&self.context, resolve, world_id, world); + self.world_defs.insert(world_id, world_def); + } + + for (func_name, func) in funcs { + self.world_defs + .get_mut(&world_id) + .unwrap() + .add_exported_function(&self.context, resolve, func_name, func); + } + + if self.context.opts.generate_skeleton { + if !self.world_skeletons.contains_key(&world_id) { + let world_skeleton = + ScalaJsWorldSkeleton::new(&self.context, resolve, world_id, world); + self.world_skeletons.insert(world_id, world_skeleton); + } + + for (func_name, func) in funcs { + self.world_skeletons + .get_mut(&world_id) + .unwrap() + .add_exported_function(&self.context, resolve, func_name, func); + } + } + + Ok(()) + } + + fn import_types( + &mut self, + resolve: &Resolve, + world_id: WorldId, + types: &[(&str, TypeId)], + _files: &mut Files, + ) { + let world = &resolve.worlds[world_id]; + + if !self.world_defs.contains_key(&world_id) { + let world_def = ScalaJsWorld::new(&self.context, resolve, world_id, world); + self.world_defs.insert(world_id, world_def); + } + + for (type_name, type_id) in types { + self.world_defs.get_mut(&world_id).unwrap().add_type( + &self.context, + resolve, + type_name, + type_id, + ); + } + } + + fn finish( + &mut self, + resolve: &Resolve, + _world: WorldId, + files: &mut Files, + ) -> anyhow::Result<()> { + let skeleton_root = &self.context.opts.skeleton_root.clone().or(self + .context + .opts + .binding_root + .clone()); + + for file in &self.generated_files { + files.push( + &file.path(&self.context.opts.binding_root), + file.source.as_bytes(), + ); + } + + for file in &self.generated_skeleton_files { + files.push(&file.path(&skeleton_root), file.source.as_bytes()); + } + + for (_, world_def) in self.world_defs.drain() { + let world_files = world_def.finalize(&self.context, resolve); + for world_file in world_files { + files.push( + &world_file.path(&self.context.opts.binding_root), + world_file.source.as_bytes(), + ); + } + } + + for (_, world_skeleton) in self.world_skeletons.drain() { + let world_file = world_skeleton.finalize(resolve); + files.push( + &world_file.path(&skeleton_root), + world_file.source.as_bytes(), + ); + } + + let rt = render_runtime_module(&self.context.opts); + files.push( + &rt.path(&self.context.opts.binding_root), + rt.source.as_bytes(), + ); + + Ok(()) + } +} + +impl ScalaJs { + fn new(opts: Opts) -> Self { + let keywords = ScalaKeywords::new(&opts.scala_dialect); + Self { + context: ScalaJsContext { + opts, + keywords, + overrides: HashMap::new(), + imports: HashSet::new(), + exports: HashSet::new(), + }, + generated_files: Vec::new(), + generated_skeleton_files: Vec::new(), + world_defs: HashMap::new(), + world_skeletons: HashMap::new(), + } + } +} diff --git a/crates/scalajs-jco/src/resource.rs b/crates/scalajs-jco/src/resource.rs new file mode 100644 index 000000000..cae7c330f --- /dev/null +++ b/crates/scalajs-jco/src/resource.rs @@ -0,0 +1,372 @@ +use crate::context::{write_doc_comment, EncodedName, OwnerContext, ScalaJsContext}; +use crate::interface::ScalaJsInterface; +use heck::{ToLowerCamelCase, ToPascalCase}; +use std::fmt::Write; +use wit_bindgen_core::wit_parser::{Function, FunctionKind, Resolve, TypeId}; +use wit_bindgen_core::{uwrite, uwriteln}; + +pub struct ScalaJsImportedResource { + _resource_id: TypeId, + resource_name: String, + class_header: String, + class_source: String, + object_source: String, + constructor_args: String, + pub name: EncodedName, + indent: String, + annotations: Vec, +} + +impl ScalaJsImportedResource { + pub fn new( + context: &ScalaJsContext, + resolve: &Resolve, + resource_id: TypeId, + indent: &str, + ) -> Self { + let resource = &resolve.types[resource_id]; + let resource_name = resource + .name + .clone() + .expect("Anonymous resources not supported"); + let encoded_resource_name = context.encode_name(resource_name.to_pascal_case()); + + let mut class_header = String::new(); + write_doc_comment(&mut class_header, " ", &resource.docs); + + uwriteln!(class_header, "{indent}@js.native"); + encoded_resource_name.write_rename_attribute(&mut class_header, " "); + uwrite!( + class_header, + "{indent}class {}(", + encoded_resource_name.scala + ); + + let mut class_source = String::new(); + uwriteln!(class_source, ") extends js.Object {{"); + + let mut object_source = String::new(); + uwriteln!(object_source, "{indent}@js.native"); + uwriteln!( + object_source, + "{indent}object {} extends js.Object {{", + encoded_resource_name.scala + ); + + Self { + _resource_id: resource_id, + resource_name, + class_header, + class_source, + object_source, + constructor_args: String::new(), + name: encoded_resource_name, + indent: indent.to_string(), + annotations: Vec::new(), + } + } + + pub fn annotate(&mut self, annotation: &str) { + self.annotations.push(annotation.to_string()); + } + + pub fn add_function( + &mut self, + context: &ScalaJsContext, + resolve: &Resolve, + owner_context: &impl OwnerContext, + func_name: &str, + func: &Function, + ) { + match &func.kind { + FunctionKind::Freestanding => unreachable!(), + FunctionKind::Method(_) => { + let args = context.render_args(owner_context, resolve, func.params.iter().skip(1)); + let (ret, throws) = + context.render_return_type(owner_context, resolve, &func.result); + let encoded_func_name = self.get_func_name(context, "[method]", func_name); + + let overrd = if context + .keywords + .base_methods + .contains(&encoded_func_name.scala) + { + "override " + } else { + "" + }; + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + write_doc_comment( + &mut self.class_source, + &format!("{} ", self.indent), + &func.docs, + ); + encoded_func_name.write_rename_attribute(&mut self.class_source, " "); + uwriteln!( + self.class_source, + "{} {overrd}def {}({args}): {ret} = js.native{postfix}", + self.indent, + encoded_func_name.scala + ); + } + FunctionKind::Static(_) => { + let args = context.render_args(owner_context, resolve, func.params.iter()); + let (ret, throws) = + context.render_return_type(owner_context, resolve, &func.result); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + let encoded_func_name = self.get_func_name(context, "[static]", func_name); + write_doc_comment(&mut self.object_source, " ", &func.docs); + encoded_func_name + .write_rename_attribute(&mut self.object_source, &format!("{} ", self.indent)); + uwriteln!( + self.object_source, + "{} def {}({args}): {ret} = js.native{postfix}", + self.indent, + encoded_func_name.scala + ); + } + FunctionKind::Constructor(_) => { + // Renaming constructor parameters because they can collide with the method names + let renamed_func_params: Vec<_> = func + .params + .iter() + .map(|(name, typ)| (format!("{name}0"), typ.clone())) + .collect(); + let args = context.render_args(owner_context, resolve, renamed_func_params.iter()); + self.constructor_args = args; + } + } + } + + pub fn finalize(self) -> String { + let mut class_source = String::new(); + for annotation in &self.annotations { + uwriteln!(class_source, "{}{}", self.indent, annotation); + } + uwriteln!(class_source, "{}", self.class_header); + uwrite!(class_source, "{}", self.constructor_args); + uwriteln!(class_source, "{}", self.class_source); + uwriteln!(class_source, "{}}}", self.indent); + let mut object_source = String::new(); + for annotation in self.annotations { + uwriteln!(object_source, "{}{}", self.indent, annotation); + } + uwriteln!(object_source, "{}", self.object_source); + uwriteln!(object_source, "{}}}", self.indent); + format!("{}\n{}\n", class_source, object_source) + } + + fn get_func_name( + &self, + context: &ScalaJsContext, + prefix: &str, + func_name: &str, + ) -> EncodedName { + let name = func_name + .strip_prefix(prefix) + .unwrap() + .strip_prefix(&self.resource_name) + .unwrap() + .to_lower_camel_case(); + context.encode_name(name) + } +} + +pub struct ScalaJsExportedResource<'a> { + owner: &'a ScalaJsInterface<'a>, + _resource_id: TypeId, + resource_name: String, + encoded_resource_name: EncodedName, + class_header: String, + class_source: String, + static_methods: String, + constructor_args: String, +} + +impl<'a> ScalaJsExportedResource<'a> { + pub fn new(owner: &'a ScalaJsInterface<'a>, resource_id: TypeId) -> Self { + let resource = &owner.resolve.types[resource_id]; + let resource_name = resource + .name + .clone() + .expect("Anonymous resources not supported"); + let encoded_resource_name = owner + .generator + .context + .encode_name(resource_name.to_pascal_case()); + + let mut class_header = String::new(); + write_doc_comment(&mut class_header, " ", &resource.docs); + + encoded_resource_name.write_rename_attribute(&mut class_header, " // "); + uwrite!( + class_header, + " abstract class {}(", + encoded_resource_name.scala + ); + + let mut class_source = String::new(); + uwriteln!(class_source, ") extends js.Object {{"); + + Self { + owner, + _resource_id: resource_id, + resource_name, + encoded_resource_name, + class_header, + class_source, + static_methods: String::new(), + constructor_args: String::new(), + } + } + + pub fn add_function(&mut self, func_name: &str, func: &Function) { + match &func.kind { + FunctionKind::Freestanding => unreachable!(), + FunctionKind::Method(_) => { + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + func.params.iter().skip(1), + ); + let (ret, throws) = self.owner.generator.context.render_return_type( + self.owner, + self.owner.resolve, + &func.result, + ); + let encoded_func_name = self.get_func_name("[method]", func_name); + + let overrd = if self + .owner + .generator + .context + .keywords + .base_methods + .contains(&encoded_func_name.scala) + { + "override " + } else { + "" + }; + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + write_doc_comment(&mut self.class_source, " ", &func.docs); + encoded_func_name.write_rename_attribute(&mut self.class_source, " "); + uwriteln!( + self.class_source, + " {overrd}def {}({args}): {ret}{postfix}", + encoded_func_name.scala + ); + } + FunctionKind::Static(_) => { + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + func.params.iter(), + ); + let (ret, throws) = self.owner.generator.context.render_return_type( + self.owner, + self.owner.resolve, + &func.result, + ); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + let encoded_func_name = self.get_func_name("[static]", func_name); + write_doc_comment(&mut self.static_methods, " ", &func.docs); + uwriteln!(self.static_methods, " // @JSExportStatic"); + encoded_func_name.write_rename_attribute(&mut self.static_methods, " "); + uwriteln!( + self.static_methods, + " def {}({args}): {ret}{postfix}", + encoded_func_name.scala + ); + } + FunctionKind::Constructor(_) => { + // Renaming constructor parameters because they can collide with the method names + let renamed_func_params: Vec<_> = func + .params + .iter() + .map(|(name, typ)| (format!("{name}0"), typ.clone())) + .collect(); + + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + renamed_func_params.iter(), + ); + self.constructor_args = args; + } + } + } + + pub fn constructor_function(&self) -> String { + let mut constructor = String::new(); + uwriteln!( + constructor, + " // @JSExport(\"{}\")", + self.encoded_resource_name.js + ); + let encoded_name = self + .owner + .generator + .context + .encode_name(self.resource_name.to_lower_camel_case()); + uwriteln!( + constructor, + " def {}({}): {}", + encoded_name.scala, + self.constructor_args, + self.encoded_resource_name.scala + ); + constructor + } + + pub fn finalize(self) -> String { + let mut class_source = self.class_header; + uwrite!(class_source, "{}", self.constructor_args); + uwriteln!(class_source, "{}", self.class_source); + uwriteln!(class_source, " }}"); + uwriteln!(class_source, ""); + + uwriteln!( + class_source, + " trait {}Static {{", + self.encoded_resource_name.scala + ); + uwriteln!(class_source, "{}", self.static_methods); + uwriteln!(class_source, " }}"); + class_source + } + + fn get_func_name(&self, prefix: &str, func_name: &str) -> EncodedName { + let name = func_name + .strip_prefix(prefix) + .unwrap() + .strip_prefix(&self.resource_name) + .unwrap() + .to_lower_camel_case(); + self.owner.generator.context.encode_name(name) + } +} diff --git a/crates/scalajs-jco/src/rt.rs b/crates/scalajs-jco/src/rt.rs new file mode 100644 index 000000000..9e68b93ed --- /dev/null +++ b/crates/scalajs-jco/src/rt.rs @@ -0,0 +1,22 @@ +use crate::context::ScalaJsFile; +use crate::Opts; +use std::fmt::Write; +use wit_bindgen_core::uwriteln; + +pub fn render_runtime_module(opts: &Opts) -> ScalaJsFile { + let wit_scala = include_str!("../scala/wit.scala"); + + let mut package = opts.base_package_segments(); + package.push("wit".to_string()); + + let mut source = String::new(); + uwriteln!(source, "package {}", opts.base_package_segments().join(".")); + uwriteln!(source, ""); + uwriteln!(source, "{wit_scala}"); + + ScalaJsFile { + package, + name: "package".to_string(), + source, + } +} diff --git a/crates/scalajs-jco/src/skeleton.rs b/crates/scalajs-jco/src/skeleton.rs new file mode 100644 index 000000000..4e44783b8 --- /dev/null +++ b/crates/scalajs-jco/src/skeleton.rs @@ -0,0 +1,526 @@ +use crate::context::{ + package_name_to_segments, write_doc_comment, EncodedName, ScalaJsContext, ScalaJsFile, +}; +use crate::ScalaJs; +use heck::{ToKebabCase, ToLowerCamelCase, ToPascalCase, ToSnakeCase}; +use std::collections::HashMap; +use std::fmt::Write; +use wit_bindgen_core::wit_parser::{ + Function, FunctionKind, Interface, InterfaceId, Resolve, TypeDefKind, TypeId, World, WorldId, +}; +use wit_bindgen_core::Direction::Export; +use wit_bindgen_core::{uwrite, uwriteln}; + +pub struct ScalaJsInterfaceSkeleton<'a> { + name: String, + encoded_name: EncodedName, + source: String, + package: Vec, + binding_package: Vec, + pub resolve: &'a Resolve, + interface: &'a Interface, + pub interface_id: InterfaceId, + pub generator: &'a mut ScalaJs, +} + +impl<'a> ScalaJsInterfaceSkeleton<'a> { + pub fn new( + wit_name: String, + resolve: &'a Resolve, + interface_id: InterfaceId, + generator: &'a mut ScalaJs, + ) -> Self { + let interface = &resolve.interfaces[interface_id]; + let name = interface + .name + .clone() + .unwrap_or(wit_name.clone()) + .to_pascal_case(); + + let encoded_name = generator.context.encode_name(&name.to_kebab_case()); + + let package_name = resolve.packages + [interface.package.expect("missing package for interface")] + .name + .clone(); + + let package = package_name_to_segments( + &generator.context.opts, + &package_name, + &Export, + &generator.context.keywords, + true, + ); + + let binding_package = package_name_to_segments( + &generator.context.opts, + &package_name, + &Export, + &generator.context.keywords, + false, + ); + + Self { + name, + encoded_name, + source: "".to_string(), + package, + binding_package, + resolve, + interface, + interface_id, + generator, + } + } + + pub fn generate(&mut self) { + let mut source = String::new(); + uwriteln!(source, "package {}", self.package.join(".")); + uwriteln!(source, ""); + uwriteln!(source, "import scala.scalajs.js"); + uwriteln!(source, "import scala.scalajs.js.annotation._"); + uwriteln!( + source, + "import {}wit._", + self.generator.context.opts.base_package_prefix() + ); + uwriteln!(source, ""); + + let base_trait_name = format!( + "{}.{}.{}", + self.binding_package.join("."), + self.generator + .context + .keywords + .escape(self.name.to_snake_case()), + self.name + ); + + let exported_resources = self.collect_exported_resources(); + + uwriteln!(source, ""); + for (_, resource) in exported_resources { + uwriteln!(source, "{}", resource.finalize()); + uwriteln!(source, ""); + } + + uwriteln!(source, "@JSExportTopLevel(\"{}\")", self.encoded_name.js); + uwriteln!( + source, + "object {} extends {} {{", + self.name, + base_trait_name + ); + + for function in self.collect_function_snippets() { + uwriteln!(source, "{}", function); + } + + uwriteln!(source, "}}"); + + self.source = source; + } + + pub fn finalize(self) -> ScalaJsFile { + ScalaJsFile { + package: self.package, + name: self.name, + source: self.source, + } + } + + fn collect_function_snippets(&self) -> Vec { + let mut functions = Vec::new(); + + for (func_name, func) in &self.interface.functions { + let func_name = self + .generator + .context + .encode_name(func_name.to_lower_camel_case()); + + match func.kind { + FunctionKind::Freestanding => { + let args = + self.generator + .context + .render_args(self, self.resolve, func.params.iter()); + let (ret, throws) = + self.generator + .context + .render_return_type(self, self.resolve, &func.result); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + let mut function = String::new(); + + func_name.write_export_attribute(&mut function, " "); + uwriteln!( + function, + " override def {}({args}): {ret} = {{{postfix}", + func_name.scala + ); + uwriteln!(function, " ???"); + uwriteln!(function, " }}"); + functions.push(function); + } + FunctionKind::Method(_) + | FunctionKind::Static(_) + | FunctionKind::Constructor(_) => {} + } + } + + functions + } + + fn collect_exported_resources(&self) -> HashMap { + let mut exported_resources = HashMap::new(); + + for (_, type_id) in &self.interface.types { + let type_def = &self.resolve.types[*type_id]; + if let TypeDefKind::Resource = &type_def.kind { + exported_resources + .entry(*type_id) + .or_insert_with(|| ScalaJsExportedResourceSkeleton::new(self, *type_id)); + } + } + + for (func_name, func) in &self.interface.functions { + match func.kind { + FunctionKind::Method(resource_type) + | FunctionKind::Static(resource_type) + | FunctionKind::Constructor(resource_type) => { + let resource = exported_resources.entry(resource_type).or_insert_with(|| { + ScalaJsExportedResourceSkeleton::new(self, resource_type) + }); + resource.add_function(func_name, func); + } + FunctionKind::Freestanding => {} + } + } + + exported_resources + } +} + +pub struct ScalaJsWorldSkeleton { + world_id: WorldId, + export_header: String, + global_exports: String, + export_package: Vec, +} + +impl ScalaJsWorldSkeleton { + pub fn new( + context: &ScalaJsContext, + resolve: &Resolve, + world_id: WorldId, + world: &World, + ) -> Self { + let package_name = resolve.packages[world.package.expect("missing package for world")] + .name + .clone(); + + let export_package = package_name_to_segments( + &context.opts, + &package_name, + &Export, + &context.keywords, + true, + ); + + let binding_package = package_name_to_segments( + &context.opts, + &package_name, + &Export, + &context.keywords, + false, + ); + + let encoded_name = context.encode_name(world.name.to_pascal_case()); + + let base_trait_name = format!( + "{}.{}.{}", + binding_package.join("."), + context.keywords.escape(world.name.to_snake_case()), + encoded_name.scala + ); + + let mut export_header = String::new(); + uwriteln!(export_header, "package {}", export_package.join(".")); + uwriteln!(export_header, ""); + uwriteln!(export_header, "import scala.scalajs.js"); + uwriteln!(export_header, "import scala.scalajs.js.annotation._"); + uwriteln!( + export_header, + "import {}wit._", + context.opts.base_package_prefix() + ); + uwriteln!(export_header, ""); + uwriteln!( + export_header, + "object {} extends {base_trait_name} {{", + encoded_name.scala + ); + + Self { + world_id, + export_header, + global_exports: String::new(), + export_package, + } + } + + pub fn add_exported_function( + &mut self, + context: &ScalaJsContext, + resolve: &Resolve, + func_name: &str, + func: &Function, + ) { + match func.kind { + FunctionKind::Freestanding => { + let encoded_name = context.encode_name(func_name.to_lower_camel_case()); + let args = context.render_args(context, resolve, func.params.iter()); + let (ret, throws) = context.render_return_type(context, resolve, &func.result); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + uwriteln!( + self.global_exports, + " def {}({args}): {ret} = {{{postfix}", + encoded_name.scala + ); + uwriteln!(self.global_exports, " ???"); + uwriteln!(self.global_exports, " }}"); + } + FunctionKind::Method(_resource_type) + | FunctionKind::Static(_resource_type) + | FunctionKind::Constructor(_resource_type) => { + panic!("Exported inline resource functions are not supported") + } + } + } + + pub fn finalize(self, resolve: &Resolve) -> ScalaJsFile { + let mut export_source = String::new(); + uwriteln!(export_source, "{}", self.export_header); + uwriteln!(export_source, "{}", self.global_exports); + uwriteln!(export_source, "}}"); + + let world = &resolve.worlds[self.world_id]; + + ScalaJsFile { + package: self.export_package, + name: world.name.to_snake_case(), + source: export_source, + } + } +} + +pub struct ScalaJsExportedResourceSkeleton<'a> { + owner: &'a ScalaJsInterfaceSkeleton<'a>, + _resource_id: TypeId, + resource_name: String, + encoded_resource_name: EncodedName, + class_header: String, + base_class_header: String, + class_source: String, + static_methods: String, + constructor_args: String, + base_constructor_args: String, + base_static_trait_name: String, +} + +impl<'a> ScalaJsExportedResourceSkeleton<'a> { + pub fn new(owner: &'a ScalaJsInterfaceSkeleton<'a>, resource_id: TypeId) -> Self { + let resource = &owner.resolve.types[resource_id]; + let resource_name = resource + .name + .clone() + .expect("Anonymous resources not supported"); + let encoded_resource_name = owner + .generator + .context + .encode_name(resource_name.to_pascal_case()); + + let base_class_name = format!( + "{}.{}.{}", + owner.binding_package.join("."), + owner + .generator + .context + .keywords + .escape(owner.name.to_snake_case()), + encoded_resource_name.scala + ); + + let base_static_trait_name = format!( + "{}.{}.{}", + owner.binding_package.join("."), + owner + .generator + .context + .keywords + .escape(owner.name.to_snake_case()), + format!("{}Static", encoded_resource_name.scala) + ); + + let mut class_header = String::new(); + write_doc_comment(&mut class_header, " ", &resource.docs); + + let prefixed_export_name = + format!("{}_{}", owner.encoded_name.js, encoded_resource_name.js); + + uwriteln!( + class_header, + "@JSExportTopLevel(\"{}\")", + prefixed_export_name + ); + uwrite!(class_header, "class {}(", encoded_resource_name.scala); + + let mut base_class_header = String::new(); + uwrite!(base_class_header, ") extends {base_class_name}("); + + let mut class_source = String::new(); + uwriteln!(class_source, ") {{"); + + Self { + owner, + _resource_id: resource_id, + resource_name, + encoded_resource_name, + class_header, + base_class_header, + class_source, + static_methods: String::new(), + constructor_args: String::new(), + base_constructor_args: String::new(), + base_static_trait_name, + } + } + + pub fn add_function(&mut self, func_name: &str, func: &Function) { + match &func.kind { + FunctionKind::Freestanding => unreachable!(), + FunctionKind::Method(_) => { + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + func.params.iter().skip(1), + ); + let (ret, throws) = self.owner.generator.context.render_return_type( + self.owner, + self.owner.resolve, + &func.result, + ); + let encoded_func_name = self.get_func_name("[method]", func_name); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + encoded_func_name.write_rename_attribute(&mut self.class_source, " "); + uwriteln!( + self.class_source, + " override def {}({args}): {ret} = {{{postfix}", + encoded_func_name.scala + ); + uwriteln!(self.class_source, " ???"); + uwriteln!(self.class_source, " }}"); + } + FunctionKind::Static(_) => { + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + func.params.iter(), + ); + let (ret, throws) = self.owner.generator.context.render_return_type( + self.owner, + self.owner.resolve, + &func.result, + ); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + let encoded_func_name = self.get_func_name("[static]", func_name); + encoded_func_name.write_static_export_attribute(&mut self.static_methods, " "); + uwriteln!( + self.static_methods, + " override def {}({args}): {ret} = {{{postfix}", + encoded_func_name.scala + ); + uwriteln!(self.static_methods, " ???"); + uwriteln!(self.static_methods, " }}"); + } + FunctionKind::Constructor(_) => { + // Renaming constructor parameters because they can collide with the method names + let renamed_func_params: Vec<_> = func + .params + .iter() + .map(|(name, typ)| (format!("{name}0"), typ.clone())) + .collect(); + let args = self.owner.generator.context.render_args( + self.owner, + self.owner.resolve, + renamed_func_params.iter(), + ); + self.constructor_args = args; + + let mut param_names = Vec::new(); + for (param_name, _) in renamed_func_params.iter() { + let param_name = self + .owner + .generator + .context + .encode_name(param_name.to_lower_camel_case()); + param_names.push(param_name.scala); + } + self.base_constructor_args = param_names.join(", "); + } + } + } + + pub fn finalize(self) -> String { + let mut class_source = self.class_header; + uwrite!(class_source, "{}", self.constructor_args); + uwrite!(class_source, "{}", self.base_class_header); + uwrite!(class_source, "{}", self.base_constructor_args); + uwriteln!(class_source, "{}", self.class_source); + uwriteln!(class_source, "}}"); + uwriteln!(class_source, ""); + + uwriteln!( + class_source, + "object {} extends {} {{", + self.encoded_resource_name.scala, + self.base_static_trait_name + ); + uwriteln!(class_source, "{}", self.static_methods); + uwriteln!(class_source, "}}"); + class_source + } + + fn get_func_name(&self, prefix: &str, func_name: &str) -> EncodedName { + let name = func_name + .strip_prefix(prefix) + .unwrap() + .strip_prefix(&self.resource_name) + .unwrap() + .to_lower_camel_case(); + self.owner.generator.context.encode_name(name) + } +} diff --git a/crates/scalajs-jco/src/world.rs b/crates/scalajs-jco/src/world.rs new file mode 100644 index 000000000..411c03711 --- /dev/null +++ b/crates/scalajs-jco/src/world.rs @@ -0,0 +1,245 @@ +use crate::context::{package_name_to_segments, write_doc_comment, ScalaJsContext}; +use crate::resource::ScalaJsImportedResource; +use crate::ScalaJsFile; +use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase}; +use std::collections::HashMap; +use std::fmt::Write; +use wit_bindgen_core::uwriteln; +use wit_bindgen_core::wit_parser::{ + Function, FunctionKind, Resolve, TypeDefKind, TypeId, World, WorldId, +}; +use wit_bindgen_core::Direction::{Export, Import}; + +pub struct ScalaJsWorld { + world_id: WorldId, + header: String, + types: String, + global_imports: String, + imported_resources: HashMap, + export_header: String, + global_exports: String, +} + +impl ScalaJsWorld { + pub fn new( + context: &ScalaJsContext, + resolve: &Resolve, + world_id: WorldId, + world: &World, + ) -> Self { + let name = world.name.clone().to_snake_case(); + + let package_name = resolve.packages[world.package.expect("missing package for world")] + .name + .clone(); + + let package = package_name_to_segments( + &context.opts, + &package_name, + &Import, + &context.keywords, + false, + ); + + let mut header = String::new(); + uwriteln!(header, "package {}", package.join(".")); + + uwriteln!(header, ""); + uwriteln!(header, "import scala.scalajs.js"); + uwriteln!(header, "import scala.scalajs.js.annotation._"); + uwriteln!(header, "import {}wit._", context.opts.base_package_prefix()); + uwriteln!(header, ""); + uwriteln!(header, "package object {} {{", name); + + let export_package = package_name_to_segments( + &context.opts, + &package_name, + &Export, + &context.keywords, + false, + ); + + let encoded_world_name = context.encode_name(world.name.clone().to_pascal_case()); + + let mut export_header = String::new(); + uwriteln!(export_header, "package {}", export_package.join(".")); + uwriteln!(export_header, ""); + uwriteln!(export_header, "import scala.scalajs.js"); + uwriteln!(export_header, "import scala.scalajs.js.annotation._"); + uwriteln!( + export_header, + "import {}wit._", + context.opts.base_package_prefix() + ); + uwriteln!(export_header, ""); + uwriteln!(export_header, "package object {} {{", name); + uwriteln!(export_header, " trait {} {{", encoded_world_name.scala); + + Self { + world_id, + header, + types: String::new(), + global_imports: String::new(), + imported_resources: HashMap::new(), + export_header, + global_exports: String::new(), + } + } + + pub fn add_imported_function( + &mut self, + context: &ScalaJsContext, + resolve: &Resolve, + func_name: &str, + func: &Function, + ) { + match func.kind { + FunctionKind::Freestanding => { + uwriteln!(self.global_imports, " @js.native"); + uwriteln!( + self.global_imports, + " @JSImport(\"{}\", JSImport.Default)", + func_name + ); + let encoded_name = context.encode_name(func_name.to_lower_camel_case()); + let args = context.render_args(context, resolve, func.params.iter()); + let (ret, throws) = context.render_return_type(context, resolve, &func.result); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + write_doc_comment(&mut self.global_imports, " ", &func.docs); + encoded_name.write_rename_attribute(&mut self.global_imports, " "); + uwriteln!( + self.global_imports, + " def {}({args}): {ret} = js.native{postfix}", + encoded_name.scala + ); + } + FunctionKind::Method(resource_type) + | FunctionKind::Static(resource_type) + | FunctionKind::Constructor(resource_type) => { + let resource = self + .imported_resources + .entry(resource_type) + .or_insert_with(|| { + ScalaJsImportedResource::new(context, resolve, resource_type, " ") + }); + resource.add_function(context, resolve, context, func_name, func); + } + } + } + + pub fn add_type( + &mut self, + context: &ScalaJsContext, + resolve: &Resolve, + name: &str, + id: &TypeId, + ) { + let typ = &resolve.types[*id]; + if let Some(typ) = context.render_typedef(context, resolve, name, typ) { + uwriteln!(self.types, "{}", typ); + uwriteln!(self.types, ""); + } + + if let TypeDefKind::Resource = &typ.kind { + self.imported_resources + .entry(*id) + .or_insert_with(|| ScalaJsImportedResource::new(context, resolve, *id, " ")); + } + } + + pub fn add_exported_function( + &mut self, + context: &ScalaJsContext, + resolve: &Resolve, + func_name: &str, + func: &Function, + ) { + match func.kind { + FunctionKind::Freestanding => { + let encoded_name = context.encode_name(func_name.to_lower_camel_case()); + let args = context.render_args(context, resolve, func.params.iter()); + let (ret, throws) = context.render_return_type(context, resolve, &func.result); + + let postfix = if let Some(throws) = throws { + format!(" // throws {}", throws) + } else { + "".to_string() + }; + + write_doc_comment(&mut self.global_exports, " ", &func.docs); + uwriteln!( + self.global_exports, + " def {}({args}): {ret}{postfix}", + encoded_name.scala + ); + } + FunctionKind::Method(_resource_type) + | FunctionKind::Static(_resource_type) + | FunctionKind::Constructor(_resource_type) => { + panic!("Exported inline resource functions are not supported") + } + } + } + + pub fn finalize(mut self, context: &ScalaJsContext, resolve: &Resolve) -> Vec { + let mut source = String::new(); + uwriteln!(source, "{}", self.header); + uwriteln!(source, "{}", self.types); + uwriteln!(source, "{}", self.global_imports); + + for (_, mut imported_resource) in self.imported_resources.drain() { + imported_resource.annotate(&format!( + "@JSImport(\"{}\", JSImport.Default)", + imported_resource.name.js + )); + uwriteln!(source, "{}", imported_resource.finalize()); + } + + uwriteln!(source, "}}"); + + let mut export_source = String::new(); + uwriteln!(export_source, "{}", self.export_header); + uwriteln!(export_source, "{}", self.global_exports); + uwriteln!(export_source, " }}"); + uwriteln!(export_source, "}}"); + + let world = &resolve.worlds[self.world_id]; + let package_name = resolve.packages[world.package.expect("missing package for world")] + .name + .clone(); + + let package = package_name_to_segments( + &context.opts, + &package_name, + &Import, + &context.keywords, + false, + ); + let export_package = package_name_to_segments( + &context.opts, + &package_name, + &Export, + &context.keywords, + false, + ); + + vec![ + ScalaJsFile { + package, + name: world.name.to_snake_case(), + source, + }, + ScalaJsFile { + package: export_package, + name: world.name.to_snake_case(), + source: export_source, + }, + ] + } +} diff --git a/crates/scalajs-jco/tests/codegen.rs b/crates/scalajs-jco/tests/codegen.rs new file mode 100644 index 000000000..77f8e8f4e --- /dev/null +++ b/crates/scalajs-jco/tests/codegen.rs @@ -0,0 +1,82 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use wit_bindgen_scalajs_jco::ScalaDialect::Scala2; + +macro_rules! codegen_test { + // TODO: implement support for stream, future, and error-context, and then + // remove these lines: + (streams $name:tt $test:tt) => {}; + (futures $name:tt $test:tt) => {}; + (resources_with_streams $name:tt $test:tt) => {}; + (resources_with_futures $name:tt $test:tt) => {}; + (error_context $name:tt $test:tt) => {}; + + ($id:ident $name:tt $test:tt) => { + #[test] + fn $id() { + test_helpers::run_world_codegen_test( + "guest-scalajs", + $test.as_ref(), + |resolve, world, files| { + wit_bindgen_scalajs_jco::Opts { + base_package: Some("test".to_string()), + skeleton_base_package: Some("skeleton".to_string()), + scala_dialect: Scala2, + generate_skeleton: true, + skeleton_root: None, + binding_root: None, + } + .build() + .generate(resolve, world, files) + .unwrap() + }, + verify, + ) + } + }; +} +test_helpers::codegen_tests!(); + +fn verify(dir: &Path, name: &str) { + println!("name: {name}, dir: {dir:?}"); + let mut files = Vec::new(); + move_scala_files(dir, &dir.join("src/main/scala"), &mut files); + + write_build_sbt(dir); + write_plugins_sbt(dir); + + let mut cmd = Command::new("sbt"); + cmd.current_dir(dir); + cmd.arg("fastLinkJS"); + + test_helpers::run_command(&mut cmd); +} + +fn move_scala_files(src: &Path, dst: &Path, files: &mut Vec) { + if src.is_dir() { + for entry in fs::read_dir(src).unwrap() { + let path = entry.unwrap().path(); + move_scala_files(&path, &dst.join(path.strip_prefix(src).unwrap()), files); + } + } else if let Some("scala") = src.extension().map(|ext| ext.to_str().unwrap()) { + fs::create_dir_all(dst.parent().unwrap()).unwrap(); + fs::rename(src, dst).unwrap(); + files.push(dst.to_owned()); + } +} + +fn write_build_sbt(dir: &Path) { + let build_sbt = include_str!("../scala/build.sbt"); + fs::write(dir.join("build.sbt"), build_sbt).unwrap(); +} + +fn write_plugins_sbt(dir: &Path) { + let plugins_sbt = include_str!("../scala/plugins.sbt"); + let build_properties = include_str!("../scala/build.properties"); + + let project_dir = dir.join("project"); + fs::create_dir_all(&project_dir).unwrap(); + fs::write(project_dir.join("plugins.sbt"), plugins_sbt).unwrap(); + fs::write(project_dir.join("build.properties"), build_properties).unwrap(); +} diff --git a/src/bin/wit-bindgen.rs b/src/bin/wit-bindgen.rs index c6e0b589b..df59fa449 100644 --- a/src/bin/wit-bindgen.rs +++ b/src/bin/wit-bindgen.rs @@ -72,6 +72,14 @@ enum Opt { #[clap(flatten)] args: Common, }, + /// Generates bindings for Scala.JS guest modules. + #[cfg(feature = "scalajs")] + ScalaJS { + #[clap(flatten)] + opts: wit_bindgen_scalajs::Opts, + #[clap(flatten)] + args: Common, + }, } #[derive(Debug, Parser)] @@ -137,6 +145,8 @@ fn main() -> Result<()> { Opt::TinyGo { opts, args } => (opts.build(), args), #[cfg(feature = "csharp")] Opt::CSharp { opts, args } => (opts.build(), args), + #[cfg(feature = "scalajs")] + Opt::ScalaJS { opts, args } => (opts.build(), args), }; gen_world(generator, &opt, &mut files).map_err(attach_with_context)?; diff --git a/tests/runtime/main.rs b/tests/runtime/main.rs index 60c66ce91..0a723700d 100644 --- a/tests/runtime/main.rs +++ b/tests/runtime/main.rs @@ -123,6 +123,7 @@ fn tests(name: &str, dir_name: &str) -> Result> { let mut java = Vec::new(); let mut go = Vec::new(); let mut c_sharp: Vec = Vec::new(); + let mut scalajs = Vec::new(); for file in dir.read_dir()? { let path = file?.path(); match path.extension().and_then(|s| s.to_str()) { @@ -131,6 +132,7 @@ fn tests(name: &str, dir_name: &str) -> Result> { Some("rs") => rust.push(path), Some("go") => go.push(path), Some("cs") => c_sharp.push(path), + Some("scala") => scalajs.push(path), _ => {} } }