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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions examples/typescript/rustify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ impl Analyzer {
}

pub fn analyze(&self, path: &str, source: &str) -> Result<FileAnalysis> {
let compiled = if path.ends_with(".tsx") {
let compiled = if path.ends_with(".tsx") || path.ends_with(".jsx") {
&self.tsx
} else {
&self.ts
Expand Down
8 changes: 5 additions & 3 deletions src/brief.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1228,7 +1228,7 @@ fn check_ui(ctx: &Ctx<'_>, ui: &Components, errors: &mut Vec<String>) -> 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 {
Expand Down
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ pub struct Config {
/// Globs (repository-relative) never added to the graph.
#[serde(default)]
pub exclude: Vec<String>,
/// 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<String>,
/// A path is a test when any of these substrings occurs in it.
pub test_markers: Vec<String>,
/// Tests matching these substrings run against the binary, not a module
Expand Down Expand Up @@ -94,6 +99,10 @@ fn normalize(path: &Path) -> String {
}
}

fn default_extensions() -> Vec<String> {
vec!["ts".to_owned(), "tsx".to_owned()]
}

fn default_upstream() -> String {
"origin/main".to_owned()
}
Expand Down Expand Up @@ -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))
}
Expand Down
15 changes: 8 additions & 7 deletions src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ impl Builder<'_> {
}

fn first_existing(&self, candidates: Vec<String>) -> Option<String> {
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 {
Expand Down Expand Up @@ -504,17 +506,12 @@ fn ts_files(ws: &Workspace, dir: &str) -> Vec<String> {
.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()
}

Expand Down Expand Up @@ -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<String> = prefix;
segments.extend(stem.split('/').map(snake));
Expand Down
44 changes: 38 additions & 6 deletions src/packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,14 @@ fn condition_targets(entry: &Value, wildcard: Option<&str>) -> Vec<String> {
}

/// `./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<String> {
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}");
}
Expand All @@ -235,12 +240,23 @@ fn source_candidates(target: &str) -> Vec<String> {
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).
Expand Down Expand Up @@ -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!({
Expand Down
89 changes: 89 additions & 0 deletions tests/porting_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Loading