From f1e30561160acb86ba19023d5f3f8efc4761b802 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Tue, 6 Oct 2026 14:23:05 -0700 Subject: [PATCH] Port JavaScript sources when their extensions are configured A new `extensions` key (default ["ts", "tsx"]) lists the source extensions in the graph. With js/mjs/cjs/jsx added, those files are discovered under roots, parsed with the TypeScript (or TSX) grammar, resolved by their own names after TypeScript candidates, and mapped to Rust files without the extension. Also exit quietly when stdout closes. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/configuration.md | 2 + docs/workflow.md | 3 +- examples/typescript/rustify.toml | 4 ++ src/analyze.rs | 2 +- src/brief.rs | 8 +-- src/commands.rs | 2 +- src/config.rs | 20 +++++++ src/graph.rs | 15 +++--- src/packages.rs | 44 +++++++++++++--- tests/porting_workflow.rs | 89 ++++++++++++++++++++++++++++++++ 11 files changed, 171 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 75e8191..8d4b74a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rustify -`rustify` helps you port a TypeScript codebase to Rust one module at a time, +`rustify` helps you port a TypeScript (or JavaScript) codebase to Rust one module at a time, and keeps the port in step while the TypeScript keeps changing. It reads your TypeScript import graph, tells you what to port next and how, diff --git a/docs/configuration.md b/docs/configuration.md index 47f8b54..15b66dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,7 @@ rust_crate = "rust/app" rust_crate_name = "app" exclude = ["packages/app/src/index-bundled.ts"] +extensions = ["ts", "tsx"] test_markers = ["/__tests__/", ".test.ts", ".test.tsx", ".spec.ts"] binary_test_markers = ["/__tests__/e2e/"] external_packages = ["@acme/generated-sdk"] @@ -48,6 +49,7 @@ max_lines = 800 | `rust_crate` | required | Directory of the crate the port writes into (it holds `src/`). | | `rust_crate_name` | required | That crate's Rust name; module paths start with it. | | `exclude` | `[]` | Globs of files never added to the graph. | +| `extensions` | `["ts", "tsx"]` | Source file extensions in the graph. Add `js`, `mjs`, `cjs`, or `jsx` to port JavaScript too: it is parsed with the TypeScript grammar (`jsx` with TSX), and an import of `./x.mjs` resolves to `x.ts` first, then `x.mjs`. `.d.ts` files and anything under `dist/` are never sources. | | `test_markers` | required | A path containing any of these substrings is a test. | | `binary_test_markers` | `[]` | Tests matching these run against the built binary. They are never assigned to a batch. | | `external_packages` | `[]` | Workspace packages replaced wholesale by a Rust crate instead of ported. Imports of them are treated as external; give each a `[[package]]` rule. | diff --git a/docs/workflow.md b/docs/workflow.md index 7dcd010..2d0068d 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -44,7 +44,8 @@ root (the git top level), wherever the file lives. ## 2. How rustify sees your code -**Files.** Every `.ts`/`.tsx` file reachable from `roots` is in the graph, +**Files.** Every `.ts`/`.tsx` file (plus any JavaScript extensions you add +to `extensions`) reachable from `roots` is in the graph, including files in other workspace packages that `roots` import. Files matching `test_markers` are tests; tests matching `binary_test_markers` are end-to-end tests that run against your binary and are never assigned to a diff --git a/examples/typescript/rustify.toml b/examples/typescript/rustify.toml index 485da78..3e23cff 100644 --- a/examples/typescript/rustify.toml +++ b/examples/typescript/rustify.toml @@ -29,6 +29,10 @@ exclude = [ "packages/app/src/index-bundled.ts", ] +# Source extensions in the graph. Add "js", "mjs", "cjs", or "jsx" to port +# JavaScript files as well. +extensions = ["ts", "tsx"] + test_markers = ["/__tests__/", ".test.ts", ".test.tsx", ".spec.ts"] # Contract tests that run against the built binary, not a module; `next` # never assigns them to batches. diff --git a/src/analyze.rs b/src/analyze.rs index ea04d34..17c1c98 100644 --- a/src/analyze.rs +++ b/src/analyze.rs @@ -81,7 +81,7 @@ impl Analyzer { } pub fn analyze(&self, path: &str, source: &str) -> Result { - let compiled = if path.ends_with(".tsx") { + let compiled = if path.ends_with(".tsx") || path.ends_with(".jsx") { &self.tsx } else { &self.ts diff --git a/src/brief.rs b/src/brief.rs index 91735d6..392f7ef 100644 --- a/src/brief.rs +++ b/src/brief.rs @@ -335,8 +335,10 @@ fn ui_section(out: &mut String, ui: &Components, graph: &Graph, f: usize) { .filter(|i| ui.library.ui_packages.contains(&i.specifier)) .flat_map(|i| i.names.iter().map(String::as_str)) .collect(); - let is_ui = - file.rel.ends_with(".tsx") || !ink_names.is_empty() || ui.module_for(&file.rel).is_some(); + let is_ui = file.rel.ends_with(".tsx") + || file.rel.ends_with(".jsx") + || !ink_names.is_empty() + || ui.module_for(&file.rel).is_some(); if !is_ui { return; } @@ -368,7 +370,7 @@ fn ui_section(out: &mut String, ui: &Components, graph: &Graph, f: usize) { let dependents = graph .files .iter() - .filter(|g| g.deps.contains(&f) && g.rel.ends_with(".tsx")) + .filter(|g| g.deps.contains(&f) && (g.rel.ends_with(".tsx") || g.rel.ends_with(".jsx"))) .count(); if ui.module_for(&file.rel).is_none() && dependents >= 2 { let _ = writeln!( diff --git a/src/commands.rs b/src/commands.rs index 394ed2c..25067c6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1228,7 +1228,7 @@ fn check_ui(ctx: &Ctx<'_>, ui: &Components, errors: &mut Vec) -> Result< } } for entry in &ctx.index.modules { - if !entry.ts.ends_with(".tsx") || !entry.status.has_rust() { + if !(entry.ts.ends_with(".tsx") || entry.ts.ends_with(".jsx")) || !entry.status.has_rust() { continue; } let (Some(rust), Some(module)) = (&entry.rust, &entry.module) else { diff --git a/src/config.rs b/src/config.rs index fedbce5..99ada03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,11 @@ pub struct Config { /// Globs (repository-relative) never added to the graph. #[serde(default)] pub exclude: Vec, + /// Source file extensions in the graph, without the dot. Add `js`, + /// `mjs`, `cjs`, or `jsx` to port JavaScript files as well; they are + /// parsed with the TypeScript (or, for `jsx`, TSX) grammar. + #[serde(default = "default_extensions")] + pub extensions: Vec, /// A path is a test when any of these substrings occurs in it. pub test_markers: Vec, /// Tests matching these substrings run against the binary, not a module @@ -94,6 +99,10 @@ fn normalize(path: &Path) -> String { } } +fn default_extensions() -> Vec { + vec!["ts".to_owned(), "tsx".to_owned()] +} + fn default_upstream() -> String { "origin/main".to_owned() } @@ -222,6 +231,17 @@ impl Workspace { } } + /// Whether `rel` is a source file the graph includes, by extension + /// (declaration files never are). + pub fn is_source(&self, rel: &str) -> bool { + if rel.ends_with(".d.ts") { + return false; + } + let name = rel.rsplit('/').next().unwrap_or(rel); + name.rsplit_once('.') + .is_some_and(|(_, ext)| self.config.extensions.iter().any(|e| e == ext)) + } + pub fn is_test(&self, rel: &str) -> bool { self.config.test_markers.iter().any(|m| rel.contains(m)) } diff --git a/src/graph.rs b/src/graph.rs index 0a3cc94..ef1d558 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -268,7 +268,9 @@ impl Builder<'_> { } fn first_existing(&self, candidates: Vec) -> Option { - candidates.into_iter().find(|c| self.exists(c)) + candidates + .into_iter() + .find(|c| self.ws.is_source(c) && self.exists(c)) } fn resolve(&self, from: &str, specifier: &str) -> Resolved { @@ -504,17 +506,12 @@ fn ts_files(ws: &Workspace, dir: &str) -> Vec { .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) .filter_map(|e| { - let name = e.file_name().to_string_lossy(); - let is_ts = - (name.ends_with(".ts") || name.ends_with(".tsx")) && !name.ends_with(".d.ts"); - if !is_ts { - return None; - } e.path() .strip_prefix(&ws.root) .ok() .map(|p| p.to_string_lossy().replace('\\', "/")) }) + .filter(|rel| ws.is_source(rel)) .collect() } @@ -558,6 +555,10 @@ pub fn rust_target(ws: &Workspace, packages: &Packages, rel: &str) -> (String, S let stem = within .strip_suffix(".tsx") .or_else(|| within.strip_suffix(".ts")) + .or_else(|| within.strip_suffix(".mjs")) + .or_else(|| within.strip_suffix(".cjs")) + .or_else(|| within.strip_suffix(".jsx")) + .or_else(|| within.strip_suffix(".js")) .unwrap_or(within); let mut segments: Vec = prefix; segments.extend(stem.split('/').map(snake)); diff --git a/src/packages.rs b/src/packages.rs index 735c502..e326f8c 100644 --- a/src/packages.rs +++ b/src/packages.rs @@ -220,9 +220,14 @@ fn condition_targets(entry: &Value, wildcard: Option<&str>) -> Vec { } /// `./dist/runtime/cli-client.js` → `src/runtime/cli-client.ts`, plus the -/// `.tsx` and `index` variants. +/// `.tsx` and `index` variants. TypeScript sources come first; the JavaScript +/// file itself (outside `dist/`) and JavaScript variants follow, for projects +/// that port `.js`/`.mjs` too. The graph keeps only candidates whose +/// extension is configured (`extensions`). fn source_candidates(target: &str) -> Vec { - let mut t = target.trim_start_matches("./").to_string(); + let original = target.trim_start_matches("./").to_string(); + let mut t = original.clone(); + let compiled = t.starts_with("dist/"); if let Some(rest) = t.strip_prefix("dist/") { t = format!("src/{rest}"); } @@ -235,12 +240,23 @@ fn source_candidates(target: &str) -> Vec { if t.ends_with(".ts") || t.ends_with(".tsx") { return vec![t]; } - vec![ + let mut out = vec![ format!("{t}.ts"), format!("{t}.tsx"), format!("{t}/index.ts"), format!("{t}/index.tsx"), - ] + ]; + if !compiled && t != original { + out.push(original); + } + for ext in ["js", "mjs", "cjs", "jsx"] { + for candidate in [format!("{t}.{ext}"), format!("{t}/index.{ext}")] { + if !out.contains(&candidate) { + out.push(candidate); + } + } + } + out } /// Candidates for a relative import from `from_dir` (repository-relative). @@ -270,17 +286,33 @@ mod tests { #[test] fn maps_compiled_targets_back_to_sources() { assert_eq!( - source_candidates("./dist/runtime/cli-client.js"), - vec![ + source_candidates("./dist/runtime/cli-client.js")[..4], + [ "src/runtime/cli-client.ts", "src/runtime/cli-client.tsx", "src/runtime/cli-client/index.ts", "src/runtime/cli-client/index.tsx", ] ); + // Compiled output itself is never a source. + assert!( + !source_candidates("./dist/runtime/cli-client.js") + .iter() + .any(|c| c.starts_with("dist/")) + ); assert_eq!(source_candidates("./src/index.tsx"), vec!["src/index.tsx"]); } + #[test] + fn a_javascript_import_falls_back_to_the_javascript_file_after_typescript() { + let got = source_candidates("bin/doctor.mjs"); + assert_eq!(got[0], "bin/doctor.ts"); + assert!(got.contains(&"bin/doctor.mjs".to_owned()), "{got:?}"); + let bare = source_candidates("lib/util"); + assert!(bare.contains(&"lib/util.js".to_owned()), "{bare:?}"); + assert!(bare.contains(&"lib/util/index.mjs".to_owned()), "{bare:?}"); + } + #[test] fn resolves_wildcard_exports_preferring_source_conditions() { let exports: Value = serde_json::json!({ diff --git a/tests/porting_workflow.rs b/tests/porting_workflow.rs index 01c5653..fd5a4f3 100644 --- a/tests/porting_workflow.rs +++ b/tests/porting_workflow.rs @@ -1436,3 +1436,92 @@ fn the_ratchet_passes_on_the_branch_that_adopts_rustify() { assert_ok(&out); assert!(text(&out).starts_with("OK:"), "{}", text(&out)); } + +#[test] +fn ports_javascript_modules_when_their_extensions_are_configured() { + // Setup: a CLI package written in plain `.mjs` that imports a TS module + // from the same repository and another `.mjs` file by its full name. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let f = Fixture { _dir: dir, root }; + let config = |extensions: &str| { + format!( + r#" +roots = ["packages/cli/bin"] +packages_dir = "packages" +primary_package = "cli" +rust_crate = "rust/cli" +rust_crate_name = "cli" +{extensions} +test_markers = [".test."] +[batch] +max_files = 6 +max_lines = 800 +"# + ) + }; + f.write("rustify.toml", &config("")); + f.write("mappings.toml", ""); + f.write("packages/cli/package.json", r#"{"name":"cli"}"#); + f.write( + "packages/cli/bin/cli.mjs", + "import { doctor } from \"./doctor.mjs\";\nimport { slug } from \"../src/slug\";\nexport function main() { doctor(); slug(); }\n", + ); + f.write( + "packages/cli/bin/doctor.mjs", + "export function doctor() {}\n", + ); + f.write("packages/cli/src/slug.ts", "export function slug() {}\n"); + f.write( + "packages/cli/bin/cli.test.mjs", + "import { main } from \"./cli.mjs\";\n", + ); + f.write("rust/cli/src/lib.rs", ""); + f.git(&["init", "-q", "-b", "main"]); + f.land_upstream("js cli"); + + // By default only TypeScript is in the graph: the roots hold no sources. + let graph = f.port_json(&["graph"]); + assert_eq!(graph["files"].as_array().unwrap().len(), 0, "{graph:#}"); + + // With `mjs` configured, the .mjs files join the graph, resolve each + // other and the TS module, and map to Rust files without the extension. + f.write( + "rustify.toml", + &config(r#"extensions = ["ts", "tsx", "mjs"]"#), + ); + let graph = f.port_json(&["graph"]); + let files: Vec<&str> = graph["files"] + .as_array() + .unwrap() + .iter() + .map(|f| f["ts"].as_str().unwrap()) + .collect(); + assert_eq!( + files, + [ + "packages/cli/bin/cli.mjs", + "packages/cli/bin/doctor.mjs", + "packages/cli/src/slug.ts" + ] + ); + let brief = text(&f.port(&["brief", "packages/cli/bin/cli.mjs"])); + assert!( + brief.contains("| `packages/cli/bin/cli.mjs` | `bin/cli.rs` | `cli::bin::cli` |"), + "{brief}" + ); + assert!(brief.contains("`packages/cli/bin/cli.test.mjs`"), "{brief}"); + + // Outcome: the JS module ports and records like a TS one. + f.write("rust/cli/src/lib.rs", "pub mod bin;\npub mod slug;\n"); + f.write("rust/cli/src/slug.rs", "pub fn slug() {}\n"); + f.write("rust/cli/src/bin/mod.rs", "pub mod doctor;\n"); + f.write("rust/cli/src/bin/doctor.rs", "pub fn doctor() {}\n"); + assert_ok(&f.port(&["start", "packages/cli/bin/doctor.mjs"])); + assert_ok(&f.port(&["done", "packages/cli/bin/doctor.mjs"])); + let index = std::fs::read_to_string(f.root.join("index.toml")).unwrap(); + assert!( + index.contains("doctor = \"cli::bin::doctor::doctor\""), + "{index}" + ); +}