serpentine-parser 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/PKG-INFO +1 -1
  2. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/pyproject.toml +1 -1
  3. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/Cargo.lock +22 -22
  4. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/Cargo.toml +4 -3
  5. serpentine_parser-0.3.2/rust/src/graph/builder.rs +441 -0
  6. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/graph/loaders.rs +279 -396
  7. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/graph/mod.rs +4 -61
  8. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/graph/resolvers.rs +73 -69
  9. serpentine_parser-0.3.2/rust/src/lib.rs +704 -0
  10. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/mod.rs +7 -0
  11. serpentine_parser-0.3.2/rust/src/subscribers/types.rs +139 -0
  12. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/__init__.py +1 -1
  13. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/_analyzer.pyi +4 -0
  14. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/cache.py +83 -0
  15. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/cli.py +2 -2
  16. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/server/routes.py +7 -0
  17. serpentine_parser-0.3.2/src/serpentine/skills/__init__.py +0 -0
  18. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/state.py +65 -20
  19. serpentine_parser-0.3.2/src/serpentine/static/assets/elk-worker.min-BqREE3LP.js +6268 -0
  20. serpentine_parser-0.3.2/src/serpentine/static/assets/index-CqW3OZBs.css +1 -0
  21. serpentine_parser-0.3.2/src/serpentine/static/assets/index-DGBMc5dK.js +32 -0
  22. serpentine_parser-0.3.2/src/serpentine/static/assets/layoutWorker-DEFCbISa.js +24 -0
  23. serpentine_parser-0.3.2/src/serpentine/static/assets/mermaidExport-DphjeTrR.js +3 -0
  24. serpentine_parser-0.3.2/src/serpentine/static/assets/svgToPng-C-twkIuO.js +2 -0
  25. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/static/index.html +2 -2
  26. serpentine_parser-0.3.1/rust/src/graph/builder.rs +0 -185
  27. serpentine_parser-0.3.1/rust/src/graph/pdg.rs +0 -94
  28. serpentine_parser-0.3.1/rust/src/lib.rs +0 -413
  29. serpentine_parser-0.3.1/src/serpentine/static/assets/elk-worker.min-BdOC9sib.js +0 -6263
  30. serpentine_parser-0.3.1/src/serpentine/static/assets/index-BPIIaXQj.js +0 -219
  31. serpentine_parser-0.3.1/src/serpentine/static/assets/index-DDwdNVWo.css +0 -1
  32. serpentine_parser-0.3.1/src/serpentine/static/assets/layoutWorker-BkzQvq4g.js +0 -24
  33. serpentine_parser-0.3.1/src/serpentine/static/assets/mermaidExport-B2JREVsY.js +0 -3
  34. serpentine_parser-0.3.1/src/serpentine/static/assets/svgToPng-CCU1Oj7k.js +0 -2
  35. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/LICENSE +0 -0
  36. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/README.md +0 -0
  37. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/CLAUDE.md +0 -0
  38. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/events.rs +0 -0
  39. /serpentine_parser-0.3.1/src/serpentine/skills/__init__.py → /serpentine_parser-0.3.2/rust/src/graph/pdg.rs +0 -0
  40. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/javascript/config.rs +0 -0
  41. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/javascript/mod.rs +0 -0
  42. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/message_bus.rs +0 -0
  43. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/python/config.rs +0 -0
  44. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/python/mod.rs +0 -0
  45. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/rust_lang/config.rs +0 -0
  46. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/rust_lang/mod.rs +0 -0
  47. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/README.md +0 -0
  48. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/code_snippet.rs +0 -0
  49. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/decorators.rs +0 -0
  50. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/definitions.rs +0 -0
  51. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/event_counter.rs +0 -0
  52. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/imports.rs +0 -0
  53. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/pdg.rs +0 -0
  54. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/raw_bindings.rs +0 -0
  55. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/scope_tree.rs +0 -0
  56. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/subscribers/uses.rs +0 -0
  57. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/terraform/config.rs +0 -0
  58. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/rust/src/terraform/mod.rs +0 -0
  59. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/config.py +0 -0
  60. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/selector.py +0 -0
  61. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/server/__init__.py +0 -0
  62. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/server/app.py +0 -0
  63. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/server/websocket.py +0 -0
  64. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/skills/agents-md.md +0 -0
  65. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/skills/code-analysis.md +0 -0
  66. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/skills/copilot-instructions.md +0 -0
  67. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/skills/cursor-rules.md +0 -0
  68. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/static/logo.svg +0 -0
  69. {serpentine_parser-0.3.1 → serpentine_parser-0.3.2}/src/serpentine/watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serpentine-parser
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Environment :: Web Environment
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "serpentine-parser"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -13,9 +13,9 @@ dependencies = [
13
13
 
14
14
  [[package]]
15
15
  name = "cc"
16
- version = "1.2.63"
16
+ version = "1.2.65"
17
17
  source = "registry+https://github.com/rust-lang/crates.io-index"
18
- checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
18
+ checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
19
19
  dependencies = [
20
20
  "find-msvc-tools",
21
21
  "shlex",
@@ -100,9 +100,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
100
100
 
101
101
  [[package]]
102
102
  name = "memchr"
103
- version = "2.8.1"
103
+ version = "2.8.2"
104
104
  source = "registry+https://github.com/rust-lang/crates.io-index"
105
- checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
105
+ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
106
106
 
107
107
  [[package]]
108
108
  name = "once_cell"
@@ -127,9 +127,9 @@ dependencies = [
127
127
 
128
128
  [[package]]
129
129
  name = "pyo3"
130
- version = "0.28.3"
130
+ version = "0.29.0"
131
131
  source = "registry+https://github.com/rust-lang/crates.io-index"
132
- checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
132
+ checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c"
133
133
  dependencies = [
134
134
  "libc",
135
135
  "once_cell",
@@ -141,18 +141,18 @@ dependencies = [
141
141
 
142
142
  [[package]]
143
143
  name = "pyo3-build-config"
144
- version = "0.28.3"
144
+ version = "0.29.0"
145
145
  source = "registry+https://github.com/rust-lang/crates.io-index"
146
- checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
146
+ checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078"
147
147
  dependencies = [
148
148
  "target-lexicon",
149
149
  ]
150
150
 
151
151
  [[package]]
152
152
  name = "pyo3-ffi"
153
- version = "0.28.3"
153
+ version = "0.29.0"
154
154
  source = "registry+https://github.com/rust-lang/crates.io-index"
155
- checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
155
+ checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b"
156
156
  dependencies = [
157
157
  "libc",
158
158
  "pyo3-build-config",
@@ -160,9 +160,9 @@ dependencies = [
160
160
 
161
161
  [[package]]
162
162
  name = "pyo3-macros"
163
- version = "0.28.3"
163
+ version = "0.29.0"
164
164
  source = "registry+https://github.com/rust-lang/crates.io-index"
165
- checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
165
+ checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771"
166
166
  dependencies = [
167
167
  "proc-macro2",
168
168
  "pyo3-macros-backend",
@@ -172,13 +172,12 @@ dependencies = [
172
172
 
173
173
  [[package]]
174
174
  name = "pyo3-macros-backend"
175
- version = "0.28.3"
175
+ version = "0.29.0"
176
176
  source = "registry+https://github.com/rust-lang/crates.io-index"
177
- checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
177
+ checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362"
178
178
  dependencies = [
179
179
  "heck",
180
180
  "proc-macro2",
181
- "pyo3-build-config",
182
181
  "quote",
183
182
  "syn",
184
183
  ]
@@ -214,9 +213,9 @@ dependencies = [
214
213
 
215
214
  [[package]]
216
215
  name = "regex"
217
- version = "1.12.3"
216
+ version = "1.12.4"
218
217
  source = "registry+https://github.com/rust-lang/crates.io-index"
219
- checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
218
+ checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
220
219
  dependencies = [
221
220
  "aho-corasick",
222
221
  "memchr",
@@ -237,9 +236,9 @@ dependencies = [
237
236
 
238
237
  [[package]]
239
238
  name = "regex-syntax"
240
- version = "0.8.10"
239
+ version = "0.8.11"
241
240
  source = "registry+https://github.com/rust-lang/crates.io-index"
242
- checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
241
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
243
242
 
244
243
  [[package]]
245
244
  name = "serde"
@@ -287,8 +286,9 @@ dependencies = [
287
286
 
288
287
  [[package]]
289
288
  name = "serpentine"
290
- version = "0.3.1"
289
+ version = "0.3.2"
291
290
  dependencies = [
291
+ "indexmap",
292
292
  "pyo3",
293
293
  "rayon",
294
294
  "serde",
@@ -315,9 +315,9 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
315
315
 
316
316
  [[package]]
317
317
  name = "syn"
318
- version = "2.0.117"
318
+ version = "2.0.118"
319
319
  source = "registry+https://github.com/rust-lang/crates.io-index"
320
- checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
320
+ checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
321
321
  dependencies = [
322
322
  "proc-macro2",
323
323
  "quote",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "serpentine"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
 
@@ -15,7 +15,8 @@ tree-sitter-javascript = { version = "0.25", default-features = false }
15
15
  tree-sitter-typescript = { version = "0.23", default-features = false }
16
16
  tree-sitter-rust = { version = "0.24", default-features = false }
17
17
  tree-sitter-hcl = "1.1.0"
18
- pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310"] }
18
+ pyo3 = { version = "0.29", features = ["extension-module", "abi3-py310"] }
19
19
  rayon = "1.10"
20
- serde = { version = "1.0", features = ["derive"] }
20
+ indexmap = "2"
21
+ serde = { version = "1.0", features = ["derive", "rc"] }
21
22
  serde_json = "1.0"
@@ -0,0 +1,441 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use indexmap::{IndexMap, IndexSet};
5
+
6
+ use super::{EdgeData, GraphMetadata, LanguageConfig, NodeData};
7
+ use crate::subscribers::RawBinding;
8
+
9
+ /// Per-file ownership record for incremental graph updates.
10
+ #[derive(Default)]
11
+ pub(crate) struct FileContributions {
12
+ /// Qualnames that this file's load passes added to `definitions`.
13
+ pub(crate) node_ids: HashSet<String>,
14
+ /// Edges from per-file load passes (uses, decorators, imports, inheritance).
15
+ /// Does NOT include raw_binding edges (tracked separately on GraphBuilder).
16
+ pub(crate) edge_keys: HashSet<EdgeData>,
17
+ /// Phantom keys this file added to `reexport_map`.
18
+ pub(crate) reexport_keys: Vec<String>,
19
+ /// Keys this file added to `import_bindings`.
20
+ pub(crate) import_binding_keys: Vec<String>,
21
+ /// Typed raw bindings from this file's subscriber output, stored for
22
+ /// global re-merge when any file is dirtied.
23
+ pub(crate) raw_bindings: Vec<RawBinding>,
24
+ /// FNV-like hash of this file's raw_bindings used to detect unchanged
25
+ /// bindings on incremental builds and skip the global re-run.
26
+ pub(crate) raw_bindings_hash: u64,
27
+ }
28
+
29
+ /// Builder that combines subscriber outputs into a DependencyGraph
30
+ pub struct GraphBuilder {
31
+ /// Local module top-level prefixes (e.g., {"src", "frontend"})
32
+ pub(crate) local_prefixes: HashSet<String>,
33
+ /// Language configs used for stdlib classification
34
+ pub(crate) lang_configs: Vec<Box<dyn LanguageConfig>>,
35
+ /// All known definitions indexed by qualname (only real definitions!)
36
+ pub(crate) definitions: HashMap<String, NodeData>,
37
+ /// Dependency edges from per-file load passes (uses, imports, decorators, inheritance).
38
+ pub(crate) edges: HashSet<EdgeData>,
39
+ /// Edges produced by the global load_raw_bindings pass, tracked separately
40
+ /// so they can be cleared and re-run incrementally without disturbing per-file edges.
41
+ pub(crate) raw_binding_edges: HashSet<EdgeData>,
42
+ /// Re-export map: phantom qualname → actual definition qualname.
43
+ /// Built from __init__.py imports so that `pkg.name` resolves to
44
+ /// `pkg.submodule.name` when the symbol is re-exported through __init__.
45
+ pub(crate) reexport_map: HashMap<String, String>,
46
+ /// Import bindings: maps "module.local_name" → "resolved_qualname"
47
+ /// e.g., "serpentine.state.Config" → "serpentine.config.Config"
48
+ ///
49
+ /// Built from scope_tree + definitions + imports subscriber outputs
50
+ /// BEFORE any edge resolution. Used by resolve_name_legb() to follow
51
+ /// what each name means in each module's namespace, just like Python's
52
+ /// LEGB rule at the G (global/module) level.
53
+ pub(crate) import_bindings: HashMap<String, String>,
54
+ /// Edge index keyed by caller — built before CALLS pass of load_raw_bindings.
55
+ /// Enables O(1) lookup in resolve_variable_type instead of O(E) scan.
56
+ pub(crate) edge_caller_index: HashMap<String, Vec<EdgeData>>,
57
+ /// Function return types: fn_qualname → class_qualname.
58
+ /// Populated by the RETURNS pass in load_raw_bindings.
59
+ /// Used by resolve_variable_type to resolve factory-function return types.
60
+ pub(crate) function_return_types: HashMap<String, String>,
61
+ /// All module qualnames for O(1) lookup in build_reexport_map,
62
+ /// replacing the O(n) `.any(|k| k.starts_with(...))` scan.
63
+ pub(crate) module_qualnames: HashSet<String>,
64
+ /// Per-file ownership records for incremental updates.
65
+ pub(crate) file_contributions: HashMap<PathBuf, FileContributions>,
66
+ /// Files modified since the last graph build. Processed on next call.
67
+ pub(crate) dirty_files: HashSet<PathBuf>,
68
+ /// File currently being loaded. Set before each load call, cleared after.
69
+ /// Used by load_* methods to attribute contributions without extra parameters.
70
+ pub(crate) current_file: Option<PathBuf>,
71
+ /// Cached JSON snapshot from the last completed build. Cleared when any
72
+ /// file is retracted (i.e., on the first dirty-file build after a change).
73
+ pub(crate) cached_snapshot: Option<String>,
74
+ /// Incremental hierarchy: parent qualname → ordered child qualnames.
75
+ /// Updated on every definitions insert and cleared on retract_file.
76
+ pub(crate) hierarchy_children: IndexMap<String, Vec<String>>,
77
+ /// Root qualnames (no parent in definitions), in insertion order.
78
+ pub(crate) hierarchy_roots: IndexSet<String>,
79
+ /// Per-node JSON fragment cache (structural fields only, excluding children).
80
+ /// Entries are invalidated in retract_file when a node is removed.
81
+ /// snapshot() takes &mut self so no Mutex is needed.
82
+ pub(crate) node_json_cache: HashMap<String, String>,
83
+ /// Per-subtree JSON cache: top-level module qualname → full subtree JSON string.
84
+ /// A subtree is dirty (and absent here) when any node within it was retracted
85
+ /// or re-added since the last snapshot. Clean subtrees are emitted directly
86
+ /// without re-traversing the node hierarchy.
87
+ pub(crate) subtree_json_cache: HashMap<String, String>,
88
+ /// Top-level module qualnames whose subtree JSON must be rebuilt on next snapshot.
89
+ /// Populated by retract_file and register_node_in_hierarchy; cleared after snapshot.
90
+ pub(crate) dirty_subtrees: HashSet<String>,
91
+ /// Cached serialized edges JSON (the "edges" section of the snapshot).
92
+ /// Invalidated when raw_binding_edges change (i.e., when clear_raw_binding_edges
93
+ /// is called). Reused when raw_bindings haven't changed on an incremental build.
94
+ pub(crate) cached_edges_json: Option<String>,
95
+ }
96
+
97
+ impl Default for GraphBuilder {
98
+ fn default() -> Self {
99
+ Self::new()
100
+ }
101
+ }
102
+
103
+ impl GraphBuilder {
104
+ pub fn new() -> Self {
105
+ GraphBuilder {
106
+ local_prefixes: HashSet::new(),
107
+ lang_configs: Vec::new(),
108
+ definitions: HashMap::new(),
109
+ edges: HashSet::new(),
110
+ raw_binding_edges: HashSet::new(),
111
+ reexport_map: HashMap::new(),
112
+ import_bindings: HashMap::new(),
113
+ edge_caller_index: HashMap::new(),
114
+ function_return_types: HashMap::new(),
115
+ module_qualnames: HashSet::new(),
116
+ file_contributions: HashMap::new(),
117
+ dirty_files: HashSet::new(),
118
+ current_file: None,
119
+ cached_snapshot: None,
120
+ hierarchy_children: IndexMap::new(),
121
+ hierarchy_roots: IndexSet::new(),
122
+ node_json_cache: HashMap::new(),
123
+ subtree_json_cache: HashMap::new(),
124
+ dirty_subtrees: HashSet::new(),
125
+ cached_edges_json: None,
126
+ }
127
+ }
128
+
129
+ /// Record a node qualname as contributed by `current_file`.
130
+ pub(crate) fn record_node_contribution(&mut self, qualname: &str) {
131
+ if let Some(path) = self.current_file.clone() {
132
+ self.file_contributions
133
+ .entry(path)
134
+ .or_default()
135
+ .node_ids
136
+ .insert(qualname.to_string());
137
+ }
138
+ }
139
+
140
+ /// Record an edge as contributed by `current_file`.
141
+ pub(crate) fn record_edge_contribution(&mut self, edge: EdgeData) {
142
+ if let Some(path) = self.current_file.clone() {
143
+ self.file_contributions
144
+ .entry(path)
145
+ .or_default()
146
+ .edge_keys
147
+ .insert(edge);
148
+ }
149
+ }
150
+
151
+ /// Record a reexport_map key as contributed by `current_file`.
152
+ pub(crate) fn record_reexport_contribution(&mut self, key: &str) {
153
+ if let Some(path) = self.current_file.clone() {
154
+ self.file_contributions
155
+ .entry(path)
156
+ .or_default()
157
+ .reexport_keys
158
+ .push(key.to_string());
159
+ }
160
+ }
161
+
162
+ /// Record an import_bindings key as contributed by `current_file`.
163
+ pub(crate) fn record_import_binding_contribution(&mut self, key: &str) {
164
+ if let Some(path) = self.current_file.clone() {
165
+ self.file_contributions
166
+ .entry(path)
167
+ .or_default()
168
+ .import_binding_keys
169
+ .push(key.to_string());
170
+ }
171
+ }
172
+
173
+ /// Register a newly-inserted node in the incremental hierarchy.
174
+ /// Must be called AFTER the node's ancestors exist in `self.definitions`
175
+ /// (i.e., after `ensure_parent_nodes` returns).
176
+ pub(crate) fn register_node_in_hierarchy(&mut self, qualname: &str) {
177
+ // Mark the top-level subtree as dirty so snapshot rebuilds it
178
+ let root = qualname.split('.').next().unwrap_or(qualname);
179
+ self.dirty_subtrees.insert(root.to_string());
180
+ self.subtree_json_cache.remove(root);
181
+
182
+ if let Some((parent, _)) = qualname.rsplit_once('.') {
183
+ if self.definitions.contains_key(parent) {
184
+ let children = self.hierarchy_children.entry(parent.to_string()).or_default();
185
+ if !children.iter().any(|c| c == qualname) {
186
+ children.push(qualname.to_string());
187
+ }
188
+ return;
189
+ }
190
+ }
191
+ self.hierarchy_roots.insert(qualname.to_string());
192
+ }
193
+
194
+ /// Build the edge_caller_index from current edges. Call before the CALLS
195
+ /// pass of load_raw_bindings so resolve_variable_type gets O(1) lookups.
196
+ pub(crate) fn build_edge_caller_index(&mut self) {
197
+ self.edge_caller_index.clear();
198
+ // Only has-a edges are ever queried from this index (resolve_variable_type,
199
+ // constructor-arg, param-type passes all filter for edge_type == "has-a").
200
+ // Indexing only has-a reduces the index by ~10-20× vs all edge types.
201
+ for edge in self.edges.iter().chain(self.raw_binding_edges.iter()) {
202
+ if edge.edge_type == "has-a" {
203
+ self.edge_caller_index
204
+ .entry(edge.caller.clone())
205
+ .or_default()
206
+ .push(edge.clone());
207
+ }
208
+ }
209
+ }
210
+
211
+ /// Remove all raw_binding-derived edges from `self.edges` and clear the
212
+ /// tracking set. Call before re-running `load_raw_bindings` globally.
213
+ pub fn clear_raw_binding_edges(&mut self) {
214
+ for edge in self.raw_binding_edges.drain() {
215
+ self.edges.remove(&edge);
216
+ }
217
+ self.cached_edges_json = None;
218
+ }
219
+
220
+ /// Remove all contributions from `path` and retract their effects on graph state.
221
+ pub fn retract_file(&mut self, path: &Path) {
222
+ self.cached_snapshot = None;
223
+ let Some(contrib) = self.file_contributions.remove(path) else {
224
+ return;
225
+ };
226
+ for qualname in &contrib.node_ids {
227
+ self.definitions.remove(qualname);
228
+ self.module_qualnames.remove(qualname);
229
+ // Remove from incremental hierarchy
230
+ self.hierarchy_roots.shift_remove(qualname.as_str());
231
+ if let Some((parent, _)) = qualname.rsplit_once('.') {
232
+ if let Some(children) = self.hierarchy_children.get_mut(parent) {
233
+ children.retain(|c| c != qualname.as_str());
234
+ }
235
+ }
236
+ self.hierarchy_children.shift_remove(qualname.as_str());
237
+ // Invalidate the per-node JSON fragment cache entry
238
+ self.node_json_cache.remove(qualname.as_str());
239
+ // Mark the top-level subtree dirty so snapshot rebuilds it
240
+ let root = qualname.split('.').next().unwrap_or(qualname);
241
+ self.dirty_subtrees.insert(root.to_string());
242
+ self.subtree_json_cache.remove(root);
243
+ }
244
+ for edge in &contrib.edge_keys {
245
+ self.edges.remove(edge);
246
+ }
247
+ for key in &contrib.reexport_keys {
248
+ self.reexport_map.remove(key);
249
+ }
250
+ for key in &contrib.import_binding_keys {
251
+ self.import_bindings.remove(key);
252
+ }
253
+ // raw_bindings removed with the FileContributions entry above.
254
+ // Caller is responsible for calling clear_raw_binding_edges() and
255
+ // re-running load_raw_bindings() globally after all retractions.
256
+ }
257
+
258
+ /// True if one endpoint is a direct ancestor of the other in the dotted hierarchy.
259
+ /// Replaces `starts_with(&format!("{}.", x))` with a zero-allocation check.
260
+ #[inline]
261
+ fn is_ancestor_edge(e: &EdgeData) -> bool {
262
+ fn one_is_ancestor(parent: &str, child: &str) -> bool {
263
+ child.len() > parent.len()
264
+ && child.as_bytes().get(parent.len()) == Some(&b'.')
265
+ && child.starts_with(parent)
266
+ }
267
+ one_is_ancestor(&e.caller, &e.callee) || one_is_ancestor(&e.callee, &e.caller)
268
+ }
269
+
270
+ /// Return the set of has-a edges that are redundant because a more-specific
271
+ /// edge from a descendant caller already covers the same top-level module.
272
+ ///
273
+ /// O(N log N): builds sorted caller lists per top-level module, then uses
274
+ /// binary search to check for prefix matches — avoids the O(N×M) inner
275
+ /// `any(starts_with)` scan of the previous implementation.
276
+ ///
277
+ /// Operates entirely on borrowed references; no EdgeData is cloned.
278
+ fn find_redundant_edges<'a>(all_edges: &[&'a EdgeData]) -> HashSet<&'a EdgeData> {
279
+ // Build sorted caller list per top-level callee module
280
+ let mut module_callers: HashMap<&str, Vec<&str>> = HashMap::new();
281
+ for edge in all_edges {
282
+ let top = edge.callee.split('.').next().unwrap_or(edge.callee.as_str());
283
+ module_callers.entry(top).or_default().push(edge.caller.as_str());
284
+ }
285
+ for callers in module_callers.values_mut() {
286
+ callers.sort_unstable();
287
+ callers.dedup();
288
+ }
289
+
290
+ let mut redundant: HashSet<&EdgeData> = HashSet::new();
291
+ for &edge in all_edges {
292
+ if edge.edge_type != "has-a" {
293
+ continue;
294
+ }
295
+ let top = edge.callee.split('.').next().unwrap_or(edge.callee.as_str());
296
+ if let Some(callers) = module_callers.get(top) {
297
+ let caller = edge.caller.as_str();
298
+ // Binary search: find first entry that could start with "caller."
299
+ // Strings starting with "caller." sort immediately after "caller" and
300
+ // before any string that is lexicographically >= "caller" + char > '.'.
301
+ let idx = callers.partition_point(|&c| c <= caller);
302
+ // Check if the entry at idx starts with "caller." without allocating
303
+ if callers.get(idx).is_some_and(|&c| {
304
+ c.len() > caller.len()
305
+ && c.as_bytes().get(caller.len()) == Some(&b'.')
306
+ && c.starts_with(caller)
307
+ }) {
308
+ redundant.insert(edge);
309
+ }
310
+ }
311
+ }
312
+ redundant
313
+ }
314
+
315
+ /// Produce a graph snapshot JSON string from current state without consuming the builder.
316
+ /// Uses the incremental hierarchy and per-node JSON fragment cache for fast incremental
317
+ /// serialization: only nodes retracted since the last snapshot need re-serialization.
318
+ pub fn snapshot(&mut self) -> String {
319
+ // Build edge JSON (cached separately from nodes so it survives node-only changes)
320
+ let edges_json = if let Some(ref cached) = self.cached_edges_json {
321
+ cached.clone()
322
+ } else {
323
+ // Collect edge refs without cloning any EdgeData. Deduplicate by content
324
+ // since self.edges and raw_binding_edges can contain value-equivalent items.
325
+ let mut seen: HashSet<(&str, &str, &str)> = HashSet::new();
326
+ let all_edges: Vec<&EdgeData> = self.edges.iter()
327
+ .chain(self.raw_binding_edges.iter())
328
+ .filter(|e| seen.insert((e.caller.as_str(), e.callee.as_str(), e.edge_type.as_str())))
329
+ .collect();
330
+
331
+ let redundant = Self::find_redundant_edges(&all_edges);
332
+ let edges: Vec<&EdgeData> = all_edges.into_iter()
333
+ .filter(|&e| !redundant.contains(e))
334
+ .filter(|e| !Self::is_ancestor_edge(e))
335
+ .collect();
336
+
337
+ let j = serde_json::to_string(&edges).unwrap_or_default();
338
+ self.cached_edges_json = Some(j.clone());
339
+ j
340
+ };
341
+
342
+ let edge_count = {
343
+ // Count from the JSON cheaply by counting top-level objects
344
+ // (serde already produced it; for metadata just recount the filtered edges)
345
+ // Re-derive from edges_json length would be wrong; just recompute edge count.
346
+ // We already know the edges: use the cached_edges_json length is wrong.
347
+ // Parse is too expensive. Store edge_count alongside cached_edges_json instead.
348
+ // For now, count commas at top level — edges_json is `[{...},{...},...]`
349
+ if edges_json == "[]" || edges_json == "null" { 0usize }
350
+ else { edges_json.bytes().filter(|&b| b == b'{').count() }
351
+ };
352
+
353
+ let mut out = String::with_capacity(8 * 1024 * 1024);
354
+ out.push_str("{\"nodes\":[");
355
+
356
+ // Emit nodes using per-subtree cache. Dirty subtrees are rebuilt and re-cached;
357
+ // clean subtrees are emitted directly from cache without any node traversal.
358
+ let mut first_root = true;
359
+ let roots: Vec<String> = self.hierarchy_roots.iter().cloned().collect();
360
+ for root in &roots {
361
+ if !self.definitions.contains_key(root.as_str()) {
362
+ continue;
363
+ }
364
+ if !first_root { out.push(','); }
365
+ first_root = false;
366
+
367
+ if let Some(cached_subtree) = self.subtree_json_cache.get(root.as_str()) {
368
+ // Clean subtree: emit cached JSON directly
369
+ out.push_str(cached_subtree);
370
+ } else {
371
+ // Dirty subtree: rebuild and cache
372
+ let subtree_start = out.len();
373
+ self.write_node_json(&mut out, root);
374
+ let subtree = out[subtree_start..].to_string();
375
+ self.subtree_json_cache.insert(root.clone(), subtree);
376
+ }
377
+ }
378
+ self.dirty_subtrees.clear();
379
+
380
+ out.push_str("],\"edges\":");
381
+ out.push_str(&edges_json);
382
+ let metadata = self.compute_metadata_inline(edge_count);
383
+ out.push_str(",\"metadata\":");
384
+ out.push_str(&serde_json::to_string(&metadata).unwrap_or_default());
385
+ out.push('}');
386
+ out
387
+ }
388
+
389
+ /// Recursively write the JSON for a single node and its children into `out`.
390
+ /// Uses `node_json_cache` to avoid re-serializing unchanged node fragments.
391
+ fn write_node_json(&mut self, out: &mut String, qualname: &str) {
392
+ // We need node data but can't hold a borrow of self.definitions while also
393
+ // borrowing self.node_json_cache mutably. Serialize fresh if cache miss,
394
+ // using a temporary clone of the node to avoid the borrow conflict.
395
+ let fragment = if let Some(frag) = self.node_json_cache.get(qualname) {
396
+ frag.clone()
397
+ } else {
398
+ let frag = self.definitions.get(qualname)
399
+ .map(|n| serde_json::to_string(n).unwrap_or_default())
400
+ .unwrap_or_default();
401
+ if !frag.is_empty() {
402
+ self.node_json_cache.insert(qualname.to_string(), frag.clone());
403
+ }
404
+ frag
405
+ };
406
+
407
+ if fragment.is_empty() { return; }
408
+
409
+ // Strip the closing `}` and inject `"children":[...]`
410
+ let base = fragment.trim_end_matches('}');
411
+ out.push_str(base);
412
+ out.push_str(",\"children\":[");
413
+ let kids: Vec<String> = self.hierarchy_children
414
+ .get(qualname).cloned()
415
+ .unwrap_or_default();
416
+ let mut first = true;
417
+ for child in &kids {
418
+ if self.definitions.contains_key(child.as_str()) {
419
+ if !first { out.push(','); }
420
+ first = false;
421
+ self.write_node_json(out, child);
422
+ }
423
+ }
424
+ out.push_str("]}");
425
+ }
426
+
427
+ /// Compute graph metadata directly from definitions (no DependencyGraph traversal).
428
+ fn compute_metadata_inline(&self, edge_count: usize) -> GraphMetadata {
429
+ let mut node_types: HashMap<String, usize> = HashMap::new();
430
+ for node in self.definitions.values() {
431
+ let type_str = format!("{:?}", node.object_type).to_lowercase();
432
+ *node_types.entry(type_str).or_insert(0) += 1;
433
+ }
434
+ GraphMetadata {
435
+ node_count: self.definitions.len(),
436
+ edge_count,
437
+ node_types,
438
+ }
439
+ }
440
+
441
+ }