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 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/Cargo.lock b/parser-Rust/Cargo.lock new file mode 100644 index 0000000..378c916 --- /dev/null +++ b/parser-Rust/Cargo.lock @@ -0,0 +1,506 @@ +# 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 = "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" +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 = "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" +dependencies = [ + "serde", + "serde_json", + "syn", + "tempfile", + "toml", +] + +[[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 = "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" +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..bc33f51 --- /dev/null +++ b/parser-Rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "uast4rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +syn = { version = "2.0", features = ["full"] } +toml = "0.8" + +[dev-dependencies] +tempfile = "3.10" diff --git a/parser-Rust/README.md b/parser-Rust/README.md new file mode 100644 index 0000000..cd52ce5 --- /dev/null +++ b/parser-Rust/README.md @@ -0,0 +1,60 @@ +# 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 +``` + +## 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 +``` diff --git a/parser-Rust/src/lib.rs b/parser-Rust/src/lib.rs new file mode 100644 index 0000000..bfb3380 --- /dev/null +++ b/parser-Rust/src/lib.rs @@ -0,0 +1,1361 @@ +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::{ + 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, + Visibility, +}; + +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 { + 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}"), + } + } +} + +#[derive(Debug)] +pub enum RunError { + InvalidInputPath { + mode: &'static str, + path: PathBuf, + }, + OutputWouldOverwriteSource { + source: PathBuf, + output: PathBuf, + }, + Io { + context: String, + source: std::io::Error, + }, + Serialize { + source: serde_json::Error, + }, + ParseSource { + path: PathBuf, + source: syn::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::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}"), + Self::ParseSource { path, source } => { + write!( + f, + "failed to parse rust source {}: {}", + path.display(), + source + ) + } + } + } +} + +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), + Self::ParseSource { source, .. } => Some(source), + } + } +} + +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 = next_arg_value(&mut iter, &arg)?; + root_dir = Some(PathBuf::from(value)); + } + "-output" | "--output" => { + let value = next_arg_value(&mut iter, &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, + }) +} + +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)) +} + +pub fn run(cli: &CliArgs) -> Result<(), RunError> { + 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<(), RunError> { + if !file.is_file() { + return Err(RunError::InvalidInputPath { + mode: "single", + path: file.to_path_buf(), + }); + } + + let source = fs::read_to_string(file).map_err(|source| RunError::Io { + context: format!("failed to read file {}", file.display()), + 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 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: compile_unit, + 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<(), RunError> { + if !root_dir.is_dir() { + return Err(RunError::InvalidInputPath { + mode: "project", + path: root_dir.to_path_buf(), + }); + } + + 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, + module_name, + cargo_toml_path, + num_of_cargo_toml, + }; + + 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() + .filter_map(lower_fn_arg) + .collect::>(); + let return_type = lower_return_type(&item_fn.sig.output); + let body = lower_block(&item_fn.block); + + 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() + .expect("fields in a named struct should have an identifier") + .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) -> Option { + match arg { + FnArg::Typed(pat_ty) => { + let name = extract_binding_name(&pat_ty.pat)?; + Some(variable_declaration( + identifier(name), + None, + lower_type(&pat_ty.ty), + false, + false, + )) + } + FnArg::Receiver(_) => None, + } +} + +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 { + 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 + .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_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; + }; + if name == "_" { + 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) => 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::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), + 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), + Expr::Group(expr_group) => lower_expr(&expr_group.expr), + _ => None, + } +} + +fn lower_path(expr_path: &ExprPath) -> Value { + 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"); + + 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) -> Option { + let callee = lower_expr(&expr_call.func)?; + let arguments = expr_call + .args + .iter() + .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) + .collect::>(); + Some(json!({ + "type": "CallExpression", + "callee": callee, + "arguments": arguments, + })) +} + +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()), + false, + ); + let arguments = expr_method_call + .args + .iter() + .map(|arg| lower_expr(arg).unwrap_or(Value::Null)) + .collect::>(); + Some(json!({ + "type": "CallExpression", + "callee": callee, + "arguments": arguments, + })) +} + +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) -> 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 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": "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_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); + 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)?; + 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_expr(arm.body.as_ref()).unwrap_or_else(empty_scoped_statement); + let tests = lower_match_arm_tests(&arm.pat); + for test in tests { + cases.push(case_clause(test, body.clone())); + } + } + Some(json!({ + "type": "SwitchStatement", + "discriminant": discriminant, + "cases": cases, + })) +} + +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()) + .map(ensure_scoped_statement) + .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, + }; + 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> { + 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) => 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, + } +} + +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_range_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_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!({ + "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 + .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 { + 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!({ + "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::Wild(_) => Some(("_".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 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", + "name": name.into(), + }) +} + +fn literal_number(value: i64) -> Value { + json!({ + "type": "Literal", + "value": value, + "literalType": "number", + }) +} + +fn literal_boolean(value: bool) -> Value { + json!({ + "type": "Literal", + "value": value, + "literalType": "boolean", + }) +} + +fn null_literal() -> Value { + json!({ + "type": "Literal", + "value": Value::Null, + "literalType": "null", + }) +} + +fn noop() -> Value { + json!({ + "type": "Noop", + }) +} + +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 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()) +} + +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) +} + +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, + 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 { + 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 { + let rel = manifest_path + .strip_prefix(root_dir) + .expect("discovered manifest should be inside root_dir"); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + 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 value = match toml::from_str::(&content) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + + let name = value + .get("package") + .and_then(|v| v.get("name")) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string); + Ok(name) +} + +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() { + 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(|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)] +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")); + } + + #[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'")); + } + + #[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"); + } + + #[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/src/main.rs b/parser-Rust/src/main.rs new file mode 100644 index 0000000..b223679 --- /dev/null +++ b/parser-Rust/src/main.rs @@ -0,0 +1,20 @@ +use std::process; + +fn main() { + 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); + } +} 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(), + } + } +} 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/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/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/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/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/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.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/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/testdata/single/expected.regression.json b/parser-Rust/testdata/single/expected.regression.json new file mode 100644 index 0000000..f000465 --- /dev/null +++ b/parser-Rust/testdata/single/expected.regression.json @@ -0,0 +1,299 @@ +{ + "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": { + "body": [ + { + "argument": { + "name": "x", + "type": "Identifier" + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "consequent": { + "body": [ + { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 20 + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "test": { + "left": { + "name": "v", + "type": "Identifier" + }, + "operator": "==", + "right": { + "literalType": "number", + "type": "Literal", + "value": 1 + }, + "type": "BinaryExpression" + }, + "type": "IfStatement" + }, + "consequent": { + "body": [ + { + "argument": { + "literalType": "number", + "type": "Literal", + "value": 10 + }, + "isYield": false, + "type": "ReturnStatement" + } + ], + "id": null, + "type": "ScopedStatement" + }, + "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 new file mode 100644 index 0000000..93f74e3 --- /dev/null +++ b/parser-Rust/tests/cli_smoke.rs @@ -0,0 +1,767 @@ +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_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] +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_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_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"); + 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"); +} + +#[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" + ); +} + +#[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"); + 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)); +} + +#[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" + ); +} + +#[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_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 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") + .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("==") + ); + 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] +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" + ); +} + +#[test] +fn single_mode_ignores_wildcard_in_let_tuple_pattern() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_file = temp.path().join("let_tuple_wild.rs"); + fs::write( + &source_file, + "fn f(pair: (i32, i32)) { let (x, _) = pair; let y = x; }\n", + ) + .expect("write source"); + let output_file = temp.path().join("out").join("let_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 tuple binding should not produce identifier '_' in UAST" + ); +} + +fn fixture_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .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); +} + +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, + } +} + +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, + } +}