Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ The **TypeScript target** for [Comline](https://github.com/ComlineProject) — o
repo per language, holding that language's codegen, libgen, runtime and
std-extra together.

Today: `codegen/` (`comline-codegen-typescript`, the `code`-mode generator,
extracted from `ComlineProject/generation`) and `runtime/` (`@comline/runtime`,
the contract layer so far). `lib` mode, the rest of the runtime, and std-extra
follow.
Today: `codegen/` (`comline-codegen-typescript` `code` and `lib` modes,
extracted from `ComlineProject/generation`) and `runtime/` (`@comline/runtime`
contract, framing, transport, `Client` / `Server`). std-extra follows; the
runtime is not published to npm yet.

## `codegen/`

Expand All @@ -19,6 +19,10 @@ per enum, and per `protocol` the full RPC shape against `@comline/runtime` — a
package default). It depends on `comline-codegen` (the language-neutral contract
+ `Registry`) and `comline-core` (the IR), both by git rev.

`code` mode writes bare `<namespace>.ts`; `lib` mode wraps them in an npm
package — `package.json` (declaring `@comline/runtime`), `tsconfig.json`, and a
`src/index.ts` barrel.

`register(&mut Registry)` contributes the generator under `typescript` / `ts` at
version `5.0`; the Comline CLI composes it into its `Registry` at startup.

Expand Down
134 changes: 120 additions & 14 deletions codegen/src/generator.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! TypeScript `code`-mode generation: frozen IR -> `.ts` source.
//! TypeScript generation: frozen IR -> `.ts` source.
//!
//! Per schema file:
//! - `struct` -> `export interface`
Expand All @@ -11,7 +11,9 @@
//! `<Proto>Dispatcher` (`implements Dispatch`), a `<Proto>Client`, and a
//! `serve<Proto>` helper. Framing follows `@framing` / the package default.
//!
//! `lib` mode (an npm package) is not built yet. See design/generation.md.
//! `code` mode emits bare `<namespace>.ts`; `lib` mode adds a `package.json`
//! (declaring `@comline/runtime`), a `tsconfig.json`, and a `src/index.ts`
//! barrel. See design/generation.md.

use std::collections::HashMap;
use std::path::PathBuf;
Expand All @@ -22,22 +24,126 @@ use comline_core::schema::ir::frozen::unit::FrozenUnit;

use eyre::{bail, Result};

use comline_codegen::{GenRequest, GeneratedFile, Mode};
use comline_codegen::{GenRequest, GeneratedFile, Mode, PackageMeta};

/// The npm package generated RPC code imports from. Not published yet — a
/// generated `lib` package declares this range and expects it resolvable
/// (`npm link`, a local registry, or a future publish). See
/// `ComlineProject/comline-typescript/runtime`.
const RUNTIME_PACKAGE: &str = "@comline/runtime";
const RUNTIME_VERSION: &str = "^0.1.0";

pub fn generate_typescript(req: &GenRequest) -> Result<Vec<GeneratedFile>> {
if req.mode == Mode::Lib {
bail!("typescript lib mode is not implemented yet (de-rot G2)");
let default_framing = req.default_framing.as_deref();

match req.mode {
Mode::Code => Ok(req
.schemas
.iter()
.map(|(namespace, units)| GeneratedFile {
path: PathBuf::from(format!("{namespace}.ts")),
contents: schema_source(units, default_framing),
})
.collect()),

Mode::Lib => {
require_flat_namespaces(req.schemas)?;

let has_protocol = req
.schemas
.iter()
.any(|(_, units)| units.iter().any(|u| matches!(u, FrozenUnit::Protocol { .. })));

let mut files = vec![
GeneratedFile {
path: PathBuf::from("package.json"),
contents: package_json(&req.package, has_protocol),
},
GeneratedFile {
path: PathBuf::from("tsconfig.json"),
contents: TSCONFIG.to_string(),
},
GeneratedFile {
path: PathBuf::from("src/index.ts"),
contents: index_ts(req.schemas),
},
];
for (namespace, units) in req.schemas {
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{namespace}.ts")),
contents: schema_source(units, default_framing),
});
}
Ok(files)
}
}
}

let default_framing = req.default_framing.as_deref();
Ok(req
.schemas
.iter()
.map(|(namespace, units)| GeneratedFile {
path: PathBuf::from(format!("{namespace}.ts")),
contents: schema_source(units, default_framing),
})
.collect())
/// `lib` mode emits a flat `export * from "./<ns>.js"` list; a `/`-joined
/// namespace would need a nested `src/` tree — a follow-up, shared with the
/// Rust generator.
fn require_flat_namespaces(schemas: &[(String, Vec<FrozenUnit>)]) -> Result<()> {
for (namespace, _) in schemas {
if namespace.contains('/') {
bail!(
"typescript lib mode does not support nested namespaces yet \
(namespace `{namespace}`)"
);
}
}
Ok(())
}

const TSCONFIG: &str = "{\n\
\x20 \"compilerOptions\": {\n\
\x20 \"target\": \"ES2022\",\n\
\x20 \"module\": \"NodeNext\",\n\
\x20 \"moduleResolution\": \"NodeNext\",\n\
\x20 \"outDir\": \"dist\",\n\
\x20 \"rootDir\": \"src\",\n\
\x20 \"declaration\": true,\n\
\x20 \"strict\": true,\n\
\x20 \"skipLibCheck\": true\n\
\x20 },\n\
\x20 \"include\": [\"src/**/*.ts\"]\n\
}\n";

fn package_json(pkg: &PackageMeta, has_protocol: bool) -> String {
let name = pkg.name.to_lowercase();
let dep = if has_protocol {
format!("\n \"dependencies\": {{\n \"{RUNTIME_PACKAGE}\": \"{RUNTIME_VERSION}\"\n }},")
} else {
String::new()
};
format!(
"{{\n\
\x20 \"name\": \"{name}\",\n\
\x20 \"version\": \"{}\",\n\
\x20 \"type\": \"module\",\n\
\x20 \"exports\": {{\n\
\x20 \".\": {{\n\
\x20 \"types\": \"./dist/index.d.ts\",\n\
\x20 \"import\": \"./dist/index.js\"\n\
\x20 }}\n\
\x20 }},\n\
\x20 \"files\": [\"dist\", \"src\"],\n\
\x20 \"scripts\": {{\n\
\x20 \"build\": \"tsc\"\n\
\x20 }},{dep}\n\
\x20 \"devDependencies\": {{\n\
\x20 \"typescript\": \"^5.6.0\"\n\
\x20 }}\n\
}}\n",
pkg.version
)
}

fn index_ts(schemas: &[(String, Vec<FrozenUnit>)]) -> String {
let mut s = String::from("// Generated by Comline\n\n");
for (namespace, _) in schemas {
s.push_str(&format!("export * from \"./{namespace}.js\";\n"));
}
s
}

fn schema_source(units: &[FrozenUnit], default_framing: Option<&str>) -> String {
Expand Down
7 changes: 5 additions & 2 deletions codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! TypeScript code generator. `code` mode only: `export interface` per struct /
//! TypeScript code generator. Per schema: `export interface` per struct /
//! `error` (+ a `<Name>Error` throwable), `export enum` (string values) per
//! enum, and per `protocol` the full RPC shape against `@comline/runtime` — an
//! `IR_HASH`, params interfaces, a provider interface, a `<Proto>Dispatcher`, a
//! `<Proto>Client`, and a `serve<Proto>` helper. `lib` mode is not implemented.
//! `<Proto>Client`, and a `serve<Proto>` helper.
//!
//! `code` mode emits bare `<namespace>.ts` files; `lib` mode wraps them in an
//! npm package (`package.json` + `tsconfig.json` + `src/index.ts` barrel).
//! See `design/generation.md`.

mod generator;
Expand Down
44 changes: 41 additions & 3 deletions codegen/tests/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,48 @@ fn generated_chat_matches_the_runtime_test_fixture() {
}

#[test]
fn lib_mode_is_not_implemented() {
let schemas = vec![("account".to_string(), vec![])];
fn lib_mode_emits_an_npm_package() {
let schemas = vec![
("chat".to_string(), chat_units()),
("billing".to_string(), vec![]),
];
let files = generate_typescript(&lib_req(&schemas)).unwrap();
let by_path =
|p: &str| &files.iter().find(|f| f.path.to_str().unwrap() == p).unwrap().contents;

let pkg = by_path("package.json");
assert!(pkg.contains("\"name\": \"chat\""));
assert!(pkg.contains("\"version\": \"0.3.0\""));
assert!(pkg.contains("\"type\": \"module\""));
assert!(pkg.contains("\"@comline/runtime\": \"^0.1.0\"")); // a protocol pulls it in

assert!(by_path("tsconfig.json").contains("\"NodeNext\""));

let index = by_path("src/index.ts");
assert!(index.contains("export * from \"./chat.js\";"));
assert!(index.contains("export * from \"./billing.js\";"));

assert!(by_path("src/chat.ts").contains("export class ChatClient {"));
assert!(files.iter().any(|f| f.path.to_str().unwrap() == "src/billing.ts"));
}

#[test]
fn lib_mode_omits_the_runtime_dep_without_a_protocol() {
let schemas = vec![("data".to_string(), vec![])];
let files = generate_typescript(&lib_req(&schemas)).unwrap();
let pkg = &files
.iter()
.find(|f| f.path.to_str().unwrap() == "package.json")
.unwrap()
.contents;
assert!(!pkg.contains("@comline/runtime"));
}

#[test]
fn lib_mode_rejects_nested_namespaces() {
let schemas = vec![("account/user".to_string(), vec![])];
let err = generate_typescript(&lib_req(&schemas))
.unwrap_err()
.to_string();
assert!(err.contains("lib mode"));
assert!(err.contains("nested namespaces"));
}
Loading