From 82538addcc4ac628d1c5a2ed6b832704587e98af Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sat, 20 Sep 2025 20:44:24 +0800 Subject: [PATCH] :tada: initial commit --- .gitignore | 17 ++++ Cargo.toml | 35 ++++++++ README.md | 128 +++++++++++++++++++++++++++++ extra/rust_fmt_node.js | 10 +++ extra/rust_fmt_vite.js | 8 ++ rust-toolchain.toml | 2 + scripts/build.sh | 6 ++ scripts/package.mjs | 39 +++++++++ src/lib.rs | 51 ++++++++++++ test_bun/bun.spec.ts | 29 +++++++ test_config.mjs | 13 +++ test_data/complex_example.rs | 22 +++++ test_data/complex_example.rs.snap | 19 +++++ test_data/simple_function.rs | 5 ++ test_data/simple_function.rs.snap | 6 ++ test_data/struct_with_impl.rs | 11 +++ test_data/struct_with_impl.rs.snap | 12 +++ test_deno/deno.test.ts | 35 ++++++++ test_node/test-node.mjs | 35 ++++++++ 19 files changed, 483 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 extra/rust_fmt_node.js create mode 100644 extra/rust_fmt_vite.js create mode 100644 rust-toolchain.toml create mode 100644 scripts/build.sh create mode 100644 scripts/package.mjs create mode 100644 src/lib.rs create mode 100644 test_bun/bun.spec.ts create mode 100644 test_config.mjs create mode 100644 test_data/complex_example.rs create mode 100644 test_data/complex_example.rs.snap create mode 100644 test_data/simple_function.rs create mode 100644 test_data/simple_function.rs.snap create mode 100644 test_data/struct_with_impl.rs create mode 100644 test_data/struct_with_impl.rs.snap create mode 100644 test_deno/deno.test.ts create mode 100644 test_node/test-node.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64b6fae --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +### Rust template +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ +.idea/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..31bbacd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors = ["landaiqing "] +description = "A WASM Based Rust Code Formatter" +name = "rust_fmt" + +edition = "2021" +homepage = "https://github.com/landaiqing/rust_fmt" +keywords = ["wasm", "rust", "formatter"] +license = "MIT" +readme = "README.md" +repository = "https://github.com/landaiqing/rust_fmt" +version = "0.1.0" + + +[dependencies] +prettyplease = "0.2" +serde = "1.0" +serde-wasm-bindgen = "0.6" +serde_json = "1.0" +syn = { version = "2.0", features = ["full", "parsing", "printing"] } +wasm-bindgen = "0.2.99" + +[lib] +crate-type = ["cdylib", "rlib"] + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[profile.release] +codegen-units = 1 +debug = false # set to `true` for debug information +lto = true +opt-level = "s" +panic = "abort" # Let it crash and force ourselves to write safe Rust. +strip = "symbols" # set to `false` for debug information \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..50bfa97 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Rust Formatter (WASM) + +一个基于 WebAssembly 的 Rust 代码格式化工具,可以在浏览器和 Node.js 环境中使用。 + +## 特性 + +- 🚀 基于 WebAssembly,性能优异 +- 🌍 支持浏览器和 Node.js 环境 +- ⚙️ 支持配置选项(tab/space 缩进) +- 📦 轻量级,无需额外的 Rust 工具链 +- 🔧 基于 `prettyplease` 和 `syn` 提供准确的格式化 + +## 安装 + +```bash +npm install @landaiqing/rust_fmt +``` + +## 使用方法 + +### Node.js + +```javascript +import init, { format } from '@landaiqing/rust_fmt'; + +await init(); + +const rustCode = `fn main(){println!("Hello, world!");}`; + +// 使用默认配置(空格缩进) +const formatted = format(rustCode); +console.log(formatted); + +// 使用 tab 缩进 +const formattedWithTabs = format(rustCode, { use_tabs: true }); +console.log(formattedWithTabs); +``` + +### 浏览器 (ES Modules) + +```javascript +import init, { format } from '@landaiqing/rust_fmt'; + +await init(); + +const rustCode = `fn main(){println!("Hello, world!");}`; +const formatted = format(rustCode); +console.log(formatted); +``` + +### Vite + +```javascript +import init, { format } from '@landaiqing/rust_fmt/vite'; + +await init(); + +const rustCode = `fn main(){println!("Hello, world!");}`; +const formatted = format(rustCode); +console.log(formatted); +``` + +## API + +### `format(input: string, config?: Config): string` + +格式化 Rust 代码字符串。 + +#### 参数 + +- `input`: 要格式化的 Rust 代码字符串 +- `config` (可选): 格式化配置选项 + +#### Config 接口 + +```typescript +interface Config { + /** + * 当设置为 true 时,使用 tab 缩进而不是空格缩进 + * + * 默认值: false + */ + use_tabs?: boolean; +} +``` + +## 开发 + +### 构建 + +```bash +# 安装依赖 +cargo build + +# 构建 WASM 包 +wasm-pack build --target=web --scope=landaiqing + +# 复制额外文件 +cp -R ./extra/. ./pkg/ + +# 处理 package.json +node ./scripts/package.mjs ./pkg/package.json +``` + +### 测试 + +```bash +# Node.js 测试 +node test_node/test-node.mjs + +# Deno 测试 +deno test test_deno/deno.test.ts --allow-read + +# Bun 测试 +bun test test_bun/bun.spec.ts +``` + +## 许可证 + +MIT + +## 致谢 + +本项目基于以下优秀的开源项目: + +- [prettyplease](https://github.com/dtolnay/prettyplease) - Rust 代码格式化库 +- [syn](https://github.com/dtolnay/syn) - Rust 语法解析库 +- [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) - Rust 和 WebAssembly 绑定生成器 \ No newline at end of file diff --git a/extra/rust_fmt_node.js b/extra/rust_fmt_node.js new file mode 100644 index 0000000..4a29759 --- /dev/null +++ b/extra/rust_fmt_node.js @@ -0,0 +1,10 @@ +import fs from "node:fs/promises"; +import initAsync from "./rust_fmt.js"; + +const wasm = new URL("./rust_fmt_bg.wasm", import.meta.url); + +export default function __wbg_init(init = { module_or_path: fs.readFile(wasm) }) { + return initAsync(init); +} + +export * from "./rust_fmt.js"; \ No newline at end of file diff --git a/extra/rust_fmt_vite.js b/extra/rust_fmt_vite.js new file mode 100644 index 0000000..747d08b --- /dev/null +++ b/extra/rust_fmt_vite.js @@ -0,0 +1,8 @@ +import initAsync from "./rust_fmt.js"; +import wasm from "./rust_fmt_bg.wasm?url"; + +export default function __wbg_init(input = { module_or_path: wasm }) { + return initAsync(input); +} + +export * from "./rust_fmt.js"; \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..31578d3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..75f17cd --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,6 @@ +cd $(dirname $0)/.. +wasm-pack build --target=web --scope=landaiqing + +cp -R ./extra/. ./pkg/ + +./scripts/package.mjs ./pkg/package.json \ No newline at end of file diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..c15486f --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import process from "node:process"; +import path from "node:path"; +import fs from "node:fs"; + +const pkg_path = path.resolve(process.cwd(), process.argv[2]); +const pkg_text = fs.readFileSync(pkg_path, { encoding: "utf-8" }); +const pkg_json = JSON.parse(pkg_text); + +delete pkg_json.files; + +pkg_json.main = pkg_json.module; +pkg_json.type = "module"; +pkg_json.publishConfig = { + access: "public", +}; +pkg_json.exports = { + ".": { + types: "./rust_fmt.d.ts", + node: "./rust_fmt_node.js", + default: "./rust_fmt.js", + }, + "./vite": { + types: "./rust_fmt.d.ts", + default: "./rust_fmt_vite.js", + }, + "./package.json": "./package.json", + "./*": "./*", +}; + +fs.writeFileSync(pkg_path, JSON.stringify(pkg_json, null, 4)); + +// JSR + +const jsr_path = path.resolve(pkg_path, "..", "jsr.jsonc"); +pkg_json.name = "@fmt/rust-fmt"; +pkg_json.exports = "./rust_fmt.js"; +pkg_json.exclude = ["!**", "*.tgz"]; +fs.writeFileSync(jsr_path, JSON.stringify(pkg_json, null, 4)); \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..517fa9c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,51 @@ +use serde::Deserialize; +use syn::{parse_file, File}; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +pub fn format(input: &str, config: Option) -> Result { + let config = config + .map(|x| serde_wasm_bindgen::from_value::(x.clone())) + .transpose() + .map_err(|op| op.to_string())? + .unwrap_or_default(); + + // 解析 Rust 代码 + let syntax_tree: File = parse_file(input).map_err(|e| format!("解析错误: {}", e))?; + + // 格式化代码 + let formatted = if config.use_tabs { + // 使用 tab 缩进 + let formatted = prettyplease::unparse(&syntax_tree); + // 将 4 个空格替换为 tab + formatted.replace(" ", "\t") + } else { + // 使用默认的空格缩进 + prettyplease::unparse(&syntax_tree) + }; + + Ok(formatted) +} + +#[wasm_bindgen(typescript_custom_section)] +const TS_Config: &'static str = r#" +export interface Config { + /** + * When set to true, uses tabs for indentation instead of spaces + * + * Default: false + */ + use_tabs?: boolean; +}"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Config")] + pub type Config; +} + +#[derive(Deserialize, Clone, Default)] +struct RustConfig { + #[serde(alias = "useTabs")] + pub use_tabs: bool, +} \ No newline at end of file diff --git a/test_bun/bun.spec.ts b/test_bun/bun.spec.ts new file mode 100644 index 0000000..de2353a --- /dev/null +++ b/test_bun/bun.spec.ts @@ -0,0 +1,29 @@ +import { Glob } from "bun"; +import { expect, test } from "bun:test"; +import { chdir } from "node:process"; +import { fileURLToPath } from "node:url"; + +import init, { format } from "../pkg/rust_fmt"; + +await init(); + +const test_root = fileURLToPath(import.meta.resolve("../test_data")); +chdir(test_root); + +const glob = new Glob("**/*.rs"); + +for await (const input_path of glob.scan()) { + const [input, expected] = await Promise.all([ + Bun.file(input_path).text(), + Bun.file(input_path + ".snap").text().catch(() => { + // 如果没有 snap 文件,就创建一个 + const formatted = format(input); + return formatted; + }), + ]); + + test(input_path, () => { + const actual = format(input); + expect(actual).toBe(expected); + }); +} \ No newline at end of file diff --git a/test_config.mjs b/test_config.mjs new file mode 100644 index 0000000..3946a99 --- /dev/null +++ b/test_config.mjs @@ -0,0 +1,13 @@ +import init, { format } from './pkg/rust_fmt_node.js'; + +await init(); + +const testCode = `fn main(){println!("Hello, world!");}`; + +console.log('=== 默认配置(空格缩进)==='); +const result1 = format(testCode); +console.log(result1); + +console.log('=== 使用 tab 缩进 ==='); +const result2 = format(testCode, { use_tabs: true }); +console.log(result2); \ No newline at end of file diff --git a/test_data/complex_example.rs b/test_data/complex_example.rs new file mode 100644 index 0000000..df10259 --- /dev/null +++ b/test_data/complex_example.rs @@ -0,0 +1,22 @@ +use std::collections::HashMap; + +fn process_data(data:Vec)->HashMap{ +let mut result=HashMap::new(); +for(index,value)in data.iter().enumerate(){ +result.insert(index as i32,*value*2); +} +result +} + +#[cfg(test)] +mod tests{ +use super::*; + +#[test] +fn test_process_data(){ +let input=vec![1,2,3,4,5]; +let result=process_data(input); +assert_eq!(result.get(&0),Some(&2)); +assert_eq!(result.get(&1),Some(&4)); +} +} \ No newline at end of file diff --git a/test_data/complex_example.rs.snap b/test_data/complex_example.rs.snap new file mode 100644 index 0000000..faab925 --- /dev/null +++ b/test_data/complex_example.rs.snap @@ -0,0 +1,19 @@ +use std::collections::HashMap; +fn process_data(data: Vec) -> HashMap { + let mut result = HashMap::new(); + for (index, value) in data.iter().enumerate() { + result.insert(index as i32, *value * 2); + } + result +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_process_data() { + let input = vec![1, 2, 3, 4, 5]; + let result = process_data(input); + assert_eq!(result.get(& 0), Some(& 2)); + assert_eq!(result.get(& 1), Some(& 4)); + } +} diff --git a/test_data/simple_function.rs b/test_data/simple_function.rs new file mode 100644 index 0000000..a42c518 --- /dev/null +++ b/test_data/simple_function.rs @@ -0,0 +1,5 @@ +fn main(){println!("Hello, world!");} + +fn add(a:i32,b:i32)->i32{ +a+b +} \ No newline at end of file diff --git a/test_data/simple_function.rs.snap b/test_data/simple_function.rs.snap new file mode 100644 index 0000000..f1f23be --- /dev/null +++ b/test_data/simple_function.rs.snap @@ -0,0 +1,6 @@ +fn main() { + println!("Hello, world!"); +} +fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/test_data/struct_with_impl.rs b/test_data/struct_with_impl.rs new file mode 100644 index 0000000..cbb2b65 --- /dev/null +++ b/test_data/struct_with_impl.rs @@ -0,0 +1,11 @@ +struct Person{name:String,age:u32} + +impl Person{ +fn new(name:String,age:u32)->Self{ +Self{name,age} +} + +fn greet(&self){ +println!("Hello, my name is {} and I'm {} years old.",self.name,self.age); +} +} \ No newline at end of file diff --git a/test_data/struct_with_impl.rs.snap b/test_data/struct_with_impl.rs.snap new file mode 100644 index 0000000..99b0f49 --- /dev/null +++ b/test_data/struct_with_impl.rs.snap @@ -0,0 +1,12 @@ +struct Person { + name: String, + age: u32, +} +impl Person { + fn new(name: String, age: u32) -> Self { + Self { name, age } + } + fn greet(&self) { + println!("Hello, my name is {} and I'm {} years old.", self.name, self.age); + } +} diff --git a/test_deno/deno.test.ts b/test_deno/deno.test.ts new file mode 100644 index 0000000..0d163ae --- /dev/null +++ b/test_deno/deno.test.ts @@ -0,0 +1,35 @@ +import init, { format } from "../pkg/rust_fmt.js"; + +import { assertEquals } from "jsr:@std/assert"; +import { walk } from "jsr:@std/fs/walk"; +import { relative } from "jsr:@std/path"; + +await init(); + +const update = Deno.args.includes("--update"); + +const test_root = new URL("../test_data", import.meta.url); + +for await (const entry of walk(test_root, { + includeDirs: false, + exts: ["rs"], +})) { + if (entry.name.startsWith(".")) { + continue; + } + + const input = Deno.readTextFileSync(entry.path); + + if (update) { + const actual = format(input); + Deno.writeTextFileSync(entry.path + ".snap", actual); + } else { + const test_name = relative(test_root.pathname, entry.path); + const expected = Deno.readTextFileSync(entry.path + ".snap"); + + Deno.test(test_name, () => { + const actual = format(input); + assertEquals(actual, expected); + }); + } +} \ No newline at end of file diff --git a/test_node/test-node.mjs b/test_node/test-node.mjs new file mode 100644 index 0000000..8dbab25 --- /dev/null +++ b/test_node/test-node.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import { basename } from "node:path"; +import { chdir } from "node:process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import init, { format } from "../pkg/rust_fmt_node.js"; + +await init(); + +const test_root = fileURLToPath(import.meta.resolve("../test_data")); +chdir(test_root); + +for await (const input_path of fs.glob("**/*.rs")) { + if (basename(input_path).startsWith(".")) { + continue; + } + + const expect_path = input_path + ".snap"; + + const [input, expected] = await Promise.all([ + fs.readFile(input_path, "utf-8"), + fs.readFile(expect_path, "utf-8").catch(() => { + // 如果没有 snap 文件,就创建一个 + const formatted = format(input); + return formatted; + }), + ]); + + test(input_path, () => { + const actual = format(input); + assert.equal(actual, expected); + }); +} \ No newline at end of file