├── rust-print-symbol-tree.png ├── .dirdocs.nuon ├── LICENSE ├── README.md └── rust_ast.nu /rust-print-symbol-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graves/nu_rust_ast/HEAD/rust-print-symbol-tree.png -------------------------------------------------------------------------------- /.dirdocs.nuon: -------------------------------------------------------------------------------- 1 | { 2 | "root": ".", 3 | "updated_at": "2025-10-06T04:09:21.680955Z", 4 | "entries": [ 5 | { 6 | "kind": "file", 7 | "name": "README.md", 8 | "path": "README.md", 9 | "hash": "babc54c865508fa42dd34fad0f5b3d668e7d33c28ecfeb17c6654ec01b59ab92", 10 | "updated_at": "2025-10-06T04:09:08.882031Z", 11 | "doc": { 12 | "fileDescription": "Documents a Nushell-powered CLI tool that extracts structured Rust symbol data for analysis and documentation.", 13 | "joyThisFileBrings": 8, 14 | "personalityEmoji": "🦣" 15 | } 16 | }, 17 | { 18 | "kind": "file", 19 | "name": "LICENSE", 20 | "path": "LICENSE", 21 | "hash": "b7a6a1ef44aa3647db780392b4fa023c613fd9fa482678bb7a729e5fe385ca00", 22 | "updated_at": "2025-10-06T04:09:01.061056Z", 23 | "doc": { 24 | "fileDescription": "Serves as the legal license granting public domain dedication to the rust-ast project.", 25 | "joyThisFileBrings": 7, 26 | "personalityEmoji": "🐘" 27 | } 28 | }, 29 | { 30 | "kind": "file", 31 | "name": "rust-print-symbol-tree.png", 32 | "path": "rust-print-symbol-tree.png", 33 | "hash": "6044ab3d86b7c76563879c42a5454d27594012b7515a665c435938177b67307d", 34 | "updated_at": "2025-10-06T04:09:17.672376Z", 35 | "doc": { 36 | "fileDescription": "Displays a visual hierarchy of Rust symbols extracted from a project's AST.", 37 | "joyThisFileBrings": 8, 38 | "personalityEmoji": "🐘" 39 | } 40 | }, 41 | { 42 | "kind": "file", 43 | "name": "rust_ast.nu", 44 | "path": "rust_ast.nu", 45 | "hash": "c3985904359f1f9c1007bffaa4d4568086c7c683e19284a302cf34b6fb2a4d42", 46 | "updated_at": "2025-10-06T04:07:36.740821Z", 47 | "doc": { 48 | "fileDescription": "Extracts and structures Rust symbols for analysis within Nushell pipelines.", 49 | "joyThisFileBrings": 9, 50 | "personalityEmoji": "🦾" 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust Symbol Harvesting with Nushell 🌾 2 | **A CLI Tool for Structured Symbol Analysis of Rust Projects** 3 | 4 | ``` 5 | ,_ .--. 6 | , , _)\/ ;--. 7 | . ' . \_\-' | .' \ 8 | -= * =- (.-, / / | 9 | ' .\' ). ))/ .' _/\ / 10 | \_ \_ /( / \ /( 11 | /_\ .--' `-. // \ 12 | ||\/ , '._// | 13 | ||/ /`(_ (_,;`-._/ / 14 | \_.' ) /`\ .' 15 | .' . | ;. /` 16 | / |\( `.( 17 | | |/ | ` ` 18 | | | / 19 | | |.' 20 | __/' / 21 | _ .' _.-` 22 | _.` `.-;`/ 23 | /_.-'` / / 24 | | / 25 | jgs ( / 26 | /_/ 27 | ``` 28 | 29 | --- 30 | 31 | ## 🎉 What This Does 32 | 33 | `rust-ast` **harvests symbols** from Rust projects into structured Nushell records. It uses `ast-grep` to: 34 | 35 | - Extract **Rust items**: `fn`, `extern_fn`, `struct`, `enum`, `type`, `trait`, `impl`, `mod`, `macro_rules`, `const`, `static`, `use` 36 | - Normalize metadata (file, span, visibility, Fully Qualified Paths) 37 | - Capture **Rustdoc comments** and **full source bodies** (when applicable) 38 | - Estimate token counts for doc/comments and bodies 39 | - Map **function definitions to call sites** within your codebase 40 | 41 | Think of it as a Nushell-first *Rust AST explorer*. Perfect for reverse-engineering, code analysis, and documentation generation. 42 | 43 | --- 44 | 45 | ## 🧠 Core Features 46 | 47 | ### 1. Structured Symbol Tables 48 | 49 | Each row represents a Rust symbol with: 50 | 51 | | Field | Description | 52 | |----------------------|--------------------------------------------------------------------------------------------------------------| 53 | | `kind` | `'fn'`, `'struct'`, `'enum'`, `'trait'`, `'impl'`, `'mod'`, … | 54 | | `name` | Best-effort symbol name (`'*'` for grouped-use leaves; file name for synthetic file `mod`s) | 55 | | `crate` | Package name from `Cargo.toml` (fallback: `"crate"`) | 56 | | `module_path` | Module path under `src/` as a list (e.g., `["foo","bar"]`) | 57 | | `fqpath` | Canonical path (`crate::foo::Bar`, UFCS for trait methods when needed) | 58 | | `visibility` | `pub`, `private`, `pub(crate)`, etc. | 59 | | `file` | Absolute file path | 60 | | `span` | `{ start_line, end_line, start_byte, end_byte }` (lines 1-based inclusive; bytes from ast-grep) | 61 | | `attrs` | Reserved (empty) | 62 | | `signature` | Single-line preamble (no body) | 63 | | `has_body` | Whether the item has a `{ … }` body | 64 | | `async/unsafe/const` | Bool flags parsed from signature | 65 | | `abi/generics/where` | Extra meta when present | 66 | | `doc` | Verbatim rustdoc or inner file docs | 67 | | `impl_of` | For `impl` and methods: `{ trait_path?, type_path? }` | 68 | | `trait_items` | Reserved | 69 | | `reexports` | Reserved | 70 | | `body_text` | Exact matched text or whole file for synthetic file `mod`s | 71 | | `synthetic` | True for synthetic file `mod` rows | 72 | | `doc_tokens` | Token estimate for `doc` | 73 | | `body_tokens` | Token estimate for `body_text` | 74 | 75 | ### 2. `ast-grep` Integration 76 | 77 | - Uses `ast-grep --json=stream` to parse Rust 78 | - Patterns cover bodies, decls, generics, where clauses, etc. 79 | 80 | ### 3. Optimized for Large Projects 81 | 82 | - Synthesizes “file module” rows for `src/foo.rs` / `src/foo/mod.rs` 83 | - Normalizes module paths directly from the filesystem layout 84 | 85 | ### 4. Rustdoc & Token Counts 86 | 87 | - Extracts leading `///`, `#[doc = "..."]`, `/** ... */`, and file inner docs (`//!`, `/*! ... */`) 88 | - Token estimation mode configurable via `RUST_AST_TOKENIZER` (`words` default; `chars`; or `tiktoken` if you wire it up) 89 | 90 | ### 5. Call Site Analysis 91 | 92 | - Finds simple call sites (`foo(...)`, `Type::foo(...)`, `recv.foo(...)`) 93 | - Attaches a `callers` list (as FQ paths) to function definitions 94 | 95 | --- 96 | 97 | ## 📦 Functions in this Script 98 | 99 | ### `rust-ast [...paths]` 100 | Flat table of symbols and metadata (see fields above). Public entry point. 101 | 102 | ### `rust-tree [...paths] [--include-use]` 103 | Builds a **nested** tree of minimal nodes for pretty printing: 104 | ```nu 105 | { kind, name, fqpath, children: [ ... ] } 106 | ``` 107 | 108 | ### `rust-print-symbol-tree [--fq-branches] [--tokens]` 109 | Pretty-prints the nested tree with aligned columns: 110 | 111 | - **Name** (ASCII tree branches + colorized name) 112 | - **Kind** (colorized + padded) 113 | - **FQ Path** (shown on leaves; optionally on branches) 114 | - **Tokens** (optional rightmost column showing `Body Tokens: N, Doc Tokens: M`) 115 | - Token sub-columns are **right-aligned** per number so all counts line up. 116 | 117 | Color is applied via `_paint-kind` using `ansi`. All alignment uses `_vlen`, which strips ANSI before measuring. Works even if your terminal doesn’t support color. 118 | 119 | ### `rust-print-call-graph [--max-depth N] [--reverse] [--show-roots]` 120 | Visualizes function call relationships as a tree. 121 | 122 | - Useful for answering the question: **_"What codepaths could be traversed whenever X is called?"_** 123 | - `--reverse:` Bottom-up callers view. Start from target and walk upward through its parents. 124 | - `--max-depth:` Limit traversal depth (default: 3). 125 | - `--show-roots:` Print a one-line header describing the direction and depth. 126 | 127 | ### `rust-print-dep-usage [dep?] [--max-depth N] [--include-maybe] [--records]` 128 | Analyze how external dependencies are **used in your codebase** and visualize their call graph impact. 129 | 130 | - **`dep?`**: Optional crate name to focus on (case-insensitive). If omitted, all detected dependencies are shown. 131 | - `--reverse:` Bottom-up callers view. Start from target and walk upward through its parents. 132 | - `--max-depth`: Limit call graph depth (default: 4). 133 | - `--include-maybe`: Include heuristic matches from glob imports (e.g., `use foo::*;`). 134 | - `--records`: Output as structured Nushell records (instead of colorized text). Useful for post-processing with `where`, `get`, `select`, etc. 135 | 136 | 137 | --- 138 | 139 | ## 🔧 Installation 140 | 141 | ```nu 142 | # ast-grep 143 | brew install ast-grep 144 | 145 | # Put the script somewhere Nushell will load it from, e.g.: 146 | cd $"($nu.data-dir)/scripts" 147 | curl -L https://raw.githubusercontent.com/graves/nu_rust_ast/refs/heads/main/rust_ast.nu -o $"($nu.data-dir)/scripts/rust_ast.nu" 148 | ``` 149 | 150 | Add to your Nushell config (`$nu.config-path`): 151 | 152 | ```nu 153 | use $"($nu.data-dir)/scripts/rust_ast.nu" * 154 | ``` 155 | 156 | Reload your shell. 157 | 158 | > **Optional:** tokenization behavior 159 | > - `RUST_AST_TOKENIZER=words` (default): fast, word-ish counting 160 | > - `RUST_AST_TOKENIZER=chars`: ~1 token per 4 chars heuristic 161 | > - `RUST_AST_TOKENIZER=tiktoken`: route to your `_token-count-via-tiktoken` if you implement it 162 | 163 | --- 164 | 165 | ## 🧪 Examples 166 | 167 | ### 1. Explore call relationships 168 | 169 | ```nu 170 | rust-ast | 171 | where kind == 'fn' | 172 | select name fqpath callers | 173 | sort-by fqpath 174 | ``` 175 | 176 | ### 2. Inspect docs and bodies for a specific function 177 | 178 | ```nu 179 | rust-ast | 180 | where kind == 'fn' and name == 'search' | 181 | select doc doc_tokens body_text body_tokens 182 | ``` 183 | ```text 184 | ╭──────┬──────────────────────────────────────────────────────────────────────────────┬───────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┬───────────────╮ 185 | │ # │ doc │ doc_tokens │ body_text │ body_tokens │ 186 | ├──────┼──────────────────────────────────────────────────────────────────────────────┼───────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤ 187 | │ 0 │ /// Query the index for the `top_k` nearest vectors to `vector`. │ 60 │ pub fn search(&self, vector: &[f32], top_k: usize) -> Result, &'static str> { │ 24 │ 188 | │ │ /// │ │ if vector.len() != self.dimension { │ │ 189 | │ │ /// # Parameters │ │ return Err("dimension mismatch"); │ │ 190 | │ │ /// - `vector`: Query vector; must have length `dimension`. │ │ } │ │ 191 | │ │ /// - `top_k`: Number of nearest IDs to return. │ │ Ok(self.index.search(vector, top_k)) │ │ 192 | │ │ /// │ │ } │ │ 193 | │ │ /// # Returns │ │ │ │ 194 | │ │ /// A `Vec` of IDs sorted by increasing distance (best first). │ │ │ │ 195 | │ │ /// │ │ │ │ 196 | │ │ /// # Errors │ │ │ │ 197 | │ │ /// - `"dimension mismatch"` if `vector.len() != self.dimension`. │ │ │ │ 198 | ╰──────┴──────────────────────────────────────────────────────────────────────────────┴───────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┴─────────────── 199 | ```` 200 | 201 | ### 3. Show signatures and body token counts 202 | 203 | ```nu 204 | rust-ast | 205 | where kind == 'fn' and name == 'search' | 206 | select signature body_tokens 207 | ``` 208 | ```text 209 | ╭───┬────────────────────────────────────────────────────────────────────────────────────────┬─────────────╮ 210 | │ # │ signature │ body_tokens │ 211 | ├───┼────────────────────────────────────────────────────────────────────────────────────────┼─────────────┤ 212 | │ 0 │ pub fn search(&self, vector: &[f32], top_k: usize) -> Result, &'static str> │ 24 │ 213 | ╰───┴────────────────────────────────────────────────────────────────────────────────────────┴─────────────╯ 214 | ``` 215 | 216 | ### 4. Print a **colorized** symbol tree 217 | 218 | ```nu 219 | rust-tree | rust-print-symbol-tree 220 | ``` 221 | ![Print Symbol Tree Screenshot](./rust-print-symbol-tree.png) 222 | 223 | ### 5. Take advantage of **Nushell's built in regex matching** inside queries 224 | 225 | ```nu 226 | rust-ast | 227 | where kind == 'fn' and name =~ 'test_' | 228 | select signature body_tokens 229 | ``` 230 | ```text 231 | ╭───┬────────────────────────────────────────────────────────────┬─────────────╮ 232 | │ # │ signature │ body_tokens │ 233 | ├───┼────────────────────────────────────────────────────────────┼─────────────┤ 234 | │ 0 │ async fn test_create_client() │ 19 │ 235 | │ 1 │ async fn test_prepare_messages() │ 68 │ 236 | │ 2 │ fn test_load_config_valid_file() │ 88 │ 237 | │ 3 │ fn test_load_config_invalid_file() │ 9 │ 238 | │ 4 │ fn test_load_config_invalid_format() │ 18 │ 239 | │ 5 │ async fn test_load_template_valid_file() │ 99 │ 240 | │ 6 │ async fn test_load_template_invalid_file() │ 15 │ 241 | │ 7 │ async fn test_load_template_invalid_format() │ 83 │ 242 | │ 8 │ async fn test_vector_store() -> Result<(), Box> │ 51 │ 243 | ╰───┴────────────────────────────────────────────────────────────┴─────────────╯ 244 | ``` 245 | 246 | ### 6. Show **token counts** with aligned sub-columns in the symbol tree 247 | 248 | ```nu 249 | rust-tree | rust-print-symbol-tree --tokens 250 | ``` 251 | 252 | ### 7. Explore call graphs 253 | 254 | Default callers view: 255 | ```nu 256 | rust-print-call-graph crate::api::prepare_messages --max-depth 5 --show-roots 257 | ``` 258 | ```text 259 | Call graph depth: 5 ← callers crate::api::prepare_messages 260 | test_prepare_messages [crate::api::test_prepare_messages] 261 | | `- prepare_messages [crate::api::prepare_messages] 262 | main [crate::main] 263 | `- run [crate::run] 264 | |- handle_ask_command [crate::handle_ask_command] 265 | | `- ask [crate::api::ask] 266 | | `- get_session_messages [crate::api::get_session_messages] 267 | | |- prepare_messages [crate::api::prepare_messages] 268 | | `- prepare_messages_for_existing_session [crate::api::prepare_messages_for_existing_session] 269 | | `- prepare_messages [crate::api::prepare_messages] 270 | `- handle_interactive_command [crate::handle_interactive_command] 271 | `- interactive_mode [crate::api::interactive_mode] 272 | `- get_session_messages [crate::api::get_session_messages] 273 | |- prepare_messages [crate::api::prepare_messages] 274 | `- prepare_messages_for_existing_session [crate::api::prepare_messages_for_existing_session] 275 | ```` 276 | 277 | Bottom-up callers view: 278 | ```nu 279 | rust-print-call-graph crate::api::prepare_messages --reverse --max-depth 5 --show-roots 280 | ``` 281 | ``` 282 | Call graph depth: 5 ← callers (inverted) crate::api::prepare_messages 283 | prepare_messages [crate::api::prepare_messages] 284 | |- get_session_messages [crate::api::get_session_messages] 285 | | |- ask [crate::api::ask] 286 | | | `- handle_ask_command [crate::handle_ask_command] 287 | | | `- run [crate::run] 288 | | | `- main [crate::main] 289 | | `- interactive_mode [crate::api::interactive_mode] 290 | | `- handle_interactive_command [crate::handle_interactive_command] 291 | | `- run [crate::run] 292 | | `- main [crate::main] 293 | |- prepare_messages_for_existing_session [crate::api::prepare_messages_for_existing_session] 294 | | `- get_session_messages [crate::api::get_session_messages] 295 | | |- ask [crate::api::ask] 296 | | | `- handle_ask_command [crate::handle_ask_command] 297 | | | `- run [crate::run] 298 | | `- interactive_mode [crate::api::interactive_mode] 299 | | `- handle_interactive_command [crate::handle_interactive_command] 300 | | `- run [crate::run] 301 | `- test_prepare_messages [crate::api::test_prepare_messages] 302 | ``` 303 | 304 | ### 8. Find all call sites where external dependencies are used 305 | 306 | Example (text view): 307 | ```nu 308 | rust-print-dep-usage crossterm --max-depth 5 --include-maybe 309 | ``` 310 | ```text 311 | Dependency usage: crossterm 312 | direct references 313 | interactive_mode [crate::api::interactive_mode] uses: cursor::position 314 | main [crate::main] 315 | `- run [crate::run] 316 | `- handle_interactive_command [crate::handle_interactive_command] 317 | `- interactive_mode [crate::api::interactive_mode] 318 | ``` 319 | 320 | Example (text view, reversed): 321 | ```nu 322 | rust-print-dep-usage crossterm --max-depth 5 --include-maybe --reverse 323 | ``` 324 | ```text 325 | Dependency usage: crossterm 326 | direct references 327 | interactive_mode [crate::api::interactive_mode] uses: cursor::position 328 | `- handle_interactive_command [crate::handle_interactive_command] 329 | `- run [crate::run] 330 | `- main [crate::main] 331 | ``` 332 | 333 | Example (records view): 334 | ```nu 335 | rust-print-dep-usage --max-depth 5 --include-maybe --records 336 | ``` 337 | ```text 338 | ╭───┬────────────┬───────────────┬──────────────────────────────────────────────╮ 339 | │ # │ crate │ category │ symbol │ 340 | ├───┼────────────┼───────────────┼──────────────────────────────────────────────┤ 341 | │ 0 │ crossterm │ direct │ crate::api::interactive_mode │ 342 | │ 1 │ crossterm │ maybe (glob) │ crate::api::ask │ 343 | │ 2 │ diesel │ direct │ crate::session_messages::SessionMessages::… │ 344 | ╰───┴────────────┴───────────────┴──────────────────────────────────────────────╯ 345 | ``` 346 | 347 | --- 348 | 349 | ## 🙋🏻‍♀️ Why This Matters 350 | 351 | Use it to: 352 | 353 | - **Debug** complex relationships (trait impls, method resolution). 354 | - **Generate docs** from raw source. 355 | - **Analyze structure** for refactors and performance work. 356 | - **Revive a Rust project** that won't build and thus cannot make use of `rust-analyzer`. 357 | 358 | It helps answer the questions: 359 | 360 | > "What is this?", "Where did it come from?", "What does it do?", "Is it documented?", "What’s it related to?", "How do we remove it?" 361 | 362 | with actionable metadata. 363 | 364 | --- 365 | 366 | ## 🧩 Limitations & Tips 367 | 368 | - **Performance:** On huge crates, filter early (e.g., `where kind == 'fn'`) or scope paths. 369 | - **Module Paths:** File-based `mod` rows reflect filesystem layout, not necessarily `use` resolution. 370 | - **ANSI:** We color via Nushell’s `ansi` command. Spacing is computed on **stripped** strings, so alignment holds even with color. 371 | - **Token counts:** Heuristic by default unless you wire up `_token-count-via-tiktoken`. 372 | - **Inverted callers view**: avoids explosion by stopping at known roots. 373 | - **Cyclical dependencies**: Cycles are marked with (⟲ cycle). 374 | - Duplicate expansions are skipped once visited. 375 | 376 | --- 377 | 378 | ## 📚 Further Reading 379 | 380 | - [Ast-grep Documentation](https://ast-grep.github.io/reference/cli.html) 381 | - [Nushell Commands](https://www.nushell.sh/commands/) 382 | 383 | --- 384 | 385 | ## 📄 License 386 | 387 | Creative Commons Zero v1.0 Universal (CC0-1.0). 388 | If you use this to document your code, high-five ✋ 389 | 390 | --- 391 | 392 | ## 🤝 Contributing / Questions 393 | 394 | PRs and issues welcome. 395 | Questions? Ping me via email. 396 | 397 | — *Written by [Thomas Gentry](https://awfulsec.com) – a real human bean.* 🫛 398 | -------------------------------------------------------------------------------- /rust_ast.nu: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # rust-ast toolkit — Harvest, analyze, and pretty-print Rust symbols & calls 3 | # Website: https://github.com/graves/nu_rust_ast 4 | # Maintained by: Thomas Gentry 5 | # ============================================================================= 6 | # 7 | # OVERVIEW 8 | # -------- 9 | # CLI-oriented Nushell helpers that scan a Rust crate (or given paths) with 10 | # ast-grep and produce: 11 | # 1) A FLAT TABLE of symbols with rich, normalized metadata (`rust-ast`) 12 | # 2) A NESTED SYMBOL TREE for pretty printing (`rust-tree` -> `rust-print-symbol-tree`) 13 | # 3) A CALL GRAPH (both callees-of and callers-of) with cycle/dup handling 14 | # (`rust-print-call-graph`, internal walkers) 15 | # 4) DEPENDENCY USAGE TREES per external crate, including grouped uses, 16 | # alias resolution, and optional JSON-like nested records (`rust-print-dep-usage`) 17 | # 18 | # The tooling is designed so you can compose commands in pipelines, or call the 19 | # printers directly. All printers accept piped input where noted. 20 | # 21 | # ============================================================================= 22 | # 1) SYMBOL HARVESTING (FLAT TABLE) 23 | # ============================================================================= 24 | # 25 | # rust-ast [...paths] 26 | # 27 | # Scans Rust sources and emits a single, flat table of symbols: 28 | # kind ∈ { fn | extern_fn | struct | enum | type | trait | impl 29 | # | mod | macro_rules | const | static | use } 30 | # 31 | # For nodes with bodies (fns, struct-with-fields, traits, impls, inline mods), 32 | # `body_text` is captured verbatim. Tuple/unit structs, trait item decls, and 33 | # `mod foo;` have `body_text = null`. File modules (src/foo.rs, src/foo/mod.rs) 34 | # get a synthesized `mod` row covering the full file, with inner file docs. 35 | # 36 | # IMPORTANT INVARIANTS 37 | # -------------------- 38 | # - Line spans (span.start_line / end_line) are 1-based and inclusive (ast-grep). 39 | # - Byte offsets are inclusive start / exclusive end (ast-grep). 40 | # - UFCS fqpaths are computed for impl methods: 41 | # crate::::method (trait impl) 42 | # crate::MyTy::method (inherent impl) 43 | # - We attach leading rustdoc/#[doc] blocks to `doc`. For file modules we also 44 | # capture top-of-file inner docs (//! or /*! ... */). 45 | # 46 | # OUTPUT SCHEMA (per row) 47 | # ----------------------- 48 | # kind, name, crate, module_path[], fqpath, visibility, 49 | # file, span{start_line,end_line,start_byte,end_byte}, 50 | # attrs[], signature, has_body, async|unsafe|const (bool flags), 51 | # abi|generics|where (optional strings), 52 | # doc?, impl_of?{trait_path?,type_path?}, trait_items[], reexports[], 53 | # body_text?, synthetic?, doc_tokens?, body_tokens? 54 | # 55 | # TOKEN COUNTING 56 | # -------------- 57 | # Token counts are heuristic and configurable via $env.RUST_AST_TOKENIZER: 58 | # - "words" (default): rough word-based tokens 59 | # - "chars" : chars/4 60 | # - "tiktoken": use tiktoken model (falls back to "words" if unavailable) 61 | # 62 | # EXTERNAL-CRATE DETECTION 63 | # ------------------------ 64 | # We parse the nearest Cargo.toml to identify *external* crates from: 65 | # [dependencies], [dev-dependencies], [build-dependencies], and 66 | # [target.*.dependencies], ignoring path/workspace deps. 67 | # 68 | # ============================================================================= 69 | # 2) NESTED SYMBOL TREE & PRINTER 70 | # ============================================================================= 71 | # 72 | # rust-tree [...paths] [--include-use] 73 | # 74 | # Produces a *minimal* nested tree of records for the crate rooted at "crate": 75 | # { kind, name, fqpath, children: [...] } 76 | # 77 | # rust-tree | rust-print-symbol-tree [--fq-branches] [--tokens] 78 | # 79 | # Pretty-prints the nested tree with columns: 80 | # - Name: tree branches + symbol name (colorized by kind) 81 | # - Kind: aligned, colorized kind 82 | # - FQ Path: shown for leaves; with --fq-branches also for branch nodes 83 | # - Tokens: optional rightmost column ("Body Tokens, Doc Tokens") when 84 | # --tokens is supplied; widths are aligned per column. 85 | # 86 | # Notes: 87 | # - Color is via `ansi`; alignment uses visible-length (`ansi strip`) so spacing 88 | # stays correct with or without color. 89 | # - FQ paths are printed as plain text (no brackets). 90 | # 91 | # Examples: 92 | # rust-tree | rust-print-symbol-tree 93 | # rust-tree | rust-print-symbol-tree --fq-branches 94 | # rust-tree | rust-print-symbol-tree --tokens 95 | # 96 | # ============================================================================= 97 | # 3) CALL GRAPH (CALLEES / CALLERS) 98 | # ============================================================================= 99 | # 100 | # Internals build a canonicalized adjacency (per-fqpath with generics/whitespace 101 | # stripped segment-wise) and a map from canonical → real fqpaths so display shows 102 | # real names even when multiple canonical variants exist. 103 | # 104 | # rust-print-call-graph 105 | # [--max-depth =3] [--reverse] [--show-roots] 106 | # 107 | # - can be an exact fqpath ("crate::api::ask"), a module tail 108 | # ("::ask"), or a bare name ("ask"); multiple matches will render sequentially. 109 | # - Default (no flags): **inverted callers** view — top-down from *roots* (callers) 110 | # to the *target* (leaf). This answers “who (eventually) calls X?” while keeping 111 | # X at the end of each branch. 112 | # - --reverse: **bottom-up callers** view — the older style that starts at the 113 | # *target* and expands upward to its parents. 114 | # - --show-roots prints a one-line header for each root. The header looks like: 115 | # Call graph depth: N ← callers (inverted) crate::api::prepare_messages 116 | # for the default inverted view, and: 117 | # Call graph depth: N ← callers crate::api::prepare_messages 118 | # for the bottom-up (--reverse) view. 119 | # - Leaf column shows the *name* (last path segment) and the full fqpath in 120 | # brackets, e.g.: 121 | # ask [crate::api::ask] 122 | # 123 | # CYCLES & DUPLICATES 124 | # ------------------- 125 | # Walkers track a visited set and annotate loops (⟲ cycle). Repeated nodes are 126 | # printed once per path and not expanded again, preventing combinatorial blowups. 127 | # 128 | # ============================================================================= 129 | # 4) DEPENDENCY USAGE (EXTERNAL CRATES) 130 | # ============================================================================= 131 | # 132 | # rust-print-dep-usage [] [--max-depth =4] [--include-maybe] [--records] 133 | # 134 | # Scans functions to find references to external crates, using: 135 | # - explicit paths (e.g., `serde::Serialize::serialize`) 136 | # - module-local aliases introduced by `use` (e.g., `use serde_json as sj;`) 137 | # - optional glob imports (`use serde::*;`) gated via `--include-maybe` 138 | # 139 | # Output modes: 140 | # (default) Pretty text per crate with callers-of trees for each seed function: 141 | # Dependency usage: serde 142 | # direct references 143 | # serialize [crate::my_mod::serialize] uses: Serialize 144 | # ├─ ... 145 | # └─ ... 146 | # 147 | # --records Emits *nested* records instead of text. For each crate: 148 | # [ 149 | # { 150 | # kind, name, fqpath, children: [...] 151 | # (leaf nodes merged with { dep, ref_type: "real"|"maybe", uses: [...] }) 152 | # } 153 | # ] 154 | # If you pass a specific , the command returns just that crate's tree. 155 | # 156 | # Filtering & heuristics: 157 | # - "direct references" are concrete symbol paths resolved to that crate 158 | # - With --include-maybe, possible references via glob imports are included, 159 | # but *only* when they lie on a callers-path that reaches a direct reference. 160 | # 161 | # DISPLAY DETAILS 162 | # --------------- 163 | # - For all call/dependency trees the first column prints the *leaf name* 164 | # (short `name`), and the full fqpath is shown in brackets for clarity. 165 | # - Parents/continuation branches use ASCII/Unicode lines; repeated ancestors 166 | # are marked with ⟲ and not expanded again. 167 | # 168 | # ============================================================================= 169 | # 5) RUST `use` HANDLING 170 | # ============================================================================= 171 | # 172 | # rust-use-records 173 | # 174 | # Emits one row per `use` statement with normalized `signature` and `fqpath`. 175 | # Handles: 176 | # - grouped imports: use foo::{bar, baz::Qux as Alias, *}; 177 | # - aliasing: use serde_json as sj; 178 | # - base resolution: `crate` is normalized against the module path 179 | # - grouped leaves: a synthetic '*' name is used where appropriate 180 | # 181 | # Auxiliary expanders: 182 | # - `_expand-grouped-use`, `_expand-group-item` split grouped uses into leaves 183 | # - `_alias-map-by-module` maps visible alias/bindings → external crate names 184 | # - `_ext_globs_by_module` records external crates imported via `::*` per module 185 | # 186 | # ============================================================================= 187 | # 6) COMPOSITION & PIPING 188 | # ============================================================================= 189 | # 190 | # - Most printers accept piped rows from `rust-ast` to avoid re-scanning: 191 | # rust-ast | rust-print-dep-usage serde 192 | # rust-ast | rust-print-dep-usage --records | to json 193 | # 194 | # - `rust-print-symbol-tree` expects the *nested* shape from `rust-tree`: 195 | # rust-tree | rust-print-symbol-tree --tokens 196 | # 197 | # ============================================================================= 198 | # 7) QUICK RECIPES 199 | # ============================================================================= 200 | # 201 | # - Query the filename, function name, fully qualified path and callers of 202 | # the first 5 functions returned by rust-ast: 203 | # rust-ast | where kind == 'fn' | select file name fqpath callers | first 5 204 | # 205 | # - Show a colorized, aligned tree of your crate (no use imports): 206 | # rust-tree | rust-print-symbol-tree 207 | # 208 | # - Same, with fqpath on every node and token counts aligned to the right: 209 | # rust-tree | rust-print-symbol-tree --fq-branches --tokens 210 | # 211 | # - Who calls `crate::api::prepare_messages` (top-down, up to 5 levels)? 212 | # rust-print-call-graph crate::api::prepare_messages --max-depth 5 --show-roots 213 | # 214 | # - Same question, legacy bottom-up view (target first, then parents): 215 | # rust-print-call-graph crate::api::prepare_messages --reverse --max-depth 5 --show-roots 216 | # 217 | # - Where do we reference `serde`? (pretty) 218 | # rust-print-dep-usage serde --max-depth 5 --include-maybe 219 | # 220 | # - Where do we reference `serde`? (structured) 221 | # rust-print-dep-usage serde --records | to json 222 | # 223 | # ============================================================================= 224 | # IMPLEMENTATION NOTES (highlights) 225 | # ============================================================================= 226 | # 227 | # - Canonicalization: `_fq_canon` strips generics/whitespace per segment so 228 | # adjacencies remain stable across monomorphizations; display uses `canon2real` 229 | # to recover a representative real path. 230 | # 231 | # - Name vs FQ display: Tree/graph printers show the short `name` (leaf) in the 232 | # first column and the full `fqpath` in brackets. This improves scanability. 233 | # 234 | # - Cycle/dup guarding: Callers/callees renderers track a `seen` set; previously 235 | # visited nodes are shown once with a ⟲ marker and not expanded again. 236 | # 237 | # - Performance: Most builders accept piped input to avoid re-running `rust-ast`. 238 | # `rust-print-dep-usage --records` shares the row index with the tree builder to 239 | # prevent re-computation. 240 | # 241 | # ============================================================================= 242 | export def rust-ast [...paths:string] { 243 | _ensure-caches 244 | # 0) Precompute inline-mod index once (files under src/ only; fast) 245 | let files_all = (_list-rust-files ...$paths) 246 | let files_src = ($files_all | where {|p| $p | path split | any {|seg| $seg == 'src' }}) 247 | let idx = (_build-inline-mods-index $files_src) 248 | _inline-idx-set $idx 249 | 250 | # 1) then do the normal harvest 251 | [ 252 | (rust-fn-records ...$paths) 253 | (rust-extern-fn-records ...$paths) 254 | (rust-struct-records ...$paths) 255 | (rust-enum-records ...$paths) 256 | (rust-type-records ...$paths) 257 | (rust-trait-records ...$paths) 258 | (rust-trait-method-records ...$paths) 259 | (rust-impl-records ...$paths) 260 | (rust-mod-records ...$paths) 261 | (rust-file-mod-records ...$paths) 262 | (rust-macro-records ...$paths) 263 | (rust-const-records ...$paths) 264 | (rust-static-records ...$paths) 265 | (rust-use-records ...$paths) 266 | ] 267 | | flatten 268 | | _attach_impl_to_fns 269 | | _uniq-records 270 | | _uniq-by-kind-fqpath 271 | | _attach_callers 272 | } 273 | 274 | # # Nested structure of symbols — MINIMAL payload (kind, name, fqpath, children). 275 | # This is exactly what `rust-print-symbol-tree` expects to render/align/paint columns. 276 | # replace the body of rust-tree with this version 277 | export def rust-tree [ 278 | ...paths:string 279 | --include-use 280 | ] { 281 | _ensure-caches 282 | let piped = $in 283 | 284 | # If we got rows via the pipe, use them; else harvest now. 285 | let rows_all = ( 286 | if (( $piped | describe ) =~ '^(list|table)') 287 | and (not ($piped | is-empty)) 288 | and (($piped | first | describe) =~ '^record<') 289 | { $piped } else { rust-ast ...$paths } 290 | ) 291 | 292 | let rows_base = if $include_use { $rows_all } else { $rows_all | where kind != 'use' } 293 | 294 | let edges = (_build-symbol-edges $rows_base) 295 | let idx = (_rows-index $rows_base) 296 | 297 | let root_kids_fq = (_children-for $edges 'crate') 298 | let root_kids = ( 299 | $root_kids_fq 300 | | each {|cfq| _build-subtree $idx $edges $cfq } 301 | | where {|x| (($x | describe) =~ '^record<') } 302 | ) 303 | 304 | [{ kind: 'mod', name: 'crate', fqpath: 'crate', children: $root_kids }] 305 | } 306 | 307 | # ============================================================================= 308 | # rust-print-symbol-tree — Pretty-print a nested Rust symbol tree with columns 309 | # ============================================================================= 310 | # Works with the nested output from `rust-tree`. 311 | # 312 | # Columns: 313 | # - Name (tree/ASCII branches + symbol name; name text is colorized by kind) 314 | # - Kind (colorized and padded; e.g., fn, struct, enum, impl, mod, …) 315 | # - FQ Path (shown for leaves; with `--fq-branches` also shown on branches) 316 | # - Tokens (optional; with `--tokens`, shows "Body Tokens: N, Doc Tokens: M") 317 | # 318 | # Color: 319 | # Names and kinds are colorized via `_paint-kind`, which uses `ansi`. 320 | # Column widths are computed using `_vlen`, which strips ANSI codes so 321 | # alignment remains correct even when color is enabled. 322 | # 323 | # FQ Path formatting: 324 | # FQ paths are printed as plain text (no brackets). 325 | # 326 | # Usage: 327 | # rust-tree | rust-print-symbol-tree 328 | # rust-tree | rust-print-symbol-tree --fq-branches 329 | # rust-tree | rust-print-symbol-tree --tokens 330 | # rust-tree | rust-print-symbol-tree --fq-branches --tokens 331 | # 332 | # Options: 333 | # --fq-branches Show fqpath on branch nodes too (defaults to leaves only). 334 | # --tokens Add a rightmost column with body/doc token counts. 335 | # 336 | # Notes: 337 | # - This command expects a *nested* node (or list of nodes) from `rust-tree`. 338 | # It tolerates a single record, a list/table of records, or a JSON string. 339 | # - If your terminal or platform doesn't support ANSI, you'll still get 340 | # correct spacing (we measure visible length with `ansi strip`). 341 | # ============================================================================= 342 | export def rust-print-symbol-tree [ 343 | --fq-branches 344 | --tokens # <-- new flag 345 | ] { 346 | let input = $in 347 | let roots = (_roots-of $input) 348 | if ($roots | is-empty) { 349 | error make { msg: "rust-print-symbol-tree: input contains no records" } 350 | } 351 | 352 | let rows = ( 353 | $roots 354 | | each {|r| _collect-rows $r [] true } 355 | | flatten 356 | ) 357 | 358 | let tok_idx = if $tokens { _build-token-index } else { null } 359 | 360 | _print-with-columns $rows ($fq_branches | default false) $tok_idx 361 | } 362 | 363 | 364 | # ---------- helpers ----------------------------------------------------------- 365 | 366 | # Normalize CLI paths: empty => ["."], else pass-through. 367 | def _target-list [...paths:string] { 368 | if ($paths | is-empty) { [ "." ] } else { $paths } 369 | } 370 | 371 | # Read Cargo.toml package.name (best effort). Falls back to "crate". 372 | def _cargo-crate-name [] { 373 | try { 374 | # open --raw Cargo.toml | from toml | get package.name 375 | open Cargo.toml | get package.name # if you prefer implicit parse 376 | } catch { "crate" } 377 | } 378 | 379 | # Find the nearest Cargo.toml by walking up from pwd 380 | def _find-cargo-root [] { 381 | mut cur = (pwd) 382 | loop { 383 | let cand = ([$cur "Cargo.toml"] | path join) 384 | let typ = (try { $cand | path type } catch { null }) 385 | 386 | if $typ == 'file' { 387 | return { root: $cur, cargo: $cand } 388 | } 389 | 390 | let parent = ($cur | path dirname) 391 | if $parent == $cur { break } # reached fs root 392 | $cur = $parent 393 | } 394 | null 395 | } 396 | 397 | # Read Cargo.toml (nearest), return {} if none 398 | def _read-cargo-toml [] { 399 | let loc = (_find-cargo-root) 400 | if $loc == null { {} } else { 401 | try { 402 | # EITHER explicit: 403 | open --raw $loc.cargo | from toml 404 | # OR implicit (no from toml): 405 | # open $loc.cargo 406 | } catch {|e| 407 | {} 408 | } 409 | } 410 | } 411 | 412 | # --- replace your _external-crate-set with this --- 413 | def _external-crate-set [] { 414 | let toml = (_read-cargo-toml) 415 | 416 | def _g [rec key] { $rec | get -i $key | default {} } 417 | def _keys [x] { if (($x | describe) =~ '^record<') { $x | columns } else { [] } } 418 | 419 | def _is-ext [v] { 420 | let t = ($v | describe) 421 | if $t == 'string' { 422 | true 423 | } else if ($t | str starts-with 'record<') { 424 | let has_path = (try { $v | get -i path } catch { null }) != null 425 | let has_ws = (try { $v | get -i workspace } catch { null }) != null 426 | (not $has_path) and (not $has_ws) 427 | } else { false } 428 | } 429 | 430 | def _dep-keys [t] { 431 | let sections = [ 432 | (_g $t dependencies) 433 | (_g $t 'dev-dependencies') 434 | (_g $t 'build-dependencies') 435 | ( (_g $t target | values | each {|sec| _g $sec "dependencies"} ) | flatten | default {} ) 436 | ] 437 | $sections 438 | | each {|rec| 439 | _keys $rec 440 | | where {|k| _is-ext (try { $rec | get $k } catch { null }) } 441 | } 442 | | flatten 443 | } 444 | 445 | _dep-keys $toml | uniq | sort 446 | } 447 | 448 | # O(1) membership 449 | def _external-crate-map [] { 450 | _external-crate-set 451 | | reduce -f {} {|name, acc| $acc | upsert $name true } 452 | } 453 | 454 | # Convert file path to "module path" (Vec) rooted at src/. 455 | # - src/lib.rs and src/main.rs => [] (crate root modules) 456 | # - src/foo.rs => ["foo"] 457 | # - src/foo/mod.rs => ["foo"] 458 | # - src/a/b.rs => ["a","b"] 459 | # - src/a/b/mod.rs => ["a","b"] 460 | def _module-path-from-file [file:string] { 461 | let p = ($file | path expand) 462 | let parts = ($p | path split) 463 | 464 | # locate "src" segment; if missing, return [] 465 | let src_idx = ( 466 | $parts 467 | | enumerate 468 | | where item == "src" 469 | | get index 470 | | get 0? 471 | | default (-1) 472 | ) 473 | 474 | if $src_idx == -1 { [] } else { 475 | # take tail after src/ 476 | let tail = ($parts | skip ($src_idx + 1)) 477 | if ($tail | is-empty) { [] } else { 478 | let filename = ($tail | last) 479 | # crate root files carry no module path components 480 | if $filename in ["lib.rs", "main.rs"] { [] } else { 481 | # mod.rs => drop the filename 482 | if ($filename == "mod.rs") { $tail | drop 1 } else { 483 | # foo.rs => strip .rs; keep intermediate dirs 484 | $tail 485 | | each {|s| 486 | if ($s | str ends-with ".rs") { $s | str replace -r '\.rs$' '' } else { $s } 487 | } 488 | } 489 | } 490 | } 491 | } 492 | } 493 | 494 | # Best-effort visibility classifier based on the *signature* text. 495 | def _visibility-of [sig:string] { 496 | let s = ($sig | into string) 497 | if ($s | str starts-with 'pub(crate)') { 498 | 'pub(crate)' 499 | } else if ($s | str starts-with 'pub(super)') { 500 | 'pub(super)' 501 | } else if ($s | str starts-with 'pub(') { 502 | 'pub(in …)' 503 | } else if ($s | str starts-with 'pub ') { 504 | 'pub' 505 | } else { 'private' } 506 | } 507 | 508 | # Crude "has body" check: we only set true if there's a '{' and not a trailing ';'). 509 | def _has-body [text: string] { 510 | let t = ($text | default '' | into string) 511 | if ($t | str ends-with ';') { 512 | false 513 | } else { 514 | $t | str contains '{' 515 | } 516 | } 517 | 518 | # Normalize the first-line "signature" of a snippet (trim whitespace/comments after '{' or ';'). 519 | def _sigline [text: string] { 520 | let t = ($text | default '' | into string | str trim) 521 | if ($t | str contains '{') { 522 | $t | split row '{' | get 0 | str trim | str replace -ra '\s+' ' ' 523 | } else if ($t | str contains ';') { 524 | $t | split row ';' | get 0 | str trim | str replace -ra '\s+' ' ' 525 | } else { 526 | $t | lines | get 0 | str trim | str replace -ra '\s+' ' ' 527 | } 528 | } 529 | 530 | # ---- sg I/O helpers ---------------------------------------------------------- 531 | 532 | # Run ast-grep safely (returns error if neither `sg` nor `ast-grep`) works. 533 | def _run_sg [...args:string] { 534 | _dbg $"sg args: ( $args | str join ' ' )" 535 | try { ^sg ...$args } catch { 536 | try { ^ast-grep ...$args } catch { 537 | error make -u { msg: "ast-grep (`sg`/`ast-grep`) not found or failed" 538 | , label: { text: (['sg' 'ast-grep'] | str join ' / ') } } 539 | } 540 | } 541 | } 542 | 543 | def _sg_json [pattern:string, ...paths:string] { 544 | let target = (_target-list ...$paths) 545 | let key = $"json|( $pattern )|( $target | str join '|' )" 546 | let hit = (_sg_cache_get $key) 547 | if $hit != null { return $hit } 548 | 549 | _dbg $"_sg_json: pattern='($pattern)' files=( $target | length )" 550 | let out = (_run_sg 'run' '-l' 'rust' '-p' $pattern '--json=stream' '--heading=never' '--color=never' ...$target) 551 | | _parse_sg_json 552 | 553 | _sg_cache_put $key $out 554 | out 555 | } 556 | 557 | def _sg_json_on [pattern:string, targets:list] { 558 | let files = ($targets | where {|f| ($f | default null) != null } | uniq) 559 | let key = $"json_on|( $pattern )|( $files | str join '|' )" 560 | let hit = (_sg_cache_get $key) 561 | if $hit != null { return $hit } 562 | 563 | _dbg $"_sg_json_on: pattern='($pattern)' files=( $files | length )" 564 | let out = (_run_sg 'run' '-l' 'rust' '-p' $pattern '--json=stream' '--heading=never' '--color=never' ...$files) 565 | | _parse_sg_json 566 | 567 | _sg_cache_put $key $out 568 | $out 569 | } 570 | 571 | # Parse ast-grep --json=stream output into a flat list of records. 572 | def _parse_sg_json [] { 573 | let v = ($in | default "") 574 | let t = ($v | describe) 575 | 576 | if $t == 'nothing' { 577 | [] 578 | } else if $t == 'string' { 579 | $v 580 | | lines 581 | | where {|l| ($l | str length) > 0 } 582 | | each {|l| (try { $l | from json } catch { null }) } 583 | | where {|x| $x != null } 584 | } else if ($t | str starts-with 'list') { 585 | $v 586 | | each {|l| (try { $l | from json } catch { null }) } 587 | | where {|x| $x != null } 588 | } else if ($t | str starts-with 'record<') { 589 | [ $v ] 590 | } else if ($t | str starts-with 'list] { 614 | let files = ($targets | uniq) 615 | mut out = [] 616 | for f in $files { 617 | let key = $"(($f | path expand))|REWRITE|($pattern)|($rewrite)" 618 | if (_seen-has $key) { continue } 619 | _seen-add $key 620 | let cnt = (_bump-file-count $f) 621 | if $cnt > (_scan_cap) { 622 | error make { msg: "loop guard tripped" 623 | , label: { text: $"too many ast-grep runs for ($f)" } } 624 | } 625 | let rows = (_run_sg 'run' '-l' 'rust' '-p' $pattern '-r' $rewrite '--json=stream' '--heading=never' '--color=never' $f 626 | | _parse_sg_json) 627 | $out = ($out | append $rows) 628 | } 629 | $out | reduce -f [] {|b,a| $a | append $b } 630 | } 631 | 632 | def _sg_text_on [pattern:string, targets:list] { 633 | let files = ($targets | uniq) 634 | mut out = [] 635 | for f in $files { 636 | let key = $"(($f | path expand))|TEXT|($pattern)" 637 | if (_seen-has $key) { continue } 638 | _seen-add $key 639 | let cnt = (_bump-file-count $f) 640 | if $cnt > (_scan_cap) { 641 | error make { msg: "loop guard tripped" 642 | , label: { text: $"too many ast-grep runs for ($f)" } } 643 | } 644 | let rows = (_run_sg 'run' '-l' 'rust' '-p' $pattern '--json=stream' '--heading=never' '--color=never' $f 645 | | _parse_sg_json) 646 | $out = ($out | append $rows) 647 | } 648 | $out | reduce -f [] {|b,a| $a | append $b } 649 | } 650 | 651 | # Map many (pattern,rewrite) pairs through sg -r and flatten unique results. 652 | def _rewrite-many [pairs:list>, ...paths:string] { 653 | $pairs 654 | | each {|it| _sg_rewrite $it.p $it.r ...$paths } 655 | | flatten 656 | | uniq 657 | | sort 658 | } 659 | 660 | # --- Token count helpers ------------------------------------------------------ 661 | 662 | def _tok_wordish [s?: string] { 663 | let t = ($s | default "" | into string | str trim) 664 | if $t == "" { 0 } else { ($t | split row -r '\s+' | length) } 665 | } 666 | 667 | def _token-count [s?: string, model?: string] { 668 | let mode = ($env.RUST_AST_TOKENIZER | default "words") 669 | 670 | if $mode == "tiktoken" { 671 | let exact = (_token-count-via-tiktoken $s ($model | default "cl100k_base")) 672 | if $exact != null { $exact } else { _tok_wordish $s } 673 | } else if $mode == "chars" { 674 | let t = ($s | default "" | into string) 675 | if ($t == "") { 0 } else { ((($t | str length) + 3) / 4 | into int) } 676 | } else { 677 | _tok_wordish $s 678 | } 679 | } 680 | 681 | # Item (outer) rustdoc just above a node: return the exact lines verbatim. 682 | def _extract-rustdoc [raw: record] { 683 | let file = ($raw.file | into string) 684 | let start_line = ($raw.range.start.line | default 1) 685 | if $start_line <= 1 { return "" } 686 | 687 | let lines = (try { open $file | into string | lines } catch { [] }) 688 | if ($lines | is-empty) { return "" } 689 | 690 | mut i = ($start_line - 2) 691 | mut acc = [] 692 | 693 | while $i >= 0 { 694 | let raw_line = ($lines | get $i) 695 | let t = ($raw_line | str trim) 696 | 697 | if $t == "" { break } 698 | 699 | if ($t | str starts-with "///") { 700 | $acc = ([$raw_line] | append $acc) 701 | $i = ($i - 1) 702 | continue 703 | } 704 | 705 | if (($t | str starts-with "#[") and ($t | str contains "doc")) { 706 | $acc = ([$raw_line] | append $acc) 707 | $i = ($i - 1) 708 | continue 709 | } 710 | 711 | if (($t | str ends-with "*/") and ($t | str contains "/*")) { 712 | mut j = $i 713 | mut block = [] 714 | loop { 715 | if $j < 0 { break } 716 | let l2 = ($lines | get $j) 717 | $block = ([$l2] | append $block) 718 | if ((($l2 | str trim) | str starts-with "/**")) { break } 719 | $j = ($j - 1) 720 | } 721 | $acc = ($block | append $acc) 722 | $i = ($j - 1) 723 | continue 724 | } 725 | 726 | break 727 | } 728 | 729 | ($acc | str join "\n") 730 | } 731 | 732 | # All inline (in-file) modules in a file, with byte spans 733 | # module-global-ish cache via env (Nu allows env mutation) 734 | def _inline-mods-in-file [file:string] { 735 | let f = ($file | path expand) 736 | 737 | # fast path: cache hit 738 | let cached = (try { $env.__INLINE_MODS_CACHE | get $f } catch { null }) 739 | if $cached != null { return $cached } 740 | 741 | # slow path: compute once 742 | let pats = [ 'mod $N { $$$B }', 'pub mod $N { $$$B }' ] 743 | mut out = [] 744 | for p in $pats { 745 | let rows = (_sg_json_on $p [ $f ]) 746 | | each {|raw| 747 | let name = ($raw.metaVariables.single?.N.text | default null) 748 | if $name == null { null } else { 749 | { 750 | name: $name 751 | file: ($raw.file | into string) 752 | start: ($raw.range.byteOffset.start | default 0) 753 | end: ($raw.range.byteOffset.end | default 0) 754 | } 755 | } 756 | } 757 | | where {|x| $x != null } 758 | $out = ($out | append $rows) 759 | } 760 | 761 | let res = ( 762 | $out 763 | | reduce -f [] {|batch, acc| $acc | append $batch } 764 | | sort-by {|m| ($m.end - $m.start) } # outermost first 765 | ) 766 | 767 | # store in cache 768 | load-env { 769 | __INLINE_MODS_CACHE: ( 770 | ($env.__INLINE_MODS_CACHE | default {} ) 771 | | upsert $f $res 772 | ) 773 | } 774 | 775 | res 776 | } 777 | 778 | # Return the inline module chain (outer → inner) that strictly encloses [s,e) in file 779 | def _enclosing-inline-mods [file:string, s:int, e:int] { 780 | let f = ($file | path expand) 781 | let spans = (_inline-idx-get $f) 782 | if $spans == null { [] } else { 783 | $spans 784 | | where {|m| ($m.start < $s) and ($m.end > $e) } 785 | | sort-by {|m| ($m.end - $m.start) } 786 | | get -i name 787 | | default [] 788 | } 789 | } 790 | 791 | # Crate/file inner docs at the top of a file (//! or /*! ... */), verbatim. 792 | def _extract-file-mod-doc [file: string] { 793 | let lines = (try { open $file | into string | lines } catch { [] }) 794 | if ($lines | is-empty) { return "" } 795 | 796 | mut i = 0 797 | mut acc = [] 798 | 799 | if ((($lines | get 0 | default "" ) | str starts-with "#!")) { $i = 1 } 800 | 801 | loop { 802 | if $i >= ($lines | length) { break } 803 | let raw_line = ($lines | get $i) 804 | let t = ($raw_line | str trim) 805 | 806 | if $t == "" { break } 807 | 808 | if ($t | str starts-with "//!") { 809 | $acc = ($acc | append $raw_line) 810 | $i = ($i + 1) 811 | continue 812 | } 813 | 814 | if ($t | str starts-with "/*!") { 815 | mut j = $i 816 | loop { 817 | if $j >= ($lines | length) { break } 818 | let l2 = ($lines | get $j) 819 | $acc = ($acc | append $l2) 820 | if ((($l2 | str trim) | str ends-with "*/")) { break } 821 | $j = ($j + 1) 822 | } 823 | $i = ($j + 1) 824 | continue 825 | } 826 | 827 | break 828 | } 829 | 830 | ($acc | str join "\n") 831 | } 832 | 833 | # Given the flat rows, annotate fn rows with enclosing impl info (if any) and 834 | # compute a better fqpath that disambiguates trait impl methods (via UFCS). 835 | def _attach_impl_to_fns [rows?: list] { 836 | let rows = if ($rows | is-empty) { $in } else { $rows } 837 | 838 | let impls = ($rows | where kind == 'impl') 839 | let fns = ($rows | where kind == 'fn') 840 | let others = ($rows | where {|r| $r.kind != 'fn' and $r.kind != 'impl' }) 841 | 842 | let annotated_fns = ( 843 | $fns | each {|f| 844 | let encl = ( 845 | $impls 846 | | where file == $f.file 847 | | where {|i| 848 | (($i.span.start_byte | default 0) <= ($f.span.start_byte | default 0)) and (($i.span.end_byte | default 0) >= ($f.span.end_byte | default 0)) 849 | } 850 | | sort-by {|i| ($i.span.end_byte | default 0) - ($i.span.start_byte | default 0) } 851 | | get 0? 852 | ) 853 | 854 | if $encl == null { 855 | $f 856 | } else { 857 | let trait_path = ($encl.impl_of.trait_path | default null) 858 | let type_path = ($encl.impl_of.type_path | default null) 859 | let modp = ($f.module_path | default []) 860 | let modp_str = (if ($modp | is-empty) { "" } else { ($modp | str join '::') }) 861 | 862 | let fq = if $trait_path != null and ($trait_path | str length) > 0 and $type_path != null and ($type_path | str length) > 0 { 863 | if ($modp | is-empty) { 864 | $"crate::<($type_path) as ($trait_path)>::($f.name)" 865 | } else { 866 | $"crate::($modp_str)::<($type_path) as ($trait_path)>::($f.name)" 867 | } 868 | } else if $type_path != null and ($type_path | str length) > 0 { 869 | if ($modp | is-empty) { 870 | $"crate::($type_path)::($f.name)" 871 | } else { 872 | $"crate::($modp_str)::($type_path)::($f.name)" 873 | } 874 | } else { 875 | $f.fqpath 876 | } 877 | 878 | $f 879 | | upsert impl_of $encl.impl_of 880 | | upsert fqpath $fq 881 | } 882 | } 883 | ) 884 | [$others, $impls, $annotated_fns] | flatten 885 | } 886 | 887 | # Call sites with qualifiers captured when present 888 | def _rust-call-sites-on [targets:list] { 889 | let files = ($targets | where {|f| ($f | default null) != null } | uniq) 890 | 891 | let pats = [ 892 | '$N($$$A)' 893 | '$Q::$N($$$A)' 894 | '$RECV.$N($$$A)' 895 | 896 | # new: turbofish on the qualifier and/or on the function 897 | '$Q::<$$$G>::$N($$$A)', 898 | '$Q::$N::<$$$G>($$$A)', 899 | '$RECV.$N::<$$$G>($$$A)' 900 | ] 901 | 902 | mut out = [] 903 | for p in $pats { 904 | let rows = ( 905 | _sg_json_on $p $files 906 | | each {|raw| 907 | let s = ($raw.metaVariables.single? | default {}) 908 | 909 | let n = ($s | get -i N | default {} | get -i text | default null) 910 | if $n == null { null } else { 911 | let has_q = (($s | get -i Q | default null) != null) 912 | let has_recv = (($s | get -i RECV | default null) != null) 913 | let qual_val = if $has_q { ($s | get -i Q | get -i text | default '') } else if $has_recv { ($s | get -i RECV | get -i text | default '') } else '' 914 | 915 | { 916 | callee: $n 917 | qual: $qual_val 918 | kind: (if $has_q { 'qualified' } else if $has_recv { 'method' } else { 'plain' }) 919 | file: ($raw.file | into string) 920 | span: { 921 | start_byte: ($raw.range.byteOffset.start | default 0) 922 | end_byte: ($raw.range.byteOffset.end | default 0) 923 | } 924 | } 925 | } 926 | } 927 | | where {|x| $x != null } 928 | ) 929 | $out = ($out | append $rows) 930 | } 931 | 932 | $out | reduce -f [] {|it, acc| $acc | append $it } 933 | } 934 | 935 | # rows: the full table you already produce 936 | # Accept rows from arg or pipeline and attach a disambiguated 'callers' list 937 | def _attach_callers [rows?: list] { 938 | let rows0 = if ($rows | is-empty) { $in } else { $rows } 939 | let rows = ($rows0 | where {|r| ($r | describe) =~ '^record<' }) 940 | 941 | let fns = ($rows | where {|r| ($r | get -i kind | default '') == 'fn' }) 942 | 943 | let files = ( 944 | $rows 945 | | each {|r| ($r | get -i file | default null) } 946 | | where {|f| $f != null } 947 | | uniq 948 | ) 949 | 950 | let calls = (_rust-call-sites-on $files) 951 | let fn_index = (_index-fns-by-file $fns) 952 | let idx = (_build-fn-indexes $fns) 953 | 954 | let pairs = ( 955 | $calls 956 | | each {|c| 957 | let caller = (_enclosing-fn $fn_index $c.file ($c.span.start_byte | default 0) ($c.span.end_byte | default 0)) 958 | let target = (_resolve-call $idx $fns $c $caller) 959 | if $target == null { 960 | { callee_fq: $"( $c.qual | default '' )::( $c.callee )" 961 | , caller_fq: ($caller | get -i fqpath | default '') 962 | , maybe: true 963 | , qual: ($c.qual | default '') 964 | , callee: ($c.callee | default '') 965 | } 966 | } else { 967 | { callee_fq: ($target | get -i fqpath | default '') 968 | , caller_fq: ($caller | get -i fqpath | default '') 969 | , maybe: false 970 | , qual: ($c.qual | default '') 971 | , callee: ($c.callee | default '') 972 | } 973 | } 974 | } 975 | | where {|x| $x != null } 976 | ) 977 | 978 | let callee_to_callers = ( 979 | $pairs 980 | | group-by callee_fq 981 | | transpose fq callers 982 | | each {|g| 983 | { fq: $g.fq 984 | , callers: ($g.callers | get caller_fq | where {|v| ($v | default '') != '' } | uniq | sort) } 985 | } 986 | ) 987 | 988 | $rows 989 | | each {|r| 990 | let t = ($r | describe) 991 | if ($t =~ '^record<') { 992 | let kind = ($r | get -i kind | default '') 993 | if $kind != 'fn' { 994 | $r 995 | } else { 996 | let fq = ($r | get -i fqpath | default '') 997 | let ent = ($callee_to_callers | where fq == $fq | get 0?) 998 | if ( ($ent | describe) =~ '^record<' ) { 999 | ($r | upsert callers ($ent.callers | default [])) 1000 | } else { 1001 | ($r | upsert callers []) 1002 | } 1003 | } 1004 | } else { 1005 | null 1006 | } 1007 | } 1008 | | where {|x| $x != null } 1009 | } 1010 | 1011 | # Find smallest enclosing fn for a call site (same file; span containment) 1012 | def _enclosing-fn [ 1013 | fn_index:list>, 1014 | file:string, 1015 | s:int, 1016 | e:int 1017 | ] { 1018 | let matches = ($fn_index | where file == $file) 1019 | if ($matches | is-empty) { 1020 | null 1021 | } else { 1022 | let bucket = ($matches | get 0 | get items | default []) 1023 | $bucket 1024 | | where {|r| 1025 | (($r.span.start_byte | default 0) <= $s) and (($r.span.end_byte | default 0) >= $e) 1026 | } 1027 | | sort-by {|r| ($r.span.end_byte - $r.span.start_byte)} 1028 | | get 0? 1029 | } 1030 | } 1031 | 1032 | # Given FN rows, return an index { file -> [fn rows sorted by span size asc] } 1033 | def _index-fns-by-file [fns:list] { 1034 | $fns 1035 | | group-by file 1036 | | transpose file items 1037 | | each {|it| 1038 | { file: $it.file, items: ($it.items | sort-by {|r| ($r.span.end_byte - $r.span.start_byte) }) } 1039 | } 1040 | } 1041 | 1042 | # Group functions by quick keys we'll use for resolution 1043 | def _build-fn-indexes [fns:list] { 1044 | let by_fqpath = ($fns | group-by fqpath | transpose key vals) 1045 | 1046 | let impl_methods = ( 1047 | $fns 1048 | | where {|r| ($r | get -i impl_of | default {} | get -i type_path | default '') != '' } 1049 | | each {|r| { key: { ty: ($r.impl_of | get -i type_path), name: $r.name }, row: $r } } 1050 | | group-by {|x| $"($x.key.ty)::($x.key.name)" } 1051 | | transpose key vals 1052 | ) 1053 | 1054 | let free_fns = ( 1055 | $fns 1056 | | where {|r| ($r | get -i impl_of | default null) == null } 1057 | | each {|r| { key: { mod: ($r.module_path | default [] | str join '::'), name: $r.name }, row: $r } } 1058 | | group-by {|x| $"($x.key.mod)::($x.key.name)" } 1059 | | transpose key vals 1060 | ) 1061 | 1062 | { by_fqpath: $by_fqpath, impl_methods: $impl_methods, free_fns: $free_fns } 1063 | } 1064 | 1065 | # Resolve a callee to *one* function row (best-effort heuristics) 1066 | def _resolve-call [ 1067 | idx: record, 1068 | fns: list, 1069 | call: record, 1070 | caller_fn?: record 1071 | ] { 1072 | let name = $call.callee 1073 | let qual = ($call.qual | default '') 1074 | let kind = ($call.kind | default 'plain') 1075 | 1076 | let caller_impl_ty = ( 1077 | if ($caller_fn | describe) =~ '^nothing' { 1078 | '' 1079 | } else { 1080 | $caller_fn | get -i impl_of | default {} | get -i type_path | default '' 1081 | } 1082 | ) 1083 | let caller_mod = ( 1084 | if ($caller_fn | describe) =~ '^nothing' { 1085 | '' 1086 | } else { 1087 | $caller_fn | get -i module_path | default [] | str join '::' 1088 | } 1089 | ) 1090 | 1091 | if ($qual | str starts-with 'crate::') { 1092 | let tail = $"($qual)::($name)" 1093 | let exact = ($idx.by_fqpath | where key == $tail | get 0? | get -i vals | default []) 1094 | if (not ($exact | is-empty)) { return ($exact | get 0) } 1095 | } 1096 | 1097 | if ($kind == 'qualified' and ($qual | str contains '::')) { 1098 | let tail = $"($qual)::($name)" 1099 | let cand1 = ($fns | where {|r| ($r.fqpath | default '' | str ends-with $tail) }) 1100 | if (not ($cand1 | is-empty)) { return ($cand1 | get 0) } 1101 | } else if ($kind == 'qualified' and (not ($qual | str contains '::'))) { 1102 | let key = $"($qual)::($name)" 1103 | let cand2 = ($idx.impl_methods | where key == $key | get 0? | get -i vals | default []) 1104 | if (not ($cand2 | is-empty)) { return ($cand2 | get 0).row } 1105 | } 1106 | 1107 | if ($kind == 'method' and $caller_impl_ty != '') { 1108 | let key = $"($caller_impl_ty)::($name)" 1109 | let cand3 = ($idx.impl_methods | where key == $key | get 0? | get -i vals | default []) 1110 | if (not ($cand3 | is-empty)) { return ($cand3 | get 0).row } 1111 | } 1112 | 1113 | let key4 = $"($caller_mod)::($name)" 1114 | let cand4 = ($idx.free_fns | where key == $key4 | get 0? | get -i vals | default []) 1115 | if (not ($cand4 | is-empty)) { return ($cand4 | get 0).row } 1116 | 1117 | let cand_mod = ( 1118 | $fns 1119 | | where name == $name 1120 | | where {|r| ($r.module_path | default []) == $caller_fn.module_path } 1121 | ) 1122 | if ($cand_mod | length) == 1 { 1123 | return ($cand_mod | get 0) 1124 | } 1125 | 1126 | let cand5 = ($fns | where name == $name) 1127 | if ($cand5 | length) == 1 { 1128 | return ($cand5 | get 0) 1129 | } else { 1130 | null 1131 | } 1132 | 1133 | null 1134 | } 1135 | 1136 | # Build once per session; safe no-op if built again 1137 | def _build-inline-mods-index [files:list] { 1138 | let pats = [ 'mod $N { $$$B }' 'pub mod $N { $$$B }' ] 1139 | mut out = {} 1140 | for f in ($files | uniq) { 1141 | mut acc = [] 1142 | for p in $pats { 1143 | let rows = ( 1144 | _sg_json_on $p [ $f ] 1145 | | each {|raw| 1146 | let n = ($raw.metaVariables.single?.N.text | default null) 1147 | if $n == null { null } else { 1148 | { 1149 | name: $n 1150 | file: ($raw.file | into string) 1151 | start: ($raw.range.byteOffset.start | default 0) 1152 | end: ($raw.range.byteOffset.end | default 0) 1153 | } 1154 | } 1155 | } 1156 | | where {|x| $x != null } 1157 | ) 1158 | $acc = ($acc | append $rows) 1159 | } 1160 | let spans = ( 1161 | $acc 1162 | | reduce -f [] {|batch, a| $a | append $batch } 1163 | | sort-by {|m| $m.end - $m.start } # outermost-first 1164 | ) 1165 | $out = ($out | upsert ($f | path expand) $spans) 1166 | } 1167 | $out 1168 | } 1169 | 1170 | # predicate: does the env var exist? 1171 | def _has-env [name:string] { 1172 | $env | columns | any {|c| $c == $name } 1173 | } 1174 | 1175 | # setter: mutate env (portable) 1176 | export def --env _inline-idx-set [idx: record] { 1177 | load-env { __INLINE_IDX: $idx } 1178 | } 1179 | 1180 | # getter: read from env if present 1181 | def _inline-idx-get [file:string] { 1182 | if not (_has-env "__INLINE_IDX") { null } else { 1183 | try { $env.__INLINE_IDX | get ($file | path expand) } catch { null } 1184 | } 1185 | } 1186 | 1187 | # Read helper (side-effect free) 1188 | def _inline_idx_get [file:string] { 1189 | try { $env.__INLINE_IDX | get ($file | path expand) } catch { null } 1190 | } 1191 | 1192 | # ---- record builder / deduper ----------------------------------------------- 1193 | 1194 | def _mk-record [ 1195 | kind:string, 1196 | raw: record, 1197 | want_body: bool, 1198 | name_from?: string 1199 | ] { 1200 | let crate = (_cargo-crate-name) 1201 | let file = ($raw.file | default '') 1202 | let text = ($raw.text | default '') 1203 | 1204 | # existing file-derived module path (src/a/b.rs → ["a","b"]) 1205 | let modp_fs = (_module-path-from-file $file) 1206 | 1207 | # NEW: inline module chain inside the same file (e.g., ["sealed"]) 1208 | let s_byte = ($raw.range.byteOffset.start | default 0) 1209 | let e_byte = ($raw.range.byteOffset.end | default 0) 1210 | let is_mod = ($kind == 'mod') 1211 | 1212 | def _is-under-src [file:string] { 1213 | $file | path split | any {|seg| $seg == 'src' } 1214 | } 1215 | 1216 | # in _mk-record, just before computing modp_inline: 1217 | let modp_inline = ( 1218 | if (not (_is-under-src $file)) or ($kind == 'mod') { [] } 1219 | else { _enclosing-inline-mods $file $s_byte $e_byte } 1220 | ) 1221 | 1222 | # combine: filesystem path + inline modules 1223 | let modp = ($modp_fs | append $modp_inline) 1224 | 1225 | let hasb = (_has-body $text) 1226 | let sig = (_sigline $text) 1227 | let vis = (_visibility-of $sig) 1228 | 1229 | let single = ($raw.metaVariables.single? | default {}) 1230 | let nmeta = ($single | get -i N | default {} | get -i text | default '') 1231 | let name = if ($name_from | default '' | str length) > 0 { $name_from } else { $nmeta } 1232 | 1233 | let abi = ($single | get -i ABI | default {} | get -i text | default null) 1234 | let gens = ($single | get -i G | default {} | get -i text | default null) 1235 | let where_txt = ($single | get -i W | default {} | get -i text | default null) 1236 | 1237 | # FQ path now respects inline modules 1238 | let fq = if ($name | is-empty) { '' } else { 1239 | if ($modp | is-empty) { $"crate::($name)" } else { $"crate::($modp | str join '::')::($name)" } 1240 | } 1241 | 1242 | let doc_txt = (_extract-rustdoc $raw) 1243 | let body_txt = (if $want_body { _extract-src $raw } else { null }) 1244 | let doc_tok = (_token-count $doc_txt) 1245 | let body_tok = (_token-count $body_txt) 1246 | 1247 | { 1248 | kind: $kind 1249 | name: $name 1250 | crate: $crate 1251 | module_path: $modp 1252 | fqpath: $fq 1253 | visibility: $vis 1254 | file: $file 1255 | span: { 1256 | start_line: ($raw.range.start.line | default null) 1257 | end_line: ($raw.range.end.line | default null) 1258 | start_byte: $s_byte 1259 | end_byte: $e_byte 1260 | } 1261 | attrs: [] 1262 | signature: $sig 1263 | has_body: $hasb 1264 | async: ( ($sig | str starts-with 'async ') or ($sig | str contains ' async ') ) 1265 | unsafe: ( ($sig | str starts-with 'unsafe ') or ($sig | str contains ' unsafe ') ) 1266 | const: ( ($sig | str starts-with 'const ') or ($sig | str contains ' const ') ) 1267 | abi: $abi 1268 | generics: $gens 1269 | where: $where_txt 1270 | doc: $doc_txt 1271 | doc_tokens: $doc_tok 1272 | impl_of: null 1273 | trait_items: [] 1274 | reexports: [] 1275 | body_text: $body_txt 1276 | body_tokens: $body_tok 1277 | } 1278 | } 1279 | 1280 | # Create a synthetic `mod` row for a file module (src/foo.rs or src/foo/mod.rs). 1281 | def _mk-synthetic-mod [file:string] { 1282 | let crate = (_cargo-crate-name) 1283 | let modp = (_module-path-from-file $file) 1284 | if ($modp | is-empty) { return null } 1285 | 1286 | let name = ($modp | last) 1287 | let fq = if ($modp | is-empty) { $"crate::($name)" } else { $"crate::($modp | str join '::')" } 1288 | 1289 | let content = (try { open $file | into string } catch { "" }) 1290 | let line_count = ($content | lines | length) 1291 | let byte_len = ($content | into binary | length) 1292 | 1293 | let doc_txt = (_extract-file-mod-doc $file) 1294 | let doc_tok = (_token-count $doc_txt) 1295 | let body_tok = (_token-count $content) 1296 | 1297 | { 1298 | kind: 'mod' 1299 | name: $name 1300 | crate: $crate 1301 | module_path: $modp 1302 | fqpath: $fq 1303 | visibility: 'private' 1304 | file: $file 1305 | span: { start_line: 1, end_line: $line_count, start_byte: 0, end_byte: $byte_len } 1306 | attrs: [] 1307 | signature: $"mod ($name) {{ ... }}" 1308 | has_body: true 1309 | async: false 1310 | unsafe: false 1311 | const: false 1312 | abi: null 1313 | generics: null 1314 | where: null 1315 | doc: $doc_txt 1316 | doc_tokens: $doc_tok 1317 | impl_of: null 1318 | trait_items: [] 1319 | reexports: [] 1320 | body_text: $content 1321 | body_tokens: $body_tok 1322 | synthetic: true 1323 | } 1324 | } 1325 | 1326 | # Deduplicate rows by (kind, file, byte span). 1327 | def _uniq-records [rows?: list] { 1328 | let r = if ($rows | is-empty) { $in } else { $rows } 1329 | $r 1330 | | group-by {|x| [$x.kind $x.file $x.span.start_byte $x.span.end_byte] | to json } 1331 | | values 1332 | | each {|g| $g.0 } 1333 | | sort-by file span.start_line 1334 | } 1335 | 1336 | # Extract exact source for a matched node. 1337 | def _extract-src [raw: record] { 1338 | let from_raw = ($raw.text | default '' | into string) 1339 | if ($from_raw | str length) > 0 { $from_raw } else { 1340 | let file = ($raw.file | into string) 1341 | let sline0 = ( ($raw.range.start.line | default 1) - 1 ) 1342 | let eline0 = ($raw.range.end.line | default 1) 1343 | 1344 | try { 1345 | open $file 1346 | | into string 1347 | | lines 1348 | | skip $sline0 1349 | | take ( ($eline0 - $sline0) | into int ) 1350 | | str join "\n" 1351 | } catch { "" } 1352 | } 1353 | } 1354 | 1355 | # Split a comma list at top-level only, respecting brace nesting depth. 1356 | def _split-top-commas [s:string] { 1357 | mut depth = 0 1358 | mut cur = "" 1359 | mut parts = [] 1360 | for ch in ($s | split chars) { 1361 | if $ch == '{' { 1362 | $depth = $depth + 1; $cur = $cur + $ch 1363 | } else if $ch == '}' { 1364 | $depth = $depth - 1; $cur = $cur + $ch 1365 | } else if ($ch == ',' and $depth == 0) { 1366 | let piece = ($cur | str trim) 1367 | if ($piece | str length) > 0 { $parts = ($parts | append $piece) } 1368 | $cur = "" 1369 | } else { $cur = $cur + $ch } 1370 | } 1371 | let tail = ($cur | str trim) 1372 | if ($tail | str length) > 0 { $parts = ($parts | append $tail) } 1373 | $parts 1374 | } 1375 | 1376 | # Expand a grouped use leaf (recursively handles nested groups). 1377 | def _expand-group-item [base:string, item:string] { 1378 | let t = ($item | str trim | str replace -ra '^\s*::' '') 1379 | 1380 | if ($t | str contains '{') { 1381 | let prefix = ($t | str replace -ra '\{.*$' '' | str trim | str replace -ra '\s+' '') 1382 | let inside = ($t | str replace -ra '^[^{]*\{' '' | str replace -ra '\}\s*$' '') 1383 | 1384 | let new_base = if ($prefix | str length) > 0 { $"($base)::($prefix)" } else { $base } 1385 | _split-top-commas $inside 1386 | | each {|leaf| _expand-group-item $new_base $leaf } 1387 | | flatten 1388 | } else { 1389 | let parts = ($t | split row ' as ') 1390 | let path = ($parts | get 0 | str replace -ra '\s+' '') 1391 | 1392 | let resolved = if $path == 'self' { 1393 | $base 1394 | } else if $path == 'super' or $path == 'crate' { 1395 | $path 1396 | } else { 1397 | if ($path | str starts-with 'crate::') { $path } else { $"($base)::($path)" } 1398 | } 1399 | 1400 | let leaf_name0 = ( 1401 | if ($parts | length) > 1 { $parts | get 1 | str trim } else { 1402 | $resolved | split row '::' | last 1403 | } 1404 | ) 1405 | 1406 | [{ name: $leaf_name0, fqpath: $resolved }] 1407 | } 1408 | } 1409 | 1410 | # Expand a single grouped-use statement string into leaf entries (name, fqpath). 1411 | def _expand-grouped-use [src_text:string] { 1412 | let s = ($src_text | str replace -ra '(?s)^\s*' '' | str replace -ra '(?s)\s*$' '') 1413 | 1414 | let base0 = ($s | str replace -ra '(?s)^.*?\buse\s+' '' | str replace -ra '(?s)\{.*$' '' | str replace -ra '\s+' '') 1415 | let base = ($base0 | str replace -ra '::$' '') 1416 | let inside = ($s | str replace -ra '(?s)^.*?\{' '' | str replace -ra '(?s)\}.*$' '') 1417 | 1418 | let base_final = if ($base | str length) > 0 { $base } else { 'crate' } 1419 | 1420 | _split-top-commas $inside 1421 | | each {|leaf| _expand-group-item $base_final $leaf } 1422 | | flatten 1423 | } 1424 | 1425 | # Expand provided paths to a list of *.rs files. 1426 | def _list-rust-files [...paths:string] { 1427 | let targets = (_target-list ...$paths) 1428 | 1429 | let files = ( 1430 | $targets 1431 | | each {|t| 1432 | let p = ($t | path expand) 1433 | let typ = (try { $p | path type } catch { null }) 1434 | if $typ == 'file' { 1435 | if ($p | str ends-with '.rs') { [$p] } else { [] } 1436 | } else if $typ == 'dir' or $typ == null { 1437 | try { glob $"($p)/**\/*.rs" } catch { [] } 1438 | } else { [] } 1439 | } 1440 | | flatten 1441 | # TEMP: exclude heavy dirs 1442 | | where {|f| not ($f | str contains "/target/") } 1443 | | where {|f| not ($f | str contains "/vendor/") } 1444 | | where {|f| not ($f | str contains "/.git/") } 1445 | | sort | uniq 1446 | ) 1447 | 1448 | _dbg $"files: ( $files | length )" 1449 | # Optional: peek a few paths 1450 | _dbg $"first 5: ( $files | first 5 | str join ', ' )" 1451 | 1452 | $files 1453 | } 1454 | 1455 | # ---------- collectors per kind --------------------------------------------- 1456 | 1457 | export def rust-fn-records [...paths:string] { 1458 | let targets = (_target-list ...$paths) 1459 | 1460 | let pats = [ 1461 | 'fn $N($$$P) { $$$B }' 1462 | 'pub fn $N($$$P) { $$$B }' 1463 | 'async fn $N($$$P) { $$$B }' 1464 | 'pub async fn $N($$$P) { $$$B }' 1465 | 'unsafe fn $N($$$P) { $$$B }' 1466 | 'pub unsafe fn $N($$$P) { $$$B }' 1467 | 'const fn $N($$$P) { $$$B }' 1468 | 'pub const fn $N($$$P) { $$$B }' 1469 | 1470 | 'fn $N($$$P) -> $R { $$$B }' 1471 | 'pub fn $N($$$P) -> $R { $$$B }' 1472 | 'async fn $N($$$P) -> $R { $$$B }' 1473 | 'pub async fn $N($$$P) -> $R { $$$B }' 1474 | 'unsafe fn $N($$$P) -> $R { $$$B }' 1475 | 'pub unsafe fn $N($$$P) -> $R { $$$B }' 1476 | 'const fn $N($$$P) -> $R { $$$B }' 1477 | 'pub const fn $N($$$P) -> $R { $$$B }' 1478 | 1479 | 'fn $N($$$P);' 1480 | 'pub fn $N($$$P);' 1481 | 'async fn $N($$$P);' 1482 | 'pub async fn $N($$$P);' 1483 | 'unsafe fn $N($$$P);' 1484 | 'pub unsafe fn $N($$$P);' 1485 | 'const fn $N($$$P);' 1486 | 'pub const fn $N($$$P);' 1487 | 1488 | 'fn $N($$$P) -> $R;' 1489 | 'pub fn $N($$$P) -> $R;' 1490 | 'async fn $N($$$P) -> $R;' 1491 | 'pub async fn $N($$$P) -> $R;' 1492 | 'unsafe fn $N($$$P) -> $R;' 1493 | 'pub unsafe fn $N($$$P) -> $R;' 1494 | 'const fn $N($$$P) -> $R;' 1495 | 'pub const fn $N($$$P) -> $R;' 1496 | 1497 | 'fn $N($$$P) where $W { $$$B }' 1498 | 'pub fn $N($$$P) where $W { $$$B }' 1499 | 'fn $N($$$P) -> $R where $W { $$$B }' 1500 | 'pub fn $N($$$P) -> $R where $W { $$$B }' 1501 | 1502 | 'pub async fn $N<$G>($$$P) -> $R { $$$B }' 1503 | 'pub async fn $N<$G>($$$P) -> $R where $W { $$$B }' 1504 | 'pub async fn $N<$G>($$$P) { $$$B }' 1505 | 'async fn $N<$G>($$$P) -> $R { $$$B }' 1506 | 'async fn $N<$G>($$$P) -> $R where $W { $$$B }' 1507 | 1508 | "impl $TY { fn $N<$G>($$$P) -> $R { $$$B } }" 1509 | "impl $TY { fn $N<$G>($$$P) -> $R where $W { $$$B } }" 1510 | "impl $TY { fn $N<$G>($$$P) { $$$B } }" 1511 | 1512 | "impl $TR for $TY { fn $N<$G>($$$P) -> $R { $$$B } }" 1513 | "impl $TR for $TY { fn $N<$G>($$$P) -> $R where $W { $$$B } }" 1514 | "impl $TR for $TY where $W { fn $N<$G>($$$P) -> $R { $$$B } }" 1515 | "impl $TR for $TY where $W { fn $N<$G>($$$P) -> $R where $W2 { $$$B } }" 1516 | 1517 | "impl<$G> $TR for $TY { fn $N<$G2>($$$P) -> $R { $$$B } }" 1518 | "impl<$G> $TR for $TY where $W { fn $N<$G2>($$$P) -> $R where $W2 { $$$B } }" 1519 | 1520 | "trait $TR { fn $N<$G>($$$P) -> $R; }" 1521 | "trait $TR { fn $N<$G>($$$P) -> $R where $W; }" 1522 | 1523 | 'fn $N<$G>($$$P) { $$$B }' 1524 | 'pub fn $N<$G>($$$P) { $$$B }' 1525 | 'async fn $N<$G>($$$P) { $$$B }' 1526 | 'pub async fn $N<$G>($$$P) { $$$B }' 1527 | 'unsafe fn $N<$G>($$$P) { $$$B }' 1528 | 'pub unsafe fn $N<$G>($$$P) { $$$B }' 1529 | 'const fn $N<$G>($$$P) { $$$B }' 1530 | 'pub const fn $N<$G>($$$P) { $$$B }' 1531 | 1532 | 'fn $N<$G>($$$P) -> $R { $$$B }' 1533 | 'pub fn $N<$G>($$$P) -> $R { $$$B }' 1534 | 'async fn $N<$G>($$$P) -> $R { $$$B }' 1535 | 'pub async fn $N<$G>($$$P) -> $R { $$$B }' 1536 | 'unsafe fn $N<$G>($$$P) -> $R { $$$B }' 1537 | 'pub unsafe fn $N<$G>($$$P) -> $R { $$$B }' 1538 | 'const fn $N<$G>($$$P) -> $R { $$$B }' 1539 | 'pub const fn $N<$G>($$$P) -> $R { $$$B }' 1540 | 1541 | 'fn $N<$G>($$$P) where $W { $$$B }' 1542 | 'pub fn $N<$G>($$$P) where $W { $$$B }' 1543 | 'fn $N<$G>($$$P) -> $R where $W { $$$B }' 1544 | 'pub fn $N<$G>($$$P) -> $R where $W { $$$B }' 1545 | 1546 | 'fn $N<$G>($$$P);' 1547 | 'pub fn $N<$G>($$$P);' 1548 | 'fn $N<$G>($$$P) -> $R;' 1549 | 'pub fn $N<$G>($$$P) -> $R;' 1550 | ] 1551 | 1552 | mut out = [] 1553 | for p in $pats { 1554 | let want_body = ($p | str contains '{ $$$B }') 1555 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'fn' $raw $want_body }) 1556 | $out = ($out | append $rows) 1557 | } 1558 | 1559 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1560 | } 1561 | 1562 | # extern "ABI" functions 1563 | export def rust-extern-fn-records [...paths:string] { 1564 | let targets = (_target-list ...$paths) 1565 | let pats = [ 1566 | 'pub unsafe extern $ABI fn $N$G?($$$P) -> $R { $$$B }' 1567 | 'pub unsafe extern $ABI fn $N$G?($$$P) { $$$B }' 1568 | 'unsafe extern $ABI fn $N$G?($$$P) -> $R { $$$B }' 1569 | 'unsafe extern $ABI fn $N$G?($$$P) { $$$B }' 1570 | 'pub extern $ABI fn $N$G?($$$P) -> $R { $$$B }' 1571 | 'pub extern $ABI fn $N$G?($$$P) { $$$B }' 1572 | 'extern $ABI fn $N$G?($$$P) -> $R { $$$B }' 1573 | 'extern $ABI fn $N$G?($$$P) { $$$B }' 1574 | 'pub unsafe extern $ABI fn $N$G?($$$P) -> $R;' 1575 | 'pub unsafe extern $ABI fn $N$G?($$$P);' 1576 | 'unsafe extern $ABI fn $N$G?($$$P) -> $R;' 1577 | 'unsafe extern $ABI fn $N$G?($$$P);' 1578 | 'pub extern $ABI fn $N$G?($$$P) -> $R;' 1579 | 'pub extern $ABI fn $N$G?($$$P);' 1580 | 'extern $ABI fn $N$G?($$$P) -> $R;' 1581 | 'extern $ABI fn $N$G?($$$P);' 1582 | ] 1583 | mut out = [] 1584 | for p in $pats { 1585 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'extern_fn' $raw ($p | str contains '{ $$$B }') }) 1586 | $out = ($out | append $rows) 1587 | } 1588 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1589 | } 1590 | 1591 | # Structs (handles generics, tuple/unit, where-clauses, and common vis forms) 1592 | # Structs — braced / tuple / unit 1593 | # - Explicit generic vs non-generic variants (no <$G>?). 1594 | # - Allow where-clauses only on braced forms (tuple/unit+where caused ERROR nodes). 1595 | export def rust-struct-records [...paths:string] { 1596 | let targets = (_target-list ...$paths) 1597 | 1598 | let pats = [ 1599 | # ---------- braced ---------- 1600 | 'struct $N { $$$F }', 1601 | 'pub struct $N { $$$F }', 1602 | 'struct $N<$G> { $$$F }', 1603 | 'pub struct $N<$G> { $$$F }', 1604 | 'struct $N where $W { $$$F }', 1605 | 'pub struct $N where $W { $$$F }', 1606 | 'struct $N<$G> where $W { $$$F }', 1607 | 'pub struct $N<$G> where $W { $$$F }', 1608 | 1609 | # ---------- tuple ---------- 1610 | 'struct $N($$$F);', 1611 | 'pub struct $N($$$F);', 1612 | 'struct $N<$G>($$$F);', 1613 | 'pub struct $N<$G>($$$F);', 1614 | 1615 | # ---------- unit ---------- 1616 | 'struct $N;', 1617 | 'pub struct $N;', 1618 | 'struct $N<$G>;', 1619 | 'pub struct $N<$G>;', 1620 | ] 1621 | 1622 | mut out = [] 1623 | for p in $pats { 1624 | let want_body = ($p | str contains '{') # only braced structs capture body_text 1625 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'struct' $raw $want_body }) 1626 | $out = ($out | append $rows) 1627 | } 1628 | 1629 | $out 1630 | | reduce -f [] {|batch, acc| $acc | append $batch } 1631 | | _uniq-records 1632 | } 1633 | 1634 | # Enums — cover: no generics, generics, where, generics+where. 1635 | export def rust-enum-records [...paths:string] { 1636 | let targets = (_target-list ...$paths) 1637 | 1638 | let pats = [ 1639 | # no generics 1640 | 'enum $N { $$$V }', 1641 | 'pub enum $N { $$$V }', 1642 | 1643 | # generics 1644 | 'enum $N<$G> { $$$V }', 1645 | 'pub enum $N<$G> { $$$V }', 1646 | 1647 | # where (on the enum itself) 1648 | 'enum $N where $W { $$$V }', 1649 | 'pub enum $N where $W { $$$V }', 1650 | 1651 | # generics + where 1652 | 'enum $N<$G> where $W { $$$V }', 1653 | 'pub enum $N<$G> where $W { $$$V }', 1654 | ] 1655 | 1656 | mut out = [] 1657 | for p in $pats { 1658 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'enum' $raw true }) 1659 | $out = ($out | append $rows) 1660 | } 1661 | 1662 | $out 1663 | | reduce -f [] {|batch, acc| $acc | append $batch } 1664 | | _uniq-records 1665 | } 1666 | 1667 | # Type aliases 1668 | export def rust-type-records [...paths:string] { 1669 | let targets = (_target-list ...$paths) 1670 | let pats = [ 1671 | 'pub type $N$G? = $$$T;' 1672 | 'type $N$G? = $$$T;' 1673 | ] 1674 | mut out = [] 1675 | for p in $pats { 1676 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'type' $raw true }) 1677 | $out = ($out | append $rows) 1678 | } 1679 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1680 | } 1681 | 1682 | # Traits 1683 | # Traits (now matches supertraits and optional where-clauses) 1684 | export def rust-trait-records [...paths:string] { 1685 | let targets = (_target-list ...$paths) 1686 | 1687 | let pats = [ 1688 | # no generics, no supertrait 1689 | 'pub unsafe trait $N { $$$B }' 1690 | 'pub trait $N { $$$B }' 1691 | 'unsafe trait $N { $$$B }' 1692 | 'trait $N { $$$B }' 1693 | 1694 | # no generics, WITH supertrait 1695 | 'pub unsafe trait $N: $T { $$$B }' 1696 | 'pub trait $N: $T { $$$B }' 1697 | 'unsafe trait $N: $T { $$$B }' 1698 | 'trait $N: $T { $$$B }' 1699 | 1700 | # no generics, WITH where 1701 | 'pub unsafe trait $N where $W { $$$B }' 1702 | 'pub trait $N where $W { $$$B }' 1703 | 'unsafe trait $N where $W { $$$B }' 1704 | 'trait $N where $W { $$$B }' 1705 | 1706 | # no generics, WITH supertrait + where 1707 | 'pub unsafe trait $N: $T where $W { $$$B }' 1708 | 'pub trait $N: $T where $W { $$$B }' 1709 | 'unsafe trait $N: $T where $W { $$$B }' 1710 | 'trait $N: $T where $W { $$$B }' 1711 | 1712 | # generics (optional), no supertrait 1713 | 'pub unsafe trait $N<$G>? { $$$B }' 1714 | 'pub trait $N<$G>? { $$$B }' 1715 | 'unsafe trait $N<$G>? { $$$B }' 1716 | 'trait $N<$G>? { $$$B }' 1717 | 1718 | # generics (optional), WITH supertrait 1719 | 'pub unsafe trait $N<$G>?: $T { $$$B }' 1720 | 'pub trait $N<$G>?: $T { $$$B }' 1721 | 'unsafe trait $N<$G>?: $T { $$$B }' 1722 | 'trait $N<$G>?: $T { $$$B }' 1723 | 1724 | # generics (optional), WITH where 1725 | 'pub unsafe trait $N<$G>? where $W { $$$B }' 1726 | 'pub trait $N<$G>? where $W { $$$B }' 1727 | 'unsafe trait $N<$G>? where $W { $$$B }' 1728 | 'trait $N<$G>? where $W { $$$B }' 1729 | 1730 | # generics (optional), WITH supertrait + where 1731 | 'pub unsafe trait $N<$G>?: $T where $W { $$$B }' 1732 | 'pub trait $N<$G>?: $T where $W { $$$B }' 1733 | 'unsafe trait $N<$G>?: $T where $W { $$$B }' 1734 | 'trait $N<$G>?: $T where $W { $$$B }' 1735 | ] 1736 | 1737 | mut out = [] 1738 | for p in $pats { 1739 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'trait' $raw false }) 1740 | $out = ($out | append $rows) 1741 | } 1742 | 1743 | $out 1744 | | reduce -f [] {|batch, acc| $acc | append $batch } 1745 | | _uniq-records 1746 | } 1747 | 1748 | # Add this helper near _uniq-records 1749 | def _uniq-by-kind-fqpath [rows?: list] { 1750 | let r = if ($rows | is-empty) { $in } else { $rows } 1751 | $r 1752 | | where {|x| ($x | describe) =~ '^record<' } 1753 | | group-by {|x| [($x.kind | default ''), ($x.fqpath | default '')] | to json } 1754 | | values 1755 | # pick a stable representative (smallest span, then earliest line) 1756 | | each {|g| 1757 | $g 1758 | | sort-by {|x| [ ($x.span.end_byte | default 0) - ($x.span.start_byte | default 0) 1759 | , ($x.span.start_line | default 0) ] } 1760 | | get 0 1761 | } 1762 | | sort-by file span.start_line 1763 | } 1764 | 1765 | # impl blocks 1766 | export def rust-impl-records [...paths:string] { 1767 | let targets = (_target-list ...$paths) 1768 | let pats = [ 1769 | 'unsafe impl $TR for $TY { $$$B }' 1770 | 'impl $TR for $TY { $$$B }' 1771 | 'unsafe impl<$G> $TR for $TY { $$$B }' 1772 | 'impl<$G> $TR for $TY { $$$B }' 1773 | 'unsafe impl $TR for $TY where $W { $$$B }' 1774 | 'impl $TR for $TY where $W { $$$B }' 1775 | 'unsafe impl<$G> $TR for $TY where $W { $$$B }' 1776 | 'impl<$G> $TR for $TY where $W { $$$B }' 1777 | 1778 | 'unsafe impl $TY { $$$B }' 1779 | 'impl $TY { $$$B }' 1780 | 'unsafe impl<$G> $TY { $$$B }' 1781 | 'impl<$G> $TY { $$$B }' 1782 | 'unsafe impl $TY where $W { $$$B }' 1783 | 'impl $TY where $W { $$$B }' 1784 | 'unsafe impl<$G> $TY where $W { $$$B }' 1785 | 'impl<$G> $TY where $W { $$$B }' 1786 | ] 1787 | 1788 | mut out = [] 1789 | for p in $pats { 1790 | let rows = ( 1791 | _sg_json_on $p $targets 1792 | | each {|raw| 1793 | let rec = (_mk-record 'impl' $raw true) 1794 | 1795 | let single = ($raw.metaVariables.single? | default {}) 1796 | let trait_path = ($single | get -i TR | default {} | get -i text | default null) 1797 | let type_path1 = ($single | get -i TY | default {} | get -i text | default null) 1798 | let type_path2 = ($single | get -i T | default {} | get -i text | default null) 1799 | let type_path = (if $type_path1 != null { $type_path1 } else { $type_path2 }) 1800 | 1801 | let impl_name = if ($trait_path | default '' | str length) > 0 and ($type_path | default '' | str length) > 0 { 1802 | $"<($type_path) as ($trait_path)>" 1803 | } else if ($type_path | default '' | str length) > 0 { 1804 | $type_path 1805 | } else { 1806 | 'impl' 1807 | } 1808 | 1809 | let modp = ($rec.module_path | default []) 1810 | let modp_str = (if ($modp | is-empty) { "" } else { ($modp | str join '::') }) 1811 | let fq = if ($modp | is-empty) { $"crate::($impl_name)" } else { $"crate::($modp_str)::($impl_name)" } 1812 | 1813 | $rec 1814 | | upsert impl_of { trait_path: $trait_path, type_path: $type_path } 1815 | | upsert name $impl_name 1816 | | upsert fqpath $fq 1817 | } 1818 | ) 1819 | $out = ($out | append $rows) 1820 | } 1821 | 1822 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1823 | } 1824 | 1825 | # Collect trait-impl methods efficiently: 1826 | # - Reuse piped rows if present (no re-scan). 1827 | # - Otherwise, for each file, harvest fns ONCE and slice by block span. 1828 | export def rust-trait-method-records [...paths:string] { 1829 | let targets = (_target-list ...$paths) 1830 | 1831 | # Prefer piped rows if present 1832 | let piped = $in 1833 | let piped_rows = ( 1834 | if (( $piped | describe ) =~ '^(list|table)') 1835 | and (not ($piped | is-empty)) 1836 | and (($piped | first | describe) =~ '^record<') 1837 | { $piped } else { null } 1838 | ) 1839 | 1840 | # Grab impl blocks once 1841 | let impl_pats = [ 1842 | 'impl $T for $S { $$$I }', 1843 | 'impl<$G> $T for $S { $$$I }', 1844 | 'impl $T for $S where $W { $$$I }', 1845 | 'impl<$G> $T for $S where $W { $$$I }', 1846 | ] 1847 | 1848 | let blocks = ( 1849 | $impl_pats 1850 | | each {|ip| 1851 | _sg_json_on $ip $targets 1852 | | each {|b| 1853 | { 1854 | file: ($b.file | into string) 1855 | s: ($b.range.byteOffset.start | default 0) 1856 | e: ($b.range.byteOffset.end | default 0) 1857 | } 1858 | } 1859 | } 1860 | | flatten 1861 | | sort-by file s e 1862 | ) 1863 | 1864 | if ($blocks | is-empty) { return [] } 1865 | 1866 | # Group blocks by file so we harvest functions ONCE per file 1867 | let by_file = ( 1868 | $blocks 1869 | | group-by file 1870 | | transpose file items 1871 | ) 1872 | 1873 | mut out = [] 1874 | 1875 | for bf in $by_file { 1876 | let file = $bf.file 1877 | let spans = ($bf.items | select s e) 1878 | 1879 | # Choose the function pool for this file: 1880 | # - prefer piped rows if present 1881 | # - else run rust-fn-records $file ONCE 1882 | let fns_in_file_all = if (($piped_rows | describe) =~ '^(list|table)') { 1883 | $piped_rows 1884 | | where kind == 'fn' 1885 | | where file == $file 1886 | } else { 1887 | rust-fn-records $file 1888 | } 1889 | 1890 | if (not ($fns_in_file_all | is-empty)) { 1891 | for b in $spans { 1892 | let s = ($b.s | default 0) 1893 | let e = ($b.e | default 0) 1894 | let subset = ( 1895 | $fns_in_file_all 1896 | | where {|it| 1897 | ((($it.span.start_byte | default 0) >= $s) and 1898 | (($it.span.end_byte | default 0) <= $e)) 1899 | } 1900 | ) 1901 | if (not ($subset | is-empty)) { 1902 | $out = ($out | append $subset) 1903 | } 1904 | } 1905 | } 1906 | } 1907 | 1908 | $out | _uniq-records 1909 | } 1910 | 1911 | # Module syntax (inline and declarations) 1912 | export def rust-mod-records [...paths:string] { 1913 | let targets = (_target-list ...$paths) 1914 | 1915 | let pats_with_body = [ 1916 | 'pub mod $N { $$$B }' 1917 | 'mod $N { $$$B }' 1918 | ] 1919 | 1920 | let pats_decl = [ 1921 | 'pub mod $N;' 1922 | 'mod $N;' 1923 | ] 1924 | 1925 | mut out = [] 1926 | 1927 | for p in $pats_with_body { 1928 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'mod' $raw true }) 1929 | $out = ($out | append $rows) 1930 | } 1931 | 1932 | for p in $pats_decl { 1933 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'mod' $raw false }) 1934 | $out = ($out | append $rows) 1935 | } 1936 | 1937 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1938 | } 1939 | 1940 | # File modules synthesized from filesystem layout 1941 | export def rust-file-mod-records [...paths:string] { 1942 | _list-rust-files ...$paths 1943 | | each {|f| _mk-synthetic-mod $f } 1944 | | where {|x| $x != null } 1945 | | _uniq-records 1946 | } 1947 | 1948 | # macro_rules! 1949 | export def rust-macro-records [...paths:string] { 1950 | let targets = (_target-list ...$paths) 1951 | let pats = [ 'macro_rules! $N { $$$B }' ] 1952 | mut out = [] 1953 | for p in $pats { 1954 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'macro_rules' $raw false }) 1955 | $out = ($out | append $rows) 1956 | } 1957 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1958 | } 1959 | 1960 | # const items 1961 | export def rust-const-records [...paths:string] { 1962 | let targets = (_target-list ...$paths) 1963 | let pats = [ 1964 | 'pub const $N: $$$T = $$$V;' 1965 | 'const $N: $$$T = $$$V;' 1966 | ] 1967 | mut out = [] 1968 | for p in $pats { 1969 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'const' $raw false }) 1970 | $out = ($out | append $rows) 1971 | } 1972 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1973 | } 1974 | 1975 | # static items 1976 | export def rust-static-records [...paths:string] { 1977 | let targets = (_target-list ...$paths) 1978 | let pats = [ 1979 | 'pub static $N: $$$T = $$$V;' 1980 | 'static $N: $$$T = $$$V;' 1981 | ] 1982 | mut out = [] 1983 | for p in $pats { 1984 | let rows = (_sg_json_on $p $targets | each {|raw| _mk-record 'static' $raw false }) 1985 | $out = ($out | append $rows) 1986 | } 1987 | $out | reduce -f [] {|batch, acc| $acc | append $batch } | _uniq-records 1988 | } 1989 | 1990 | # use/import statements (one row per statement) 1991 | export def rust-use-records [...paths:string] { 1992 | let targets = (_target-list ...$paths) 1993 | 1994 | # also match pub(crate), pub(in …), etc. 1995 | let pats = ['pub use $$$I;' 'pub($$$V) use $$$I;' 'use $$$I;'] 1996 | mut out = [] 1997 | 1998 | for p in $pats { 1999 | let rows = ( 2000 | _sg_json_on $p $targets 2001 | | each {|raw| 2002 | let file = ($raw.file | into string) 2003 | 2004 | # Use the exact matched snippet from ast-grep. 2005 | mut stmt = ($raw.text | into string | str trim) 2006 | if ($stmt | is-empty) { 2007 | # very rare fallback 2008 | let sline = ( ($raw.range.start.line | default 1) - 1 ) 2009 | let eline = ($raw.range.end.line | default 1) 2010 | let stmt = ( 2011 | try { 2012 | open $file 2013 | | into string 2014 | | lines 2015 | | skip $sline 2016 | | take ( ($eline - $sline + 1) | into int ) 2017 | | str join "\n" 2018 | } catch { "" } 2019 | ) | str trim 2020 | } 2021 | 2022 | if ($stmt | is-empty) { null } else { 2023 | # Normalize whitespace for downstream parsing 2024 | let stmt = ($stmt | str replace -ra '\s+' ' ' | str trim) 2025 | 2026 | # Strip leading visibility (`pub` or `pub(...)`) and trailing ';' 2027 | let body0 = ( 2028 | $stmt 2029 | | str replace -ra '^\s*(pub\s*(\([^)]+\)\s*)?)?use\s+' '' 2030 | | str replace -ra '\s*;\s*$' '' 2031 | | str trim 2032 | ) 2033 | 2034 | if ($body0 == '' or $body0 == '}') { 2035 | null 2036 | } else { 2037 | let is_grouped = ($body0 | str contains '{') 2038 | let modp = (_module-path-from-file $file) 2039 | let crate_base = if ($modp | is-empty) { 'crate' } else { $"crate::($modp | str join '::')" } 2040 | 2041 | if $is_grouped { 2042 | let prefix0 = ( 2043 | $body0 2044 | | str replace -ra '(?s)\{.*$' '' 2045 | | str replace -ra '\s+' '' 2046 | | str replace -ra '::$' '' 2047 | ) 2048 | let base = if $prefix0 == 'crate' { $crate_base } else { $prefix0 } 2049 | 2050 | (_mk-record 'use' $raw false '*') 2051 | | upsert signature $stmt 2052 | | upsert fqpath $"($base)::*" 2053 | } else { 2054 | let body_norm = ($body0 | str replace -ra '\s+' ' ' | str trim) 2055 | let alias_parts = ($body_norm | split row ' as ') 2056 | let alias = (if ($alias_parts | length) > 1 { $alias_parts | get 1 | str trim } else { null }) 2057 | let path0 = ($alias_parts | get 0 | str replace -ra '\s+' '') 2058 | 2059 | let path = if $path0 == 'crate' { $crate_base } else { $path0 } 2060 | let is_star = ($path | str ends-with '::*') 2061 | let base_nm = if $is_star { '*' } else { ($path | split row '::' | last) } 2062 | let name = (if ($alias | default '' | str length) > 0 { $alias } else { $base_nm }) 2063 | 2064 | if ($name | is-empty) or $name == '}' { 2065 | null 2066 | } else { 2067 | (_mk-record 'use' $raw false $name) 2068 | | upsert signature $stmt 2069 | | upsert fqpath $path 2070 | } 2071 | } 2072 | } 2073 | } 2074 | } 2075 | | where {|x| $x != null } 2076 | ) 2077 | 2078 | $out = ($out | append $rows) 2079 | } 2080 | 2081 | $out 2082 | | reduce -f [] {|batch, acc| $acc | append $batch } 2083 | | _uniq-records 2084 | } 2085 | 2086 | # Quick finder for `use` leaves by name or path substring. 2087 | export def rust-find-use [ 2088 | needle:string, 2089 | ...paths:string 2090 | ] { 2091 | rust-use-records ...$paths 2092 | | where {|u| 2093 | let n = ($needle | str downcase) 2094 | ( ($u.name | default '' | str downcase | str contains $n) ) or ( ($u.fqpath | default '' | str downcase | str contains $n) ) 2095 | } 2096 | | select file span.start_line name fqpath signature 2097 | | sort-by file span.start_line 2098 | } 2099 | 2100 | # ---------- nesting helpers (for rust-tree) ---------------------------------- 2101 | 2102 | # Build parent→children edges (each as fq strings) 2103 | def _build-symbol-edges [rows:list] { 2104 | let keyed = ($rows | where {|r| ($r | get -i fqpath | default '') != '' }) 2105 | 2106 | let parent_of = {|fq| 2107 | if $fq == 'crate' { null } else { 2108 | let parts = ($fq | split row '::') 2109 | let len = ($parts | length) 2110 | if $len <= 1 { null } else { ($parts | take ($len - 1) | str join '::') } 2111 | } 2112 | } 2113 | 2114 | $keyed 2115 | | each {|r| 2116 | let fq = ($r | get -i fqpath | default '') 2117 | let p = (do $parent_of $fq) 2118 | if $p == null or $p == $fq { null } else { { parent: $p, child: $fq } } 2119 | } 2120 | | where {|e| $e != null } 2121 | | group-by parent 2122 | | transpose parent children 2123 | | each {|g| { parent: $g.parent, children: ($g.children | get child | uniq | sort) } } 2124 | } 2125 | 2126 | def _paint-kind [kind:string, text:string] { 2127 | let t = ($text | default "") 2128 | match $kind { 2129 | "mod" => $"(ansi blue)($t)(ansi reset)" 2130 | "fn" => $"(ansi green)($t)(ansi reset)" 2131 | "extern_fn" => $"(ansi light_green)($t)(ansi reset)" 2132 | "struct" => $"(ansi magenta)($t)(ansi reset)" 2133 | "enum" => $"(ansi light_purple)($t)(ansi reset)" 2134 | "trait" => $"(ansi cyan)($t)(ansi reset)" # or 'purple' (alias) 2135 | "impl" => $"(ansi yellow)($t)(ansi reset)" 2136 | "const" => $"(ansi light_red)($t)(ansi reset)" 2137 | "static" => $"(ansi light_red)($t)(ansi reset)" 2138 | "macro_rules" => $"(ansi dark_gray)($t)(ansi reset)" # or purple 2139 | "use" => $"(ansi white_dimmed)($t)(ansi reset)" # “dim” style 2140 | _ => $t 2141 | } 2142 | } 2143 | 2144 | # --- helpers for nested build (no flatten) ------------------------------------ 2145 | 2146 | # Build an index of MINIMAL rows (only fields the printer needs). 2147 | def _rows-index [rows: list] { 2148 | $rows 2149 | | reduce -f {} {|r, acc| 2150 | let fq = ($r.fqpath | default '') 2151 | if $fq == '' { 2152 | $acc 2153 | } else { 2154 | # store minimal payload; avoid other list-typed columns 2155 | let minimal = { 2156 | kind: ($r.kind | default '') 2157 | name: ($r.name | default '') 2158 | fqpath: $fq 2159 | children: [] # placeholder; we'll fill this when we build the tree 2160 | } 2161 | $acc | upsert $fq $minimal 2162 | } 2163 | } 2164 | } 2165 | 2166 | # Safely get child fq list for a parent from edges structure 2167 | def _children-for [edges: list>>, parent_fq: string] { 2168 | $edges | where parent == $parent_fq | get 0? | get -i children | default [] 2169 | } 2170 | 2171 | # Recursive builder: construct a fresh record (avoid upsert-on-record issues) 2172 | def _build-subtree [idx: record, edges: list, fq: string] { 2173 | let self = (try { $idx | get $fq } catch { null }) 2174 | if $self == null { 2175 | null 2176 | } else { 2177 | let kids_fq = (_children-for $edges $fq) 2178 | let kids = ( 2179 | $kids_fq 2180 | | each {|cfq| _build-subtree $idx $edges $cfq } 2181 | | where {|x| (($x | describe) =~ '^record<') } 2182 | ) 2183 | { 2184 | kind: ($self.kind | default '') 2185 | name: ($self.name | default '') 2186 | fqpath: ($self.fqpath | default '') 2187 | children: (if (($kids | describe) =~ '^(list|table)') { $kids } else { [] }) 2188 | } 2189 | } 2190 | } 2191 | 2192 | # Build { fqpath -> { body_tokens:int, doc_tokens:int } } 2193 | def _build-token-index [] { 2194 | let piped = $in 2195 | let rows = ( 2196 | if (( $piped | describe ) =~ '^(list|table)') 2197 | and (not ($piped | is-empty)) 2198 | and (($piped | first | describe) =~ '^record<') 2199 | { $piped } else { rust-ast } 2200 | ) 2201 | $rows 2202 | | reduce -f {} {|r, acc| 2203 | let fq = ($r.fqpath | default '') 2204 | if $fq == '' { $acc } else { 2205 | $acc | upsert $fq { 2206 | body_tokens: ($r.body_tokens | default 0) 2207 | doc_tokens: ($r.doc_tokens | default 0) 2208 | } 2209 | } 2210 | } 2211 | } 2212 | 2213 | def _spaces [n: int] { 2214 | if $n <= 0 { "" } else { (0..<$n | each { " " } | str join "") } 2215 | } 2216 | 2217 | def _display-name [r: record] { 2218 | let fq = ($r.fqpath | default '') 2219 | if $fq == '' { ($r.name | default "") } else { $fq | split row '::' | last } 2220 | } 2221 | 2222 | def _kind-width [rows: list] { 2223 | $rows 2224 | | each {|r| (_vlen ($r.kind | default '')) } 2225 | | math max 2226 | | default 0 2227 | } 2228 | 2229 | # Robustly pull a single root record out of whatever came in 2230 | def _roots-of [x: any] { 2231 | let t = ($x | describe) 2232 | 2233 | if ($t =~ '^record<') { 2234 | [ $x ] # single root -> list of 1 2235 | } else if ($t =~ '^(list|table)') { 2236 | $x # keep top-level items only 2237 | | where {|it| (($it | describe) =~ '^record<') } # only records 2238 | } else if $t == 'string' { 2239 | let parsed = (try { $x | from json } catch { null }) 2240 | if $parsed == null { 2241 | error make { msg: "rust-print-symbol-tree: got a string that isn't JSON" } 2242 | } else { 2243 | _roots-of $parsed 2244 | } 2245 | } else { 2246 | error make { msg: $"rust-print-symbol-tree: unsupported input type: ($t)" } 2247 | } 2248 | } 2249 | 2250 | # ---------- tree walking (first pass: collect rows) --------------------------- 2251 | 2252 | def _collect-rows [ 2253 | node: record, 2254 | ancestors_last: list = [], 2255 | is_last: bool = true 2256 | ] { 2257 | # Coerce `children` → always a list of records 2258 | let kids0 = (try { $node | get -i children } catch { [] }) 2259 | let kids = ( 2260 | [ (try { $node | get -i children } catch { [] }) ] 2261 | | flatten 2262 | | where {|x| (($x | describe) =~ '^record<') } 2263 | ) 2264 | let n = ($kids | length) 2265 | 2266 | let prefix_parts = ($ancestors_last | each {|last| if $last { " " } else { "| " } }) 2267 | let tee = (if ($ancestors_last | length) == 0 { "" } else { if $is_last { "`- " } else { "|- " } }) 2268 | let prefix = ($prefix_parts | str join "") 2269 | let line_prefix = ( $prefix + $tee ) 2270 | 2271 | let row = { 2272 | line_prefix: $line_prefix 2273 | depth: ($ancestors_last | length) 2274 | is_last: $is_last 2275 | is_leaf: ($n == 0) 2276 | name: (_display-name $node) 2277 | kind: ($node.kind | default '') 2278 | fqpath: ($node.fqpath | default '') 2279 | } 2280 | 2281 | let children_rows = ( 2282 | 0..<( $n ) 2283 | | each {|i| 2284 | let child = ($kids | get $i) 2285 | let lastf = ($i == ($n - 1)) 2286 | _collect-rows $child ($ancestors_last | append $is_last) $lastf 2287 | } 2288 | | flatten 2289 | ) 2290 | 2291 | [$row] | append $children_rows 2292 | } 2293 | 2294 | # ---------- second pass: compute widths & print ------------------------------- 2295 | 2296 | def _print-with-columns [ 2297 | rows: list, 2298 | show_fq_on_branches: bool = false, 2299 | token_idx?: record 2300 | ] { 2301 | if ($rows | is-empty) { return } 2302 | 2303 | let tok_enabled = ( ($token_idx | describe) =~ '^record<' ) 2304 | 2305 | # Pipe position uses PAINTED names so spacing matches the visible output 2306 | let target_pipe_col = ( 2307 | $rows 2308 | | each {|r| (_vlen $r.line_prefix) + (_vlen (_paint-kind ($r.kind | default '') ($r.name | default ''))) } 2309 | | math max 2310 | | default 20 2311 | ) + 1 2312 | 2313 | # Kind column width (painted) 2314 | let kind_w = ( 2315 | $rows 2316 | | each {|r| (_vlen (_paint-kind ($r.kind | default '') ($r.kind | default ''))) } 2317 | | math max 2318 | | default 0 2319 | ) 2320 | 2321 | # fqpath width (only where shown) 2322 | let fq_w = ( 2323 | $rows 2324 | | each {|r| 2325 | let show_fq = ($r.is_leaf or $show_fq_on_branches) 2326 | if $show_fq { (_vlen ($r.fqpath | default '')) } else { 0 } 2327 | } 2328 | | math max 2329 | | default 0 2330 | ) 2331 | 2332 | # -------- token sub-column widths (right-align numbers) -------- 2333 | let body_w = if $tok_enabled { 2334 | $rows 2335 | | each {|r| (try { $token_idx | get $r.fqpath | get body_tokens } catch { null }) | default 0 | into string | str length } 2336 | | math max 2337 | | default 1 2338 | } else { 0 } 2339 | 2340 | let doc_w = if $tok_enabled { 2341 | $rows 2342 | | each {|r| (try { $token_idx | get $r.fqpath | get doc_tokens } catch { null }) | default 0 | into string | str length } 2343 | | math max 2344 | | default 1 2345 | } else { 0 } 2346 | 2347 | # total width of the tokens column once numbers are padded 2348 | let tok_w = if $tok_enabled { 2349 | (_vlen "Body Tokens: ") + $body_w + (_vlen ", Doc Tokens: ") + $doc_w 2350 | } else { 0 } 2351 | 2352 | for r in $rows { 2353 | # Name (painted) + left padding to the first pipe 2354 | let name_raw = ($r.name | default '') 2355 | let name_col = (_paint-kind ($r.kind | default '') $name_raw) 2356 | let pre_len = (_vlen $r.line_prefix) 2357 | let name_len = (_vlen $name_col) 2358 | let pad = $target_pipe_col - ($pre_len + $name_len) 2359 | let pad = if $pad < 1 { 1 } else { $pad } 2360 | 2361 | # Kind (painted + padded) 2362 | let kind_raw = ($r.kind | default '') 2363 | let kind_txt = (_paint-kind $kind_raw $kind_raw) 2364 | let kind_pad = $kind_w - (_vlen $kind_txt) 2365 | let kind_pad = if $kind_pad < 0 { 0 } else { $kind_pad } 2366 | let kind_col = ($kind_txt + (_spaces $kind_pad)) 2367 | 2368 | # fqpath (no brackets) 2369 | let show_fq = ($r.is_leaf or $show_fq_on_branches) 2370 | let fq_txt = if $show_fq { ($r.fqpath | default '') } else { '' } 2371 | let fq_pad = $fq_w - (_vlen $fq_txt) 2372 | let fq_pad = if $fq_pad < 0 { 0 } else { $fq_pad } 2373 | let fq_col = ($fq_txt + (_spaces $fq_pad)) 2374 | 2375 | # tokens (optional column, with per-number alignment) 2376 | let tok_txt = if $tok_enabled { 2377 | let info = (try { $token_idx | get $r.fqpath } catch { null }) 2378 | if $info == null { 2379 | # produce a blank cell of the correct width so the column stays aligned 2380 | (_spaces $tok_w) 2381 | } else { 2382 | let btxt = (($info.body_tokens | default 0) | into string) 2383 | let dtxt = (($info.doc_tokens | default 0) | into string) 2384 | let bpad = $body_w - (_vlen $btxt) 2385 | let dpad = $doc_w - (_vlen $dtxt) 2386 | let bfmt = (_spaces (if $bpad < 0 { 0 } else { $bpad })) + $btxt 2387 | let dfmt = (_spaces (if $dpad < 0 { 0 } else { $dpad })) + $dtxt 2388 | $"Body Tokens: ($bfmt), Doc Tokens: ($dfmt)" 2389 | } 2390 | } else { "" } 2391 | 2392 | let prefix_gray = $"(ansi dark_gray)($r.line_prefix)(ansi reset)" 2393 | 2394 | # Assemble line 2395 | mut parts = [ 2396 | $prefix_gray, 2397 | $name_col, # painted name 2398 | (_spaces $pad), 2399 | "| ", 2400 | $kind_col, 2401 | " | ", 2402 | $fq_col 2403 | ] 2404 | if $tok_enabled { $parts = ($parts | append " | " | append $tok_txt) } 2405 | 2406 | print ($parts | str join "") 2407 | } 2408 | } 2409 | 2410 | def _vlen [s: any] { 2411 | ($s | into string | ansi strip | str length) 2412 | } 2413 | 2414 | # Map a free-form pattern → seed fqpaths to start the graph from 2415 | # - Accepts: exact fqpath ("crate::api::ask"), ends-with module path ("::ask"), 2416 | # or bare name ("ask"). If multiple matches, returns them all. 2417 | def _lookup-fn-seeds [fns:list, pattern:string] { 2418 | let pat = ($pattern | into string | str trim) 2419 | if ($pat | str starts-with 'crate::') { 2420 | $fns | where {|r| ($r.fqpath | default '') == $pat } | get -i fqpath 2421 | } else if ($pat | str contains '::') { 2422 | $fns | where {|r| ($r.fqpath | default '') | str ends-with $pat } | get -i fqpath 2423 | } else { 2424 | let by_name = ($fns | where name == $pat | get -i fqpath) 2425 | if (not ($by_name | is-empty)) { $by_name } else { 2426 | $fns | where {|r| ($r.fqpath | default '') | str ends-with $"::($pat)" } | get -i fqpath 2427 | } 2428 | } 2429 | } 2430 | 2431 | # Walk a *canonical* adjacency but print the *real* fqpaths. 2432 | def _walk-fq-tree [ 2433 | adj: record, 2434 | canon2real: record, 2435 | node_c: string, 2436 | max_depth: int, 2437 | ancestors_last: list = [], 2438 | visited: list = [], 2439 | is_last: bool = true, 2440 | ] { 2441 | let is_cycle = ($visited | any {|v| $v == $node_c }) 2442 | let indent = ($ancestors_last | each {|last| if $last { " " } else { "| " } } | str join "") 2443 | let tee = (if ($ancestors_last | length) == 0 { "" } else { if $is_last { "`- " } else { "|- " } }) 2444 | let face_fq = (try { $canon2real | get $node_c | get 0 } catch { $node_c }) 2445 | let face_nm = (_leaf-name $face_fq) 2446 | 2447 | # branch chars (indent + tee) now gray like fqpaths 2448 | mut out = [ 2449 | $"(ansi dark_gray)($indent)($tee)(ansi reset)(ansi white)($face_nm)(ansi reset) (ansi dark_gray)[($face_fq)](ansi reset)" 2450 | ] 2451 | 2452 | if $is_cycle or $max_depth <= 0 { 2453 | # keep the branch padding gray here too 2454 | if $is_cycle { let out = ($out | append $"(ansi dark_gray)($indent) (ansi red)⟲ cycle(ansi reset)") } 2455 | return $out 2456 | } 2457 | 2458 | let kids = (try { $adj | get $node_c } catch { [] }) | default [] | uniq | sort 2459 | let n = ($kids | length) 2460 | for i in 0..<( $n ) { 2461 | let ch = ($kids | get $i) 2462 | let lastf = ($i == ($n - 1)) 2463 | let sub = _walk-fq-tree $adj $canon2real $ch ($max_depth - 1) ($ancestors_last | append $is_last) ($visited | append $node_c) $lastf 2464 | $out = ($out | append $sub) 2465 | } 2466 | 2467 | $out 2468 | } 2469 | 2470 | def _leaf [fq:string] { $fq | split row '::' | last } 2471 | 2472 | # Render callers but inverted: show roots at the top and the seed as the leaf on every branch. 2473 | def _render_callers_tree_inverted [root maxd callers canon2real] { 2474 | 2475 | let C_hdr = (ansi cyan) 2476 | let C_fn = (ansi white) 2477 | let C_fq = (ansi dark_gray) 2478 | let C_br = (ansi dark_gray) 2479 | let R = (ansi reset) 2480 | 2481 | def _fq_of [canon canon2real] { 2482 | let v = ($canon2real | get -i $canon | default []) 2483 | if ($v | length) > 0 { $v | get 0 } else { $canon } 2484 | } 2485 | 2486 | let root_fq = (_fq_of $root $canon2real) 2487 | let root_short = ($root_fq | split row '::' | last) 2488 | 2489 | # depth-first over PARENTS, printing each parent chain downward to the seed leaf 2490 | def _go_up [node prefix depth_left callers canon2real seen is_last] { 2491 | let fq = (_fq_of $node $canon2real) 2492 | let short = ($fq | split row '::' | last) 2493 | 2494 | let branch = (if $is_last { "`- " } else { "|- " }) 2495 | let cont = (if $is_last { " " } else { "| " }) 2496 | 2497 | # current parent line 2498 | mut out = [ $"(ansi dark_gray)($prefix)($branch)(ansi reset)(ansi white)($short)(ansi reset) (ansi dark_gray)[($fq)](ansi reset)" ] 2499 | 2500 | # cycle guard 2501 | if ($seen | any {|x| $x == $node }) { 2502 | $out = ($out | append $"(ansi dark_gray)($prefix)($cont)(ansi reset)(ansi red)⟲ cycle(ansi reset)") 2503 | # still terminate this path with the seed for consistency 2504 | $out = ($out | append $"(ansi dark_gray)($prefix)($cont)`- (ansi reset)(ansi white)($root_short)(ansi reset) (ansi dark_gray)[($root_fq)](ansi reset)") 2505 | return $out 2506 | } 2507 | 2508 | # next parents (i.e., expand upward) 2509 | let parents = ($callers | get -i $node | default [] | uniq | sort) 2510 | if ($parents | is-empty) or ($depth_left <= 1) { 2511 | # terminate branch with the seed as a leaf 2512 | $out = ($out | append $"(ansi dark_gray)($prefix)($cont)`- (ansi reset)(ansi white)($root_short)(ansi reset) (ansi dark_gray)[($root_fq)](ansi reset)") 2513 | return $out 2514 | } 2515 | 2516 | let last_idx = (($parents | length) - 1) 2517 | for it in ($parents | enumerate) { 2518 | let p = $it.item 2519 | let i = $it.index 2520 | let lastf = ($i == $last_idx) 2521 | let sub = (_go_up $p $"($prefix)($cont)" ($depth_left - 1) $callers $canon2real ($seen | append $node) $lastf) 2522 | $out = ($out | append $sub) 2523 | } 2524 | 2525 | $out 2526 | } 2527 | 2528 | let header = [ 2529 | $C_hdr, "Call graph depth: ", ($maxd | into string), (ansi reset), 2530 | (ansi dark_gray), "← callers ", "(inverted)", (ansi reset), " ", 2531 | (ansi white), $root_fq, (ansi reset) 2532 | ] | str join "" 2533 | 2534 | let parents = ($callers | get -i $root | default [] | uniq | sort) 2535 | if ($parents | is-empty) { 2536 | # no callers → nothing to invert; still show a header and a single leaf 2537 | [ $header, $"(ansi white)($root_short)(ansi reset) (ansi dark_gray)[($root_fq)](ansi reset)" ] 2538 | } else { 2539 | let last_idx = (($parents | length) - 1) 2540 | mut lines = [ $header ] 2541 | for it in ($parents | enumerate) { 2542 | let p = $it.item 2543 | let i = $it.index 2544 | let lastf = ($i == $last_idx) 2545 | let sub = (_go_up $p "" $maxd $callers $canon2real [] $lastf) 2546 | $lines = ($lines | append $sub) 2547 | } 2548 | $lines 2549 | } 2550 | } 2551 | 2552 | # Collect the ancestors (within max_depth) of a target canon and turn 2553 | # the callers adjacency (callee <- caller) into a forward map (parent → child) 2554 | def _build_inverted_callers_forest [ 2555 | callers: record, # map list> (callee -> callers) 2556 | target_c: string, # canon of the target function 2557 | maxd: int 2558 | ] { 2559 | # BFS upward to collect nodes/edges within depth 2560 | mut seen_depth = { $target_c: 0 } 2561 | mut frontier = [ $target_c ] 2562 | mut edges = [] # list of { parent: , child: } (forward) 2563 | 2564 | mut d = 0 2565 | while $d < $maxd and (not ($frontier | is-empty)) { 2566 | mut next = [] 2567 | for child in $frontier { 2568 | let parents = (try { $callers | get $child } catch { [] }) | default [] 2569 | for p in ($parents | uniq | sort) { 2570 | # record edge p -> child (toward the target) 2571 | $edges = ($edges | append { parent: $p, child: $child }) 2572 | let prior = (try { $seen_depth | get $p } catch { null }) 2573 | if $prior == null { 2574 | $seen_depth = ($seen_depth | upsert $p ($d + 1)) 2575 | $next = ($next | append $p) 2576 | } 2577 | } 2578 | } 2579 | $frontier = $next 2580 | $d = ($d + 1) 2581 | } 2582 | 2583 | # Nodes participating in the forest 2584 | let nodes = ($seen_depth | columns | append $target_c | uniq | sort) 2585 | 2586 | # Forward adjacency (parent -> [children]) restricted to collected nodes 2587 | let fwd = ( 2588 | $edges 2589 | | where {|e| ($nodes | any {|n| $n == $e.parent}) and ($nodes | any {|n| $n == $e.child}) } 2590 | | group-by parent 2591 | | transpose parent rows 2592 | | reduce -f {} {|g, acc| $acc | upsert $g.parent ($g.rows | get child | uniq | sort) } 2593 | ) 2594 | 2595 | # Compute roots of the forest (parents that are never a child) 2596 | let all_parents = ($edges | get -i parent | default [] | uniq | sort) 2597 | let all_children = ($edges | get -i child | default [] | uniq | sort) 2598 | let roots = ($all_parents | where {|p| not ($all_children | any {|c| $c == $p }) }) 2599 | 2600 | { fwd: $fwd, roots: ($roots | default []) } 2601 | } 2602 | 2603 | def _render_callers_forest_inverted [ 2604 | forest: record, # { fwd: record, roots: list } 2605 | canon2real: record, 2606 | target_c: string 2607 | ] { 2608 | let C_fn = (ansi white) 2609 | let C_fq = (ansi dark_gray) 2610 | let C_br = (ansi dark_gray) 2611 | let R = (ansi reset) 2612 | 2613 | def _fq_of [canon canon2real] { 2614 | let v = ($canon2real | get -i $canon | default []) 2615 | if ($v | length) > 0 { $v | get 0 } else { $canon } 2616 | } 2617 | 2618 | def _label [prefix branch node_c canon2real] { 2619 | let fq = (_fq_of $node_c $canon2real) 2620 | let short = ($fq | split row '::' | last) 2621 | $"($C_br)($prefix)($branch)($R)($C_fn)($short)($R) ($C_fq)[($fq)]($R)" 2622 | } 2623 | 2624 | def _walk [node_c prefix is_last fwd canon2real target_c visited:list] { 2625 | let branch = (if $prefix == "" { "" } else { if $is_last { "`- " } else { "|- " } }) 2626 | let line = (_label $prefix $branch $node_c $canon2real) 2627 | 2628 | # stop expanding at the target (it must be a leaf in the inverted view) 2629 | if $node_c == $target_c { 2630 | return [ $line ] 2631 | } 2632 | 2633 | # cycle guard 2634 | if ($visited | any {|x| $x == $node_c }) { 2635 | return [ $line ] 2636 | } 2637 | 2638 | let kids = (try { $fwd | get $node_c } catch { [] }) | default [] 2639 | if ($kids | is-empty) { 2640 | return [ $line ] 2641 | } 2642 | 2643 | let cont = (if $is_last { " " } else { "| " }) 2644 | let last_idx = (($kids | length) - 1) 2645 | 2646 | mut out = [ $line ] 2647 | for it in ($kids | enumerate) { 2648 | let ch = $it.item 2649 | let il = ($it.index == $last_idx) 2650 | let sub = (_walk $ch $"($prefix)($cont)" $il $fwd $canon2real $target_c ($visited | append $node_c)) 2651 | $out = ($out | append $sub) 2652 | } 2653 | $out 2654 | } 2655 | 2656 | # Top-level: render each root as a branch under a virtual top 2657 | let roots = ($forest.roots | default [] | uniq | sort) 2658 | mut out = [] 2659 | for it in ($roots | enumerate) { 2660 | let r = $it.item 2661 | let il = ($it.index == (($roots | length) - 1)) 2662 | let lines = (_walk $r "" $il $forest.fwd $canon2real $target_c []) 2663 | $out = ($out | append $lines) 2664 | } 2665 | $out 2666 | } 2667 | 2668 | export def rust-print-call-graph [ 2669 | pattern:string, 2670 | --max-depth:int = 3, 2671 | --reverse, 2672 | --show-roots, 2673 | ] { 2674 | let piped = $in 2675 | let rows = ( 2676 | if (( $piped | describe ) =~ '^(list|table)') 2677 | and (not ($piped | is-empty)) 2678 | and (($piped | first | describe) =~ '^record<') 2679 | { $piped } else { rust-ast } 2680 | ) 2681 | let fns = ($rows | where kind == 'fn') 2682 | 2683 | let seeds_real = (_lookup-fn-seeds $fns $pattern) 2684 | if ($seeds_real | is-empty) { 2685 | error make { msg: $"rust-print-call-graph: no function matched: '($pattern)'" } 2686 | } 2687 | let seeds_c = ($seeds_real | each {|fq| _fq_canon $fq } | uniq) 2688 | 2689 | let built = (_adj_from_rows $rows) 2690 | let callers = $built.callers_of 2691 | let map = $built.canon2real 2692 | 2693 | for root_c in $seeds_c { 2694 | let root_fq = (try { $map | get $root_c | get 0 } catch { ($seeds_real | where {|fq| (_fq_canon $fq) == $root_c } | get 0) }) 2695 | 2696 | if ($reverse | default false) { 2697 | # Existing bottom-up callers view (target first, then parents) 2698 | if ($show_roots | default false) { 2699 | print $"(ansi cyan)Call graph depth: ($max_depth) (ansi reset)(ansi dark_gray)← callers \(inverted\)(ansi reset) (ansi white)($root_fq)(ansi reset)" 2700 | } 2701 | let lines = (_render_callers_tree $root_c $max_depth $callers $map "") 2702 | for ln in $lines { print $ln } 2703 | } else { 2704 | # NEW default: inverted callers view (top-down; target is the leaf) 2705 | if ($show_roots | default false) { 2706 | # note: escape parentheses 2707 | print $"(ansi cyan)Call graph depth: ($max_depth) (ansi reset)(ansi dark_gray)← callers(ansi reset) (ansi white)($root_fq)(ansi reset)" 2708 | } 2709 | let forest = (_build_inverted_callers_forest $callers $root_c $max_depth) 2710 | let lines = (_render_callers_forest_inverted $forest $map $root_c) 2711 | for ln in $lines { print $ln } 2712 | } 2713 | 2714 | if ($seeds_c | length) > 1 { print "" } 2715 | } 2716 | } 2717 | 2718 | # Split an fqpath into (module_chain, leaf_name) 2719 | # "crate::a::b::c" -> (["crate","crate::a","crate::a::b"], "c") 2720 | def _fq_split [fq:string] { 2721 | let parts = ($fq | split row '::') 2722 | if ($parts | is-empty) { return [[], $fq] } 2723 | let leaf = ($parts | last) 2724 | let mods = ( 2725 | 0..<( ($parts | length) - 1 ) 2726 | | each {|i| ($parts | take ($i + 1) | str join '::') } 2727 | ) 2728 | [ $mods, $leaf ] 2729 | } 2730 | 2731 | # Map: module_path -> { alias_or_leaf -> external_crate_name } 2732 | def _alias-map-by-module [rows:list] { 2733 | let exts = (_external-crate-map) 2734 | $rows 2735 | | where kind == 'use' 2736 | | where {|u| not (($u.fqpath | default '') | str ends-with '::*') } # skip globs here 2737 | | each {|u| 2738 | let mp = ($u.module_path | default [] | str join '::') 2739 | let path = ($u.fqpath | default '') 2740 | if ($path == '' or $path == 'crate') { null } else { 2741 | let segs = ($path | split row '::') 2742 | let first = (if ($segs | is-empty) { '' } else { $segs | get 0 }) 2743 | # Only care if the 'first' segment is an external crate 2744 | let is_ext = (try { $exts | get $first } catch { null }) == true 2745 | if (not $is_ext) { null } else { 2746 | # Determine binding name visible in this module: 2747 | # - if signature had "as Alias", rust-use-records put that in `name` 2748 | # - else leaf of the path 2749 | let bind = (try { $u.name } catch { null }) | default ( $segs | last ) 2750 | { mod: $mp, bind: $bind, crate: $first } 2751 | } 2752 | } 2753 | } 2754 | | where {|x| $x != null } 2755 | | group-by mod 2756 | | transpose mod items 2757 | | reduce -f {} {|it, acc| 2758 | let pairs = ($it.items | each {|r| { ($r.bind): $r.crate } } ) 2759 | let merged = ($pairs | reduce -f {} {|p, a| $a | merge $p }) 2760 | $acc | upsert $it.mod $merged 2761 | } 2762 | } 2763 | 2764 | # Canonicalize an fqpath by removing generic/lifetime args in each segment. 2765 | # Accepts null/empty and returns "" in that case. 2766 | def _fq_canon [fq?: string] { 2767 | let s = ($fq | default "" | into string) 2768 | if $s == "" { "" } else { 2769 | ($s 2770 | | split row '::' 2771 | | each {|seg| 2772 | $seg 2773 | | str replace --regex --all '<[^>]*>' '' # strip <...> 2774 | | str replace --regex --all '\s+' '' # strip spaces 2775 | } 2776 | | str join '::') 2777 | } 2778 | } 2779 | 2780 | # Return table: { fqpath, uses: list, maybe: list } 2781 | def _scan-ext-refs-on-fns [rows:list] { 2782 | let ex_crates = (_external-crate-set) 2783 | let ex_set = ($ex_crates | reduce -f {} {|c, acc| $acc | upsert $c true }) 2784 | let globs = (_ext_globs_by_module $rows) 2785 | let aliases = (_alias-map-by-module $rows) 2786 | 2787 | let fns = ($rows | where kind == 'fn') 2788 | 2789 | # split into “pathish” tokens that contain '::' 2790 | def _path_tokens [s:string] { 2791 | $s 2792 | | split row -r '[^A-Za-z0-9_:]+' 2793 | | where {|t| $t | str contains '::'} 2794 | } 2795 | 2796 | $fns 2797 | | each {|f| 2798 | let mp = ($f.module_path | default [] | str join '::') 2799 | let glb = (try { $globs | get $mp } catch { [] }) | default [] 2800 | let a_map = (try { $aliases | get $mp } catch { {} }) 2801 | 2802 | let sig = ($f.signature | default '') 2803 | let body = ($f.body_text | default '') 2804 | let txt = $"($sig)\n($body)" 2805 | 2806 | # 1) gather full path tokens 2807 | let paths = (_path_tokens $txt) 2808 | 2809 | # 2) map token → (dep, sym) by resolving first segment via external set or alias map 2810 | let details = ( 2811 | $paths 2812 | | each {|p| 2813 | let segs = ($p | split row '::') 2814 | if ($segs | is-empty) { null } else { 2815 | let first = ($segs | get 0) 2816 | let dep0 = (if ((try { $ex_set | get $first } catch { false }) == true) { 2817 | $first 2818 | } else { 2819 | (try { $a_map | get $first } catch { null }) 2820 | }) 2821 | if ($dep0 == null) { null } else { 2822 | let sym = ($segs | skip 1 | str join '::') # what’s used *within* the dep 2823 | { dep: ($dep0 | str downcase), sym: $sym } 2824 | } 2825 | } 2826 | } 2827 | | where {|x| $x != null } 2828 | ) 2829 | 2830 | # 3) coarse “uses” set (deps only) for compatibility 2831 | let direct_deps = ($details | get dep | uniq | sort | default []) 2832 | 2833 | # 4) maybe (glob) heuristic 2834 | let maybe_from_glob = if ($glb | is-empty) { 2835 | [] 2836 | } else { 2837 | if ($txt | str contains '(') { $glb } else { [] } 2838 | } 2839 | 2840 | { 2841 | fqpath: $f.fqpath, 2842 | uses: $direct_deps, 2843 | maybe: ($maybe_from_glob | uniq | sort), 2844 | uses_detail: ( 2845 | $details 2846 | | group-by dep 2847 | | transpose dep items 2848 | | each {|g| { dep: $g.dep, syms: ($g.items | get sym | where {|s| ($s | default '' | str length) > 0 } | uniq | sort) } } 2849 | ) 2850 | } 2851 | } 2852 | } 2853 | 2854 | # Build canonical adjacency and canon→real map using rows that already have `callers: [...]` 2855 | def _adj_from_rows [ 2856 | rows: list, # output of rust-ast (already has callers on fn rows) 2857 | ] { 2858 | let fns = ($rows | where kind == 'fn') 2859 | 2860 | # canon→real display names (prefer de-duped, sorted) 2861 | let canon2real = ( 2862 | $fns 2863 | | reduce -f {} {|r, acc| 2864 | let fq = ($r.fqpath | default '') 2865 | if $fq == '' { $acc } else { 2866 | let c = (_fq_canon $fq) 2867 | let cur = (try { $acc | get $c } catch { [] }) 2868 | $acc | upsert $c ($cur | append $fq | uniq | sort) 2869 | } 2870 | } 2871 | ) 2872 | 2873 | # Two directed adjacencies from the same data: 2874 | # - callers_of: callee_canon -> [caller_canon] 2875 | # - callees_of: caller_canon -> [callee_canon] 2876 | let callers_of = ( 2877 | $fns 2878 | | reduce -f {} {|r, acc| 2879 | let callee_fq = ($r.fqpath | default '') 2880 | if $callee_fq == '' { $acc } else { 2881 | let callee_c = (_fq_canon $callee_fq) 2882 | let callers = ($r.callers | default [] | where {|x| ($x | default '') != '' }) 2883 | let caller_cs = ($callers | each {|cfq| _fq_canon $cfq }) 2884 | let cur = (try { $acc | get $callee_c } catch { [] }) 2885 | $acc | upsert $callee_c ($cur | append $caller_cs | flatten | uniq | sort) 2886 | } 2887 | } 2888 | ) 2889 | 2890 | let callees_of = ( 2891 | $fns 2892 | | reduce -f {} {|r, acc| 2893 | let callee_fq = ($r.fqpath | default '') 2894 | let callers = ($r.callers | default []) 2895 | if ($callee_fq == '' or ($callers | is-empty)) { $acc } else { 2896 | let callee_c = (_fq_canon $callee_fq) 2897 | $callers 2898 | | each {|caller_fq| 2899 | let caller_c = (_fq_canon $caller_fq) 2900 | let cur = (try { $acc | get $caller_c } catch { [] }) 2901 | $acc | upsert $caller_c ($cur | append $callee_c | uniq | sort) 2902 | } 2903 | | reduce -f $acc {|_, a| $a } # passthrough 2904 | } 2905 | } 2906 | ) 2907 | 2908 | { callers_of: $callers_of, callees_of: $callees_of, canon2real: $canon2real } 2909 | } 2910 | 2911 | # Map: module_path (joined by ::) -> set 2912 | def _ext_globs_by_module [rows:list] { 2913 | let exts = (_external-crate-map) 2914 | $rows 2915 | | where kind == 'use' 2916 | | where {|u| ($u.fqpath | default '') | str ends-with '::*' } 2917 | | each {|u| 2918 | let mp = ($u.module_path | default [] | str join '::') 2919 | let fst = ($u.fqpath | split row '::' | get 0) 2920 | let is_ext = (try { $exts | get $fst } catch { null }) == true 2921 | if $is_ext { { mod: $mp, ext: $fst } } else { null } 2922 | } 2923 | | where {|x| $x != null } 2924 | | group-by mod 2925 | | transpose mod items 2926 | | reduce -f {} {|it, acc| $acc | upsert $it.mod ($it.items | get ext | uniq | sort) } 2927 | } 2928 | 2929 | # Renders a callers tree (up to max depth) for a canon name (ASCII branches). 2930 | # callers: map list> 2931 | # canon2real: map list> 2932 | def _render_callers_tree [root maxd callers canon2real root_label?: string] { 2933 | let C_hdr = (ansi cyan) 2934 | let C_fn = (ansi white) 2935 | let C_fq = (ansi dark_gray) 2936 | let C_br = (ansi dark_gray) 2937 | let R = (ansi reset) 2938 | 2939 | def _fq_of [canon canon2real] { 2940 | let v = ($canon2real | get -i $canon | default []) 2941 | if ($v | length) > 0 { $v | get 0 } else { $canon } 2942 | } 2943 | 2944 | def _go [node prefix depth_left callers canon2real seen:list] { 2945 | if $depth_left <= 0 { return [] } 2946 | let parents = ($callers | get -i $node | default [] | enumerate) 2947 | if ($parents | is-empty) { return [] } 2948 | let last_idx = (($parents | length) - 1) 2949 | mut out = [] 2950 | for it in $parents { 2951 | let p = $it.item 2952 | let i = $it.index 2953 | let is_last = ($i == $last_idx) 2954 | let branch = (if $is_last { "`- " } else { "|- " }) 2955 | let cont = (if $is_last { " " } else { "| " }) 2956 | 2957 | let fq = (_fq_of $p $canon2real) 2958 | let short = (_leaf $fq) 2959 | 2960 | if ($seen | any {|x| $x == $p }) { 2961 | let line = $"($C_fq)($prefix)($branch)($R)($C_fn)($short)($R) ($C_fq)[($fq)] (ansi red)⟲(ansi reset)" 2962 | $out = ($out | append $line) 2963 | continue 2964 | } 2965 | 2966 | let line = $"($C_fq)($prefix)($branch)($R)($C_fn)($short)($R) ($C_fq)[($fq)]($R)" 2967 | $out = ($out | append $line) 2968 | $out = ($out | append (_go $p $"($prefix)($cont)" ($depth_left - 1) $callers $canon2real ($seen | append $p))) 2969 | } 2970 | $out 2971 | } 2972 | 2973 | # NOTE: no header emitted here anymore 2974 | let fq0 = (_fq_of $root $canon2real) 2975 | let short = (_leaf $fq0) 2976 | let first = if ($root_label | default '' | str length) > 0 { 2977 | $root_label 2978 | } else { 2979 | $"($C_fn)($short)($R) ($C_fq)[($fq0)]($R)" 2980 | } 2981 | 2982 | [ $first ] | append (_go $root "" $maxd $callers $canon2real []) 2983 | } 2984 | 2985 | def _leaf-name [fq:string] { 2986 | $fq | split row '::' | last 2987 | } 2988 | 2989 | # --- helper: build a nested tree from a set of fqpaths (and optionally leaf annotations) 2990 | # Accept an optional prebuilt rows index to avoid recomputing rust-ast during tree build. 2991 | # Build a nested tree from a set of fqpaths (and optionally leaf annotations). 2992 | # fq_list can be any type; we'll sanitize it to a list. 2993 | def _tree_from_fqpaths [ 2994 | fq_list:any, # tolerant input 2995 | leaf_info?: record, # optional: { -> {...} } 2996 | rows_idx?: record # optional: { -> minimal row } 2997 | ] { 2998 | # 0) sanitize inputs to a clean, unique, sorted list 2999 | let fqs = ( 3000 | [ $fq_list ] | flatten 3001 | | where {|x| ($x | describe) == 'string' } 3002 | | where {|x| ($x | str length) > 0 } 3003 | | uniq | sort 3004 | ) 3005 | 3006 | if ($fqs | is-empty) { 3007 | return [ { kind: "mod", name: "crate", fqpath: "crate", children: [] } ] 3008 | } 3009 | 3010 | # 1) collect all intermediate nodes from crate to each leaf 3011 | def _chains_of [fq:string] { 3012 | let parts = ($fq | split row '::') 3013 | 0..<( $parts | length ) 3014 | | each {|i| ($parts | take ($i + 1) | str join '::') } 3015 | } 3016 | 3017 | let all_nodes = ( 3018 | $fqs 3019 | | reduce -f (["crate"]) {|fq, acc| $acc | append (_chains_of $fq) } 3020 | | flatten | uniq | sort 3021 | ) 3022 | 3023 | # 2) parent → children table (query via a list to avoid 'nothing' inputs) 3024 | let edges = ( 3025 | $all_nodes 3026 | | each {|fq| 3027 | if $fq == "crate" { null } else { 3028 | let parent = ($fq | split row '::' | drop 1 | str join '::' | default "crate") 3029 | { parent: (if $parent == "" { "crate" } else { $parent }), child: $fq } 3030 | } 3031 | } 3032 | | where {|x| $x != null } 3033 | | group-by parent 3034 | | transpose parent rows 3035 | | each {|g| { parent: $g.parent, children: ($g.rows | get child | uniq | sort) } } 3036 | ) 3037 | 3038 | # 3) minimal row lookup (prefer provided index, else fall back) 3039 | let idx = ( 3040 | if (($rows_idx | describe) =~ '^record<') { $rows_idx } else { _rows-index (rust-ast) } 3041 | ) 3042 | 3043 | def _mk_node [fq:string] { 3044 | let base = (try { $idx | get $fq } catch { null }) 3045 | if $base != null { $base } else { 3046 | { kind: "mod", name: ($fq | split row '::' | last), fqpath: $fq, children: [] } 3047 | } 3048 | } 3049 | 3050 | def _nest [fq:string] { 3051 | let base = (_mk_node $fq) 3052 | let kids_fq = ( 3053 | [ $edges ] | flatten 3054 | | where parent == $fq 3055 | | get 0? 3056 | | get -i children 3057 | | default [] 3058 | ) 3059 | let kids = ( 3060 | $kids_fq 3061 | | each {|c| _nest $c } 3062 | | where {|x| (($x | describe) =~ '^record<') } 3063 | ) 3064 | 3065 | let node0 = ($base | upsert children $kids) 3066 | 3067 | if (($leaf_info | describe) =~ '^record<') and ($kids | is-empty) { 3068 | let ann = (try { $leaf_info | get $fq } catch { null }) 3069 | if $ann == null { $node0 } else { $node0 | merge $ann } 3070 | } else { 3071 | $node0 3072 | } 3073 | } 3074 | 3075 | [ (_nest "crate") ] 3076 | } 3077 | 3078 | export def rust-print-dep-usage [ 3079 | dep?: string 3080 | --max-depth:int = 4 3081 | --include-maybe 3082 | --records # emit nested records instead of pretty text 3083 | --reverse # when set, use legacy bottom-up callers view; default is inverted callers (top-down to target) 3084 | ] { 3085 | # Prefer piped rows if present 3086 | let piped = $in 3087 | let rows = ( 3088 | if (( $piped | describe ) =~ '^(list|table)') and (not ($piped | is-empty)) and (($piped | first | describe) =~ '^record<') { 3089 | $piped 3090 | } else { 3091 | rust-ast 3092 | } 3093 | ) 3094 | 3095 | let ext_set = (_external-crate-set) 3096 | if ($ext_set | is-empty) { 3097 | error make { msg: "rust-print-dep-usage: no external deps found in Cargo.toml" } 3098 | } 3099 | 3100 | let scanned = (_scan-ext-refs-on-fns $rows) 3101 | let built = (_adj_from_rows $rows) 3102 | let callers = $built.callers_of 3103 | let canon2real = $built.canon2real 3104 | let rows_idx = (_rows-index $rows) # build once, pass into tree builder 3105 | 3106 | # Build per-dep index like before (real/maybe) 3107 | mut dep_index = {} 3108 | for row in $scanned { 3109 | let fq = ($row.fqpath | default '') 3110 | let uses_det = ($row.uses_detail | default []) 3111 | let maybes = ($row.maybe | default []) 3112 | 3113 | for d in $uses_det { 3114 | let key = ($d.dep | str downcase) 3115 | if ($ext_set | any {|e| ($e | str downcase) == $key }) { 3116 | let cur = ($dep_index | get -i $key | default { real: {}, maybe: {} }) 3117 | let cur_syms = ($cur.real | get -i $fq | default []) 3118 | let next_syms = ($cur_syms | append $d.syms | flatten | uniq | sort) 3119 | let nxt = ($cur | upsert real ($cur.real | upsert $fq $next_syms)) 3120 | $dep_index = ($dep_index | upsert $key $nxt) 3121 | } 3122 | } 3123 | 3124 | if $include_maybe { 3125 | for m in $maybes { 3126 | let key = ($m | str downcase) 3127 | if ($ext_set | any {|e| ($e | str downcase) == $key }) { 3128 | let cur = ($dep_index | get -i $key | default { real: {}, maybe: {} }) 3129 | let cur_syms = ($cur.maybe | get -i $fq | default []) 3130 | let nxt = ($cur | upsert maybe ($cur.maybe | upsert $fq $cur_syms)) 3131 | $dep_index = ($dep_index | upsert $key $nxt) 3132 | } 3133 | } 3134 | } 3135 | } 3136 | 3137 | let wanted = if ($dep | default '' | str length) > 0 { 3138 | let key = ($dep | str downcase) 3139 | if ($dep_index | columns | any {|k| $k == $key }) { [ $key ] } else { [] } 3140 | } else { 3141 | ($dep_index | columns | sort) 3142 | } 3143 | 3144 | # If --records is set: structural output is unaffected by --reverse (orientation only applies to pretty text mode) 3145 | if $records { 3146 | mut out = [] 3147 | for crate_name in $wanted { 3148 | let info = ($dep_index | get $crate_name | default { real: {}, maybe: {} }) 3149 | let seeds_real_map = ($info.real | default {}) 3150 | let seeds_maybe_map = ($info.maybe | default {}) 3151 | 3152 | # limit maybe-seeds to callers-path of real seeds 3153 | def _collect_ancestors [start_canon: string, maxd: int] { 3154 | mut seen = [$start_canon] 3155 | mut frontier = [$start_canon] 3156 | mut depth = 0 3157 | while ( $depth < $maxd ) { 3158 | mut nxt = [] 3159 | for n in $frontier { 3160 | let parents = ($callers | get -i $n | default []) 3161 | for p in $parents { 3162 | if not ($seen | any {|x| $x == $p }) { 3163 | $seen = ($seen | append $p) 3164 | $nxt = ($nxt | append $p) 3165 | } 3166 | } 3167 | } 3168 | if ($nxt | is-empty) { break } 3169 | $frontier = $nxt 3170 | $depth = ($depth + 1) 3171 | } 3172 | $seen 3173 | } 3174 | 3175 | let seeds_real = ($seeds_real_map | columns | sort) 3176 | let seeds_maybe_all = ($seeds_maybe_map | columns | sort) 3177 | 3178 | mut on_path = {} 3179 | for s in $seeds_real { 3180 | let c = (_fq_canon $s) 3181 | let anc = (_collect_ancestors $c $max_depth) 3182 | for a in $anc { $on_path = ($on_path | upsert $a true) } 3183 | } 3184 | let seeds_maybe = ( 3185 | $seeds_maybe_all 3186 | | where {|fq| ($on_path | get -i (_fq_canon $fq) | default false) } 3187 | ) 3188 | 3189 | let keep_fqs = ($seeds_real | append $seeds_maybe) 3190 | 3191 | if (not ($keep_fqs | is-empty)) { 3192 | # annotate leaves: { fq -> {dep, ref_type, uses} } 3193 | let leaf_info = ( 3194 | $keep_fqs 3195 | | reduce -f {} {|fq, acc| 3196 | let uses = ($seeds_real_map | get -i $fq | default []) 3197 | let ref_type = (if (not ($uses | is-empty)) { "real" } else { "maybe" }) 3198 | $acc | upsert $fq { 3199 | dep: $crate_name, 3200 | ref_type: $ref_type, 3201 | uses: $uses 3202 | } 3203 | } 3204 | ) 3205 | 3206 | # pass rows_idx so _tree_from_fqpaths doesn't call rust-ast 3207 | let tree = (_tree_from_fqpaths $keep_fqs $leaf_info $rows_idx) 3208 | 3209 | if ($wanted | length) == 1 { 3210 | return $tree 3211 | } else { 3212 | $out = ($out | append { dep: $crate_name, tree: $tree }) 3213 | } 3214 | } 3215 | } 3216 | return $out 3217 | } 3218 | 3219 | # ---------- pretty printing path (now supports --reverse) ---------- 3220 | for crate_name in $wanted { 3221 | let info = ($dep_index | get $crate_name | default { real: {}, maybe: {} }) 3222 | let seeds_real_map = ($info.real | default {}) 3223 | let seeds_maybe_map = ($info.maybe | default {}) 3224 | let seeds_real = ($seeds_real_map | columns | sort) 3225 | let seeds_maybe_all = ($seeds_maybe_map | columns | sort) 3226 | 3227 | if (($seeds_real | is-empty) and ($seeds_maybe_all | is-empty)) { continue } 3228 | 3229 | print $"(ansi green)Dependency usage: (ansi red)($crate_name)(ansi reset)" 3230 | 3231 | def _collect_ancestors [start_canon: string, maxd: int] { 3232 | mut seen = [$start_canon] 3233 | mut frontier = [$start_canon] 3234 | mut depth = 0 3235 | while ( $depth < $maxd ) { 3236 | mut nxt = [] 3237 | for n in $frontier { 3238 | let parents = ($callers | get -i $n | default []) 3239 | for p in $parents { 3240 | if not ($seen | any {|x| $x == $p }) { 3241 | $seen = ($seen | append $p) 3242 | $nxt = ($nxt | append $p) 3243 | } 3244 | } 3245 | } 3246 | if ($nxt | is-empty) { break } 3247 | $frontier = $nxt 3248 | $depth = ($depth + 1) 3249 | } 3250 | $seen 3251 | } 3252 | 3253 | mut on_path = {} 3254 | for s in $seeds_real { 3255 | let c = (_fq_canon $s) 3256 | let anc = (_collect_ancestors $c $max_depth) 3257 | for a in $anc { $on_path = ($on_path | upsert $a true) } 3258 | } 3259 | let seeds_maybe = ( 3260 | $seeds_maybe_all 3261 | | where {|fq| ($on_path | get -i (_fq_canon $fq) | default false) } 3262 | ) 3263 | 3264 | if (not ($seeds_real | is-empty)) { 3265 | print $"(ansi dark_gray)direct references(ansi reset)" 3266 | for s in $seeds_real { 3267 | let c = (_fq_canon $s) 3268 | let sym_list = ($seeds_real_map | get -i $s | default [] | uniq | sort) 3269 | let sym_suffix = if ($sym_list | is-empty) { "" } else { 3270 | $" (ansi dark_gray)uses:(ansi reset) (ansi light_yellow)($sym_list | str join ', ')(ansi reset)" 3271 | } 3272 | if ($reverse | default false) { 3273 | # legacy bottom-up callers view (target first, then parents) 3274 | let leaf = (_leaf $s) 3275 | let root_lbl = $"(ansi white)($leaf)(ansi reset) (ansi dark_gray)[($s)](ansi reset)($sym_suffix)" 3276 | let lines = (_render_callers_tree $c $max_depth $callers $canon2real $root_lbl) 3277 | for ln in $lines { print $ln } 3278 | } else { 3279 | # default: inverted callers view (top-down; target is the leaf) 3280 | # show the seed line with uses, then render the inverted forest 3281 | let leaf = (_leaf $s) 3282 | print $"(ansi white)($leaf)(ansi reset) (ansi dark_gray)[($s)](ansi reset)($sym_suffix)" 3283 | let forest = (_build_inverted_callers_forest $callers $c $max_depth) 3284 | let lines = (_render_callers_forest_inverted $forest $canon2real $c) 3285 | for ln in $lines { print $ln } 3286 | } 3287 | print "" 3288 | } 3289 | } 3290 | 3291 | if $include_maybe and (not ($seeds_maybe | is-empty)) { 3292 | print $"(ansi dark_gray)[?] from glob imports(ansi reset)" 3293 | for s in $seeds_maybe { 3294 | let c = (_fq_canon $s) 3295 | if ($reverse | default false) { 3296 | let leaf = (_leaf $s) 3297 | let root_lbl = $"(ansi white)($leaf)(ansi reset) (ansi dark_gray)[($s)](ansi reset)" 3298 | let lines = (_render_callers_tree $c $max_depth $callers $canon2real $root_lbl) 3299 | for ln in $lines { print $ln } 3300 | } else { 3301 | let leaf = (_leaf $s) 3302 | print $"(ansi white)($leaf)(ansi reset) (ansi dark_gray)[($s)](ansi reset)" 3303 | let forest = (_build_inverted_callers_forest $callers $c $max_depth) 3304 | let lines = (_render_callers_forest_inverted $forest $canon2real $c) 3305 | for ln in $lines { print $ln } 3306 | } 3307 | print "" 3308 | } 3309 | } 3310 | } 3311 | } 3312 | 3313 | # ---------- LOOP GUARDS (seen-set + per-file cap) ---------------------------- 3314 | 3315 | # track (file|pattern) we've already run 3316 | export def --env _seen-add [key:string] { 3317 | let m = (try { $env.__SEEN } catch { {} }) 3318 | load-env { __SEEN: ($m | upsert $key true) } 3319 | } 3320 | 3321 | def _seen-has [key:string] { 3322 | let m = (try { $env.__SEEN } catch { {} }) 3323 | try { $m | get $key } catch { false } 3324 | } 3325 | 3326 | # bump a per-file counter; bail out if it gets silly 3327 | export def --env _bump-file-count [file:string] { 3328 | let f = ($file | path expand) 3329 | let m = (try { $env.__SCAN_COUNT } catch { {} }) 3330 | let n = ((try { $m | get $f } catch { 0 }) + 1) 3331 | load-env { __SCAN_COUNT: ($m | upsert $f $n) } 3332 | $n 3333 | } 3334 | 3335 | # how many sg runs will we allow per file this session? 3336 | def _scan_cap [] { 3337 | ($env.RUST_AST_SCAN_CAP | default 500) | into int 3338 | } 3339 | 3340 | # ---- small JSON-result cache (safe env access) ------------------------------ 3341 | 3342 | def _sg_cache_get [k:string] { 3343 | let cur = (try { $env | get __SG_JSON_CACHE } catch { {} }) 3344 | try { $cur | get $k } catch { null } 3345 | } 3346 | 3347 | export def --env _sg_cache_put [k:string, v:any] { 3348 | let cur = (try { $env | get __SG_JSON_CACHE } catch { {} }) 3349 | let next = ($cur | upsert $k $v) 3350 | load-env { __SG_JSON_CACHE: $next } 3351 | } 3352 | 3353 | export def --env _sg_cache_clear [] { 3354 | load-env { __SG_JSON_CACHE: {} } 3355 | } 3356 | 3357 | export def --env _ensure-caches [] { 3358 | if (try { $env | get __SG_JSON_CACHE } catch { null }) == null { 3359 | load-env { __SG_JSON_CACHE: {} } 3360 | } 3361 | if (try { $env | get __INLINE_IDX } catch { null }) == null { 3362 | load-env { __INLINE_IDX: {} } 3363 | } 3364 | if (try { $env | get __INLINE_MODS_CACHE } catch { null }) == null { 3365 | load-env { __INLINE_MODS_CACHE: {} } 3366 | } 3367 | } 3368 | 3369 | # Treat these as "true": 1, true, yes, on (case-insensitive) 3370 | def _debug_enabled [] { 3371 | let raw = (_env_str 'RUST_AST_DEBUG' | str downcase | str trim) 3372 | match $raw { 3373 | "1" | "true" | "yes" | "on" => true 3374 | _ => false 3375 | } 3376 | } 3377 | 3378 | def _dbg [msg:string] { 3379 | if (_debug_enabled) { 3380 | print $"(ansi dark_gray)[DBG](ansi reset) ($msg)" 3381 | } 3382 | } 3383 | 3384 | def _env_str [name:string] { 3385 | # returns "" if the var is unset; always a string 3386 | (try { $env | get $name } catch { null }) | default '' | into string 3387 | } 3388 | --------------------------------------------------------------------------------