devlensio 0.6.2 → 1.0.0

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 (129) hide show
  1. package/README.md +89 -31
  2. package/dist/extractors/detectLanguage.d.ts +2 -0
  3. package/dist/extractors/detectLanguage.js +33 -0
  4. package/dist/extractors/index.d.ts +6 -0
  5. package/dist/extractors/index.js +116 -0
  6. package/dist/extractors/runner.d.ts +7 -0
  7. package/dist/extractors/runner.js +194 -0
  8. package/dist/extractors/types.d.ts +37 -0
  9. package/dist/extractors/types.js +8 -0
  10. package/dist/graph/buildLookup.d.ts +1 -0
  11. package/dist/graph/buildLookup.js +2 -1
  12. package/dist/graph/edges/callEdges.js +19 -5
  13. package/dist/graph/edges/callEdges.test.d.ts +1 -0
  14. package/dist/graph/edges/callEdges.test.js +200 -0
  15. package/dist/graph/edges/importEdges.js +12 -0
  16. package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
  17. package/dist/graph/edges/inheritanceEdges.js +73 -0
  18. package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
  19. package/dist/graph/edges/inheritanceEdges.test.js +140 -0
  20. package/dist/graph/index.js +4 -0
  21. package/dist/parser/classes.test.d.ts +1 -0
  22. package/dist/parser/classes.test.js +360 -0
  23. package/dist/parser/extractors/classes.d.ts +5 -0
  24. package/dist/parser/extractors/classes.js +241 -0
  25. package/dist/parser/index.d.ts +2 -0
  26. package/dist/parser/index.js +7 -1
  27. package/dist/pipeline/index.d.ts +2 -1
  28. package/dist/pipeline/index.js +30 -42
  29. package/dist/scoring/index.js +8 -0
  30. package/dist/scoring/index.test.js +43 -0
  31. package/dist/scoring/nodeScorer.js +2 -0
  32. package/dist/scoring/pruneDisconnected.d.ts +8 -0
  33. package/dist/scoring/pruneDisconnected.js +66 -0
  34. package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
  35. package/dist/scoring/pruneDisconnected.test.js +123 -0
  36. package/dist/summarizer/prompts.d.ts +1 -1
  37. package/dist/types.d.ts +5 -5
  38. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  39. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  40. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  41. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  42. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  43. package/extractors/go/build.mjs +43 -0
  44. package/extractors/go/calls.go +289 -0
  45. package/extractors/go/contract.go +140 -0
  46. package/extractors/go/extractor.go +161 -0
  47. package/extractors/go/fingerprint.go +167 -0
  48. package/extractors/go/go.mod +3 -0
  49. package/extractors/go/imports.go +90 -0
  50. package/extractors/go/inheritance.go +138 -0
  51. package/extractors/go/lookup.go +106 -0
  52. package/extractors/go/main.go +57 -0
  53. package/extractors/go/nodes.go +177 -0
  54. package/extractors/go/orm_edges.go +277 -0
  55. package/extractors/go/parser.go +469 -0
  56. package/extractors/go/routes.go +521 -0
  57. package/extractors/go/tests.go +45 -0
  58. package/extractors/go/thirdparty.go +138 -0
  59. package/extractors/go/typeload.go +178 -0
  60. package/extractors/go/walker.go +67 -0
  61. package/extractors/java/build.mjs +90 -0
  62. package/extractors/java/devlens_java_extractor.jar +0 -0
  63. package/extractors/java/src/devlens/extractor/Contract.java +171 -0
  64. package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
  65. package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
  66. package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
  67. package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
  68. package/extractors/java/src/devlens/extractor/Main.java +66 -0
  69. package/extractors/java/src/devlens/extractor/Parser.java +522 -0
  70. package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
  71. package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
  72. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
  73. package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
  74. package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
  75. package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
  76. package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
  77. package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
  78. package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
  79. package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
  80. package/extractors/python/devlens_extractors_python/__init__.py +3 -0
  81. package/extractors/python/devlens_extractors_python/__main__.py +33 -0
  82. package/extractors/python/devlens_extractors_python/contract.py +94 -0
  83. package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
  84. package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
  85. package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
  86. package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
  87. package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
  88. package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
  89. package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
  90. package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
  91. package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
  92. package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
  93. package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
  94. package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
  95. package/extractors/python/devlens_extractors_python/extractor.py +107 -0
  96. package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
  97. package/extractors/python/devlens_extractors_python/lookup.py +72 -0
  98. package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
  99. package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
  100. package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
  101. package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
  102. package/extractors/python/devlens_extractors_python/third_party.py +103 -0
  103. package/extractors/python/pyproject.toml +16 -0
  104. package/extractors/python/setup.mjs +54 -0
  105. package/extractors/rust/Cargo.toml +29 -0
  106. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  107. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  108. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  109. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  110. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  111. package/extractors/rust/build.mjs +85 -0
  112. package/extractors/rust/src/calls.rs +295 -0
  113. package/extractors/rust/src/contract.rs +236 -0
  114. package/extractors/rust/src/enrich.rs +107 -0
  115. package/extractors/rust/src/extractor.rs +199 -0
  116. package/extractors/rust/src/fingerprint.rs +238 -0
  117. package/extractors/rust/src/imports.rs +101 -0
  118. package/extractors/rust/src/inheritance.rs +233 -0
  119. package/extractors/rust/src/lookup.rs +226 -0
  120. package/extractors/rust/src/main.rs +53 -0
  121. package/extractors/rust/src/module_map.rs +131 -0
  122. package/extractors/rust/src/nodes.rs +174 -0
  123. package/extractors/rust/src/orm_edges.rs +125 -0
  124. package/extractors/rust/src/parser.rs +859 -0
  125. package/extractors/rust/src/routes.rs +1265 -0
  126. package/extractors/rust/src/tests.rs +109 -0
  127. package/extractors/rust/src/thirdparty.rs +114 -0
  128. package/extractors/rust/src/walker.rs +82 -0
  129. package/package.json +22 -4
@@ -0,0 +1,859 @@
1
+ // parser.rs — parse-time FACT collection via syn. Everything an edge detector
2
+ // needs is collected HERE in one pass per file; detectors resolve from the
3
+ // facts via lookup maps, never re-walking ASTs (playbook rule).
4
+ //
5
+ // syn is SYNTACTIC — no type resolution (rust-analyzer would be V2). All
6
+ // name-based resolution downstream is flagged where heuristic.
7
+
8
+ use proc_macro2::{Span, TokenStream, TokenTree};
9
+ use quote::ToTokens;
10
+ use std::collections::BTreeSet;
11
+ use syn::spanned::Spanned;
12
+ use syn::visit::Visit;
13
+
14
+ // ── line index: (line, char-col) → byte offset (exact rawCode slicing) ──
15
+
16
+ pub struct LineIndex {
17
+ /// per line: byte offset of the line start, then byte offset of each
18
+ /// char boundary within the line (0-based char index → byte offset)
19
+ lines: Vec<LineEntry>,
20
+ }
21
+
22
+ struct LineEntry {
23
+ byte_offset: usize,
24
+ char_offsets: Vec<usize>,
25
+ }
26
+
27
+ impl LineIndex {
28
+ pub fn new(source: &str) -> Self {
29
+ let mut lines = vec![];
30
+ let mut line_start = 0usize;
31
+ for line in source.split('\n') {
32
+ let mut char_offsets = vec![0usize];
33
+ for (idx, (byte, _c)) in line.char_indices().enumerate() {
34
+ if idx > 0 {
35
+ char_offsets.push(byte);
36
+ }
37
+ }
38
+ char_offsets.push(line.len());
39
+ lines.push(LineEntry {
40
+ byte_offset: line_start,
41
+ char_offsets,
42
+ });
43
+ line_start += line.len() + 1; // +1 for '\n'
44
+ }
45
+ LineIndex { lines }
46
+ }
47
+
48
+ /// line: 1-based, col: 1-based char column → byte offset into source.
49
+ fn byte_offset(&self, line: usize, col: usize) -> usize {
50
+ if line == 0 || line > self.lines.len() {
51
+ return 0;
52
+ }
53
+ let entry = &self.lines[line - 1];
54
+ entry.byte_offset
55
+ + entry
56
+ .char_offsets
57
+ .get(col.saturating_sub(1))
58
+ .copied()
59
+ .unwrap_or(0)
60
+ }
61
+
62
+ /// Byte range of a proc_macro2 span (span.start()/end() are 1-based).
63
+ pub fn span_range(&self, span: Span) -> (usize, usize) {
64
+ let start = span.start();
65
+ let end = span.end();
66
+ (
67
+ self.byte_offset(start.line, start.column + 1),
68
+ self.byte_offset(end.line, end.column + 1),
69
+ )
70
+ }
71
+
72
+ pub fn line_count(&self) -> usize {
73
+ self.lines.len()
74
+ }
75
+ }
76
+
77
+ // ── parsed facts ──
78
+
79
+ #[derive(Clone)]
80
+ pub struct UseDecl {
81
+ /// alias in scope (last path segment, or explicit `as` name)
82
+ pub alias: String,
83
+ /// full target path (crate::foo::bar / axum::Router / super::x)
84
+ pub target: String,
85
+ /// true for `use foo::*;` / `use foo::{a::*, b};` glob portions
86
+ pub glob: bool,
87
+ }
88
+
89
+ #[derive(Clone)]
90
+ pub struct TableDecl {
91
+ pub name: String,
92
+ pub columns: Vec<(String, String)>,
93
+ }
94
+
95
+ #[derive(Clone, Default)]
96
+ pub struct AttrInfo {
97
+ /// attr path joined with :: (e.g. "actix_web::get", "derive", "diesel")
98
+ pub path: String,
99
+ /// raw arg tokens for list metas (e.g. `"/users/{id}"` or `table_name = users`)
100
+ pub args: String,
101
+ }
102
+
103
+ #[derive(Clone)]
104
+ pub struct FieldInfo {
105
+ pub name: String,
106
+ }
107
+
108
+ pub const KIND_FUNCTION: &str = "FUNCTION";
109
+ pub const KIND_METHOD: &str = "METHOD";
110
+ pub const KIND_STRUCT: &str = "STRUCT";
111
+ pub const KIND_ENUM: &str = "ENUM";
112
+ pub const KIND_TRAIT: &str = "TRAIT";
113
+ pub const KIND_IMPL_BLOCK: &str = "IMPL_BLOCK";
114
+
115
+ #[derive(Clone, Default)]
116
+ pub struct OrmCall {
117
+ /// "read" | "write"
118
+ pub kind: String,
119
+ /// table reference as written (e.g. "users::table", "users::dsl::users",
120
+ /// "users" — or the first arg of diesel::insert_into/update/delete)
121
+ pub table_ref: String,
122
+ }
123
+
124
+ #[derive(Clone)]
125
+ pub struct ParsedItem {
126
+ pub kind: &'static str,
127
+ pub name: String,
128
+ pub start_line: i64,
129
+ pub end_line: i64,
130
+ pub raw_code: String,
131
+ pub attrs: Vec<AttrInfo>,
132
+ pub is_pub: bool,
133
+ pub is_async: bool,
134
+ /// method receiver: "&self" / "&mut self" / "self" / "" (free fn)
135
+ pub receiver: String,
136
+ pub calls: Vec<String>,
137
+ pub is_test: bool,
138
+ /// Diesel R/W call facts (collected alongside calls)
139
+ pub orm_calls: Vec<OrmCall>,
140
+ // kind-specific
141
+ pub fields: Vec<FieldInfo>,
142
+ pub variants: Vec<String>,
143
+ pub supertraits: Vec<String>,
144
+ pub derives: Vec<String>,
145
+ /// impl blocks: Some(path) when `impl Trait for Type`
146
+ pub impl_trait: Option<String>,
147
+ /// impl blocks: the target type path (e.g. "User", "Vec < T >" → "Vec<T>")
148
+ pub impl_target: String,
149
+ /// impl blocks: for trait impls, the trait path
150
+ pub impl_trait_path: Option<String>,
151
+ }
152
+
153
+ #[derive(Clone)]
154
+ pub struct ParsedFile {
155
+ pub rel_path: String,
156
+ /// the parsed AST — kept for detection passes that need expression
157
+ /// structure (routes.rs walks fn bodies for axum chains; mirrors go's
158
+ /// `Decl *ast.FuncDecl` retention)
159
+ #[allow(dead_code)]
160
+ pub ast: syn::File,
161
+ pub uses: Vec<UseDecl>,
162
+ pub items: Vec<ParsedItem>,
163
+ pub tables: Vec<TableDecl>,
164
+ pub test_fns: Vec<String>,
165
+ pub is_test_file: bool,
166
+ pub line_count: i64,
167
+ }
168
+
169
+ // ── attribute helpers ──
170
+
171
+ pub fn attr_info(a: &syn::Attribute) -> AttrInfo {
172
+ let path = a
173
+ .path()
174
+ .segments
175
+ .iter()
176
+ .map(|s| s.ident.to_string())
177
+ .collect::<Vec<_>>()
178
+ .join("::");
179
+ let args = match &a.meta {
180
+ syn::Meta::List(l) => l.tokens.to_string(),
181
+ syn::Meta::NameValue(nv) => nv.value.to_token_stream().to_string(),
182
+ syn::Meta::Path(_) => String::new(),
183
+ };
184
+ AttrInfo { path, args }
185
+ }
186
+
187
+ /// last path segment of an attr path (e.g. "get" for actix_web::get)
188
+ pub fn attr_last_segment(a: &syn::Attribute) -> String {
189
+ a.path()
190
+ .segments
191
+ .last()
192
+ .map(|s| s.ident.to_string())
193
+ .unwrap_or_default()
194
+ }
195
+
196
+ fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
197
+ attrs.iter().any(|a| {
198
+ let p = attr_info(a).path;
199
+ p == "cfg" && a.meta.to_token_stream().to_string().contains("test")
200
+ })
201
+ }
202
+
203
+ // ── call collection (per fn body; nested items are NOT descended into) ──
204
+
205
+ struct CallCollector {
206
+ calls: Vec<String>,
207
+ orm_calls: Vec<OrmCall>,
208
+ }
209
+
210
+ /// Diesel query-DSL verbs (documented diesel API — anti-overfit rule).
211
+ const ORM_READ_VERBS: [&str; 9] = [
212
+ "filter",
213
+ "first",
214
+ "get_result",
215
+ "get_results",
216
+ "load",
217
+ "find",
218
+ "select",
219
+ "count",
220
+ "all",
221
+ ];
222
+ const ORM_WRITE_VERBS: [&str; 3] = ["insert", "update", "delete"];
223
+ const DIESEL_WRITE_FNS: [&str; 3] = ["insert_into", "update", "delete"];
224
+
225
+ /// Collapse token-stream rendering ("users :: table" → "users::table").
226
+ /// to_token_stream() emits spaces around punctuation; removing ALL whitespace
227
+ /// yields the canonical compact form every detector matches on.
228
+ fn collapse_tokens(s: &str) -> String {
229
+ s.split_whitespace().collect::<Vec<_>>().join("")
230
+ }
231
+
232
+ fn path_string(p: &syn::Path) -> String {
233
+ p.segments
234
+ .iter()
235
+ .map(|s| s.ident.to_string())
236
+ .collect::<Vec<_>>()
237
+ .join("::")
238
+ }
239
+
240
+ /// Normalize a table reference to its bare table name:
241
+ /// users::table / users::dsl::users / users::dsl::users::columns::id /
242
+ /// schema::users::table → users
243
+ pub fn table_name_from_ref(r: &str) -> String {
244
+ let parts: Vec<&str> = r.split("::").collect();
245
+ for (i, part) in parts.iter().enumerate() {
246
+ if *part == "table" && i > 0 {
247
+ return parts[i - 1].to_string();
248
+ }
249
+ if *part == "dsl" && i + 1 < parts.len() {
250
+ return parts[i + 1].to_string();
251
+ }
252
+ }
253
+ // bare identifier: first segment (e.g. `users` imported via dsl::*)
254
+ parts[0].to_string()
255
+ }
256
+
257
+ impl<'ast> Visit<'ast> for CallCollector {
258
+ // scope rule: nested item definitions (fns/impls inside a body) do NOT
259
+ // belong to the enclosing function — skip them entirely
260
+ fn visit_item(&mut self, _item: &'ast syn::Item) {}
261
+
262
+ fn visit_expr_call(&mut self, e: &'ast syn::ExprCall) {
263
+ let name = match &*e.func {
264
+ syn::Expr::Path(p) => {
265
+ if p.qself.is_none() {
266
+ path_string(&p.path)
267
+ } else {
268
+ collapse_tokens(&p.to_token_stream().to_string())
269
+ }
270
+ }
271
+ other => collapse_tokens(&other.to_token_stream().to_string()),
272
+ };
273
+ self.calls.push(name.clone());
274
+ // diesel top-level write fns: insert_into(table) / update(table) /
275
+ // delete(table)
276
+ if let Some(first) = DIESEL_WRITE_FNS
277
+ .iter()
278
+ .find(|f| name.ends_with(&format!("::{}", f)))
279
+ {
280
+ let _ = first;
281
+ if let Some(arg) = e.args.first() {
282
+ self.orm_calls.push(OrmCall {
283
+ kind: "write".to_string(),
284
+ table_ref: collapse_tokens(&arg.to_token_stream().to_string()),
285
+ });
286
+ }
287
+ }
288
+ syn::visit::visit_expr_call(self, e);
289
+ }
290
+
291
+ fn visit_expr_method_call(&mut self, e: &'ast syn::ExprMethodCall) {
292
+ let recv = collapse_tokens(&e.receiver.to_token_stream().to_string());
293
+ let method = e.method.to_string();
294
+ self.calls.push(format!("{}.{}", recv, method));
295
+ // Diesel query DSL on a table path (receiver contains ::table or
296
+ // ::dsl::, or is a bare table-name alias)
297
+ let is_table_path = recv.contains("::table")
298
+ || recv.contains("::dsl::")
299
+ || (!recv.contains("::") && !recv.contains('.'));
300
+ if is_table_path {
301
+ if ORM_READ_VERBS.contains(&method.as_str()) {
302
+ self.orm_calls.push(OrmCall {
303
+ kind: "read".to_string(),
304
+ table_ref: table_name_from_ref(&recv),
305
+ });
306
+ } else if ORM_WRITE_VERBS.contains(&method.as_str()) {
307
+ self.orm_calls.push(OrmCall {
308
+ kind: "write".to_string(),
309
+ table_ref: table_name_from_ref(&recv),
310
+ });
311
+ }
312
+ }
313
+ syn::visit::visit_expr_method_call(self, e);
314
+ }
315
+ }
316
+
317
+ // ── file parsing ──
318
+
319
+ pub fn parse_file(rel_path: &str, source: &str) -> Result<ParsedFile, String> {
320
+ let ast = syn::parse_file(source).map_err(|e| e.to_string())?;
321
+ let line_index = LineIndex::new(source);
322
+ let mut pf = ParsedFile {
323
+ rel_path: rel_path.to_string(),
324
+ ast,
325
+ uses: vec![],
326
+ items: vec![],
327
+ tables: vec![],
328
+ test_fns: vec![],
329
+ is_test_file: false,
330
+ line_count: line_index.line_count() as i64,
331
+ };
332
+
333
+ // module path from the file's location (walker::module_path_for_file)
334
+ let module_path = crate::walker::module_path_for_file(rel_path);
335
+
336
+ // collect facts from the AST items (take avoids the immutable-borrow-vs-
337
+ // mutable-pf conflict; put them BACK — routes.rs walks pf.ast for
338
+ // expression-level detection)
339
+ let ast_items = std::mem::take(&mut pf.ast.items);
340
+ for item in &ast_items {
341
+ collect_item(item, &module_path, source, &line_index, &mut pf);
342
+ }
343
+ pf.ast.items = ast_items;
344
+
345
+ pf.is_test_file = !pf.test_fns.is_empty();
346
+ Ok(pf)
347
+ }
348
+
349
+ fn slice_range(source: &str, start: usize, end: usize) -> String {
350
+ if start >= end || start >= source.len() {
351
+ return String::new();
352
+ }
353
+ source[start..end.min(source.len())].to_string()
354
+ }
355
+
356
+ fn item_span_code<T: Spanned>(
357
+ item: &T,
358
+ source: &str,
359
+ line_index: &LineIndex,
360
+ ) -> (i64, i64, String) {
361
+ let span = item.span();
362
+ let (s, e) = line_index.span_range(span);
363
+ let start_line = span.start().line as i64;
364
+ let end_line = span.end().line as i64;
365
+ (start_line, end_line, slice_range(source, s, e))
366
+ }
367
+
368
+ fn collect_item(
369
+ item: &syn::Item,
370
+ module_path: &str,
371
+ source: &str,
372
+ line_index: &LineIndex,
373
+ pf: &mut ParsedFile,
374
+ ) {
375
+ match item {
376
+ syn::Item::Use(u) => {
377
+ collect_use_tree(&u.tree, &mut pf.uses);
378
+ }
379
+ syn::Item::Mod(m) => {
380
+ let cfg_test = has_cfg_test(&m.attrs);
381
+ match &m.content {
382
+ None => {
383
+ // external `mod x;` — the file exists on disk; the module
384
+ // map is filesystem-derived, nothing to record here
385
+ }
386
+ Some((_, items)) => {
387
+ if cfg_test {
388
+ // #[cfg(test)] mod — items stay OUT of the graph, but
389
+ // #[test] fns still feed the file's testCases (leaf
390
+ // rule: a file with tests IS a TEST node)
391
+ collect_test_names(items, pf);
392
+ } else {
393
+ let child_module = if module_path.is_empty() {
394
+ m.ident.to_string()
395
+ } else {
396
+ format!("{}::{}", module_path, m.ident)
397
+ };
398
+ for it in items {
399
+ collect_item(it, &child_module, source, line_index, pf);
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ syn::Item::Macro(mac) => {
406
+ // Diesel table! — the extractor's one macro we understand
407
+ if mac
408
+ .mac
409
+ .path
410
+ .segments
411
+ .last()
412
+ .map(|s| s.ident == "table")
413
+ .unwrap_or(false)
414
+ {
415
+ if let Some(t) = parse_table_macro(&mac.mac.tokens) {
416
+ pf.tables.push(t);
417
+ }
418
+ }
419
+ }
420
+ syn::Item::Fn(f) => collect_fn(
421
+ &f.attrs,
422
+ &f.sig,
423
+ &f.block,
424
+ &f.vis,
425
+ module_path,
426
+ source,
427
+ line_index,
428
+ pf,
429
+ ),
430
+ syn::Item::Struct(s) => {
431
+ let (sl, el, raw) = item_span_code(s, source, line_index);
432
+ let mut pi = base_item(
433
+ KIND_STRUCT,
434
+ &s.ident.to_string(),
435
+ sl,
436
+ el,
437
+ raw,
438
+ &s.attrs,
439
+ s.vis.clone(),
440
+ );
441
+ pi.derives = collect_derives(&s.attrs);
442
+ if let syn::Fields::Named(named) = &s.fields {
443
+ for f in &named.named {
444
+ if let Some(name) = &f.ident {
445
+ pi.fields.push(FieldInfo {
446
+ name: name.to_string(),
447
+ });
448
+ }
449
+ }
450
+ }
451
+ // struct generics bounds → metadata (EXTENDS for local traits is
452
+ // resolved in inheritance.rs from the bound paths)
453
+ collect_generic_bounds(&s.generics, &mut pi);
454
+ pf.items.push(pi);
455
+ }
456
+ syn::Item::Enum(e) => {
457
+ let (sl, el, raw) = item_span_code(e, source, line_index);
458
+ let mut pi = base_item(
459
+ KIND_ENUM,
460
+ &e.ident.to_string(),
461
+ sl,
462
+ el,
463
+ raw,
464
+ &e.attrs,
465
+ e.vis.clone(),
466
+ );
467
+ pi.derives = collect_derives(&e.attrs);
468
+ for v in &e.variants {
469
+ pi.variants.push(v.ident.to_string());
470
+ }
471
+ collect_generic_bounds(&e.generics, &mut pi);
472
+ pf.items.push(pi);
473
+ }
474
+ syn::Item::Trait(t) => {
475
+ let (sl, el, raw) = item_span_code(t, source, line_index);
476
+ let mut pi = base_item(
477
+ KIND_TRAIT,
478
+ &t.ident.to_string(),
479
+ sl,
480
+ el,
481
+ raw,
482
+ &t.attrs,
483
+ t.vis.clone(),
484
+ );
485
+ pi.supertraits = t
486
+ .supertraits
487
+ .iter()
488
+ .filter_map(|b| match b {
489
+ syn::TypeParamBound::Trait(tb) => Some(path_string(&tb.path)),
490
+ _ => None,
491
+ })
492
+ .collect();
493
+ pf.items.push(pi);
494
+ }
495
+ syn::Item::Impl(im) => {
496
+ let (sl, el, raw) = item_span_code(im, source, line_index);
497
+ let trait_opt = im.trait_.as_ref().map(|(_, path, _)| path_string(path));
498
+ let target = collapse_tokens(&im.self_ty.to_token_stream().to_string());
499
+ let name = match &trait_opt {
500
+ Some(t) => format!("impl {} for {}", t, target),
501
+ None => format!("impl {}", target),
502
+ };
503
+ let mut pi = base_item(
504
+ KIND_IMPL_BLOCK,
505
+ &name,
506
+ sl,
507
+ el,
508
+ raw,
509
+ &im.attrs,
510
+ // impl blocks carry no visibility in syn (always inherited)
511
+ syn::Visibility::Inherited,
512
+ );
513
+ pi.impl_trait = trait_opt.clone();
514
+ pi.impl_trait_path = trait_opt.clone();
515
+ pi.impl_target = target;
516
+ for ii in &im.items {
517
+ if let syn::ImplItem::Fn(ifn) = ii {
518
+ let mut m = parse_fn_like(
519
+ &ifn.attrs,
520
+ &ifn.sig,
521
+ &ifn.block,
522
+ &ifn.vis,
523
+ module_path,
524
+ source,
525
+ line_index,
526
+ true,
527
+ );
528
+ // the method belongs to the impl's target type (and trait
529
+ // when `impl Trait for Type`) — id + lookup keys need it
530
+ m.impl_target = pi.impl_target.clone();
531
+ m.impl_trait_path = pi.impl_trait_path.clone();
532
+ pf.items.push(m);
533
+ }
534
+ }
535
+ pf.items.push(pi);
536
+ }
537
+ _ => {}
538
+ }
539
+ }
540
+
541
+ fn base_item(
542
+ kind: &'static str,
543
+ name: &str,
544
+ start_line: i64,
545
+ end_line: i64,
546
+ raw_code: String,
547
+ attrs: &[syn::Attribute],
548
+ vis: syn::Visibility,
549
+ ) -> ParsedItem {
550
+ ParsedItem {
551
+ kind,
552
+ name: name.to_string(),
553
+ start_line,
554
+ end_line,
555
+ raw_code,
556
+ attrs: attrs.iter().map(attr_info).collect(),
557
+ is_pub: matches!(vis, syn::Visibility::Public(_)),
558
+ is_async: false,
559
+ receiver: String::new(),
560
+ calls: vec![],
561
+ is_test: false,
562
+ orm_calls: vec![],
563
+ fields: vec![],
564
+ variants: vec![],
565
+ supertraits: vec![],
566
+ derives: vec![],
567
+ impl_trait: None,
568
+ impl_target: String::new(),
569
+ impl_trait_path: None,
570
+ }
571
+ }
572
+
573
+ #[allow(clippy::too_many_arguments)]
574
+ fn collect_fn(
575
+ attrs: &[syn::Attribute],
576
+ sig: &syn::Signature,
577
+ block: &syn::Block,
578
+ vis: &syn::Visibility,
579
+ module_path: &str,
580
+ source: &str,
581
+ line_index: &LineIndex,
582
+ pf: &mut ParsedFile,
583
+ ) {
584
+ let pi = parse_fn_like(
585
+ attrs,
586
+ sig,
587
+ block,
588
+ vis,
589
+ module_path,
590
+ source,
591
+ line_index,
592
+ false,
593
+ );
594
+ if pi.is_test {
595
+ pf.test_fns.push(pi.name.clone());
596
+ }
597
+ pf.items.push(pi);
598
+ }
599
+
600
+ /// Scan items (recursively through mods) for #[test]-attributed fns — used
601
+ /// for #[cfg(test)] mods whose items are otherwise excluded from the graph.
602
+ fn collect_test_names(items: &[syn::Item], pf: &mut ParsedFile) {
603
+ for item in items {
604
+ match item {
605
+ syn::Item::Fn(f) => {
606
+ if f.attrs.iter().any(|a| attr_last_segment(a) == "test") {
607
+ pf.test_fns.push(f.sig.ident.to_string());
608
+ }
609
+ }
610
+ syn::Item::Mod(m) => {
611
+ if let Some((_, inner)) = &m.content {
612
+ collect_test_names(inner, pf);
613
+ }
614
+ }
615
+ _ => {}
616
+ }
617
+ }
618
+ }
619
+
620
+ #[allow(clippy::too_many_arguments)]
621
+ fn parse_fn_like(
622
+ attrs: &[syn::Attribute],
623
+ sig: &syn::Signature,
624
+ block: &syn::Block,
625
+ vis: &syn::Visibility,
626
+ _module_path: &str,
627
+ source: &str,
628
+ line_index: &LineIndex,
629
+ is_method: bool,
630
+ ) -> ParsedItem {
631
+ let sig_span = sig.span();
632
+ let block_span = block.span();
633
+ let start_line = sig_span.start().line as i64;
634
+ let end_line = block_span.end().line as i64;
635
+ let (s, _) = line_index.span_range(sig_span);
636
+ let (_, e) = line_index.span_range(block_span);
637
+ let raw = slice_range(source, s, e);
638
+
639
+ let mut pi = base_item(
640
+ if is_method {
641
+ KIND_METHOD
642
+ } else {
643
+ KIND_FUNCTION
644
+ },
645
+ &sig.ident.to_string(),
646
+ start_line,
647
+ end_line,
648
+ raw,
649
+ attrs,
650
+ vis.clone(),
651
+ );
652
+ pi.is_async = sig.asyncness.is_some();
653
+
654
+ // receiver (methods only): &self / &mut self / self
655
+ if is_method {
656
+ pi.receiver = match sig.receiver() {
657
+ Some(r) => {
658
+ let m = if r.mutability.is_some() { "mut " } else { "" };
659
+ let a = if r.reference.is_some() { "&" } else { "" };
660
+ format!("{}{}self", a, m)
661
+ }
662
+ None => String::new(),
663
+ };
664
+ }
665
+
666
+ // #[test] / #[tokio::test] / #[actix_web::test] — last segment "test"
667
+ for a in attrs {
668
+ if attr_last_segment(a) == "test" {
669
+ pi.is_test = true;
670
+ }
671
+ }
672
+
673
+ // calls + Diesel R/W facts within THIS body only (nested items skipped)
674
+ let mut cc = CallCollector {
675
+ calls: vec![],
676
+ orm_calls: vec![],
677
+ };
678
+ cc.visit_block(block);
679
+ let mut seen: BTreeSet<String> = BTreeSet::new();
680
+ for c in cc.calls {
681
+ seen.insert(c);
682
+ }
683
+ pi.calls = seen.into_iter().collect();
684
+ pi.orm_calls = cc.orm_calls;
685
+ pi
686
+ }
687
+
688
+ fn collect_use_tree(tree: &syn::UseTree, out: &mut Vec<UseDecl>) {
689
+ let mut acc: Vec<UseDecl> = vec![];
690
+ collect_use_tree_inner(tree, "", &mut acc);
691
+ out.extend(acc);
692
+ }
693
+
694
+ /// Recursively collect use targets, threading the path prefix.
695
+ /// `use crate::models::{user::User, helpers::*};` →
696
+ /// User → crate::models::user::User
697
+ /// (glob) helpers → crate::models::helpers
698
+ fn collect_use_tree_inner(tree: &syn::UseTree, prefix: &str, out: &mut Vec<UseDecl>) {
699
+ match tree {
700
+ syn::UseTree::Path(p) => {
701
+ let seg = p.ident.to_string();
702
+ let next = if prefix.is_empty() {
703
+ seg.clone()
704
+ } else {
705
+ format!("{}::{}", prefix, seg)
706
+ };
707
+ collect_use_tree_inner(&p.tree, &next, out);
708
+ }
709
+ syn::UseTree::Name(n) => {
710
+ let target = if prefix.is_empty() {
711
+ n.ident.to_string()
712
+ } else {
713
+ format!("{}::{}", prefix, n.ident)
714
+ };
715
+ out.push(UseDecl {
716
+ alias: n.ident.to_string(),
717
+ target,
718
+ glob: false,
719
+ });
720
+ }
721
+ syn::UseTree::Rename(r) => {
722
+ let target = if prefix.is_empty() {
723
+ r.ident.to_string()
724
+ } else {
725
+ format!("{}::{}", prefix, r.ident)
726
+ };
727
+ out.push(UseDecl {
728
+ alias: r.rename.to_string(),
729
+ target,
730
+ glob: false,
731
+ });
732
+ }
733
+ syn::UseTree::Glob(_) => {
734
+ out.push(UseDecl {
735
+ alias: String::new(),
736
+ target: prefix.to_string(),
737
+ glob: true,
738
+ });
739
+ }
740
+ syn::UseTree::Group(g) => {
741
+ for item in &g.items {
742
+ collect_use_tree_inner(item, prefix, out);
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ fn collect_derives(attrs: &[syn::Attribute]) -> Vec<String> {
749
+ let mut out = vec![];
750
+ for a in attrs {
751
+ if attr_last_segment(a) != "derive" {
752
+ continue;
753
+ }
754
+ if let syn::Meta::List(l) = &a.meta {
755
+ let paths = l.parse_args_with(
756
+ syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
757
+ );
758
+ if let Ok(paths) = paths {
759
+ for p in paths {
760
+ out.push(path_string(&p));
761
+ }
762
+ }
763
+ }
764
+ }
765
+ out
766
+ }
767
+
768
+ fn collect_generic_bounds(generics: &syn::Generics, pi: &mut ParsedItem) {
769
+ for param in &generics.params {
770
+ if let syn::GenericParam::Type(tp) = param {
771
+ for bound in &tp.bounds {
772
+ if let syn::TypeParamBound::Trait(tb) = bound {
773
+ pi.supertraits.push(path_string(&tb.path));
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+
780
+ // ── Diesel table! macro ──
781
+ //
782
+ fn parse_table_macro(tokens: &TokenStream) -> Option<TableDecl> {
783
+ let mut iter = tokens.clone().into_iter();
784
+ // first token: table name ident
785
+ let name = match iter.next()? {
786
+ TokenTree::Ident(i) => i.to_string(),
787
+ _ => return None,
788
+ };
789
+ // optional `(pk)` group, then the `{ columns }` group
790
+ let columns_group: proc_macro2::Group = match iter.next()? {
791
+ TokenTree::Group(g) if g.delimiter() == proc_macro2::Delimiter::Brace => g,
792
+ TokenTree::Group(g) if g.delimiter() == proc_macro2::Delimiter::Parenthesis => {
793
+ match iter.next()? {
794
+ TokenTree::Group(g2) if g2.delimiter() == proc_macro2::Delimiter::Brace => g2,
795
+ _ => return None,
796
+ }
797
+ }
798
+ _ => return None,
799
+ };
800
+ Some(TableDecl {
801
+ name,
802
+ columns: parse_table_columns(&columns_group.stream()),
803
+ })
804
+ }
805
+
806
+ fn parse_table_columns(tokens: &TokenStream) -> Vec<(String, String)> {
807
+ let mut out = vec![];
808
+ let tokens: Vec<TokenTree> = tokens.clone().into_iter().collect();
809
+ let mut idx = 0;
810
+ while idx < tokens.len() {
811
+ // column name ident
812
+ let name = match &tokens[idx] {
813
+ TokenTree::Ident(i) => i.to_string(),
814
+ _ => {
815
+ idx += 1;
816
+ continue;
817
+ }
818
+ };
819
+ idx += 1;
820
+ // skip `->` (TWO puncts: '-' then '>') and ',' separators
821
+ while idx < tokens.len() {
822
+ match &tokens[idx] {
823
+ TokenTree::Punct(p) if p.as_char() == '-' || p.as_char() == '>' => {
824
+ idx += 1;
825
+ }
826
+ TokenTree::Punct(p) if p.as_char() == ',' => {
827
+ idx += 1;
828
+ break;
829
+ }
830
+ _ => break,
831
+ }
832
+ }
833
+ // type: idents (possibly with ::)
834
+ let mut ty_parts: Vec<String> = vec![];
835
+ while idx < tokens.len() {
836
+ match &tokens[idx] {
837
+ TokenTree::Ident(i) => {
838
+ ty_parts.push(i.to_string());
839
+ idx += 1;
840
+ }
841
+ TokenTree::Punct(p) if p.as_char() == ':' => {
842
+ idx += 1; // skip second ':'
843
+ if idx < tokens.len() {
844
+ idx += 1;
845
+ }
846
+ }
847
+ TokenTree::Punct(p) if p.as_char() == ',' => {
848
+ idx += 1;
849
+ break;
850
+ }
851
+ _ => break,
852
+ }
853
+ }
854
+ if !ty_parts.is_empty() {
855
+ out.push((name, ty_parts.join("::")));
856
+ }
857
+ }
858
+ out
859
+ }