From a3215248fb7ec5c719371a5f82d2bcc7413cdd96 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:30:02 +0800 Subject: [PATCH 01/34] feat(rust-parser): scaffold uast4rust cli and minimal output model --- parser-Rust/Cargo.lock | 445 +++++++++++++++++++++++++++++++++++++++ parser-Rust/Cargo.toml | 11 + parser-Rust/src/lib.rs | 201 ++++++++++++++++++ parser-Rust/src/main.rs | 19 ++ parser-Rust/src/model.rs | 65 ++++++ 5 files changed, 741 insertions(+) create mode 100644 parser-Rust/Cargo.lock create mode 100644 parser-Rust/Cargo.toml create mode 100644 parser-Rust/src/lib.rs create mode 100644 parser-Rust/src/main.rs create mode 100644 parser-Rust/src/model.rs diff --git a/parser-Rust/Cargo.lock b/parser-Rust/Cargo.lock new file mode 100644 index 0000000..55fbcc5 --- /dev/null +++ b/parser-Rust/Cargo.lock @@ -0,0 +1,445 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "uast4rust" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/parser-Rust/Cargo.toml b/parser-Rust/Cargo.toml new file mode 100644 index 0000000..1a41f22 --- /dev/null +++ b/parser-Rust/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "uast4rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3.10" diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs new file mode 100644 index 0000000..a4ff8af --- /dev/null +++ b/parser-Rust/src/lib.rs @@ -0,0 +1,201 @@ +mod model; + +pub use model::{CompileUnit, NodeInfo, Output, PackagePathInfo, LANGUAGE}; +use std::collections::BTreeMap; +use std::env; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; +const SINGLE_FILE_MODULE_NAME: &str = "__single_module__"; +const UNKNOWN_MODULE_NAME: &str = "__unknown_module__"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliArgs { + pub root_dir: PathBuf, + pub output: PathBuf, + pub single: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseArgsError { + HelpRequested, + Message(String), +} + +impl fmt::Display for ParseArgsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HelpRequested => write!(f, "{}", usage()), + Self::Message(msg) => write!(f, "{msg}"), + } + } +} + +pub fn usage() -> &'static str { + "Usage: uast4rust -rootDir -output [-single]" +} + +pub fn parse_args_from(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut root_dir: Option = None; + let mut output: Option = None; + let mut single = false; + let mut iter = args.into_iter().map(Into::into); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "-rootDir" | "--rootDir" => { + let value = iter.next().ok_or_else(|| { + ParseArgsError::Message("Missing value for -rootDir".to_string()) + })?; + root_dir = Some(PathBuf::from(value)); + } + "-output" | "--output" => { + let value = iter.next().ok_or_else(|| { + ParseArgsError::Message("Missing value for -output".to_string()) + })?; + output = Some(PathBuf::from(value)); + } + "-single" | "--single" => { + single = true; + } + "-h" | "--help" => { + return Err(ParseArgsError::HelpRequested); + } + unknown => { + return Err(ParseArgsError::Message(format!( + "Unknown argument: {unknown}" + ))); + } + } + } + + let root_dir = root_dir.ok_or_else(|| { + ParseArgsError::Message(format!("Missing required argument: -rootDir\n{}", usage())) + })?; + let output = output.ok_or_else(|| { + ParseArgsError::Message(format!("Missing required argument: -output\n{}", usage())) + })?; + + Ok(CliArgs { + root_dir, + output, + single, + }) +} + +pub fn parse_args() -> Result { + parse_args_from(env::args().skip(1)) +} + +pub fn run(cli: &CliArgs) -> Result<(), String> { + if cli.single { + parse_single_file(&cli.root_dir, &cli.output) + } else { + parse_project(&cli.root_dir, &cli.output) + } +} + +fn parse_single_file(file: &Path, output: &Path) -> Result<(), String> { + if !file.is_file() { + return Err(format!( + "single mode expects a file path, got: {}", + file.display() + )); + } + + fs::read_to_string(file) + .map_err(|err| format!("failed to read file {}: {err}", file.display()))?; + + let file_key = file.to_string_lossy().to_string(); + let mut files = BTreeMap::new(); + files.insert( + file_key.clone(), + NodeInfo { + node: CompileUnit::empty(file_key), + package_name: SINGLE_FILE_PACKAGE_NAME.to_string(), + }, + ); + + let output_model = Output { + package_info: PackagePathInfo { + path_name: "/".to_string(), + files, + subs: BTreeMap::new(), + }, + module_name: SINGLE_FILE_MODULE_NAME.to_string(), + cargo_toml_path: String::new(), + num_of_cargo_toml: 0, + }; + + write_output(output, &output_model) +} + +fn parse_project(root_dir: &Path, output: &Path) -> Result<(), String> { + if !root_dir.is_dir() { + return Err(format!( + "project mode expects a directory path, got: {}", + root_dir.display() + )); + } + + let output_model = Output { + package_info: PackagePathInfo::empty_root(), + module_name: UNKNOWN_MODULE_NAME.to_string(), + cargo_toml_path: String::new(), + num_of_cargo_toml: 0, + }; + + write_output(output, &output_model) +} + +fn write_output(output_path: &Path, output: &Output) -> Result<(), String> { + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create output directory {}: {err}", + parent.display() + ) + })?; + } + } + + let json = serde_json::to_vec(output) + .map_err(|err| format!("failed to serialize output json: {err}"))?; + fs::write(output_path, json) + .map_err(|err| format!("failed to write output {}: {err}", output_path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_accepts_expected_flags() { + let args = vec![ + "-rootDir", + "/tmp/example.rs", + "-output", + "/tmp/output.json", + "-single", + ]; + let cli = parse_args_from(args).expect("parse args"); + assert_eq!(cli.root_dir, PathBuf::from("/tmp/example.rs")); + assert_eq!(cli.output, PathBuf::from("/tmp/output.json")); + assert!(cli.single); + } + + #[test] + fn parse_args_rejects_missing_required_flags() { + let args = vec!["-rootDir", "/tmp/example.rs"]; + let err = parse_args_from(args).expect_err("missing output should fail"); + assert!(matches!(err, ParseArgsError::Message(_))); + assert!(err.to_string().contains("-output")); + } +} diff --git a/parser-Rust/src/main.rs b/parser-Rust/src/main.rs new file mode 100644 index 0000000..91914dc --- /dev/null +++ b/parser-Rust/src/main.rs @@ -0,0 +1,19 @@ +use std::process; + +fn main() { + match uast4rust::parse_args() { + Ok(cli) => { + if let Err(err) = uast4rust::run(&cli) { + eprintln!("{err}"); + process::exit(1); + } + } + Err(uast4rust::ParseArgsError::HelpRequested) => { + println!("{}", uast4rust::usage()); + } + Err(err) => { + eprintln!("{err}"); + process::exit(2); + } + } +} diff --git a/parser-Rust/src/model.rs b/parser-Rust/src/model.rs new file mode 100644 index 0000000..6ab9d7a --- /dev/null +++ b/parser-Rust/src/model.rs @@ -0,0 +1,65 @@ +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; + +pub const LANGUAGE: &str = "rust"; +pub const LANGUAGE_VERSION: &str = ""; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Output { + pub package_info: PackagePathInfo, + pub module_name: String, + pub cargo_toml_path: String, + pub num_of_cargo_toml: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PackagePathInfo { + pub path_name: String, + pub files: BTreeMap, + pub subs: BTreeMap, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeInfo { + pub node: CompileUnit, + pub package_name: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompileUnit { + #[serde(rename = "type")] + pub node_type: String, + pub body: Vec, + pub language: String, + pub language_version: String, + pub uri: String, + pub version: String, +} + +impl CompileUnit { + pub fn empty(uri: String) -> Self { + Self { + node_type: "CompileUnit".to_string(), + body: Vec::new(), + language: LANGUAGE.to_string(), + language_version: LANGUAGE_VERSION.to_string(), + uri, + version: String::new(), + } + } +} + +impl PackagePathInfo { + pub fn empty_root() -> Self { + Self { + path_name: "/".to_string(), + files: BTreeMap::new(), + subs: BTreeMap::new(), + } + } +} From 7c0f0e8f8d70c23d8f62e11641934caf757c0fe1 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:30:06 +0800 Subject: [PATCH 02/34] test(rust-parser): add smoke tests with synthetic fixtures --- parser-Rust/testdata/project/basic/Cargo.toml | 4 + .../testdata/project/basic/src/main.rs | 3 + parser-Rust/testdata/single/basic.rs | 3 + parser-Rust/tests/cli_smoke.rs | 92 +++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 parser-Rust/testdata/project/basic/Cargo.toml create mode 100644 parser-Rust/testdata/project/basic/src/main.rs create mode 100644 parser-Rust/testdata/single/basic.rs create mode 100644 parser-Rust/tests/cli_smoke.rs diff --git a/parser-Rust/testdata/project/basic/Cargo.toml b/parser-Rust/testdata/project/basic/Cargo.toml new file mode 100644 index 0000000..cd86e21 --- /dev/null +++ b/parser-Rust/testdata/project/basic/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "demo" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/basic/src/main.rs b/parser-Rust/testdata/project/basic/src/main.rs new file mode 100644 index 0000000..bd7dd93 --- /dev/null +++ b/parser-Rust/testdata/project/basic/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("project fixture"); +} diff --git a/parser-Rust/testdata/single/basic.rs b/parser-Rust/testdata/single/basic.rs new file mode 100644 index 0000000..d9952e7 --- /dev/null +++ b/parser-Rust/testdata/single/basic.rs @@ -0,0 +1,3 @@ +fn main() { + println!("hello, yasa"); +} diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs new file mode 100644 index 0000000..8944963 --- /dev/null +++ b/parser-Rust/tests/cli_smoke.rs @@ -0,0 +1,92 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn run_cli(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_uast4rust")) + .args(args) + .output() + .expect("failed to run uast4rust") +} + +#[test] +fn single_mode_writes_minimal_uast_with_rust_language() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = fixture_path("single/basic.rs"); + let output_file = temp.path().join("out").join("single.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("fixture path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + + assert!(json.get("packageInfo").is_some()); + assert_eq!( + json.get("moduleName").and_then(Value::as_str), + Some("__single_module__") + ); + assert!(json.get("cargoTomlPath").is_some()); + assert!(json.get("numOfCargoToml").is_some()); + + let files = json + .get("packageInfo") + .and_then(|v| v.get("files")) + .and_then(Value::as_object) + .expect("packageInfo.files object"); + assert_eq!(files.len(), 1); + let only_file = files.values().next().expect("file entry"); + assert_eq!( + only_file + .get("node") + .and_then(|v| v.get("language")) + .and_then(Value::as_str), + Some("rust") + ); +} + +#[test] +fn project_mode_writes_required_top_level_fields() { + let temp = tempfile::tempdir().expect("tempdir"); + let project_dir = fixture_path("project/basic"); + let output_file = temp.path().join("out").join("project.json"); + + let out = run_cli(&[ + "-rootDir", + project_dir.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + + assert!(json.get("packageInfo").is_some()); + assert!(json.get("moduleName").is_some()); + assert!(json.get("cargoTomlPath").is_some()); + assert!(json.get("numOfCargoToml").is_some()); +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(relative) +} From 57ebf9ba8438dc2deea3dabee9b6d1ac8648c08b Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:30:10 +0800 Subject: [PATCH 03/34] docs(rust-parser): add uast4rust usage README --- parser-Rust/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 parser-Rust/README.md diff --git a/parser-Rust/README.md b/parser-Rust/README.md new file mode 100644 index 0000000..29c9c7b --- /dev/null +++ b/parser-Rust/README.md @@ -0,0 +1,23 @@ +# uast4rust + +`uast4rust` is the Rust parser binary for YASA-UAST. + +## Build + +```bash +cargo build --release +``` + +## Usage + +Single-file mode: + +```bash +./target/release/uast4rust -rootDir /path/to/file.rs -output /path/to/output.json -single +``` + +Project mode: + +```bash +./target/release/uast4rust -rootDir /path/to/project -output /path/to/output.json +``` From d42f103f227fe745c47530431110c37aa3be861d Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:42:19 +0800 Subject: [PATCH 04/34] fix(rust-parser): include flag name in missing-value arg errors --- parser-Rust/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index a4ff8af..6fe2a16 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -51,13 +51,13 @@ where match arg.as_str() { "-rootDir" | "--rootDir" => { let value = iter.next().ok_or_else(|| { - ParseArgsError::Message("Missing value for -rootDir".to_string()) + ParseArgsError::Message(format!("Missing value for '{}'", arg)) })?; root_dir = Some(PathBuf::from(value)); } "-output" | "--output" => { let value = iter.next().ok_or_else(|| { - ParseArgsError::Message("Missing value for -output".to_string()) + ParseArgsError::Message(format!("Missing value for '{}'", arg)) })?; output = Some(PathBuf::from(value)); } From bc17bb8e73358dbaf7e15becd3884277e04009cb Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:43:02 +0800 Subject: [PATCH 05/34] refactor(rust-parser): replace stringly errors with typed RunError --- parser-Rust/src/lib.rs | 79 ++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 6fe2a16..7c3546b 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -33,6 +33,38 @@ impl fmt::Display for ParseArgsError { } } +#[derive(Debug)] +pub enum RunError { + InvalidInputPath { mode: &'static str, path: PathBuf }, + Io { + context: String, + source: std::io::Error, + }, + Serialize { source: serde_json::Error }, +} + +impl fmt::Display for RunError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInputPath { mode, path } => { + write!(f, "{} mode expects a valid path: {}", mode, path.display()) + } + Self::Io { context, source } => write!(f, "{context}: {source}"), + Self::Serialize { source } => write!(f, "failed to serialize output json: {source}"), + } + } +} + +impl std::error::Error for RunError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidInputPath { .. } => None, + Self::Io { source, .. } => Some(source), + Self::Serialize { source } => Some(source), + } + } +} + pub fn usage() -> &'static str { "Usage: uast4rust -rootDir -output [-single]" } @@ -93,7 +125,7 @@ pub fn parse_args() -> Result { parse_args_from(env::args().skip(1)) } -pub fn run(cli: &CliArgs) -> Result<(), String> { +pub fn run(cli: &CliArgs) -> Result<(), RunError> { if cli.single { parse_single_file(&cli.root_dir, &cli.output) } else { @@ -101,16 +133,18 @@ pub fn run(cli: &CliArgs) -> Result<(), String> { } } -fn parse_single_file(file: &Path, output: &Path) -> Result<(), String> { +fn parse_single_file(file: &Path, output: &Path) -> Result<(), RunError> { if !file.is_file() { - return Err(format!( - "single mode expects a file path, got: {}", - file.display() - )); + return Err(RunError::InvalidInputPath { + mode: "single", + path: file.to_path_buf(), + }); } - fs::read_to_string(file) - .map_err(|err| format!("failed to read file {}: {err}", file.display()))?; + fs::read_to_string(file).map_err(|source| RunError::Io { + context: format!("failed to read file {}", file.display()), + source, + })?; let file_key = file.to_string_lossy().to_string(); let mut files = BTreeMap::new(); @@ -136,12 +170,12 @@ fn parse_single_file(file: &Path, output: &Path) -> Result<(), String> { write_output(output, &output_model) } -fn parse_project(root_dir: &Path, output: &Path) -> Result<(), String> { +fn parse_project(root_dir: &Path, output: &Path) -> Result<(), RunError> { if !root_dir.is_dir() { - return Err(format!( - "project mode expects a directory path, got: {}", - root_dir.display() - )); + return Err(RunError::InvalidInputPath { + mode: "project", + path: root_dir.to_path_buf(), + }); } let output_model = Output { @@ -154,22 +188,21 @@ fn parse_project(root_dir: &Path, output: &Path) -> Result<(), String> { write_output(output, &output_model) } -fn write_output(output_path: &Path, output: &Output) -> Result<(), String> { +fn write_output(output_path: &Path, output: &Output) -> Result<(), RunError> { if let Some(parent) = output_path.parent() { if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create output directory {}: {err}", - parent.display() - ) + fs::create_dir_all(parent).map_err(|source| RunError::Io { + context: format!("failed to create output directory {}", parent.display()), + source, })?; } } - let json = serde_json::to_vec(output) - .map_err(|err| format!("failed to serialize output json: {err}"))?; - fs::write(output_path, json) - .map_err(|err| format!("failed to write output {}: {err}", output_path.display())) + let json = serde_json::to_vec(output).map_err(|source| RunError::Serialize { source })?; + fs::write(output_path, json).map_err(|source| RunError::Io { + context: format!("failed to write output {}", output_path.display()), + source, + }) } #[cfg(test)] From 90ebe0a661f177758ed8d095a20505efd7ceb9cc Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:43:10 +0800 Subject: [PATCH 06/34] perf(rust-parser): avoid full file read when checking readability --- parser-Rust/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 7c3546b..5287494 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -141,7 +141,7 @@ fn parse_single_file(file: &Path, output: &Path) -> Result<(), RunError> { }); } - fs::read_to_string(file).map_err(|source| RunError::Io { + fs::File::open(file).map_err(|source| RunError::Io { context: format!("failed to read file {}", file.display()), source, })?; From 34143e3c85edb1098f1e60e564d7177b45afd2a0 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:43:23 +0800 Subject: [PATCH 07/34] refactor(rust-parser): simplify main flow and use code 0 for help --- parser-Rust/src/main.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/parser-Rust/src/main.rs b/parser-Rust/src/main.rs index 91914dc..b223679 100644 --- a/parser-Rust/src/main.rs +++ b/parser-Rust/src/main.rs @@ -1,19 +1,20 @@ use std::process; fn main() { - match uast4rust::parse_args() { - Ok(cli) => { - if let Err(err) = uast4rust::run(&cli) { - eprintln!("{err}"); - process::exit(1); - } - } + let cli = match uast4rust::parse_args() { + Ok(cli) => cli, Err(uast4rust::ParseArgsError::HelpRequested) => { println!("{}", uast4rust::usage()); + process::exit(0); } Err(err) => { eprintln!("{err}"); process::exit(2); } + }; + + if let Err(err) = uast4rust::run(&cli) { + eprintln!("{err}"); + process::exit(1); } } From f0956bbc42f398b44bebd0c734556cc182ca5968 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:52:33 +0800 Subject: [PATCH 08/34] refactor(rust-parser): switch cli arg parsing to clap with legacy flag compatibility --- parser-Rust/Cargo.lock | 121 +++++++++++++++++++++++++++++++++++++++++ parser-Rust/Cargo.toml | 1 + parser-Rust/src/lib.rs | 84 +++++++++++++--------------- 3 files changed, 159 insertions(+), 47 deletions(-) diff --git a/parser-Rust/Cargo.lock b/parser-Rust/Cargo.lock index 55fbcc5..d469b4c 100644 --- a/parser-Rust/Cargo.lock +++ b/parser-Rust/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -20,6 +70,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "equivalent" version = "1.0.2" @@ -100,6 +196,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -142,6 +244,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "prettyplease" version = "0.2.37" @@ -238,6 +346,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -266,6 +380,7 @@ dependencies = [ name = "uast4rust" version = "0.1.0" dependencies = [ + "clap", "serde", "serde_json", "tempfile", @@ -283,6 +398,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/parser-Rust/Cargo.toml b/parser-Rust/Cargo.toml index 1a41f22..51016ba 100644 --- a/parser-Rust/Cargo.toml +++ b/parser-Rust/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 5287494..9f94b13 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -1,5 +1,6 @@ mod model; +use clap::{error::ErrorKind, Parser}; pub use model::{CompileUnit, NodeInfo, Output, PackagePathInfo, LANGUAGE}; use std::collections::BTreeMap; use std::env; @@ -11,10 +12,18 @@ const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; const SINGLE_FILE_MODULE_NAME: &str = "__single_module__"; const UNKNOWN_MODULE_NAME: &str = "__unknown_module__"; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Parser)] +#[command( + name = "uast4rust", + disable_help_subcommand = true, + override_usage = "uast4rust -rootDir -output [-single]" +)] pub struct CliArgs { + #[arg(long = "rootDir", value_name = "path")] pub root_dir: PathBuf, + #[arg(long = "output", value_name = "path")] pub output: PathBuf, + #[arg(long = "single", default_value_t = false)] pub single: bool, } @@ -35,12 +44,17 @@ impl fmt::Display for ParseArgsError { #[derive(Debug)] pub enum RunError { - InvalidInputPath { mode: &'static str, path: PathBuf }, + InvalidInputPath { + mode: &'static str, + path: PathBuf, + }, Io { context: String, source: std::io::Error, }, - Serialize { source: serde_json::Error }, + Serialize { + source: serde_json::Error, + }, } impl fmt::Display for RunError { @@ -74,57 +88,33 @@ where I: IntoIterator, S: Into, { - let mut root_dir: Option = None; - let mut output: Option = None; - let mut single = false; - let mut iter = args.into_iter().map(Into::into); - - while let Some(arg) = iter.next() { - match arg.as_str() { - "-rootDir" | "--rootDir" => { - let value = iter.next().ok_or_else(|| { - ParseArgsError::Message(format!("Missing value for '{}'", arg)) - })?; - root_dir = Some(PathBuf::from(value)); - } - "-output" | "--output" => { - let value = iter.next().ok_or_else(|| { - ParseArgsError::Message(format!("Missing value for '{}'", arg)) - })?; - output = Some(PathBuf::from(value)); - } - "-single" | "--single" => { - single = true; + let mut argv = vec!["uast4rust".to_string()]; + argv.extend(args.into_iter().map(Into::into).map(normalize_legacy_flag)); + + match CliArgs::try_parse_from(argv) { + Ok(cli) => Ok(cli), + Err(err) => match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + Err(ParseArgsError::HelpRequested) } - "-h" | "--help" => { - return Err(ParseArgsError::HelpRequested); - } - unknown => { - return Err(ParseArgsError::Message(format!( - "Unknown argument: {unknown}" - ))); - } - } + _ => Err(ParseArgsError::Message(err.to_string())), + }, } - - let root_dir = root_dir.ok_or_else(|| { - ParseArgsError::Message(format!("Missing required argument: -rootDir\n{}", usage())) - })?; - let output = output.ok_or_else(|| { - ParseArgsError::Message(format!("Missing required argument: -output\n{}", usage())) - })?; - - Ok(CliArgs { - root_dir, - output, - single, - }) } pub fn parse_args() -> Result { parse_args_from(env::args().skip(1)) } +fn normalize_legacy_flag(arg: String) -> String { + match arg.as_str() { + "-rootDir" => "--rootDir".to_string(), + "-output" => "--output".to_string(), + "-single" => "--single".to_string(), + _ => arg, + } +} + pub fn run(cli: &CliArgs) -> Result<(), RunError> { if cli.single { parse_single_file(&cli.root_dir, &cli.output) @@ -229,6 +219,6 @@ mod tests { let args = vec!["-rootDir", "/tmp/example.rs"]; let err = parse_args_from(args).expect_err("missing output should fail"); assert!(matches!(err, ParseArgsError::Message(_))); - assert!(err.to_string().contains("-output")); + assert!(err.to_string().contains("output")); } } From 90900afcdfb18127e4b6e4f45380483b5d5cff7d Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:01:31 +0800 Subject: [PATCH 09/34] Revert "refactor(rust-parser): switch cli arg parsing to clap with legacy flag compatibility" This reverts commit f0956bbc42f398b44bebd0c734556cc182ca5968. --- parser-Rust/Cargo.lock | 121 ----------------------------------------- parser-Rust/Cargo.toml | 1 - parser-Rust/src/lib.rs | 84 +++++++++++++++------------- 3 files changed, 47 insertions(+), 159 deletions(-) diff --git a/parser-Rust/Cargo.lock b/parser-Rust/Cargo.lock index d469b4c..55fbcc5 100644 --- a/parser-Rust/Cargo.lock +++ b/parser-Rust/Cargo.lock @@ -2,56 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -70,52 +20,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "clap" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "equivalent" version = "1.0.2" @@ -196,12 +100,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itoa" version = "1.0.17" @@ -244,12 +142,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "prettyplease" version = "0.2.37" @@ -346,12 +238,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "syn" version = "2.0.117" @@ -380,7 +266,6 @@ dependencies = [ name = "uast4rust" version = "0.1.0" dependencies = [ - "clap", "serde", "serde_json", "tempfile", @@ -398,12 +283,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/parser-Rust/Cargo.toml b/parser-Rust/Cargo.toml index 51016ba..1a41f22 100644 --- a/parser-Rust/Cargo.toml +++ b/parser-Rust/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.0" edition = "2021" [dependencies] -clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 9f94b13..5287494 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -1,6 +1,5 @@ mod model; -use clap::{error::ErrorKind, Parser}; pub use model::{CompileUnit, NodeInfo, Output, PackagePathInfo, LANGUAGE}; use std::collections::BTreeMap; use std::env; @@ -12,18 +11,10 @@ const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; const SINGLE_FILE_MODULE_NAME: &str = "__single_module__"; const UNKNOWN_MODULE_NAME: &str = "__unknown_module__"; -#[derive(Debug, Clone, PartialEq, Eq, Parser)] -#[command( - name = "uast4rust", - disable_help_subcommand = true, - override_usage = "uast4rust -rootDir -output [-single]" -)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CliArgs { - #[arg(long = "rootDir", value_name = "path")] pub root_dir: PathBuf, - #[arg(long = "output", value_name = "path")] pub output: PathBuf, - #[arg(long = "single", default_value_t = false)] pub single: bool, } @@ -44,17 +35,12 @@ impl fmt::Display for ParseArgsError { #[derive(Debug)] pub enum RunError { - InvalidInputPath { - mode: &'static str, - path: PathBuf, - }, + InvalidInputPath { mode: &'static str, path: PathBuf }, Io { context: String, source: std::io::Error, }, - Serialize { - source: serde_json::Error, - }, + Serialize { source: serde_json::Error }, } impl fmt::Display for RunError { @@ -88,33 +74,57 @@ where I: IntoIterator, S: Into, { - let mut argv = vec!["uast4rust".to_string()]; - argv.extend(args.into_iter().map(Into::into).map(normalize_legacy_flag)); - - match CliArgs::try_parse_from(argv) { - Ok(cli) => Ok(cli), - Err(err) => match err.kind() { - ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { - Err(ParseArgsError::HelpRequested) + let mut root_dir: Option = None; + let mut output: Option = None; + let mut single = false; + let mut iter = args.into_iter().map(Into::into); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "-rootDir" | "--rootDir" => { + let value = iter.next().ok_or_else(|| { + ParseArgsError::Message(format!("Missing value for '{}'", arg)) + })?; + root_dir = Some(PathBuf::from(value)); } - _ => Err(ParseArgsError::Message(err.to_string())), - }, + "-output" | "--output" => { + let value = iter.next().ok_or_else(|| { + ParseArgsError::Message(format!("Missing value for '{}'", arg)) + })?; + output = Some(PathBuf::from(value)); + } + "-single" | "--single" => { + single = true; + } + "-h" | "--help" => { + return Err(ParseArgsError::HelpRequested); + } + unknown => { + return Err(ParseArgsError::Message(format!( + "Unknown argument: {unknown}" + ))); + } + } } + + let root_dir = root_dir.ok_or_else(|| { + ParseArgsError::Message(format!("Missing required argument: -rootDir\n{}", usage())) + })?; + let output = output.ok_or_else(|| { + ParseArgsError::Message(format!("Missing required argument: -output\n{}", usage())) + })?; + + Ok(CliArgs { + root_dir, + output, + single, + }) } pub fn parse_args() -> Result { parse_args_from(env::args().skip(1)) } -fn normalize_legacy_flag(arg: String) -> String { - match arg.as_str() { - "-rootDir" => "--rootDir".to_string(), - "-output" => "--output".to_string(), - "-single" => "--single".to_string(), - _ => arg, - } -} - pub fn run(cli: &CliArgs) -> Result<(), RunError> { if cli.single { parse_single_file(&cli.root_dir, &cli.output) @@ -219,6 +229,6 @@ mod tests { let args = vec!["-rootDir", "/tmp/example.rs"]; let err = parse_args_from(args).expect_err("missing output should fail"); assert!(matches!(err, ParseArgsError::Message(_))); - assert!(err.to_string().contains("output")); + assert!(err.to_string().contains("-output")); } } From 8eccdf03555c59794bf88115c0f7296989a73c02 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:08:06 +0800 Subject: [PATCH 10/34] fix(rust-parser): prevent source overwrite when output path equals input --- parser-Rust/src/lib.rs | 32 ++++++++++++++++++++++++++++++-- parser-Rust/tests/cli_smoke.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 5287494..1dd8631 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -35,12 +35,21 @@ impl fmt::Display for ParseArgsError { #[derive(Debug)] pub enum RunError { - InvalidInputPath { mode: &'static str, path: PathBuf }, + InvalidInputPath { + mode: &'static str, + path: PathBuf, + }, + OutputWouldOverwriteSource { + source: PathBuf, + output: PathBuf, + }, Io { context: String, source: std::io::Error, }, - Serialize { source: serde_json::Error }, + Serialize { + source: serde_json::Error, + }, } impl fmt::Display for RunError { @@ -49,6 +58,12 @@ impl fmt::Display for RunError { Self::InvalidInputPath { mode, path } => { write!(f, "{} mode expects a valid path: {}", mode, path.display()) } + Self::OutputWouldOverwriteSource { source, output } => write!( + f, + "refusing to overwrite source file in single mode: source={} output={}", + source.display(), + output.display() + ), Self::Io { context, source } => write!(f, "{context}: {source}"), Self::Serialize { source } => write!(f, "failed to serialize output json: {source}"), } @@ -59,6 +74,7 @@ impl std::error::Error for RunError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::InvalidInputPath { .. } => None, + Self::OutputWouldOverwriteSource { .. } => None, Self::Io { source, .. } => Some(source), Self::Serialize { source } => Some(source), } @@ -146,6 +162,18 @@ fn parse_single_file(file: &Path, output: &Path) -> Result<(), RunError> { source, })?; + let source_canonical = fs::canonicalize(file).map_err(|source| RunError::Io { + context: format!("failed to canonicalize source file {}", file.display()), + source, + })?; + let output_canonical = fs::canonicalize(output).unwrap_or_else(|_| output.to_path_buf()); + if source_canonical == output_canonical { + return Err(RunError::OutputWouldOverwriteSource { + source: source_canonical, + output: output.to_path_buf(), + }); + } + let file_key = file.to_string_lossy().to_string(); let mut files = BTreeMap::new(); files.insert( diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 8944963..5327f76 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -85,6 +85,34 @@ fn project_mode_writes_required_top_level_fields() { assert!(json.get("numOfCargoToml").is_some()); } +#[test] +fn single_mode_rejects_output_equal_to_source_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("same.rs"); + fs::write(&source_file, "fn main() {}\n").expect("write source file"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + source_file.to_str().expect("utf8 path"), + "-single", + ]); + + assert!( + !out.status.success(), + "cli unexpectedly succeeded with same input/output path" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("refusing to overwrite source file"), + "unexpected error message: {stderr}" + ); + + let source_after = fs::read_to_string(&source_file).expect("read source file"); + assert_eq!(source_after, "fn main() {}\n"); +} + fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata") From 51282bcd9fbeb8c203339f561d90a85397cfc265 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:08:35 +0800 Subject: [PATCH 11/34] fix(rust-parser): reject known flags as option values --- parser-Rust/src/lib.rs | 46 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 1dd8631..d46e988 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -98,15 +98,11 @@ where while let Some(arg) = iter.next() { match arg.as_str() { "-rootDir" | "--rootDir" => { - let value = iter.next().ok_or_else(|| { - ParseArgsError::Message(format!("Missing value for '{}'", arg)) - })?; + let value = next_arg_value(&mut iter, &arg)?; root_dir = Some(PathBuf::from(value)); } "-output" | "--output" => { - let value = iter.next().ok_or_else(|| { - ParseArgsError::Message(format!("Missing value for '{}'", arg)) - })?; + let value = next_arg_value(&mut iter, &arg)?; output = Some(PathBuf::from(value)); } "-single" | "--single" => { @@ -137,6 +133,36 @@ where }) } +fn next_arg_value(iter: &mut I, flag: &str) -> Result +where + I: Iterator, +{ + let value = iter + .next() + .ok_or_else(|| ParseArgsError::Message(format!("Missing value for '{}'", flag)))?; + if is_known_flag(&value) { + return Err(ParseArgsError::Message(format!( + "Missing value for '{}'", + flag + ))); + } + Ok(value) +} + +fn is_known_flag(value: &str) -> bool { + matches!( + value, + "-rootDir" + | "--rootDir" + | "-output" + | "--output" + | "-single" + | "--single" + | "-h" + | "--help" + ) +} + pub fn parse_args() -> Result { parse_args_from(env::args().skip(1)) } @@ -259,4 +285,12 @@ mod tests { assert!(matches!(err, ParseArgsError::Message(_))); assert!(err.to_string().contains("-output")); } + + #[test] + fn parse_args_rejects_flag_as_value() { + let args = vec!["-rootDir", "-output", "-output", "/tmp/out.json"]; + let err = parse_args_from(args).expect_err("flag used as value should fail"); + assert!(matches!(err, ParseArgsError::Message(_))); + assert!(err.to_string().contains("Missing value for '-rootDir'")); + } } From 365bc5c56bf23bba075f5be7d218add3e34c10b5 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:15:10 +0800 Subject: [PATCH 12/34] feat(rust-parser): discover Cargo manifests and build project metadata tree --- parser-Rust/src/lib.rs | 199 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 4 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index d46e988..22f6870 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -3,6 +3,7 @@ mod model; pub use model::{CompileUnit, NodeInfo, Output, PackagePathInfo, LANGUAGE}; use std::collections::BTreeMap; use std::env; +use std::ffi::OsStr; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -10,6 +11,7 @@ use std::path::{Path, PathBuf}; const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; const SINGLE_FILE_MODULE_NAME: &str = "__single_module__"; const UNKNOWN_MODULE_NAME: &str = "__unknown_module__"; +const CARGO_TOML_FILE_NAME: &str = "Cargo.toml"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliArgs { @@ -232,16 +234,189 @@ fn parse_project(root_dir: &Path, output: &Path) -> Result<(), RunError> { }); } + let mut manifests = discover_cargo_toml(root_dir)?; + manifests.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + + let module_name = manifests + .iter() + .find_map(|m| m.package_name.clone()) + .unwrap_or_else(|| UNKNOWN_MODULE_NAME.to_string()); + let cargo_toml_path = manifests + .first() + .map(|m| m.rel_path.clone()) + .unwrap_or_default(); + let num_of_cargo_toml = manifests.len(); + let package_info = build_package_info(&manifests); + let output_model = Output { - package_info: PackagePathInfo::empty_root(), - module_name: UNKNOWN_MODULE_NAME.to_string(), - cargo_toml_path: String::new(), - num_of_cargo_toml: 0, + package_info, + module_name, + cargo_toml_path, + num_of_cargo_toml, }; write_output(output, &output_model) } +#[derive(Debug)] +struct CargoManifestInfo { + rel_path: String, + package_name: Option, +} + +fn discover_cargo_toml(root_dir: &Path) -> Result, RunError> { + let mut found_paths = Vec::new(); + walk_for_cargo_toml(root_dir, &mut found_paths)?; + let mut manifests = Vec::with_capacity(found_paths.len()); + for manifest_path in found_paths { + let rel_path = manifest_rel_path(root_dir, &manifest_path); + let package_name = parse_package_name_from_manifest(&manifest_path)?; + manifests.push(CargoManifestInfo { + rel_path, + package_name, + }); + } + Ok(manifests) +} + +fn walk_for_cargo_toml(dir: &Path, found_paths: &mut Vec) -> Result<(), RunError> { + let entries = fs::read_dir(dir).map_err(|source| RunError::Io { + context: format!("failed to read directory {}", dir.display()), + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| RunError::Io { + context: format!("failed to read directory entry in {}", dir.display()), + source, + })?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| RunError::Io { + context: format!("failed to inspect file type {}", path.display()), + source, + })?; + + if file_type.is_dir() { + if should_skip_dir(path.file_name()) { + continue; + } + walk_for_cargo_toml(&path, found_paths)?; + continue; + } + + if file_type.is_file() && path.file_name() == Some(OsStr::new(CARGO_TOML_FILE_NAME)) { + found_paths.push(path); + } + } + + Ok(()) +} + +fn should_skip_dir(name: Option<&OsStr>) -> bool { + matches!( + name.and_then(OsStr::to_str), + Some(".git") + | Some(".hg") + | Some(".svn") + | Some("target") + | Some("node_modules") + | Some("vendor") + | Some(".venv") + ) +} + +fn manifest_rel_path(root_dir: &Path, manifest_path: &Path) -> String { + let rel = manifest_path + .strip_prefix(root_dir) + .unwrap_or(manifest_path); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.starts_with('/') { + rel_str + } else { + format!("/{rel_str}") + } +} + +fn parse_package_name_from_manifest(manifest_path: &Path) -> Result, RunError> { + let content = fs::read_to_string(manifest_path).map_err(|source| RunError::Io { + context: format!("failed to read manifest {}", manifest_path.display()), + source, + })?; + + let mut in_package_section = false; + for raw_line in content.lines() { + let line_no_comment = raw_line.split('#').next().unwrap_or_default().trim(); + if line_no_comment.is_empty() { + continue; + } + + if line_no_comment.starts_with('[') && line_no_comment.ends_with(']') { + in_package_section = line_no_comment == "[package]"; + continue; + } + + if !in_package_section { + continue; + } + + let Some((key, value)) = line_no_comment.split_once('=') else { + continue; + }; + if key.trim() != "name" { + continue; + } + let parsed = value.trim().trim_matches('"').trim_matches('\'').trim(); + if parsed.is_empty() { + return Ok(None); + } + return Ok(Some(parsed.to_string())); + } + + Ok(None) +} + +fn build_package_info(manifests: &[CargoManifestInfo]) -> PackagePathInfo { + let mut root = PackagePathInfo::empty_root(); + for manifest in manifests { + insert_manifest_into_tree(&mut root, manifest); + } + root +} + +fn insert_manifest_into_tree(root: &mut PackagePathInfo, manifest: &CargoManifestInfo) { + let mut node = root; + let parts: Vec<&str> = manifest + .rel_path + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + if parts.is_empty() { + return; + } + for part in &parts[..parts.len().saturating_sub(1)] { + node = node + .subs + .entry((*part).to_string()) + .or_insert_with(|| PackagePathInfo { + path_name: (*part).to_string(), + files: BTreeMap::new(), + subs: BTreeMap::new(), + }); + } + + let package_name = manifest + .package_name + .clone() + .unwrap_or_else(|| UNKNOWN_MODULE_NAME.to_string()); + node.files.insert( + manifest.rel_path.clone(), + NodeInfo { + node: CompileUnit::empty(manifest.rel_path.clone()), + package_name, + }, + ); +} + fn write_output(output_path: &Path, output: &Output) -> Result<(), RunError> { if let Some(parent) = output_path.parent() { if !parent.as_os_str().is_empty() { @@ -293,4 +468,20 @@ mod tests { assert!(matches!(err, ParseArgsError::Message(_))); assert!(err.to_string().contains("Missing value for '-rootDir'")); } + + #[test] + fn parse_package_name_reads_package_section_name() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = temp.path().join("Cargo.toml"); + fs::write( + &manifest, + "[workspace]\nmembers=[\"a\"]\n\n[package]\nname = \"demo_name\"\nversion = \"0.1.0\"\n", + ) + .expect("write manifest"); + + let name = parse_package_name_from_manifest(&manifest) + .expect("parse manifest") + .expect("package name"); + assert_eq!(name, "demo_name"); + } } From 1d1360022cf1b3b54c7a1431761be381605192d9 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:15:17 +0800 Subject: [PATCH 13/34] test(rust-parser): add project discovery golden and stability checks --- parser-Rust/testdata/project/multi/Cargo.toml | 2 + .../project/multi/crates/a/Cargo.toml | 4 ++ .../project/multi/crates/a/src/main.rs | 3 + .../project/multi/crates/b/Cargo.toml | 4 ++ .../project/multi/crates/b/src/lib.rs | 3 + .../project/multi/expected.project.json | 63 +++++++++++++++++++ parser-Rust/tests/cli_smoke.rs | 54 +++++++++++++++- 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 parser-Rust/testdata/project/multi/Cargo.toml create mode 100644 parser-Rust/testdata/project/multi/crates/a/Cargo.toml create mode 100644 parser-Rust/testdata/project/multi/crates/a/src/main.rs create mode 100644 parser-Rust/testdata/project/multi/crates/b/Cargo.toml create mode 100644 parser-Rust/testdata/project/multi/crates/b/src/lib.rs create mode 100644 parser-Rust/testdata/project/multi/expected.project.json diff --git a/parser-Rust/testdata/project/multi/Cargo.toml b/parser-Rust/testdata/project/multi/Cargo.toml new file mode 100644 index 0000000..36db098 --- /dev/null +++ b/parser-Rust/testdata/project/multi/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["crates/a", "crates/b"] diff --git a/parser-Rust/testdata/project/multi/crates/a/Cargo.toml b/parser-Rust/testdata/project/multi/crates/a/Cargo.toml new file mode 100644 index 0000000..0e16f88 --- /dev/null +++ b/parser-Rust/testdata/project/multi/crates/a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate_a" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/multi/crates/a/src/main.rs b/parser-Rust/testdata/project/multi/crates/a/src/main.rs new file mode 100644 index 0000000..b60aaca --- /dev/null +++ b/parser-Rust/testdata/project/multi/crates/a/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("crate a"); +} diff --git a/parser-Rust/testdata/project/multi/crates/b/Cargo.toml b/parser-Rust/testdata/project/multi/crates/b/Cargo.toml new file mode 100644 index 0000000..e39d248 --- /dev/null +++ b/parser-Rust/testdata/project/multi/crates/b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate_b" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/multi/crates/b/src/lib.rs b/parser-Rust/testdata/project/multi/crates/b/src/lib.rs new file mode 100644 index 0000000..d3b00b4 --- /dev/null +++ b/parser-Rust/testdata/project/multi/crates/b/src/lib.rs @@ -0,0 +1,3 @@ +pub fn greet() -> &'static str { + "crate b" +} diff --git a/parser-Rust/testdata/project/multi/expected.project.json b/parser-Rust/testdata/project/multi/expected.project.json new file mode 100644 index 0000000..1c93155 --- /dev/null +++ b/parser-Rust/testdata/project/multi/expected.project.json @@ -0,0 +1,63 @@ +{ + "packageInfo": { + "pathName": "/", + "files": { + "/Cargo.toml": { + "node": { + "type": "CompileUnit", + "body": [], + "language": "rust", + "languageVersion": "", + "uri": "/Cargo.toml", + "version": "" + }, + "packageName": "__unknown_module__" + } + }, + "subs": { + "crates": { + "pathName": "crates", + "files": {}, + "subs": { + "a": { + "pathName": "a", + "files": { + "/crates/a/Cargo.toml": { + "node": { + "type": "CompileUnit", + "body": [], + "language": "rust", + "languageVersion": "", + "uri": "/crates/a/Cargo.toml", + "version": "" + }, + "packageName": "crate_a" + } + }, + "subs": {} + }, + "b": { + "pathName": "b", + "files": { + "/crates/b/Cargo.toml": { + "node": { + "type": "CompileUnit", + "body": [], + "language": "rust", + "languageVersion": "", + "uri": "/crates/b/Cargo.toml", + "version": "" + }, + "packageName": "crate_b" + } + }, + "subs": {} + } + } + } + } + }, + "moduleName": "crate_a", + "cargoTomlPath": "/Cargo.toml", + "numOfCargoToml": 3 +} diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 5327f76..94eb84f 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -80,9 +80,12 @@ fn project_mode_writes_required_top_level_fields() { let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); assert!(json.get("packageInfo").is_some()); - assert!(json.get("moduleName").is_some()); - assert!(json.get("cargoTomlPath").is_some()); - assert!(json.get("numOfCargoToml").is_some()); + assert_eq!(json.get("moduleName").and_then(Value::as_str), Some("demo")); + assert_eq!( + json.get("cargoTomlPath").and_then(Value::as_str), + Some("/Cargo.toml") + ); + assert_eq!(json.get("numOfCargoToml").and_then(Value::as_u64), Some(1)); } #[test] @@ -113,6 +116,51 @@ fn single_mode_rejects_output_equal_to_source_file() { assert_eq!(source_after, "fn main() {}\n"); } +#[test] +fn project_mode_matches_golden_and_is_stable() { + let temp = tempfile::tempdir().expect("tempdir"); + let project_dir = fixture_path("project/multi"); + let output_file_1 = temp.path().join("out").join("project1.json"); + let output_file_2 = temp.path().join("out").join("project2.json"); + + let out1 = run_cli(&[ + "-rootDir", + project_dir.to_str().expect("utf8 path"), + "-output", + output_file_1.to_str().expect("utf8 path"), + ]); + assert!( + out1.status.success(), + "first cli run failed: {}", + String::from_utf8_lossy(&out1.stderr) + ); + + let out2 = run_cli(&[ + "-rootDir", + project_dir.to_str().expect("utf8 path"), + "-output", + output_file_2.to_str().expect("utf8 path"), + ]); + assert!( + out2.status.success(), + "second cli run failed: {}", + String::from_utf8_lossy(&out2.stderr) + ); + + let bytes_1 = fs::read(&output_file_1).expect("read first output"); + let bytes_2 = fs::read(&output_file_2).expect("read second output"); + assert_eq!(bytes_1, bytes_2, "project output is not stable across runs"); + + let actual: Value = serde_json::from_slice(&bytes_1).expect("valid project output json"); + let expected_raw = fs::read_to_string(fixture_path("project/multi/expected.project.json")) + .expect("read golden json"); + let expected: Value = serde_json::from_str(&expected_raw).expect("valid golden json"); + assert_eq!( + actual, expected, + "project discovery output mismatches golden" + ); +} + fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata") From d01405b618f2c3fa7d382c9240dc363abc10769f Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:27:35 +0800 Subject: [PATCH 14/34] fix(rust-parser): skip hidden dirs and parse quoted manifest names safely --- parser-Rust/src/lib.rs | 86 +++++++++++++++++++++++++++++----- parser-Rust/tests/cli_smoke.rs | 47 +++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 22f6870..639bc89 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -313,16 +313,13 @@ fn walk_for_cargo_toml(dir: &Path, found_paths: &mut Vec) -> Result<(), } fn should_skip_dir(name: Option<&OsStr>) -> bool { - matches!( - name.and_then(OsStr::to_str), - Some(".git") - | Some(".hg") - | Some(".svn") - | Some("target") - | Some("node_modules") - | Some("vendor") - | Some(".venv") - ) + let Some(name) = name.and_then(OsStr::to_str) else { + return false; + }; + if name.starts_with('.') { + return true; + } + matches!(name, "target" | "node_modules" | "vendor" | ".venv") } fn manifest_rel_path(root_dir: &Path, manifest_path: &Path) -> String { @@ -345,7 +342,8 @@ fn parse_package_name_from_manifest(manifest_path: &Path) -> Result Result String { + let mut in_single = false; + let mut in_double = false; + let mut escaped = false; + + for (idx, ch) in line.char_indices() { + if in_double { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '"' => in_double = false, + _ => {} + } + continue; + } + if in_single { + if ch == '\'' { + in_single = false; + } + continue; + } + match ch { + '"' => in_double = true, + '\'' => in_single = true, + '#' => return line[..idx].to_string(), + _ => {} + } + } + line.to_string() +} + +fn parse_toml_string_value(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.len() >= 2 { + let first = trimmed.as_bytes()[0] as char; + let last = trimmed.as_bytes()[trimmed.len() - 1] as char; + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + return trimmed[1..trimmed.len() - 1].to_string(); + } + } + trimmed.to_string() +} + fn build_package_info(manifests: &[CargoManifestInfo]) -> PackagePathInfo { let mut root = PackagePathInfo::empty_root(); for manifest in manifests { @@ -484,4 +528,20 @@ mod tests { .expect("package name"); assert_eq!(name, "demo_name"); } + + #[test] + fn parse_package_name_keeps_hash_inside_quoted_name() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = temp.path().join("Cargo.toml"); + fs::write( + &manifest, + "[package]\nname = \"foo#bar\" # trailing comment\nversion = \"0.1.0\"\n", + ) + .expect("write manifest"); + + let name = parse_package_name_from_manifest(&manifest) + .expect("parse manifest") + .expect("package name"); + assert_eq!(name, "foo#bar"); + } } diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 94eb84f..95f19a2 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -161,6 +161,53 @@ fn project_mode_matches_golden_and_is_stable() { ); } +#[test] +fn project_mode_skips_hidden_directories_when_discovering_manifests() { + let temp = tempfile::tempdir().expect("tempdir"); + let project_dir = temp.path().join("proj"); + fs::create_dir_all(&project_dir).expect("create project dir"); + fs::write( + project_dir.join("Cargo.toml"), + "[package]\nname = \"rootpkg\"\nversion = \"0.1.0\"\n", + ) + .expect("write root manifest"); + + let hidden_manifest = project_dir.join(".cargo/registry/src/foo"); + fs::create_dir_all(&hidden_manifest).expect("create hidden directory"); + fs::write( + hidden_manifest.join("Cargo.toml"), + "[package]\nname = \"vendored\"\nversion = \"0.1.0\"\n", + ) + .expect("write hidden manifest"); + + let output_file = temp.path().join("out").join("hidden-skip.json"); + let out = run_cli(&[ + "-rootDir", + project_dir.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + + assert_eq!( + json.get("moduleName").and_then(Value::as_str), + Some("rootpkg") + ); + assert_eq!( + json.get("cargoTomlPath").and_then(Value::as_str), + Some("/Cargo.toml") + ); + assert_eq!(json.get("numOfCargoToml").and_then(Value::as_u64), Some(1)); +} + fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata") From 07bc1afa0715e7440d5edb5c4e187057925d338a Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:41:04 +0800 Subject: [PATCH 15/34] refactor(rust-parser): parse Cargo.toml package name via toml crate --- parser-Rust/Cargo.lock | 60 +++++++++++++++++++++++++++++ parser-Rust/Cargo.toml | 1 + parser-Rust/src/lib.rs | 86 +++++------------------------------------- 3 files changed, 71 insertions(+), 76 deletions(-) diff --git a/parser-Rust/Cargo.lock b/parser-Rust/Cargo.lock index 55fbcc5..eb39078 100644 --- a/parser-Rust/Cargo.lock +++ b/parser-Rust/Cargo.lock @@ -238,6 +238,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "syn" version = "2.0.117" @@ -262,6 +271,47 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "uast4rust" version = "0.1.0" @@ -269,6 +319,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml", ] [[package]] @@ -350,6 +401,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/parser-Rust/Cargo.toml b/parser-Rust/Cargo.toml index 1a41f22..8ceae55 100644 --- a/parser-Rust/Cargo.toml +++ b/parser-Rust/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.8" [dev-dependencies] tempfile = "3.10" diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 639bc89..4d2e724 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -340,83 +340,17 @@ fn parse_package_name_from_manifest(manifest_path: &Path) -> Result String { - let mut in_single = false; - let mut in_double = false; - let mut escaped = false; - - for (idx, ch) in line.char_indices() { - if in_double { - if escaped { - escaped = false; - continue; - } - match ch { - '\\' => escaped = true, - '"' => in_double = false, - _ => {} - } - continue; - } - if in_single { - if ch == '\'' { - in_single = false; - } - continue; - } - match ch { - '"' => in_double = true, - '\'' => in_single = true, - '#' => return line[..idx].to_string(), - _ => {} - } - } - line.to_string() -} + let value = match toml::from_str::(&content) { + Ok(v) => v, + Err(_) => return Ok(None), + }; -fn parse_toml_string_value(raw: &str) -> String { - let trimmed = raw.trim(); - if trimmed.len() >= 2 { - let first = trimmed.as_bytes()[0] as char; - let last = trimmed.as_bytes()[trimmed.len() - 1] as char; - if (first == '"' && last == '"') || (first == '\'' && last == '\'') { - return trimmed[1..trimmed.len() - 1].to_string(); - } - } - trimmed.to_string() + let name = value + .get("package") + .and_then(|v| v.get("name")) + .and_then(toml::Value::as_str) + .map(str::to_string); + Ok(name.filter(|name| !name.trim().is_empty())) } fn build_package_info(manifests: &[CargoManifestInfo]) -> PackagePathInfo { From 8879b61e0db186622a187a69d78798de41ef8d61 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:41:27 +0800 Subject: [PATCH 16/34] refactor(rust-parser): make manifest path assumption explicit with expect --- parser-Rust/src/lib.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 4d2e724..362a86d 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -325,13 +325,9 @@ fn should_skip_dir(name: Option<&OsStr>) -> bool { fn manifest_rel_path(root_dir: &Path, manifest_path: &Path) -> String { let rel = manifest_path .strip_prefix(root_dir) - .unwrap_or(manifest_path); + .expect("discovered manifest should be inside root_dir"); let rel_str = rel.to_string_lossy().replace('\\', "/"); - if rel_str.starts_with('/') { - rel_str - } else { - format!("/{rel_str}") - } + format!("/{rel_str}") } fn parse_package_name_from_manifest(manifest_path: &Path) -> Result, RunError> { From 851df419d8680a7f4f05963280fd1d4cefccd772 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:41:56 +0800 Subject: [PATCH 17/34] refactor(rust-parser): simplify package name extraction chain --- parser-Rust/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 362a86d..5d72cf9 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -345,8 +345,10 @@ fn parse_package_name_from_manifest(manifest_path: &Path) -> Result PackagePathInfo { From 2adf42dd098ee4958c221bb2bba6878590839e37 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:56:51 +0800 Subject: [PATCH 18/34] feat(rust-parser): lower core single-file rust syntax into uast --- parser-Rust/Cargo.lock | 1 + parser-Rust/Cargo.toml | 1 + parser-Rust/src/lib.rs | 450 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 450 insertions(+), 2 deletions(-) diff --git a/parser-Rust/Cargo.lock b/parser-Rust/Cargo.lock index eb39078..378c916 100644 --- a/parser-Rust/Cargo.lock +++ b/parser-Rust/Cargo.lock @@ -318,6 +318,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "syn", "tempfile", "toml", ] diff --git a/parser-Rust/Cargo.toml b/parser-Rust/Cargo.toml index 8ceae55..bc33f51 100644 --- a/parser-Rust/Cargo.toml +++ b/parser-Rust/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +syn = { version = "2.0", features = ["full"] } toml = "0.8" [dev-dependencies] diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 5d72cf9..2656a74 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -1,12 +1,18 @@ mod model; pub use model::{CompileUnit, NodeInfo, Output, PackagePathInfo, LANGUAGE}; +use serde_json::{json, Value}; use std::collections::BTreeMap; use std::env; use std::ffi::OsStr; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use syn::{ + BinOp, Expr, ExprAssign, ExprBinary, ExprCall, ExprField, ExprLit, ExprMethodCall, ExprPath, + ExprReturn, Field, Fields, FnArg, Item, ItemFn, ItemStruct, Lit, Local, Pat, ReturnType, Stmt, + Type, Visibility, +}; const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; const SINGLE_FILE_MODULE_NAME: &str = "__single_module__"; @@ -52,6 +58,10 @@ pub enum RunError { Serialize { source: serde_json::Error, }, + ParseSource { + path: PathBuf, + source: syn::Error, + }, } impl fmt::Display for RunError { @@ -68,6 +78,14 @@ impl fmt::Display for RunError { ), Self::Io { context, source } => write!(f, "{context}: {source}"), Self::Serialize { source } => write!(f, "failed to serialize output json: {source}"), + Self::ParseSource { path, source } => { + write!( + f, + "failed to parse rust source {}: {}", + path.display(), + source + ) + } } } } @@ -79,6 +97,7 @@ impl std::error::Error for RunError { Self::OutputWouldOverwriteSource { .. } => None, Self::Io { source, .. } => Some(source), Self::Serialize { source } => Some(source), + Self::ParseSource { source, .. } => Some(source), } } } @@ -185,7 +204,7 @@ fn parse_single_file(file: &Path, output: &Path) -> Result<(), RunError> { }); } - fs::File::open(file).map_err(|source| RunError::Io { + let source = fs::read_to_string(file).map_err(|source| RunError::Io { context: format!("failed to read file {}", file.display()), source, })?; @@ -203,11 +222,16 @@ fn parse_single_file(file: &Path, output: &Path) -> Result<(), RunError> { } let file_key = file.to_string_lossy().to_string(); + let parsed_file = syn::parse_file(&source).map_err(|source| RunError::ParseSource { + path: file.to_path_buf(), + source, + })?; + let compile_unit = lower_single_file_uast(&parsed_file, file_key.clone()); let mut files = BTreeMap::new(); files.insert( file_key.clone(), NodeInfo { - node: CompileUnit::empty(file_key), + node: compile_unit, package_name: SINGLE_FILE_PACKAGE_NAME.to_string(), }, ); @@ -258,6 +282,428 @@ fn parse_project(root_dir: &Path, output: &Path) -> Result<(), RunError> { write_output(output, &output_model) } +fn lower_single_file_uast(file: &syn::File, uri: String) -> CompileUnit { + let mut unit = CompileUnit::empty(uri); + unit.body = file.items.iter().filter_map(lower_item).collect(); + unit +} + +fn lower_item(item: &Item) -> Option { + match item { + Item::Fn(item_fn) => Some(lower_function(item_fn)), + Item::Struct(item_struct) => Some(lower_struct(item_struct)), + _ => None, + } +} + +fn lower_function(item_fn: &ItemFn) -> Value { + let parameters = item_fn + .sig + .inputs + .iter() + .map(lower_fn_arg) + .collect::>(); + let return_type = lower_return_type(&item_fn.sig.output); + let body = scoped_statement(item_fn.block.stmts.iter().filter_map(lower_stmt).collect()); + + json!({ + "type": "FunctionDefinition", + "id": identifier(item_fn.sig.ident.to_string()), + "parameters": parameters, + "returnType": return_type, + "body": body, + "modifiers": function_modifiers(item_fn), + }) +} + +fn function_modifiers(item_fn: &ItemFn) -> Vec { + let mut modifiers = Vec::new(); + if matches!(item_fn.vis, Visibility::Public(_)) { + modifiers.push("pub".to_string()); + } + if item_fn.sig.constness.is_some() { + modifiers.push("const".to_string()); + } + if item_fn.sig.asyncness.is_some() { + modifiers.push("async".to_string()); + } + if item_fn.sig.unsafety.is_some() { + modifiers.push("unsafe".to_string()); + } + modifiers +} + +fn lower_struct(item_struct: &ItemStruct) -> Value { + let body = match &item_struct.fields { + Fields::Named(named) => named + .named + .iter() + .map(lower_struct_field) + .collect::>(), + Fields::Unnamed(unnamed) => unnamed + .unnamed + .iter() + .enumerate() + .map(|(idx, field)| lower_unnamed_struct_field(idx, field)) + .collect::>(), + Fields::Unit => Vec::new(), + }; + + json!({ + "type": "ClassDefinition", + "id": identifier(item_struct.ident.to_string()), + "body": body, + "supers": [], + }) +} + +fn lower_struct_field(field: &Field) -> Value { + let name = field + .ident + .as_ref() + .map(|ident| ident.to_string()) + .unwrap_or_else(|| "__field__".to_string()); + variable_declaration(identifier(name), None, lower_type(&field.ty), false, false) +} + +fn lower_unnamed_struct_field(index: usize, field: &Field) -> Value { + variable_declaration( + identifier(format!("_{index}")), + None, + lower_type(&field.ty), + false, + false, + ) +} + +fn lower_fn_arg(arg: &FnArg) -> Value { + match arg { + FnArg::Typed(pat_ty) => { + let name = extract_binding_name(&pat_ty.pat).unwrap_or_else(|| "__param__".to_string()); + variable_declaration(identifier(name), None, lower_type(&pat_ty.ty), false, false) + } + FnArg::Receiver(_) => Value::Null, + } +} + +fn lower_stmt(stmt: &Stmt) -> Option { + match stmt { + Stmt::Local(local) => lower_local(local), + Stmt::Item(item) => lower_item(item), + Stmt::Expr(expr, _) => lower_expr(expr), + Stmt::Macro(_) => None, + } +} + +fn lower_local(local: &Local) -> Option { + let (name, explicit_type) = extract_binding_name_and_type(&local.pat)?; + let init = local + .init + .as_ref() + .and_then(|local_init| lower_expr(&local_init.expr)); + let var_type = explicit_type.map(lower_type).unwrap_or_else(dynamic_type); + let cloned = init.is_some(); + Some(variable_declaration( + identifier(name), + init, + var_type, + cloned, + false, + )) +} + +fn lower_expr(expr: &Expr) -> Option { + match expr { + Expr::Path(expr_path) => Some(lower_path(expr_path)), + Expr::Call(expr_call) => Some(lower_call(expr_call)), + Expr::MethodCall(expr_method_call) => Some(lower_method_call(expr_method_call)), + Expr::Field(expr_field) => Some(lower_field_access(expr_field)), + Expr::Assign(expr_assign) => Some(lower_assignment(expr_assign)), + Expr::Binary(expr_binary) => lower_binary(expr_binary), + Expr::Return(expr_return) => Some(lower_return(expr_return)), + Expr::Lit(expr_lit) => Some(lower_literal(expr_lit)), + Expr::Paren(expr_paren) => lower_expr(&expr_paren.expr), + Expr::Group(expr_group) => lower_expr(&expr_group.expr), + _ => None, + } +} + +fn lower_path(expr_path: &ExprPath) -> Value { + let mut segments = expr_path.path.segments.iter(); + let Some(first) = segments.next() else { + return identifier("__path__"); + }; + + let mut value = identifier(first.ident.to_string()); + for segment in segments { + value = member_access(value, identifier(segment.ident.to_string()), false); + } + value +} + +fn lower_call(expr_call: &ExprCall) -> Value { + let callee = lower_expr(&expr_call.func).unwrap_or_else(|| identifier("__callee__")); + let arguments = expr_call + .args + .iter() + .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) + .collect::>(); + json!({ + "type": "CallExpression", + "callee": callee, + "arguments": arguments, + }) +} + +fn lower_method_call(expr_method_call: &ExprMethodCall) -> Value { + let object = + lower_expr(&expr_method_call.receiver).unwrap_or_else(|| identifier("__receiver__")); + let callee = member_access( + object, + identifier(expr_method_call.method.to_string()), + false, + ); + let arguments = expr_method_call + .args + .iter() + .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) + .collect::>(); + json!({ + "type": "CallExpression", + "callee": callee, + "arguments": arguments, + }) +} + +fn lower_field_access(expr_field: &ExprField) -> Value { + let object = lower_expr(&expr_field.base).unwrap_or_else(|| identifier("__object__")); + match &expr_field.member { + syn::Member::Named(ident) => member_access(object, identifier(ident.to_string()), false), + syn::Member::Unnamed(index) => { + member_access(object, literal_number(index.index as i64), true) + } + } +} + +fn lower_assignment(expr_assign: &ExprAssign) -> Value { + let left = lower_expr(&expr_assign.left).unwrap_or_else(|| identifier("__lhs__")); + let right = lower_expr(&expr_assign.right).unwrap_or_else(|| identifier("__rhs__")); + json!({ + "type": "AssignmentExpression", + "left": left, + "right": right, + "operator": "=", + "cloned": true, + }) +} + +fn lower_binary(expr_binary: &ExprBinary) -> Option { + let operator = match &expr_binary.op { + BinOp::Add(_) => "+", + BinOp::Sub(_) => "-", + BinOp::Mul(_) => "*", + BinOp::Div(_) => "/", + BinOp::Rem(_) => "%", + BinOp::And(_) => "&&", + BinOp::Or(_) => "||", + BinOp::BitXor(_) => "^", + BinOp::BitAnd(_) => "&", + BinOp::BitOr(_) => "|", + BinOp::Shl(_) => "<<", + BinOp::Shr(_) => ">>", + BinOp::Eq(_) => "==", + BinOp::Lt(_) => "<", + BinOp::Le(_) => "<=", + BinOp::Ne(_) => "!=", + BinOp::Ge(_) => ">=", + BinOp::Gt(_) => ">", + _ => return None, + }; + let left = lower_expr(&expr_binary.left).unwrap_or_else(|| identifier("__left__")); + let right = lower_expr(&expr_binary.right).unwrap_or_else(|| identifier("__right__")); + Some(json!({ + "type": "BinaryExpression", + "operator": operator, + "left": left, + "right": right, + })) +} + +fn lower_return(expr_return: &ExprReturn) -> Value { + let argument = expr_return + .expr + .as_ref() + .and_then(|expr| lower_expr(expr)) + .unwrap_or(Value::Null); + json!({ + "type": "ReturnStatement", + "argument": argument, + "isYield": false, + }) +} + +fn lower_literal(expr_lit: &ExprLit) -> Value { + match &expr_lit.lit { + Lit::Int(value) => match value.base10_parse::() { + Ok(number) => literal_number(number), + Err(_) => json!({ + "type": "Literal", + "value": value.base10_digits(), + "literalType": "number", + }), + }, + Lit::Float(value) => match value.base10_parse::() { + Ok(number) => json!({ + "type": "Literal", + "value": number, + "literalType": "number", + }), + Err(_) => json!({ + "type": "Literal", + "value": value.base10_digits(), + "literalType": "number", + }), + }, + Lit::Bool(value) => json!({ + "type": "Literal", + "value": value.value, + "literalType": "boolean", + }), + Lit::Str(value) => json!({ + "type": "Literal", + "value": value.value(), + "literalType": "string", + }), + Lit::Char(value) => json!({ + "type": "Literal", + "value": value.value().to_string(), + "literalType": "string", + }), + _ => null_literal(), + } +} + +fn lower_return_type(return_type: &ReturnType) -> Value { + match return_type { + ReturnType::Default => void_type(), + ReturnType::Type(_, ty) => { + if let Type::Tuple(tuple) = ty.as_ref() { + if tuple.elems.is_empty() { + return void_type(); + } + } + lower_type(ty) + } + } +} + +fn lower_type(ty: &Type) -> Value { + match ty { + Type::Path(path) => dynamic_type_with_id( + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()), + ), + Type::Reference(reference) => lower_type(&reference.elem), + Type::Tuple(tuple) if tuple.elems.is_empty() => void_type(), + _ => dynamic_type(), + } +} + +fn extract_binding_name_and_type<'a>(pat: &'a Pat) -> Option<(String, Option<&'a Type>)> { + match pat { + Pat::Type(pat_type) => { + let (name, _) = extract_binding_name_and_type(&pat_type.pat)?; + Some((name, Some(pat_type.ty.as_ref()))) + } + Pat::Ident(pat_ident) => Some((pat_ident.ident.to_string(), None)), + Pat::Reference(pat_ref) => extract_binding_name_and_type(&pat_ref.pat), + _ => None, + } +} + +fn extract_binding_name(pat: &Pat) -> Option { + extract_binding_name_and_type(pat).map(|(name, _)| name) +} + +fn variable_declaration( + id: Value, + init: Option, + var_type: Value, + cloned: bool, + variable_param: bool, +) -> Value { + json!({ + "type": "VariableDeclaration", + "id": id, + "init": init.unwrap_or(Value::Null), + "cloned": cloned, + "varType": var_type, + "variableParam": variable_param, + }) +} + +fn identifier(name: impl Into) -> Value { + json!({ + "type": "Identifier", + "name": name.into(), + }) +} + +fn literal_number(value: i64) -> Value { + json!({ + "type": "Literal", + "value": value, + "literalType": "number", + }) +} + +fn null_literal() -> Value { + json!({ + "type": "Literal", + "value": Value::Null, + "literalType": "null", + }) +} + +fn member_access(object: Value, property: Value, computed: bool) -> Value { + json!({ + "type": "MemberAccess", + "object": object, + "property": property, + "computed": computed, + }) +} + +fn scoped_statement(body: Vec) -> Value { + json!({ + "type": "ScopedStatement", + "body": body, + "id": Value::Null, + }) +} + +fn dynamic_type() -> Value { + dynamic_type_with_id(None) +} + +fn dynamic_type_with_id(id: Option) -> Value { + json!({ + "type": "DynamicType", + "id": id.map(identifier).unwrap_or(Value::Null), + "typeArguments": Value::Null, + }) +} + +fn void_type() -> Value { + json!({ + "type": "VoidType", + "id": Value::Null, + "typeArguments": Value::Null, + }) +} + #[derive(Debug)] struct CargoManifestInfo { rel_path: String, From 4f42be804413059addce11e76e42a4f9ed75ef18 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:56:58 +0800 Subject: [PATCH 19/34] test(rust-parser): add core syntax single-file golden coverage --- parser-Rust/testdata/single/core_syntax.rs | 11 + .../testdata/single/expected.core.json | 199 ++++++++++++++++++ parser-Rust/tests/cli_smoke.rs | 57 +++++ 3 files changed, 267 insertions(+) create mode 100644 parser-Rust/testdata/single/core_syntax.rs create mode 100644 parser-Rust/testdata/single/expected.core.json diff --git a/parser-Rust/testdata/single/core_syntax.rs b/parser-Rust/testdata/single/core_syntax.rs new file mode 100644 index 0000000..499eaec --- /dev/null +++ b/parser-Rust/testdata/single/core_syntax.rs @@ -0,0 +1,11 @@ +pub struct User { + id: i32, + name: String, +} + +pub fn process(user: User, input: i32) -> i32 { + let mut value = input; + value = user.id; + user.touch(value); + return value; +} diff --git a/parser-Rust/testdata/single/expected.core.json b/parser-Rust/testdata/single/expected.core.json new file mode 100644 index 0000000..2c64284 --- /dev/null +++ b/parser-Rust/testdata/single/expected.core.json @@ -0,0 +1,199 @@ +{ + "packageInfo": { + "pathName": "/", + "files": { + "__FILE__": { + "node": { + "type": "CompileUnit", + "body": [ + { + "body": [ + { + "cloned": false, + "id": { + "name": "id", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": false, + "id": { + "name": "name", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "String", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + } + ], + "id": { + "name": "User", + "type": "Identifier" + }, + "supers": [], + "type": "ClassDefinition" + }, + { + "body": { + "body": [ + { + "cloned": true, + "id": { + "name": "value", + "type": "Identifier" + }, + "init": { + "name": "input", + "type": "Identifier" + }, + "type": "VariableDeclaration", + "varType": { + "id": null, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": true, + "left": { + "name": "value", + "type": "Identifier" + }, + "operator": "=", + "right": { + "computed": false, + "object": { + "name": "user", + "type": "Identifier" + }, + "property": { + "name": "id", + "type": "Identifier" + }, + "type": "MemberAccess" + }, + "type": "AssignmentExpression" + }, + { + "arguments": [ + { + "name": "value", + "type": "Identifier" + } + ], + "callee": { + "computed": false, + "object": { + "name": "user", + "type": "Identifier" + }, + "property": { + "name": "touch", + "type": "Identifier" + }, + "type": "MemberAccess" + }, + "type": "CallExpression" + }, + { + "argument": { + "name": "value", + "type": "Identifier" + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "id": { + "name": "process", + "type": "Identifier" + }, + "modifiers": [ + "pub" + ], + "parameters": [ + { + "cloned": false, + "id": { + "name": "user", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "User", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": false, + "id": { + "name": "input", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + } + ], + "returnType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "type": "FunctionDefinition" + } + ], + "language": "rust", + "languageVersion": "", + "uri": "__FILE__", + "version": "" + }, + "packageName": "__single__" + } + }, + "subs": {} + }, + "moduleName": "__single_module__", + "cargoTomlPath": "", + "numOfCargoToml": 0 +} diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 95f19a2..713e739 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -88,6 +88,40 @@ fn project_mode_writes_required_top_level_fields() { assert_eq!(json.get("numOfCargoToml").and_then(Value::as_u64), Some(1)); } +#[test] +fn single_mode_lowers_core_syntax_and_matches_golden() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = fixture_path("single/core_syntax.rs"); + let output_file = temp.path().join("out").join("core.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("fixture path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let output_bytes = fs::read(&output_file).expect("read output"); + let mut actual: Value = serde_json::from_slice(&output_bytes).expect("valid output json"); + normalize_single_file_paths(&mut actual); + + let expected_raw = fs::read_to_string(fixture_path("single/expected.core.json")) + .expect("read expected core golden"); + let expected: Value = serde_json::from_str(&expected_raw).expect("valid expected json"); + + assert_eq!( + actual, expected, + "single file core syntax output mismatches golden" + ); +} + #[test] fn single_mode_rejects_output_equal_to_source_file() { let temp = tempfile::tempdir().expect("tempdir"); @@ -213,3 +247,26 @@ fn fixture_path(relative: &str) -> PathBuf { .join("testdata") .join(relative) } + +fn normalize_single_file_paths(json: &mut Value) { + let files = json + .get_mut("packageInfo") + .and_then(|v| v.get_mut("files")) + .and_then(Value::as_object_mut) + .expect("packageInfo.files object"); + assert_eq!(files.len(), 1, "single-mode output should contain one file"); + + let original_key = files.keys().next().cloned().expect("single file key"); + let mut file_entry = files + .remove(&original_key) + .expect("single file entry should exist"); + + if let Some(uri) = file_entry + .get_mut("node") + .and_then(|node| node.get_mut("uri")) + { + *uri = Value::String("__FILE__".to_string()); + } + + files.insert("__FILE__".to_string(), file_entry); +} From 844895cc7f1b2a2beac1428142f724926c045ba5 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:06 +0800 Subject: [PATCH 20/34] fix(rust-parser): avoid placeholder fallback for core syntax lowering --- parser-Rust/src/lib.rs | 237 +++++++++++++++++++++++++-------- parser-Rust/tests/cli_smoke.rs | 143 ++++++++++++++++++++ 2 files changed, 325 insertions(+), 55 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 2656a74..dc4d0b2 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -10,8 +10,8 @@ use std::fs; use std::path::{Path, PathBuf}; use syn::{ BinOp, Expr, ExprAssign, ExprBinary, ExprCall, ExprField, ExprLit, ExprMethodCall, ExprPath, - ExprReturn, Field, Fields, FnArg, Item, ItemFn, ItemStruct, Lit, Local, Pat, ReturnType, Stmt, - Type, Visibility, + ExprReference, ExprReturn, ExprTuple, ExprUnary, Field, Fields, FnArg, Item, ItemFn, + ItemStruct, Lit, Local, Pat, PatTuple, ReturnType, Stmt, Type, UnOp, Visibility, }; const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; @@ -379,7 +379,9 @@ fn lower_unnamed_struct_field(index: usize, field: &Field) -> Value { fn lower_fn_arg(arg: &FnArg) -> Value { match arg { FnArg::Typed(pat_ty) => { - let name = extract_binding_name(&pat_ty.pat).unwrap_or_else(|| "__param__".to_string()); + let Some(name) = extract_binding_name(&pat_ty.pat) else { + return Value::Null; + }; variable_declaration(identifier(name), None, lower_type(&pat_ty.ty), false, false) } FnArg::Receiver(_) => Value::Null, @@ -396,6 +398,13 @@ fn lower_stmt(stmt: &Stmt) -> Option { } fn lower_local(local: &Local) -> Option { + match &local.pat { + Pat::Tuple(tuple_pat) => lower_tuple_pattern_local(local, tuple_pat), + _ => lower_single_pattern_local(local), + } +} + +fn lower_single_pattern_local(local: &Local) -> Option { let (name, explicit_type) = extract_binding_name_and_type(&local.pat)?; let init = local .init @@ -412,14 +421,59 @@ fn lower_local(local: &Local) -> Option { )) } +fn lower_tuple_pattern_local(local: &Local, tuple_pat: &PatTuple) -> Option { + let tuple_init_elements = + local + .init + .as_ref() + .and_then(|local_init| match local_init.expr.as_ref() { + Expr::Tuple(expr_tuple) => Some(&expr_tuple.elems), + _ => None, + }); + + let mut declarations = Vec::new(); + for (idx, pat) in tuple_pat.elems.iter().enumerate() { + let Some((name, explicit_type)) = extract_binding_name_and_type(pat) else { + continue; + }; + let init = tuple_init_elements + .as_ref() + .and_then(|elems| elems.iter().nth(idx)) + .and_then(lower_expr); + let var_type = explicit_type.map(lower_type).unwrap_or_else(dynamic_type); + let cloned = init.is_some(); + declarations.push(variable_declaration( + identifier(name), + init, + var_type, + cloned, + false, + )); + } + + if declarations.is_empty() { + return local + .init + .as_ref() + .and_then(|local_init| lower_expr(&local_init.expr)); + } + if declarations.len() == 1 { + return declarations.into_iter().next(); + } + Some(sequence(declarations)) +} + fn lower_expr(expr: &Expr) -> Option { match expr { Expr::Path(expr_path) => Some(lower_path(expr_path)), - Expr::Call(expr_call) => Some(lower_call(expr_call)), - Expr::MethodCall(expr_method_call) => Some(lower_method_call(expr_method_call)), - Expr::Field(expr_field) => Some(lower_field_access(expr_field)), - Expr::Assign(expr_assign) => Some(lower_assignment(expr_assign)), + Expr::Call(expr_call) => lower_call(expr_call), + Expr::MethodCall(expr_method_call) => lower_method_call(expr_method_call), + Expr::Field(expr_field) => lower_field_access(expr_field), + Expr::Assign(expr_assign) => lower_assignment(expr_assign), Expr::Binary(expr_binary) => lower_binary(expr_binary), + Expr::Unary(expr_unary) => lower_unary(expr_unary), + Expr::Reference(expr_reference) => lower_reference(expr_reference), + Expr::Tuple(expr_tuple) => Some(lower_tuple(expr_tuple)), Expr::Return(expr_return) => Some(lower_return(expr_return)), Expr::Lit(expr_lit) => Some(lower_literal(expr_lit)), Expr::Paren(expr_paren) => lower_expr(&expr_paren.expr), @@ -430,9 +484,9 @@ fn lower_expr(expr: &Expr) -> Option { fn lower_path(expr_path: &ExprPath) -> Value { let mut segments = expr_path.path.segments.iter(); - let Some(first) = segments.next() else { - return identifier("__path__"); - }; + let first = segments + .next() + .expect("path should have at least one segment"); let mut value = identifier(first.ident.to_string()); for segment in segments { @@ -441,23 +495,22 @@ fn lower_path(expr_path: &ExprPath) -> Value { value } -fn lower_call(expr_call: &ExprCall) -> Value { - let callee = lower_expr(&expr_call.func).unwrap_or_else(|| identifier("__callee__")); +fn lower_call(expr_call: &ExprCall) -> Option { + let callee = lower_expr(&expr_call.func)?; let arguments = expr_call .args .iter() .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) .collect::>(); - json!({ + Some(json!({ "type": "CallExpression", "callee": callee, "arguments": arguments, - }) + })) } -fn lower_method_call(expr_method_call: &ExprMethodCall) -> Value { - let object = - lower_expr(&expr_method_call.receiver).unwrap_or_else(|| identifier("__receiver__")); +fn lower_method_call(expr_method_call: &ExprMethodCall) -> Option { + let object = lower_expr(&expr_method_call.receiver)?; let callee = member_access( object, identifier(expr_method_call.method.to_string()), @@ -468,67 +521,105 @@ fn lower_method_call(expr_method_call: &ExprMethodCall) -> Value { .iter() .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) .collect::>(); - json!({ + Some(json!({ "type": "CallExpression", "callee": callee, "arguments": arguments, - }) + })) } -fn lower_field_access(expr_field: &ExprField) -> Value { - let object = lower_expr(&expr_field.base).unwrap_or_else(|| identifier("__object__")); - match &expr_field.member { +fn lower_field_access(expr_field: &ExprField) -> Option { + let object = lower_expr(&expr_field.base)?; + Some(match &expr_field.member { syn::Member::Named(ident) => member_access(object, identifier(ident.to_string()), false), syn::Member::Unnamed(index) => { member_access(object, literal_number(index.index as i64), true) } - } + }) } -fn lower_assignment(expr_assign: &ExprAssign) -> Value { - let left = lower_expr(&expr_assign.left).unwrap_or_else(|| identifier("__lhs__")); - let right = lower_expr(&expr_assign.right).unwrap_or_else(|| identifier("__rhs__")); - json!({ +fn lower_assignment(expr_assign: &ExprAssign) -> Option { + let left = lower_expr(&expr_assign.left)?; + let right = lower_expr(&expr_assign.right)?; + Some(json!({ "type": "AssignmentExpression", "left": left, "right": right, "operator": "=", "cloned": true, - }) + })) } fn lower_binary(expr_binary: &ExprBinary) -> Option { - let operator = match &expr_binary.op { - BinOp::Add(_) => "+", - BinOp::Sub(_) => "-", - BinOp::Mul(_) => "*", - BinOp::Div(_) => "/", - BinOp::Rem(_) => "%", - BinOp::And(_) => "&&", - BinOp::Or(_) => "||", - BinOp::BitXor(_) => "^", - BinOp::BitAnd(_) => "&", - BinOp::BitOr(_) => "|", - BinOp::Shl(_) => "<<", - BinOp::Shr(_) => ">>", - BinOp::Eq(_) => "==", - BinOp::Lt(_) => "<", - BinOp::Le(_) => "<=", - BinOp::Ne(_) => "!=", - BinOp::Ge(_) => ">=", - BinOp::Gt(_) => ">", - _ => return None, - }; - let left = lower_expr(&expr_binary.left).unwrap_or_else(|| identifier("__left__")); - let right = lower_expr(&expr_binary.right).unwrap_or_else(|| identifier("__right__")); + let left = lower_expr(&expr_binary.left)?; + let right = lower_expr(&expr_binary.right)?; + match &expr_binary.op { + BinOp::Add(_) => Some(binary_expression("+", left, right)), + BinOp::Sub(_) => Some(binary_expression("-", left, right)), + BinOp::Mul(_) => Some(binary_expression("*", left, right)), + BinOp::Div(_) => Some(binary_expression("/", left, right)), + BinOp::Rem(_) => Some(binary_expression("%", left, right)), + BinOp::And(_) => Some(binary_expression("&&", left, right)), + BinOp::Or(_) => Some(binary_expression("||", left, right)), + BinOp::BitXor(_) => Some(binary_expression("^", left, right)), + BinOp::BitAnd(_) => Some(binary_expression("&", left, right)), + BinOp::BitOr(_) => Some(binary_expression("|", left, right)), + BinOp::Shl(_) => Some(binary_expression("<<", left, right)), + BinOp::Shr(_) => Some(binary_expression(">>", left, right)), + BinOp::Eq(_) => Some(binary_expression("==", left, right)), + BinOp::Lt(_) => Some(binary_expression("<", left, right)), + BinOp::Le(_) => Some(binary_expression("<=", left, right)), + BinOp::Ne(_) => Some(binary_expression("!=", left, right)), + BinOp::Ge(_) => Some(binary_expression(">=", left, right)), + BinOp::Gt(_) => Some(binary_expression(">", left, right)), + BinOp::AddAssign(_) => Some(assignment_expression("+=", left, right)), + BinOp::SubAssign(_) => Some(assignment_expression("-=", left, right)), + BinOp::MulAssign(_) => Some(assignment_expression("*=", left, right)), + BinOp::DivAssign(_) => Some(assignment_expression("/=", left, right)), + BinOp::RemAssign(_) => Some(assignment_expression("%=", left, right)), + BinOp::BitXorAssign(_) => Some(assignment_expression("^=", left, right)), + BinOp::BitAndAssign(_) => Some(assignment_expression("&=", left, right)), + BinOp::BitOrAssign(_) => Some(assignment_expression("|=", left, right)), + BinOp::ShlAssign(_) => Some(assignment_expression("<<=", left, right)), + BinOp::ShrAssign(_) => Some(assignment_expression(">>=", left, right)), + _ => None, + } +} + +fn lower_unary(expr_unary: &ExprUnary) -> Option { + let argument = lower_expr(&expr_unary.expr)?; + match &expr_unary.op { + UnOp::Neg(_) => Some(unary_expression("-", argument)), + UnOp::Not(_) => Some(unary_expression("!", argument)), + UnOp::Deref(_) => Some(json!({ + "type": "DereferenceExpression", + "argument": argument, + })), + _ => None, + } +} + +fn lower_reference(expr_reference: &ExprReference) -> Option { + let argument = lower_expr(&expr_reference.expr)?; Some(json!({ - "type": "BinaryExpression", - "operator": operator, - "left": left, - "right": right, + "type": "ReferenceExpression", + "argument": argument, })) } +fn lower_tuple(expr_tuple: &ExprTuple) -> Value { + let elements = expr_tuple + .elems + .iter() + .filter_map(lower_expr) + .collect::>(); + json!({ + "type": "TupleExpression", + "elements": elements, + "modifiable": false, + }) +} + fn lower_return(expr_return: &ExprReturn) -> Value { let argument = expr_return .expr @@ -618,6 +709,7 @@ fn extract_binding_name_and_type<'a>(pat: &'a Pat) -> Option<(String, Option<&'a Some((name, Some(pat_type.ty.as_ref()))) } Pat::Ident(pat_ident) => Some((pat_ident.ident.to_string(), None)), + Pat::Wild(_) => Some(("_".to_string(), None)), Pat::Reference(pat_ref) => extract_binding_name_and_type(&pat_ref.pat), _ => None, } @@ -644,6 +736,41 @@ fn variable_declaration( }) } +fn sequence(expressions: Vec) -> Value { + json!({ + "type": "Sequence", + "expressions": expressions, + }) +} + +fn assignment_expression(operator: &str, left: Value, right: Value) -> Value { + json!({ + "type": "AssignmentExpression", + "left": left, + "right": right, + "operator": operator, + "cloned": true, + }) +} + +fn binary_expression(operator: &str, left: Value, right: Value) -> Value { + json!({ + "type": "BinaryExpression", + "operator": operator, + "left": left, + "right": right, + }) +} + +fn unary_expression(operator: &str, argument: Value) -> Value { + json!({ + "type": "UnaryExpression", + "operator": operator, + "argument": argument, + "isSuffix": false, + }) +} + fn identifier(name: impl Into) -> Value { json!({ "type": "Identifier", diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 713e739..e108914 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -242,6 +242,104 @@ fn project_mode_skips_hidden_directories_when_discovering_manifests() { assert_eq!(json.get("numOfCargoToml").and_then(Value::as_u64), Some(1)); } +#[test] +fn single_mode_preserves_wildcard_let_initializer_call() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("wildcard.rs"); + fs::write( + &source_file, + "fn sink(x: i32) {}\nfn f(input: i32) { let _ = sink(input); }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("wildcard.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + assert!( + count_nodes_of_type(&json, "CallExpression") >= 1, + "expected at least one CallExpression in lowered output" + ); +} + +#[test] +fn single_mode_lowers_compound_assignment_operator() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("assign_op.rs"); + fs::write(&source_file, "fn f() { let mut v = 0; v += 1; }\n").expect("write source"); + let output_file = temp.path().join("out").join("assign_op.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + assert!( + has_assignment_operator(&json, "+="), + "expected AssignmentExpression with operator '+='" + ); +} + +#[test] +fn single_mode_lowers_unary_negation_in_initializer() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("unary.rs"); + fs::write(&source_file, "fn f() { let x = -1; }\n").expect("write source"); + let output_file = temp.path().join("out").join("unary.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + assert!( + count_nodes_of_type(&json, "UnaryExpression") >= 1, + "expected UnaryExpression for negative initializer" + ); + let serialized = serde_json::to_string(&json).expect("serialize output"); + assert!( + !serialized.contains("__rhs__") + && !serialized.contains("__lhs__") + && !serialized.contains("__callee__") + && !serialized.contains("__receiver__") + && !serialized.contains("__object__") + && !serialized.contains("__param__"), + "placeholder identifiers should not appear in output" + ); +} + fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata") @@ -270,3 +368,48 @@ fn normalize_single_file_paths(json: &mut Value) { files.insert("__FILE__".to_string(), file_entry); } + +fn count_nodes_of_type(value: &Value, target_type: &str) -> usize { + match value { + Value::Object(map) => { + let self_count = map + .get("type") + .and_then(Value::as_str) + .map(|node_type| usize::from(node_type == target_type)) + .unwrap_or(0); + self_count + + map + .values() + .map(|child| count_nodes_of_type(child, target_type)) + .sum::() + } + Value::Array(items) => items + .iter() + .map(|child| count_nodes_of_type(child, target_type)) + .sum(), + _ => 0, + } +} + +fn has_assignment_operator(value: &Value, operator: &str) -> bool { + match value { + Value::Object(map) => { + let self_match = map + .get("type") + .and_then(Value::as_str) + .zip(map.get("operator").and_then(Value::as_str)) + .map(|(node_type, node_operator)| { + node_type == "AssignmentExpression" && node_operator == operator + }) + .unwrap_or(false); + self_match + || map + .values() + .any(|child| has_assignment_operator(child, operator)) + } + Value::Array(items) => items + .iter() + .any(|child| has_assignment_operator(child, operator)), + _ => false, + } +} From 87fed63a7b80dcea695a4d3531e953de5d8414a5 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:10:03 +0800 Subject: [PATCH 21/34] fix(rust-parser): return Option for lowered function args --- parser-Rust/src/lib.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index dc4d0b2..aef6ba0 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -376,15 +376,19 @@ fn lower_unnamed_struct_field(index: usize, field: &Field) -> Value { ) } -fn lower_fn_arg(arg: &FnArg) -> Value { +fn lower_fn_arg(arg: &FnArg) -> Option { match arg { FnArg::Typed(pat_ty) => { - let Some(name) = extract_binding_name(&pat_ty.pat) else { - return Value::Null; - }; - variable_declaration(identifier(name), None, lower_type(&pat_ty.ty), false, false) + let name = extract_binding_name(&pat_ty.pat)?; + Some(variable_declaration( + identifier(name), + None, + lower_type(&pat_ty.ty), + false, + false, + )) } - FnArg::Receiver(_) => Value::Null, + FnArg::Receiver(_) => None, } } From de30fc3473fa3b5d6bd20fc7a1ed36280246e2af Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:10:13 +0800 Subject: [PATCH 22/34] fix(rust-parser): filter out unsupported function parameters --- parser-Rust/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index aef6ba0..e9a7902 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -301,7 +301,7 @@ fn lower_function(item_fn: &ItemFn) -> Value { .sig .inputs .iter() - .map(lower_fn_arg) + .filter_map(lower_fn_arg) .collect::>(); let return_type = lower_return_type(&item_fn.sig.output); let body = scoped_statement(item_fn.block.stmts.iter().filter_map(lower_stmt).collect()); From 3c6471890b4c49750c3b19a90f50829ab06d6d73 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:10:24 +0800 Subject: [PATCH 23/34] refactor(rust-parser): assert named struct field identifiers --- parser-Rust/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index e9a7902..d5c8b18 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -361,8 +361,8 @@ fn lower_struct_field(field: &Field) -> Value { let name = field .ident .as_ref() - .map(|ident| ident.to_string()) - .unwrap_or_else(|| "__field__".to_string()); + .expect("fields in a named struct should have an identifier") + .to_string(); variable_declaration(identifier(name), None, lower_type(&field.ty), false, false) } From 11d3fb94f3e72dd0ba685cd0326715c14e863597 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:28:49 +0800 Subject: [PATCH 24/34] feat(rust-parser): lower common control-flow constructs --- parser-Rust/src/lib.rs | 215 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 5 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index d5c8b18..26cc748 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -9,8 +9,9 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use syn::{ - BinOp, Expr, ExprAssign, ExprBinary, ExprCall, ExprField, ExprLit, ExprMethodCall, ExprPath, - ExprReference, ExprReturn, ExprTuple, ExprUnary, Field, Fields, FnArg, Item, ItemFn, + BinOp, Block, Expr, ExprAssign, ExprBinary, ExprBlock, ExprBreak, ExprCall, ExprContinue, + ExprField, ExprForLoop, ExprIf, ExprLit, ExprLoop, ExprMatch, ExprMethodCall, ExprPath, + ExprReference, ExprReturn, ExprTuple, ExprUnary, ExprWhile, Field, Fields, FnArg, Item, ItemFn, ItemStruct, Lit, Local, Pat, PatTuple, ReturnType, Stmt, Type, UnOp, Visibility, }; @@ -304,7 +305,7 @@ fn lower_function(item_fn: &ItemFn) -> Value { .filter_map(lower_fn_arg) .collect::>(); let return_type = lower_return_type(&item_fn.sig.output); - let body = scoped_statement(item_fn.block.stmts.iter().filter_map(lower_stmt).collect()); + let body = lower_block(&item_fn.block); json!({ "type": "FunctionDefinition", @@ -478,6 +479,14 @@ fn lower_expr(expr: &Expr) -> Option { Expr::Unary(expr_unary) => lower_unary(expr_unary), Expr::Reference(expr_reference) => lower_reference(expr_reference), Expr::Tuple(expr_tuple) => Some(lower_tuple(expr_tuple)), + Expr::If(expr_if) => lower_if(expr_if), + Expr::Match(expr_match) => lower_match(expr_match), + Expr::ForLoop(expr_for_loop) => lower_for_loop(expr_for_loop), + Expr::While(expr_while) => lower_while(expr_while), + Expr::Loop(expr_loop) => lower_loop(expr_loop), + Expr::Break(expr_break) => Some(lower_break(expr_break)), + Expr::Continue(expr_continue) => Some(lower_continue(expr_continue)), + Expr::Block(expr_block) => Some(lower_block(&expr_block.block)), Expr::Return(expr_return) => Some(lower_return(expr_return)), Expr::Lit(expr_lit) => Some(lower_literal(expr_lit)), Expr::Paren(expr_paren) => lower_expr(&expr_paren.expr), @@ -487,7 +496,11 @@ fn lower_expr(expr: &Expr) -> Option { } fn lower_path(expr_path: &ExprPath) -> Value { - let mut segments = expr_path.path.segments.iter(); + lower_syn_path(&expr_path.path) +} + +fn lower_syn_path(path: &syn::Path) -> Value { + let mut segments = path.segments.iter(); let first = segments .next() .expect("path should have at least one segment"); @@ -624,6 +637,174 @@ fn lower_tuple(expr_tuple: &ExprTuple) -> Value { }) } +fn lower_if(expr_if: &ExprIf) -> Option { + let test = lower_expr(&expr_if.cond)?; + let consequent = lower_block(&expr_if.then_branch); + let alternative = expr_if + .else_branch + .as_ref() + .and_then(|(_, else_expr)| lower_if_alternative(else_expr)) + .unwrap_or(Value::Null); + Some(json!({ + "type": "IfStatement", + "test": test, + "consequent": consequent, + "alternative": alternative, + })) +} + +fn lower_if_alternative(else_expr: &Expr) -> Option { + match else_expr { + Expr::If(expr_if) => lower_if(expr_if), + Expr::Block(ExprBlock { block, .. }) => Some(lower_block(block)), + _ => lower_expr(else_expr), + } +} + +fn lower_match(expr_match: &ExprMatch) -> Option { + let discriminant = lower_expr(&expr_match.expr)?; + let mut cases = Vec::new(); + for arm in &expr_match.arms { + let body = lower_match_arm_body( + arm.body.as_ref(), + arm.guard.as_ref().map(|(_, g)| g.as_ref()), + ); + let tests = lower_match_arm_tests(&arm.pat); + if tests.is_empty() { + cases.push(case_clause(None, body.clone())); + continue; + } + for test in tests { + cases.push(case_clause(test, body.clone())); + } + } + Some(json!({ + "type": "SwitchStatement", + "discriminant": discriminant, + "cases": cases, + })) +} + +fn lower_match_arm_body(body: &Expr, guard: Option<&Expr>) -> Value { + let lowered_body = lower_expr(body).unwrap_or_else(empty_scoped_statement); + let Some(guard_expr) = guard.and_then(lower_expr) else { + return lowered_body; + }; + json!({ + "type": "IfStatement", + "test": guard_expr, + "consequent": lowered_body, + "alternative": Value::Null, + }) +} + +fn lower_match_arm_tests(pat: &Pat) -> Vec> { + match pat { + Pat::Or(pat_or) => pat_or + .cases + .iter() + .map(lower_match_pattern_test) + .collect::>(), + _ => vec![lower_match_pattern_test(pat)], + } +} + +fn lower_match_pattern_test(pat: &Pat) -> Option { + match pat { + Pat::Lit(pat_lit) => Some(lower_lit(&pat_lit.lit)), + Pat::Path(pat_path) => Some(lower_syn_path(&pat_path.path)), + Pat::TupleStruct(pat_tuple_struct) => Some(lower_syn_path(&pat_tuple_struct.path)), + Pat::Struct(pat_struct) => Some(lower_syn_path(&pat_struct.path)), + Pat::Ident(pat_ident) => Some(identifier(pat_ident.ident.to_string())), + Pat::Reference(pat_reference) => lower_match_pattern_test(&pat_reference.pat), + Pat::Wild(_) => None, + _ => None, + } +} + +fn lower_for_loop(expr_for_loop: &ExprForLoop) -> Option { + let right = lower_expr(&expr_for_loop.expr)?; + let (key, value) = lower_range_binding(&expr_for_loop.pat); + Some(json!({ + "type": "RangeStatement", + "key": key.unwrap_or(Value::Null), + "value": value.unwrap_or(Value::Null), + "right": right, + "body": lower_block(&expr_for_loop.body), + })) +} + +fn lower_range_binding(pat: &Pat) -> (Option, Option) { + match pat { + Pat::Ident(pat_ident) => (None, Some(identifier(pat_ident.ident.to_string()))), + Pat::Tuple(tuple) => { + let mut names = tuple + .elems + .iter() + .filter_map(extract_binding_name) + .map(identifier) + .collect::>(); + match names.len() { + 0 => (None, None), + 1 => (None, names.pop()), + _ => (Some(names.remove(0)), Some(names.remove(0))), + } + } + Pat::Reference(pat_reference) => lower_range_binding(&pat_reference.pat), + _ => { + let value = extract_binding_name(pat).map(identifier); + (None, value) + } + } +} + +fn lower_while(expr_while: &ExprWhile) -> Option { + let test = lower_expr(&expr_while.cond)?; + Some(json!({ + "type": "WhileStatement", + "test": test, + "body": lower_block(&expr_while.body), + "isPostTest": Value::Null, + })) +} + +fn lower_loop(expr_loop: &ExprLoop) -> Option { + Some(json!({ + "type": "WhileStatement", + "test": literal_boolean(true), + "body": lower_block(&expr_loop.body), + "isPostTest": Value::Null, + })) +} + +fn lower_break(expr_break: &ExprBreak) -> Value { + let label = expr_break + .label + .as_ref() + .map(|lifetime| identifier(lifetime.ident.to_string())) + .unwrap_or(Value::Null); + json!({ + "type": "BreakStatement", + "label": label, + }) +} + +fn lower_continue(expr_continue: &ExprContinue) -> Value { + let label = expr_continue + .label + .as_ref() + .map(|lifetime| identifier(lifetime.ident.to_string())) + .unwrap_or(Value::Null); + json!({ + "type": "ContinueStatement", + "label": label, + }) +} + +fn lower_block(block: &Block) -> Value { + scoped_statement(block.stmts.iter().filter_map(lower_stmt).collect()) +} + fn lower_return(expr_return: &ExprReturn) -> Value { let argument = expr_return .expr @@ -638,7 +819,11 @@ fn lower_return(expr_return: &ExprReturn) -> Value { } fn lower_literal(expr_lit: &ExprLit) -> Value { - match &expr_lit.lit { + lower_lit(&expr_lit.lit) +} + +fn lower_lit(lit: &Lit) -> Value { + match lit { Lit::Int(value) => match value.base10_parse::() { Ok(number) => literal_number(number), Err(_) => json!({ @@ -790,6 +975,14 @@ fn literal_number(value: i64) -> Value { }) } +fn literal_boolean(value: bool) -> Value { + json!({ + "type": "Literal", + "value": value, + "literalType": "boolean", + }) +} + fn null_literal() -> Value { json!({ "type": "Literal", @@ -815,6 +1008,18 @@ fn scoped_statement(body: Vec) -> Value { }) } +fn empty_scoped_statement() -> Value { + scoped_statement(Vec::new()) +} + +fn case_clause(test: Option, body: Value) -> Value { + json!({ + "type": "CaseClause", + "test": test.unwrap_or(Value::Null), + "body": body, + }) +} + fn dynamic_type() -> Value { dynamic_type_with_id(None) } From cc92922de63a863d0f70e5be6366f384616b9ddc Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:29:13 +0800 Subject: [PATCH 25/34] test(rust-parser): add control-flow golden coverage --- parser-Rust/testdata/single/control_flow.rs | 30 ++ .../single/expected.control_flow.json | 341 ++++++++++++++++++ parser-Rust/tests/cli_smoke.rs | 34 ++ 3 files changed, 405 insertions(+) create mode 100644 parser-Rust/testdata/single/control_flow.rs create mode 100644 parser-Rust/testdata/single/expected.control_flow.json diff --git a/parser-Rust/testdata/single/control_flow.rs b/parser-Rust/testdata/single/control_flow.rs new file mode 100644 index 0000000..1cb8ef8 --- /dev/null +++ b/parser-Rust/testdata/single/control_flow.rs @@ -0,0 +1,30 @@ +pub fn flow(xs: Vec, flag: bool) -> i32 { + let mut sum = 0; + + if flag { + sum += 1; + } else { + sum += 2; + } + + for x in xs { + while sum < 10 { + if x == 0 { + break; + } + sum += x; + continue; + } + } + + loop { + sum += 1; + break; + } + + match sum { + 0 => return 0, + 1 | 2 => return 1, + _ => return sum, + } +} diff --git a/parser-Rust/testdata/single/expected.control_flow.json b/parser-Rust/testdata/single/expected.control_flow.json new file mode 100644 index 0000000..ef5f76e --- /dev/null +++ b/parser-Rust/testdata/single/expected.control_flow.json @@ -0,0 +1,341 @@ +{ + "packageInfo": { + "pathName": "/", + "files": { + "__FILE__": { + "node": { + "type": "CompileUnit", + "body": [ + { + "body": { + "body": [ + { + "cloned": true, + "id": { + "name": "sum", + "type": "Identifier" + }, + "init": { + "literalType": "number", + "type": "Literal", + "value": 0 + }, + "type": "VariableDeclaration", + "varType": { + "id": null, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "alternative": { + "body": [ + { + "cloned": true, + "left": { + "name": "sum", + "type": "Identifier" + }, + "operator": "+=", + "right": { + "literalType": "number", + "type": "Literal", + "value": 2 + }, + "type": "AssignmentExpression" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "consequent": { + "body": [ + { + "cloned": true, + "left": { + "name": "sum", + "type": "Identifier" + }, + "operator": "+=", + "right": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "AssignmentExpression" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "test": { + "name": "flag", + "type": "Identifier" + }, + "type": "IfStatement" + }, + { + "body": { + "body": [ + { + "body": { + "body": [ + { + "alternative": null, + "consequent": { + "body": [ + { + "label": null, + "type": "BreakStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "test": { + "left": { + "name": "x", + "type": "Identifier" + }, + "operator": "==", + "right": { + "literalType": "number", + "type": "Literal", + "value": 0 + }, + "type": "BinaryExpression" + }, + "type": "IfStatement" + }, + { + "cloned": true, + "left": { + "name": "sum", + "type": "Identifier" + }, + "operator": "+=", + "right": { + "name": "x", + "type": "Identifier" + }, + "type": "AssignmentExpression" + }, + { + "label": null, + "type": "ContinueStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "isPostTest": null, + "test": { + "left": { + "name": "sum", + "type": "Identifier" + }, + "operator": "<", + "right": { + "literalType": "number", + "type": "Literal", + "value": 10 + }, + "type": "BinaryExpression" + }, + "type": "WhileStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "key": null, + "right": { + "name": "xs", + "type": "Identifier" + }, + "type": "RangeStatement", + "value": { + "name": "x", + "type": "Identifier" + } + }, + { + "body": { + "body": [ + { + "cloned": true, + "left": { + "name": "sum", + "type": "Identifier" + }, + "operator": "+=", + "right": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "AssignmentExpression" + }, + { + "label": null, + "type": "BreakStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "isPostTest": null, + "test": { + "literalType": "boolean", + "type": "Literal", + "value": true + }, + "type": "WhileStatement" + }, + { + "cases": [ + { + "body": { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 0 + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": { + "literalType": "number", + "type": "Literal", + "value": 0 + }, + "type": "CaseClause" + }, + { + "body": { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "CaseClause" + }, + { + "body": { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": { + "literalType": "number", + "type": "Literal", + "value": 2 + }, + "type": "CaseClause" + }, + { + "body": { + "argument": { + "name": "sum", + "type": "Identifier" + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": null, + "type": "CaseClause" + } + ], + "discriminant": { + "name": "sum", + "type": "Identifier" + }, + "type": "SwitchStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "id": { + "name": "flow", + "type": "Identifier" + }, + "modifiers": [ + "pub" + ], + "parameters": [ + { + "cloned": false, + "id": { + "name": "xs", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "Vec", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": false, + "id": { + "name": "flag", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "bool", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + } + ], + "returnType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "type": "FunctionDefinition" + } + ], + "language": "rust", + "languageVersion": "", + "uri": "__FILE__", + "version": "" + }, + "packageName": "__single__" + } + }, + "subs": {} + }, + "moduleName": "__single_module__", + "cargoTomlPath": "", + "numOfCargoToml": 0 +} diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index e108914..de77ff7 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -122,6 +122,40 @@ fn single_mode_lowers_core_syntax_and_matches_golden() { ); } +#[test] +fn single_mode_lowers_control_flow_and_matches_golden() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = fixture_path("single/control_flow.rs"); + let output_file = temp.path().join("out").join("control_flow.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("fixture path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let output_bytes = fs::read(&output_file).expect("read output"); + let mut actual: Value = serde_json::from_slice(&output_bytes).expect("valid output json"); + normalize_single_file_paths(&mut actual); + + let expected_raw = fs::read_to_string(fixture_path("single/expected.control_flow.json")) + .expect("read expected control-flow golden"); + let expected: Value = serde_json::from_str(&expected_raw).expect("valid expected json"); + + assert_eq!( + actual, expected, + "single file control-flow output mismatches golden" + ); +} + #[test] fn single_mode_rejects_output_equal_to_source_file() { let temp = tempfile::tempdir().expect("tempdir"); From 58b712020ee30c63b0db331399132b57c65415d8 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:38:36 +0800 Subject: [PATCH 26/34] Fix rust range/match lowering and add regressions --- parser-Rust/src/lib.rs | 51 +++++++++++-- parser-Rust/tests/cli_smoke.rs | 128 +++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 26cc748..fa28a76 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -11,8 +11,9 @@ use std::path::{Path, PathBuf}; use syn::{ BinOp, Block, Expr, ExprAssign, ExprBinary, ExprBlock, ExprBreak, ExprCall, ExprContinue, ExprField, ExprForLoop, ExprIf, ExprLit, ExprLoop, ExprMatch, ExprMethodCall, ExprPath, - ExprReference, ExprReturn, ExprTuple, ExprUnary, ExprWhile, Field, Fields, FnArg, Item, ItemFn, - ItemStruct, Lit, Local, Pat, PatTuple, ReturnType, Stmt, Type, UnOp, Visibility, + ExprRange, ExprReference, ExprReturn, ExprTuple, ExprUnary, ExprWhile, Field, Fields, FnArg, + Item, ItemFn, ItemStruct, Lit, Local, Pat, PatTuple, RangeLimits, ReturnType, Stmt, Type, UnOp, + Visibility, }; const SINGLE_FILE_PACKAGE_NAME: &str = "__single__"; @@ -479,6 +480,7 @@ fn lower_expr(expr: &Expr) -> Option { Expr::Unary(expr_unary) => lower_unary(expr_unary), Expr::Reference(expr_reference) => lower_reference(expr_reference), Expr::Tuple(expr_tuple) => Some(lower_tuple(expr_tuple)), + Expr::Range(expr_range) => lower_range_expr(expr_range), Expr::If(expr_if) => lower_if(expr_if), Expr::Match(expr_match) => lower_match(expr_match), Expr::ForLoop(expr_for_loop) => lower_for_loop(expr_for_loop), @@ -637,6 +639,26 @@ fn lower_tuple(expr_tuple: &ExprTuple) -> Value { }) } +fn lower_range_expr(expr_range: &ExprRange) -> Option { + let start = expr_range + .start + .as_ref() + .and_then(|expr| lower_expr(expr)) + .unwrap_or_else(noop); + let end = expr_range + .end + .as_ref() + .and_then(|expr| lower_expr(expr)) + .unwrap_or_else(noop); + let inclusive = matches!(expr_range.limits, RangeLimits::Closed(_)); + + Some(json!({ + "type": "TupleExpression", + "elements": [start, end, literal_boolean(inclusive)], + "modifiable": false, + })) +} + fn lower_if(expr_if: &ExprIf) -> Option { let test = lower_expr(&expr_if.cond)?; let consequent = lower_block(&expr_if.then_branch); @@ -715,7 +737,10 @@ fn lower_match_pattern_test(pat: &Pat) -> Option { Pat::Path(pat_path) => Some(lower_syn_path(&pat_path.path)), Pat::TupleStruct(pat_tuple_struct) => Some(lower_syn_path(&pat_tuple_struct.path)), Pat::Struct(pat_struct) => Some(lower_syn_path(&pat_struct.path)), - Pat::Ident(pat_ident) => Some(identifier(pat_ident.ident.to_string())), + Pat::Ident(pat_ident) => pat_ident + .subpat + .as_ref() + .and_then(|(_, subpat)| lower_match_pattern_test(subpat)), Pat::Reference(pat_reference) => lower_match_pattern_test(&pat_reference.pat), Pat::Wild(_) => None, _ => None, @@ -741,7 +766,7 @@ fn lower_range_binding(pat: &Pat) -> (Option, Option) { let mut names = tuple .elems .iter() - .filter_map(extract_binding_name) + .filter_map(extract_range_binding_name) .map(identifier) .collect::>(); match names.len() { @@ -752,12 +777,22 @@ fn lower_range_binding(pat: &Pat) -> (Option, Option) { } Pat::Reference(pat_reference) => lower_range_binding(&pat_reference.pat), _ => { - let value = extract_binding_name(pat).map(identifier); + let value = extract_range_binding_name(pat).map(identifier); (None, value) } } } +fn extract_range_binding_name(pat: &Pat) -> Option { + match pat { + Pat::Wild(_) => None, + Pat::Ident(pat_ident) => Some(pat_ident.ident.to_string()), + Pat::Reference(pat_reference) => extract_range_binding_name(&pat_reference.pat), + Pat::Type(pat_type) => extract_range_binding_name(&pat_type.pat), + _ => extract_binding_name(pat).and_then(|name| if name == "_" { None } else { Some(name) }), + } +} + fn lower_while(expr_while: &ExprWhile) -> Option { let test = lower_expr(&expr_while.cond)?; Some(json!({ @@ -991,6 +1026,12 @@ fn null_literal() -> Value { }) } +fn noop() -> Value { + json!({ + "type": "Noop", + }) +} + fn member_access(object: Value, property: Value, computed: bool) -> Value { json!({ "type": "MemberAccess", diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index de77ff7..a282411 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -374,6 +374,114 @@ fn single_mode_lowers_unary_negation_in_initializer() { ); } +#[test] +fn single_mode_keeps_for_loop_with_range_expression() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("for_range.rs"); + fs::write( + &source_file, + "fn f() { for i in 0..3 { if i == 1 { break; } } }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("for_range.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + let range_stmt = find_first_node_by_type(&json, "RangeStatement").expect("RangeStatement"); + let right = range_stmt + .get("right") + .and_then(Value::as_object) + .expect("RangeStatement.right object"); + assert_eq!( + right.get("type").and_then(Value::as_str), + Some("TupleExpression") + ); +} + +#[test] +fn single_mode_treats_match_binding_pattern_as_default_case() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("match_binding.rs"); + fs::write( + &source_file, + "fn f(v: i32) { match v { x => return x, } }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("match_binding.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + let switch_stmt = find_first_node_by_type(&json, "SwitchStatement").expect("SwitchStatement"); + let first_case = switch_stmt + .get("cases") + .and_then(Value::as_array) + .and_then(|cases| cases.first()) + .expect("first case"); + assert!( + first_case.get("test").is_some_and(Value::is_null), + "binding pattern should lower to default case (null test)" + ); +} + +#[test] +fn single_mode_ignores_wildcard_in_for_tuple_binding() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("for_tuple_wild.rs"); + fs::write( + &source_file, + "fn g(xs: Vec<(i32, i32)>) { for (i, _) in xs { if i > 0 { continue; } } }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("for_tuple_wild.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + let serialized = serde_json::to_string(&json).expect("serialize output"); + assert!( + !serialized.contains("\"name\":\"_\""), + "wildcard binding should not produce identifier '_' in UAST" + ); +} + fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata") @@ -447,3 +555,23 @@ fn has_assignment_operator(value: &Value, operator: &str) -> bool { _ => false, } } + +fn find_first_node_by_type<'a>(value: &'a Value, target_type: &str) -> Option<&'a Value> { + match value { + Value::Object(map) => { + if map + .get("type") + .and_then(Value::as_str) + .is_some_and(|node_type| node_type == target_type) + { + return Some(value); + } + map.values() + .find_map(|child| find_first_node_by_type(child, target_type)) + } + Value::Array(items) => items + .iter() + .find_map(|child| find_first_node_by_type(child, target_type)), + _ => None, + } +} From 9fb9f205b782fa26d35659993b2409e5fec3f3c6 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:57:42 +0800 Subject: [PATCH 27/34] Fix guarded match lowering to if/else chain --- parser-Rust/src/lib.rs | 81 +++++++++++++++++++++++++++------- parser-Rust/tests/cli_smoke.rs | 68 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 15 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index fa28a76..7feb81f 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -9,7 +9,7 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use syn::{ - BinOp, Block, Expr, ExprAssign, ExprBinary, ExprBlock, ExprBreak, ExprCall, ExprContinue, + Arm, BinOp, Block, Expr, ExprAssign, ExprBinary, ExprBlock, ExprBreak, ExprCall, ExprContinue, ExprField, ExprForLoop, ExprIf, ExprLit, ExprLoop, ExprMatch, ExprMethodCall, ExprPath, ExprRange, ExprReference, ExprReturn, ExprTuple, ExprUnary, ExprWhile, Field, Fields, FnArg, Item, ItemFn, ItemStruct, Lit, Local, Pat, PatTuple, RangeLimits, ReturnType, Stmt, Type, UnOp, @@ -685,12 +685,13 @@ fn lower_if_alternative(else_expr: &Expr) -> Option { fn lower_match(expr_match: &ExprMatch) -> Option { let discriminant = lower_expr(&expr_match.expr)?; + if expr_match.arms.iter().any(|arm| arm.guard.is_some()) { + return lower_match_with_guards(&discriminant, &expr_match.arms); + } + let mut cases = Vec::new(); for arm in &expr_match.arms { - let body = lower_match_arm_body( - arm.body.as_ref(), - arm.guard.as_ref().map(|(_, g)| g.as_ref()), - ); + let body = lower_expr(arm.body.as_ref()).unwrap_or_else(empty_scoped_statement); let tests = lower_match_arm_tests(&arm.pat); if tests.is_empty() { cases.push(case_clause(None, body.clone())); @@ -707,17 +708,67 @@ fn lower_match(expr_match: &ExprMatch) -> Option { })) } -fn lower_match_arm_body(body: &Expr, guard: Option<&Expr>) -> Value { - let lowered_body = lower_expr(body).unwrap_or_else(empty_scoped_statement); - let Some(guard_expr) = guard.and_then(lower_expr) else { - return lowered_body; +fn lower_match_with_guards(discriminant: &Value, arms: &[Arm]) -> Option { + let mut alternative = Value::Null; + for arm in arms.iter().rev() { + let consequent = lower_expr(arm.body.as_ref()).unwrap_or_else(empty_scoped_statement); + let condition = lower_match_arm_condition( + discriminant, + &arm.pat, + arm.guard.as_ref().map(|(_, guard)| guard.as_ref()), + )?; + alternative = match condition { + Some(test) => json!({ + "type": "IfStatement", + "test": test, + "consequent": consequent, + "alternative": alternative, + }), + None => consequent, + }; + } + if alternative.is_null() { + return Some(empty_scoped_statement()); + } + Some(alternative) +} + +fn lower_match_arm_condition( + discriminant: &Value, + pat: &Pat, + guard: Option<&Expr>, +) -> Option> { + let pattern_test = lower_match_pattern_condition(discriminant, pat); + let guard_test = match guard { + Some(guard_expr) => Some(lower_expr(guard_expr)?), + None => None, }; - json!({ - "type": "IfStatement", - "test": guard_expr, - "consequent": lowered_body, - "alternative": Value::Null, - }) + let condition = match (pattern_test, guard_test) { + (Some(pattern), Some(guard)) => Some(binary_expression("&&", pattern, guard)), + (Some(pattern), None) => Some(pattern), + (None, Some(guard)) => Some(guard), + (None, None) => None, + }; + Some(condition) +} + +fn lower_match_pattern_condition(discriminant: &Value, pat: &Pat) -> Option { + let tests = lower_match_arm_tests(pat); + if tests.iter().any(Option::is_none) { + return None; + } + + let mut checks = tests + .into_iter() + .flatten() + .map(|test| binary_expression("==", discriminant.clone(), test)) + .collect::>(); + + let mut condition = checks.pop()?; + while let Some(next) = checks.pop() { + condition = binary_expression("||", next, condition); + } + Some(condition) } fn lower_match_arm_tests(pat: &Pat) -> Vec> { diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index a282411..e810055 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -449,6 +449,74 @@ fn single_mode_treats_match_binding_pattern_as_default_case() { ); } +#[test] +fn single_mode_lowers_match_guard_as_if_else_chain() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("match_guard.rs"); + fs::write( + &source_file, + "fn f(v: i32, cond: bool) -> i32 { match v { 1 if cond => return 10, 1 => return 20, _ => return 30, } }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("match_guard.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json_bytes = fs::read(&output_file).expect("read output"); + let json: Value = serde_json::from_slice(&json_bytes).expect("valid json"); + assert_eq!( + count_nodes_of_type(&json, "SwitchStatement"), + 0, + "guarded match should lower to if/else chain" + ); + + let first_if = find_first_node_by_type(&json, "IfStatement").expect("first IfStatement"); + let first_test = first_if + .get("test") + .and_then(Value::as_object) + .expect("first if test"); + assert_eq!( + first_test.get("operator").and_then(Value::as_str), + Some("&&") + ); + let first_test_left = first_test + .get("left") + .and_then(Value::as_object) + .expect("first if left"); + assert_eq!( + first_test_left.get("operator").and_then(Value::as_str), + Some("==") + ); + + let second_if = first_if + .get("alternative") + .and_then(Value::as_object) + .expect("second IfStatement"); + assert_eq!( + second_if.get("type").and_then(Value::as_str), + Some("IfStatement") + ); + let second_test = second_if + .get("test") + .and_then(Value::as_object) + .expect("second if test"); + assert_eq!( + second_test.get("operator").and_then(Value::as_str), + Some("==") + ); +} + #[test] fn single_mode_ignores_wildcard_in_for_tuple_binding() { let temp = tempfile::tempdir().expect("tempdir"); From 003b781ab9932305563b2b62a52a5d59f083876c Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:30:27 +0800 Subject: [PATCH 28/34] Add PR checks workflow for Rust parser --- .github/workflows/pr-check.yml | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..665985f --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,35 @@ +name: PR Checks + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'parser-Rust/**' + - '.github/workflows/pr-check.yml' + +jobs: + rust-parser: + name: Rust Parser Checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: parser-Rust + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: parser-Rust + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run tests + run: cargo test --all-targets From a7379039852eb3fff76e255939155af61f24e14f Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:00:31 +0800 Subject: [PATCH 29/34] Add golden regression fixtures for Rust parser --- .../.cargo/registry/src/vendor/Cargo.toml | 4 + .../.cargo/registry/src/vendor/src/lib.rs | 1 + .../testdata/project/hidden_hash/Cargo.toml | 4 + .../hidden_hash/crates/core/Cargo.toml | 4 + .../hidden_hash/crates/core/src/lib.rs | 3 + .../project/hidden_hash/expected.project.json | 46 +++ .../testdata/project/hidden_hash/src/main.rs | 3 + .../testdata/single/expected.regression.json | 281 ++++++++++++++++++ parser-Rust/testdata/single/regression.rs | 17 ++ parser-Rust/tests/cli_smoke.rs | 65 ++++ 10 files changed, 428 insertions(+) create mode 100644 parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/Cargo.toml create mode 100644 parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/src/lib.rs create mode 100644 parser-Rust/testdata/project/hidden_hash/Cargo.toml create mode 100644 parser-Rust/testdata/project/hidden_hash/crates/core/Cargo.toml create mode 100644 parser-Rust/testdata/project/hidden_hash/crates/core/src/lib.rs create mode 100644 parser-Rust/testdata/project/hidden_hash/expected.project.json create mode 100644 parser-Rust/testdata/project/hidden_hash/src/main.rs create mode 100644 parser-Rust/testdata/single/expected.regression.json create mode 100644 parser-Rust/testdata/single/regression.rs diff --git a/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/Cargo.toml b/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/Cargo.toml new file mode 100644 index 0000000..ccc80e0 --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "hidden_vendor" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/src/lib.rs b/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/src/lib.rs new file mode 100644 index 0000000..438d9ff --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/.cargo/registry/src/vendor/src/lib.rs @@ -0,0 +1 @@ +pub fn vendored() {} diff --git a/parser-Rust/testdata/project/hidden_hash/Cargo.toml b/parser-Rust/testdata/project/hidden_hash/Cargo.toml new file mode 100644 index 0000000..806a44d --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "root#pkg" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/hidden_hash/crates/core/Cargo.toml b/parser-Rust/testdata/project/hidden_hash/crates/core/Cargo.toml new file mode 100644 index 0000000..bb811c8 --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/crates/core/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "core_pkg" +version = "0.1.0" +edition = "2021" diff --git a/parser-Rust/testdata/project/hidden_hash/crates/core/src/lib.rs b/parser-Rust/testdata/project/hidden_hash/crates/core/src/lib.rs new file mode 100644 index 0000000..b709680 --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/crates/core/src/lib.rs @@ -0,0 +1,3 @@ +pub fn id(v: i32) -> i32 { + v +} diff --git a/parser-Rust/testdata/project/hidden_hash/expected.project.json b/parser-Rust/testdata/project/hidden_hash/expected.project.json new file mode 100644 index 0000000..4932806 --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/expected.project.json @@ -0,0 +1,46 @@ +{ + "packageInfo": { + "pathName": "/", + "files": { + "/Cargo.toml": { + "node": { + "type": "CompileUnit", + "body": [], + "language": "rust", + "languageVersion": "", + "uri": "/Cargo.toml", + "version": "" + }, + "packageName": "root#pkg" + } + }, + "subs": { + "crates": { + "pathName": "crates", + "files": {}, + "subs": { + "core": { + "pathName": "core", + "files": { + "/crates/core/Cargo.toml": { + "node": { + "type": "CompileUnit", + "body": [], + "language": "rust", + "languageVersion": "", + "uri": "/crates/core/Cargo.toml", + "version": "" + }, + "packageName": "core_pkg" + } + }, + "subs": {} + } + } + } + } + }, + "moduleName": "root#pkg", + "cargoTomlPath": "/Cargo.toml", + "numOfCargoToml": 2 +} diff --git a/parser-Rust/testdata/project/hidden_hash/src/main.rs b/parser-Rust/testdata/project/hidden_hash/src/main.rs new file mode 100644 index 0000000..c31d504 --- /dev/null +++ b/parser-Rust/testdata/project/hidden_hash/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("hidden-hash fixture"); +} diff --git a/parser-Rust/testdata/single/expected.regression.json b/parser-Rust/testdata/single/expected.regression.json new file mode 100644 index 0000000..435f4f7 --- /dev/null +++ b/parser-Rust/testdata/single/expected.regression.json @@ -0,0 +1,281 @@ +{ + "packageInfo": { + "pathName": "/", + "files": { + "__FILE__": { + "node": { + "type": "CompileUnit", + "body": [ + { + "body": { + "body": [ + { + "cloned": true, + "id": { + "name": "_range_to", + "type": "Identifier" + }, + "init": { + "elements": [ + { + "type": "Noop" + }, + { + "literalType": "number", + "type": "Literal", + "value": 3 + }, + { + "literalType": "boolean", + "type": "Literal", + "value": false + } + ], + "modifiable": false, + "type": "TupleExpression" + }, + "type": "VariableDeclaration", + "varType": { + "id": null, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "body": { + "body": [ + { + "body": { + "body": [ + { + "alternative": null, + "consequent": { + "body": [ + { + "label": null, + "type": "BreakStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "test": { + "left": { + "name": "i", + "type": "Identifier" + }, + "operator": "==", + "right": { + "name": "j", + "type": "Identifier" + }, + "type": "BinaryExpression" + }, + "type": "IfStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "key": null, + "right": { + "elements": [ + { + "literalType": "number", + "type": "Literal", + "value": 0 + }, + { + "literalType": "number", + "type": "Literal", + "value": 2 + }, + { + "literalType": "boolean", + "type": "Literal", + "value": true + } + ], + "modifiable": false, + "type": "TupleExpression" + }, + "type": "RangeStatement", + "value": { + "name": "j", + "type": "Identifier" + } + } + ], + "id": null, + "type": "ScopedStatement" + }, + "key": null, + "right": { + "name": "xs", + "type": "Identifier" + }, + "type": "RangeStatement", + "value": { + "name": "i", + "type": "Identifier" + } + }, + { + "alternative": { + "alternative": { + "argument": { + "name": "x", + "type": "Identifier" + }, + "isYield": false, + "type": "ReturnStatement" + }, + "consequent": { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 20 + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": { + "left": { + "name": "v", + "type": "Identifier" + }, + "operator": "==", + "right": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "BinaryExpression" + }, + "type": "IfStatement" + }, + "consequent": { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 10 + }, + "isYield": false, + "type": "ReturnStatement" + }, + "test": { + "left": { + "left": { + "name": "v", + "type": "Identifier" + }, + "operator": "==", + "right": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "BinaryExpression" + }, + "operator": "&&", + "right": { + "name": "cond", + "type": "Identifier" + }, + "type": "BinaryExpression" + }, + "type": "IfStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "id": { + "name": "regression", + "type": "Identifier" + }, + "modifiers": [ + "pub" + ], + "parameters": [ + { + "cloned": false, + "id": { + "name": "xs", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "Vec", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": false, + "id": { + "name": "v", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + }, + { + "cloned": false, + "id": { + "name": "cond", + "type": "Identifier" + }, + "init": null, + "type": "VariableDeclaration", + "varType": { + "id": { + "name": "bool", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "variableParam": false + } + ], + "returnType": { + "id": { + "name": "i32", + "type": "Identifier" + }, + "type": "DynamicType", + "typeArguments": null + }, + "type": "FunctionDefinition" + } + ], + "language": "rust", + "languageVersion": "", + "uri": "__FILE__", + "version": "" + }, + "packageName": "__single__" + } + }, + "subs": {} + }, + "moduleName": "__single_module__", + "cargoTomlPath": "", + "numOfCargoToml": 0 +} diff --git a/parser-Rust/testdata/single/regression.rs b/parser-Rust/testdata/single/regression.rs new file mode 100644 index 0000000..5997a45 --- /dev/null +++ b/parser-Rust/testdata/single/regression.rs @@ -0,0 +1,17 @@ +pub fn regression(xs: Vec<(i32, i32)>, v: i32, cond: bool) -> i32 { + let _range_to = ..3; + + for (i, _) in xs { + for j in 0..=2 { + if i == j { + break; + } + } + } + + match v { + 1 if cond => return 10, + 1 => return 20, + x => return x, + } +} diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index e810055..216f8ae 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -156,6 +156,40 @@ fn single_mode_lowers_control_flow_and_matches_golden() { ); } +#[test] +fn single_mode_lowers_regression_fixture_and_matches_golden() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = fixture_path("single/regression.rs"); + let output_file = temp.path().join("out").join("regression.json"); + + let out = run_cli(&[ + "-rootDir", + source_file.to_str().expect("fixture path"), + "-output", + output_file.to_str().expect("utf8 path"), + "-single", + ]); + + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let output_bytes = fs::read(&output_file).expect("read output"); + let mut actual: Value = serde_json::from_slice(&output_bytes).expect("valid output json"); + normalize_single_file_paths(&mut actual); + + let expected_raw = fs::read_to_string(fixture_path("single/expected.regression.json")) + .expect("read expected regression golden"); + let expected: Value = serde_json::from_str(&expected_raw).expect("valid expected json"); + + assert_eq!( + actual, expected, + "single file regression output mismatches golden" + ); +} + #[test] fn single_mode_rejects_output_equal_to_source_file() { let temp = tempfile::tempdir().expect("tempdir"); @@ -229,6 +263,37 @@ fn project_mode_matches_golden_and_is_stable() { ); } +#[test] +fn project_mode_hidden_hash_fixture_matches_golden() { + let temp = tempfile::tempdir().expect("tempdir"); + let project_dir = fixture_path("project/hidden_hash"); + let output_file = temp.path().join("out").join("hidden-hash.json"); + + let out = run_cli(&[ + "-rootDir", + project_dir.to_str().expect("utf8 path"), + "-output", + output_file.to_str().expect("utf8 path"), + ]); + assert!( + out.status.success(), + "cli failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let output_bytes = fs::read(&output_file).expect("read output"); + let actual: Value = serde_json::from_slice(&output_bytes).expect("valid output json"); + let expected_raw = + fs::read_to_string(fixture_path("project/hidden_hash/expected.project.json")) + .expect("read hidden-hash project golden"); + let expected: Value = serde_json::from_str(&expected_raw).expect("valid expected json"); + + assert_eq!( + actual, expected, + "project hidden/hash fixture output mismatches golden" + ); +} + #[test] fn project_mode_skips_hidden_directories_when_discovering_manifests() { let temp = tempfile::tempdir().expect("tempdir"); From 81da54497e41d4148f12b44806be2d9cf4b44dbd Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:00:39 +0800 Subject: [PATCH 30/34] Document Rust parser usage and scope --- README.md | 5 ++++- README_ZH.md | 3 +++ parser-Rust/README.md | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff59850..f202ee8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ YASA-UAST is an intermediate representation structure for multi-language program ## parser-Python - Python language parser for UAST, parsing Python code into UAST. +## parser-Rust +- Rust language parser for UAST, parsing Rust code into UAST. + ## Join Us Welcome to submit issues if you encounter any problems! @@ -58,4 +61,4 @@ YASA - Making code analysis more precise, easier, and smarter. ## Contact Us [Official Website](https://cybersec.antgroup.com/) - \ No newline at end of file + diff --git a/README_ZH.md b/README_ZH.md index c2f199a..eb403e7 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -35,6 +35,9 @@ UAST(Unified Abstract Syntax Tree)是一种面向多语言程序分析的中 ## parser-Python - UAST Python 语言的解析器,将 Python 语言代码解析为 UAST +## parser-Rust +- UAST Rust 语言解析器,将 Rust 语言代码解析为 UAST + # 加入我们 遇到问题欢迎提交issue! diff --git a/parser-Rust/README.md b/parser-Rust/README.md index 29c9c7b..cd52ce5 100644 --- a/parser-Rust/README.md +++ b/parser-Rust/README.md @@ -21,3 +21,40 @@ Project mode: ```bash ./target/release/uast4rust -rootDir /path/to/project -output /path/to/output.json ``` + +## Output Contract + +Top-level JSON fields: + +- `packageInfo` +- `moduleName` +- `cargoTomlPath` +- `numOfCargoToml` + +`CompileUnit.language` is always `"rust"` for this parser. + +## Coverage (Current) + +Single-file lowering includes: + +- function and struct definitions +- variable declarations and assignments (including compound assignments) +- call/member expressions +- control flow: `if`, `match`, `for`, `while`, `loop`, `break`, `continue`, `return` + +Project mode includes: + +- recursive Cargo manifest discovery +- stable package tree output (`packageInfo.subs`) +- hidden directory skipping during discovery + +## Limitations + +This parser currently focuses on parser-first coverage. Some Rust constructs are intentionally not lowered yet (for example macro-heavy forms and advanced type/trait semantics). + +## Validate + +```bash +cargo fmt -- --check +cargo test +``` From 97187e55dacad8d65d3bf50167043c51fa3229ac Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:31:46 +0800 Subject: [PATCH 31/34] Ensure guarded match if-branches use scoped statements --- parser-Rust/src/lib.rs | 11 ++++++++++- parser-Rust/tests/cli_smoke.rs | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 7feb81f..43e0383 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -711,7 +711,9 @@ fn lower_match(expr_match: &ExprMatch) -> Option { fn lower_match_with_guards(discriminant: &Value, arms: &[Arm]) -> Option { let mut alternative = Value::Null; for arm in arms.iter().rev() { - let consequent = lower_expr(arm.body.as_ref()).unwrap_or_else(empty_scoped_statement); + let consequent = lower_expr(arm.body.as_ref()) + .map(ensure_scoped_statement) + .unwrap_or_else(empty_scoped_statement); let condition = lower_match_arm_condition( discriminant, &arm.pat, @@ -1100,6 +1102,13 @@ fn scoped_statement(body: Vec) -> Value { }) } +fn ensure_scoped_statement(instruction: Value) -> Value { + match instruction.get("type").and_then(Value::as_str) { + Some("ScopedStatement") => instruction, + _ => scoped_statement(vec![instruction]), + } +} + fn empty_scoped_statement() -> Value { scoped_statement(Vec::new()) } diff --git a/parser-Rust/tests/cli_smoke.rs b/parser-Rust/tests/cli_smoke.rs index 216f8ae..dc49062 100644 --- a/parser-Rust/tests/cli_smoke.rs +++ b/parser-Rust/tests/cli_smoke.rs @@ -563,6 +563,14 @@ fn single_mode_lowers_match_guard_as_if_else_chain() { first_test_left.get("operator").and_then(Value::as_str), Some("==") ); + let first_consequent = first_if + .get("consequent") + .and_then(Value::as_object) + .expect("first if consequent"); + assert_eq!( + first_consequent.get("type").and_then(Value::as_str), + Some("ScopedStatement") + ); let second_if = first_if .get("alternative") @@ -580,6 +588,22 @@ fn single_mode_lowers_match_guard_as_if_else_chain() { second_test.get("operator").and_then(Value::as_str), Some("==") ); + let second_consequent = second_if + .get("consequent") + .and_then(Value::as_object) + .expect("second if consequent"); + assert_eq!( + second_consequent.get("type").and_then(Value::as_str), + Some("ScopedStatement") + ); + let second_alternative = second_if + .get("alternative") + .and_then(Value::as_object) + .expect("second if alternative"); + assert_eq!( + second_alternative.get("type").and_then(Value::as_str), + Some("ScopedStatement") + ); } #[test] From 11f711d40bfc1d89226c12ab8f7a1dd0a461380f Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:32:08 +0800 Subject: [PATCH 32/34] Remove dead empty-check in match case lowering --- parser-Rust/src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 43e0383..17d97ce 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -693,10 +693,6 @@ fn lower_match(expr_match: &ExprMatch) -> Option { for arm in &expr_match.arms { let body = lower_expr(arm.body.as_ref()).unwrap_or_else(empty_scoped_statement); let tests = lower_match_arm_tests(&arm.pat); - if tests.is_empty() { - cases.push(case_clause(None, body.clone())); - continue; - } for test in tests { cases.push(case_clause(test, body.clone())); } From 603f9fb4d5aa2176cb2ca984440abdfd41370759 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:01:52 +0800 Subject: [PATCH 33/34] Refresh regression golden for scoped guarded-match output --- .../testdata/single/expected.regression.json | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/parser-Rust/testdata/single/expected.regression.json b/parser-Rust/testdata/single/expected.regression.json index 435f4f7..f000465 100644 --- a/parser-Rust/testdata/single/expected.regression.json +++ b/parser-Rust/testdata/single/expected.regression.json @@ -124,21 +124,33 @@ { "alternative": { "alternative": { - "argument": { - "name": "x", - "type": "Identifier" - }, - "isYield": false, - "type": "ReturnStatement" + "body": [ + { + "argument": { + "name": "x", + "type": "Identifier" + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" }, "consequent": { - "argument": { - "literalType": "number", - "type": "Literal", - "value": 20 - }, - "isYield": false, - "type": "ReturnStatement" + "body": [ + { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 20 + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" }, "test": { "left": { @@ -156,13 +168,19 @@ "type": "IfStatement" }, "consequent": { - "argument": { - "literalType": "number", - "type": "Literal", - "value": 10 - }, - "isYield": false, - "type": "ReturnStatement" + "body": [ + { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 10 + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" }, "test": { "left": { From 948172a39aef8f5d1dde2c295da71d709610b226 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:46:00 +0800 Subject: [PATCH 34/34] Skip wildcard names in tuple destructuring locals --- parser-Rust/src/lib.rs | 3 +++ parser-Rust/tests/cli_smoke.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs index 17d97ce..bfb3380 100644 --- a/parser-Rust/src/lib.rs +++ b/parser-Rust/src/lib.rs @@ -442,6 +442,9 @@ fn lower_tuple_pattern_local(local: &Local, tuple_pat: &PatTuple) -> Option PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("testdata")