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}" + ); +}