aja-codeintel 0.1.0__py3-none-any.whl

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 (68) hide show
  1. aja_codeintel-0.1.0.dist-info/METADATA +436 -0
  2. aja_codeintel-0.1.0.dist-info/RECORD +68 -0
  3. aja_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. aja_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. aja_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. aja_codeintel-0.1.0.dist-info/top_level.txt +1 -0
  7. codeintel_cli/__init__.py +1 -0
  8. codeintel_cli/__main__.py +4 -0
  9. codeintel_cli/cli.py +41 -0
  10. codeintel_cli/commands/__init__.py +1 -0
  11. codeintel_cli/commands/graph/__init__.py +18 -0
  12. codeintel_cli/commands/graph/deps_cmd.py +35 -0
  13. codeintel_cli/commands/graph/related_cmd.py +121 -0
  14. codeintel_cli/commands/graph/relsymbols_cmd.py +347 -0
  15. codeintel_cli/commands/graph/reverse_related_cmd.py +54 -0
  16. codeintel_cli/commands/nav/__init__.py +12 -0
  17. codeintel_cli/commands/nav/copy_cmd.py +101 -0
  18. codeintel_cli/commands/nav/open_cmd.py +18 -0
  19. codeintel_cli/commands/nav/where_cmd.py +21 -0
  20. codeintel_cli/commands/project/__init__.py +26 -0
  21. codeintel_cli/commands/project/context_cmd.py +326 -0
  22. codeintel_cli/commands/project/folder_cmd.py +51 -0
  23. codeintel_cli/commands/project/imports_cmd.py +90 -0
  24. codeintel_cli/commands/project/models_cmd.py +98 -0
  25. codeintel_cli/commands/project/modeltree_cmd.py +476 -0
  26. codeintel_cli/commands/project/new.py +0 -0
  27. codeintel_cli/commands/project/resolve_cmd.py +29 -0
  28. codeintel_cli/commands/project/scan_cmd.py +51 -0
  29. codeintel_cli/commands/project/servicemap_cmd.py +180 -0
  30. codeintel_cli/commands/project/tree_cmd.py +203 -0
  31. codeintel_cli/commands/project/version_cmd.py +14 -0
  32. codeintel_cli/context/java_context.py +180 -0
  33. codeintel_cli/context/java_rel.py +299 -0
  34. codeintel_cli/context/java_service.py +291 -0
  35. codeintel_cli/context/python_context.py +91 -0
  36. codeintel_cli/context/python_rel.py +251 -0
  37. codeintel_cli/context/python_service.py +205 -0
  38. codeintel_cli/core/fuzzy.py +72 -0
  39. codeintel_cli/core/opener.py +37 -0
  40. codeintel_cli/core/project.py +34 -0
  41. codeintel_cli/core/resolve_folder.py +68 -0
  42. codeintel_cli/core/resolve_model_target.py +92 -0
  43. codeintel_cli/core/resolve_target.py +53 -0
  44. codeintel_cli/core/timing.py +13 -0
  45. codeintel_cli/core/where.py +77 -0
  46. codeintel_cli/db/__init__.py +7 -0
  47. codeintel_cli/db/cache.py +224 -0
  48. codeintel_cli/db/operations.py +333 -0
  49. codeintel_cli/db/schema.py +102 -0
  50. codeintel_cli/errors.py +78 -0
  51. codeintel_cli/graph/__init__.py +1 -0
  52. codeintel_cli/graph/builder.py +149 -0
  53. codeintel_cli/graph/query.py +30 -0
  54. codeintel_cli/graph/traverse.py +49 -0
  55. codeintel_cli/lang/__init__.py +0 -0
  56. codeintel_cli/lang/java/__init__.py +0 -0
  57. codeintel_cli/lang/java/engine.py +18 -0
  58. codeintel_cli/lang/java/models.py +105 -0
  59. codeintel_cli/lang/java/resolve.py +49 -0
  60. codeintel_cli/lang/python/__init__.py +0 -0
  61. codeintel_cli/lang/python/engine.py +8 -0
  62. codeintel_cli/lang/python/models.py +86 -0
  63. codeintel_cli/lang/router.py +24 -0
  64. codeintel_cli/parser/imports.py +26 -0
  65. codeintel_cli/parser/resolve.py +49 -0
  66. codeintel_cli/parser/symbols.py +92 -0
  67. codeintel_cli/scanner/__init__.py +0 -0
  68. codeintel_cli/scanner/scanner.py +41 -0
@@ -0,0 +1,476 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import typer
8
+
9
+ from ...errors import InvalidPathError
10
+ from ...core.resolve_model_target import resolve_model_target_file as _resolve_model_target_file
11
+ from ...lang.java.resolve import resolve_java_import_to_file
12
+ from ...graph.builder import build_graph_with_counts, get_hub_files_by_ratio
13
+ from ...context.java_rel import (
14
+ clean_java,
15
+ top_type_name,
16
+ java_fields_and_rels,
17
+ model_fields_from_extractor,
18
+ Rel,
19
+ )
20
+ from ...context.python_rel import python_collect_relationship_map
21
+ from ...db.cache import CacheManager
22
+
23
+ VIEWS = ["compact", "tree", "pretty", "flow", "db"]
24
+
25
+
26
+ def _get_cached_files(project_root: Path) -> list[Path]:
27
+ with CacheManager(project_root) as cache:
28
+ if cache.needs_rescan():
29
+ cache.scan_project(verbose=False)
30
+ return [p.resolve() for p in cache.get_cached_files()]
31
+
32
+
33
+ def _fmt_fields(fields: list[tuple[str, str, bool]], indent: str = " ") -> list[str]:
34
+ out: list[str] = []
35
+ for n, t, pk in fields:
36
+ out.append(f"{indent}- {n}: {t}" + (" (PK)" if pk else ""))
37
+ return out
38
+
39
+
40
+ def _rel_label(kind: str) -> str:
41
+ return {"OneToOne": "1:1", "OneToMany": "1:N", "ManyToMany": "N:M", "ManyToOne": "N:1"}.get(kind, kind)
42
+
43
+
44
+ def _rel_meta(r: Rel) -> str:
45
+ parts: list[str] = []
46
+ if r.mapped_by:
47
+ parts.append(f"mappedBy={r.mapped_by}")
48
+ if r.join_table:
49
+ parts.append(f"joinTable={r.join_table}")
50
+ if r.join_columns:
51
+ parts.append("joinCols=" + ",".join(r.join_columns))
52
+ return (" [" + "; ".join(parts) + "]") if parts else ""
53
+
54
+
55
+ def _box(body: list[str]) -> list[str]:
56
+ w = max([len(x) for x in body] + [0])
57
+ top = "┌" + "─" * (w + 2) + "┐"
58
+ sep = "├" + "─" * (w + 2) + "┤"
59
+ bot = "└" + "─" * (w + 2) + "┘"
60
+ lines = [top, sep]
61
+ for x in body:
62
+ lines.append("│ " + x.ljust(w) + " │")
63
+ lines.append(bot)
64
+ return lines
65
+
66
+
67
+ def _default_outfile(root: Path, name: str) -> Path:
68
+ base = root / f"codeintel-modeltree-{name}.txt"
69
+ if not base.exists():
70
+ return base
71
+ i = 2
72
+ while True:
73
+ p = root / f"codeintel-modeltree-{name}-{i}.txt"
74
+ if not p.exists():
75
+ return p
76
+ i += 1
77
+
78
+
79
+ def _open_in_editor(path: Path) -> None:
80
+ p = path.resolve()
81
+ try:
82
+ from ...core.opener import open_file
83
+
84
+ open_file(p)
85
+ return
86
+ except Exception:
87
+ pass
88
+ try:
89
+ subprocess.Popen(["code", str(p)], close_fds=True)
90
+ return
91
+ except Exception:
92
+ pass
93
+ try:
94
+ if sys.platform.startswith("win"):
95
+ subprocess.Popen(["cmd", "/c", "start", "", str(p)], close_fds=True)
96
+ elif sys.platform == "darwin":
97
+ subprocess.Popen(["open", str(p)], close_fds=True)
98
+ else:
99
+ subprocess.Popen(["xdg-open", str(p)], close_fds=True)
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ def _is_domain_model_path(p: Path) -> bool:
105
+ parts = {x.lower() for x in p.parts}
106
+ return bool(parts & {"model", "models", "entity", "entities", "domain"})
107
+
108
+
109
+ def _java_model_annotation_hint(path: Path) -> bool:
110
+ try:
111
+ t = path.read_text(encoding="utf-8", errors="ignore")
112
+ except Exception:
113
+ return False
114
+ return any(x in t for x in ("@Entity", "@Table", "@Embeddable", "@MappedSuperclass"))
115
+
116
+
117
+ def _has_class_definition(path: Path) -> bool:
118
+ try:
119
+ text = path.read_text(encoding="utf-8", errors="ignore")
120
+ return "class " in text and len(text.strip()) > 50
121
+ except Exception:
122
+ return False
123
+
124
+
125
+ def _is_java_model_file(p: Path) -> bool:
126
+ if p.suffix.lower() != ".java":
127
+ return False
128
+ if p.name == "__init__.py":
129
+ return False
130
+ if not (_is_domain_model_path(p) or _java_model_annotation_hint(p)):
131
+ return False
132
+ return _has_class_definition(p)
133
+
134
+
135
+ def _is_python_model_file(p: Path) -> bool:
136
+ if p.suffix.lower() != ".py":
137
+ return False
138
+ if p.name in {"__init__.py", "models.py"}:
139
+ return False
140
+ if not _is_domain_model_path(p):
141
+ return False
142
+ return _has_class_definition(p)
143
+
144
+
145
+ def _choose_view_interactive(default: str = "tree") -> str:
146
+ typer.echo("Select view:")
147
+ for i, v in enumerate(VIEWS, 1):
148
+ typer.echo(f"{i}. {v}")
149
+ pick = typer.prompt("Enter number", type=int, default=VIEWS.index(default) + 1)
150
+ if 1 <= pick <= len(VIEWS):
151
+ return VIEWS[pick - 1]
152
+ return default
153
+
154
+
155
+ def _layered_related(
156
+ graph: dict[Path, set[Path]],
157
+ start: Path,
158
+ depth: int,
159
+ include_reverse: bool,
160
+ hubs: set[Path],
161
+ ) -> list[list[Path]]:
162
+ adj: dict[Path, set[Path]] = {k: set(v) for k, v in graph.items()}
163
+ if include_reverse:
164
+ rev: dict[Path, set[Path]] = {}
165
+ for src, deps in adj.items():
166
+ for dst in deps:
167
+ rev.setdefault(dst, set()).add(src)
168
+ for node, incoming in rev.items():
169
+ adj.setdefault(node, set()).update(incoming)
170
+
171
+ visited: set[Path] = {start}
172
+ q: deque[tuple[Path, int]] = deque([(start, 0)])
173
+ by_depth: dict[int, list[Path]] = {}
174
+
175
+ while q:
176
+ node, d = q.popleft()
177
+ if d == depth:
178
+ continue
179
+ for nxt in adj.get(node, set()):
180
+ if nxt in visited or nxt in hubs:
181
+ continue
182
+ visited.add(nxt)
183
+ nd = d + 1
184
+ by_depth.setdefault(nd, []).append(nxt)
185
+ q.append((nxt, nd))
186
+
187
+ return [sorted(by_depth.get(i, [])) for i in range(1, depth + 1)]
188
+
189
+
190
+ def _file_for_java_class(name: str, scope: list[Path], root: Path) -> Path | None:
191
+ if not name:
192
+ return None
193
+ exact = [p for p in scope if p.suffix.lower() == ".java" and p.stem == name]
194
+ if exact:
195
+ return exact[0].resolve()
196
+ try:
197
+ p = resolve_java_import_to_file(name, root)
198
+ if p and p.exists():
199
+ return p.resolve()
200
+ except Exception:
201
+ pass
202
+ return None
203
+
204
+
205
+ def _java_model_scope_from_files(files: list[Path]) -> list[Path]:
206
+ return [p.resolve() for p in files if _is_java_model_file(p)]
207
+
208
+
209
+ def _collect_java_relationship_map(
210
+ target: Path,
211
+ project_root: Path,
212
+ depth: int,
213
+ forward_only: bool,
214
+ include_hubs: bool,
215
+ ):
216
+ all_files = _get_cached_files(project_root)
217
+
218
+ graph, dep_counts = build_graph_with_counts(all_files, project_root, use_sqlite_cache=True)
219
+
220
+ hubs: set[Path] = set()
221
+ if not include_hubs:
222
+ hubs = get_hub_files_by_ratio(dep_counts, len(all_files), 0.5)
223
+
224
+ layers = _layered_related(
225
+ graph=graph,
226
+ start=target.resolve(),
227
+ depth=depth,
228
+ include_reverse=not forward_only,
229
+ hubs=hubs,
230
+ )
231
+
232
+ model_files = set(_java_model_scope_from_files(all_files))
233
+ scope: list[Path] = []
234
+ for layer in layers:
235
+ for p in layer:
236
+ rp = p.resolve()
237
+ if rp in model_files:
238
+ scope.append(rp)
239
+
240
+ try:
241
+ text = clean_java(target.read_text(encoding="utf-8", errors="ignore"))
242
+ except Exception:
243
+ text = ""
244
+
245
+ model_name = top_type_name(text, target.stem)
246
+ main_fields, main_rels = java_fields_and_rels(target)
247
+
248
+ fields_by_entity: dict[str, list[tuple[str, str, bool]]] = {}
249
+ for r in main_rels:
250
+ if not r or not r.target:
251
+ continue
252
+ p = _file_for_java_class(r.target, scope + [target], project_root)
253
+ if p and p.exists():
254
+ flds = model_fields_from_extractor(p, project_root)
255
+ if not flds:
256
+ flds, _ = java_fields_and_rels(p)
257
+ if flds:
258
+ fields_by_entity[r.target] = flds
259
+
260
+ reverse: list[tuple[str, Rel]] = []
261
+ for p in model_files:
262
+ if p.resolve() == target.resolve():
263
+ continue
264
+ try:
265
+ t = clean_java(p.read_text(encoding="utf-8", errors="ignore"))
266
+ except Exception:
267
+ continue
268
+ src_name = top_type_name(t, p.stem)
269
+ _, rels = java_fields_and_rels(p)
270
+ for rr in rels:
271
+ if rr and rr.target == model_name:
272
+ reverse.append((src_name, rr))
273
+ if src_name not in fields_by_entity:
274
+ flds = model_fields_from_extractor(p, project_root)
275
+ if not flds:
276
+ flds, _ = java_fields_and_rels(p)
277
+ if flds:
278
+ fields_by_entity[src_name] = flds
279
+
280
+ reverse.sort(key=lambda x: (x[0].lower(), x[1].kind, x[1].field.lower()))
281
+ return model_name, main_fields, main_rels, fields_by_entity, reverse
282
+
283
+
284
+ def _view_compact(model: str, fields, rels, reverse) -> list[str]:
285
+ lines: list[str] = [model]
286
+ lines.extend(_fmt_fields(fields))
287
+ lines.append("")
288
+ lines.append("Direct relationships:")
289
+ if not rels:
290
+ lines.append(" (none)")
291
+ else:
292
+ for r in rels:
293
+ lines.append(f" - @{r.kind} -> {r.target} (field: {r.field}){_rel_meta(r)}")
294
+ lines.append("")
295
+ lines.append("Reverse relationships:")
296
+ if not reverse:
297
+ lines.append(" (none)")
298
+ else:
299
+ for src, r in reverse:
300
+ lines.append(f" - {src} -> {model} (@{r.kind} via {r.field}){_rel_meta(r)}")
301
+ return lines
302
+
303
+
304
+ def _view_tree(model: str, fields, rels, fields_by, reverse) -> list[str]:
305
+ lines: list[str] = [model, "├─ Fields"]
306
+ for i, (n, t, pk) in enumerate(fields):
307
+ end = "└─" if i == len(fields) - 1 else "├─"
308
+ lines.append(f"│ {end} {n}: {t}" + (" (PK)" if pk else ""))
309
+ lines.append("│")
310
+
311
+ groups: dict[str, list] = {}
312
+ for r in rels:
313
+ groups.setdefault(r.kind, []).append(r)
314
+
315
+ order = ["OneToOne", "OneToMany", "ManyToMany", "ManyToOne"]
316
+ kinds = [k for k in order if k in groups]
317
+
318
+ if not kinds:
319
+ lines.append("└─ Relationships")
320
+ lines.append(" └─ (none)")
321
+ else:
322
+ for ki, kind in enumerate(kinds):
323
+ last_kind = ki == len(kinds) - 1
324
+ kbranch = "└─" if last_kind else "├─"
325
+ kpad = " " if last_kind else "│ "
326
+ lines.append(f"{kbranch} @{kind}")
327
+ rs = groups[kind]
328
+ for ri, r in enumerate(rs):
329
+ rlast = ri == len(rs) - 1
330
+ rbranch = "└─" if rlast else "├─"
331
+ suffix = "[]" if kind in {"OneToMany", "ManyToMany"} else ""
332
+ lines.append(f"{kpad}{rbranch} {r.target}{suffix} (field: {r.field}){_rel_meta(r)}")
333
+ flds = fields_by.get(r.target, [])
334
+ for fi, (fn, ft, _pk) in enumerate(flds):
335
+ flast = fi == len(flds) - 1
336
+ fbranch = "└─" if flast else "├─"
337
+ lines.append(f"{kpad}{' ' if rlast else '│ '} {fbranch} {fn}: {ft}")
338
+
339
+ lines.append("")
340
+ lines.append("Reverse References:")
341
+ if not reverse:
342
+ lines.append(" (none)")
343
+ else:
344
+ for src, r in reverse:
345
+ lines.append(f" ↑ {src} (@{r.kind} via {r.field}){_rel_meta(r)}")
346
+ return lines
347
+
348
+
349
+ def _view_pretty(model: str, fields, rels, fields_by, reverse) -> list[str]:
350
+ lines: list[str] = [model, "━" * 80]
351
+ for n, t, pk in fields:
352
+ icon = "🔑" if pk else "•"
353
+ lines.append(f" {icon} {n}: {t}")
354
+
355
+ lines.append("")
356
+ lines.append("Relationships")
357
+ lines.append("━" * 80)
358
+
359
+ if not rels:
360
+ lines.append(" (none)")
361
+ else:
362
+ for r in rels:
363
+ tgt_fields = fields_by.get(r.target, [])
364
+ body: list[str] = [f"{_rel_label(r.kind)} {model} -> {r.target} (field: {r.field}){_rel_meta(r)}", ""]
365
+ if tgt_fields:
366
+ for fn, ft, pk in tgt_fields:
367
+ icon = "🔑" if pk else "•"
368
+ body.append(f"{icon} {fn}: {ft}")
369
+ else:
370
+ body.append("(target fields not found)")
371
+ lines.extend(_box(body))
372
+ lines.append("")
373
+
374
+ lines.append("Reverse References")
375
+ lines.append("━" * 80)
376
+
377
+ if not reverse:
378
+ lines.append(" (none)")
379
+ else:
380
+ for src, rr in reverse:
381
+ lines.append(f" ↑ {src} (@{rr.kind} via {rr.field}){_rel_meta(rr)}")
382
+
383
+ return lines
384
+
385
+
386
+ def _view_flow(model: str, fields, rels, reverse) -> list[str]:
387
+ lines: list[str] = [model, ""]
388
+ for n, t, pk in fields:
389
+ lines.append(f" - {n}: {t}" + (" (PK)" if pk else ""))
390
+ lines.append("")
391
+ lines.append("Outgoing:")
392
+ if not rels:
393
+ lines.append(" (none)")
394
+ else:
395
+ for r in rels:
396
+ lines.append(f" {model} --@{r.kind}--> {r.target} (field: {r.field}){_rel_meta(r)}")
397
+ lines.append("")
398
+ lines.append("Incoming:")
399
+ if not reverse:
400
+ lines.append(" (none)")
401
+ else:
402
+ for src, r in reverse:
403
+ lines.append(f" {src} --@{r.kind}--> {model} (field: {r.field}){_rel_meta(r)}")
404
+ return lines
405
+
406
+
407
+ def _view_db(model: str, fields, rels, reverse) -> list[str]:
408
+ lines: list[str] = [f"TABLE: {model.lower()}"]
409
+ for n, t, pk in fields:
410
+ lines.append(f" - {n}: {t}" + (" (PK)" if pk else ""))
411
+ lines.append("")
412
+ lines.append("Relationships:")
413
+ if not rels and not reverse:
414
+ lines.append(" (none)")
415
+ return lines
416
+ for r in rels:
417
+ lines.append(f" - @{r.kind} -> {r.target} (field: {r.field}){_rel_meta(r)}")
418
+ for src, r in reverse:
419
+ lines.append(f" - {src} -> {model} (@{r.kind} via {r.field}){_rel_meta(r)}")
420
+ return lines
421
+
422
+
423
+ def register_modeltree(app: typer.Typer) -> None:
424
+ @app.command(help="Model relationship tree")
425
+ def modeltree(
426
+ model: str = typer.Argument(...),
427
+ root: str = typer.Option("."),
428
+ depth: int = typer.Option(2, "--depth", "-d"),
429
+ forward_only: bool = typer.Option(False, "--forward-only"),
430
+ include_hubs: bool = typer.Option(False, "--include-hubs"),
431
+ view: str | None = typer.Option(None, "--view", "-v"),
432
+ out: Path | None = typer.Option(None, "--out"),
433
+ ) -> None:
434
+ target, project_root = _resolve_model_target_file(model, root=root)
435
+
436
+ if view is None:
437
+ view = _choose_view_interactive(default="tree")
438
+ if view not in VIEWS:
439
+ raise typer.BadParameter(f"view must be one of: {', '.join(VIEWS)}")
440
+
441
+ create_file = typer.confirm("Create .txt file?", default=True)
442
+
443
+ if target.suffix.lower() == ".java":
444
+ if not _is_java_model_file(target):
445
+ raise InvalidPathError(message="Not a model/entity file", path=target)
446
+ model_name, fields, rels, fields_by, reverse = _collect_java_relationship_map(
447
+ target, project_root, depth, forward_only, include_hubs
448
+ )
449
+ elif target.suffix.lower() == ".py":
450
+ if not _is_python_model_file(target):
451
+ raise InvalidPathError(message="Not a model/entity file", path=target)
452
+ model_name, fields, rels, fields_by, reverse = python_collect_relationship_map(target, project_root)
453
+ else:
454
+ raise InvalidPathError(message="Unsupported model file type", path=target)
455
+
456
+ if view == "compact":
457
+ lines = _view_compact(model_name, fields, rels, reverse)
458
+ elif view == "tree":
459
+ lines = _view_tree(model_name, fields, rels, fields_by, reverse)
460
+ elif view == "pretty":
461
+ lines = _view_pretty(model_name, fields, rels, fields_by, reverse)
462
+ elif view == "flow":
463
+ lines = _view_flow(model_name, fields, rels, reverse)
464
+ else:
465
+ lines = _view_db(model_name, fields, rels, reverse)
466
+
467
+ if not create_file:
468
+ typer.echo("")
469
+ for ln in lines:
470
+ typer.echo(ln)
471
+ return
472
+
473
+ out_path = out.resolve() if out else _default_outfile(project_root, model_name)
474
+ out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
475
+ typer.echo(out_path.as_posix())
476
+ _open_in_editor(out_path)
File without changes
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import typer
5
+
6
+ from ...parser.resolve import resolve_module_to_file
7
+ from ...errors import InvalidPathError
8
+ from ...core.project import find_project_root
9
+ from ...core.resolve_target import resolve_target_file
10
+
11
+
12
+ def register_resolve(app: typer.Typer) -> None:
13
+ @app.command(help="Resolve a module path or filename to a project file.")
14
+ def resolve(
15
+ target: str = typer.Argument(..., help="Module path or filename"),
16
+ ):
17
+ root = find_project_root(Path.cwd())
18
+
19
+ mod_hit = resolve_module_to_file(target, root)
20
+ if mod_hit:
21
+ typer.echo(mod_hit.resolve().relative_to(root))
22
+ return
23
+
24
+ file_hit, root = resolve_target_file(target)
25
+ if file_hit.exists():
26
+ typer.echo(file_hit.resolve().relative_to(root))
27
+ return
28
+
29
+ raise InvalidPathError(message="Not found", path=Path(target))
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ import time
4
+ import typer
5
+
6
+ from ...errors import InvalidPathError
7
+ from ...core.project import find_project_root
8
+ from ...db.cache import get_cache
9
+
10
+ def validate_folder(path: str) -> Path:
11
+ folder = Path(path).resolve()
12
+ if not folder.exists() or not folder.is_dir():
13
+ raise InvalidPathError(message="Invalid folder", path=folder)
14
+ return folder
15
+
16
+ def register_scan(app: typer.Typer) -> None:
17
+ @app.command(help="Scan and index project files")
18
+ def scan(
19
+ path: str = typer.Argument(".", help="Folder to scan"),
20
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show progress"),
21
+ force: bool = typer.Option(False, "--force", "-f", help="Force rescan all files"),
22
+ ):
23
+ folder = validate_folder(path)
24
+ project_root = find_project_root(folder)
25
+
26
+ start_time = time.perf_counter()
27
+
28
+ with get_cache(project_root) as cache:
29
+ if not force and not cache.needs_rescan():
30
+ files = cache.get_cached_files()
31
+ elapsed = time.perf_counter() - start_time
32
+ py_count = sum(1 for f in files if f.suffix == ".py")
33
+ java_count = sum(1 for f in files if f.suffix == ".java")
34
+ typer.echo(f"✓ Cache up to date")
35
+ typer.echo(f"Cached {len(files)} files (py={py_count}, java={java_count}) in {elapsed:.3f}s")
36
+ typer.echo(f"Database: {cache.db_path}")
37
+ return
38
+
39
+ if verbose:
40
+ typer.echo(f"Scanning: {project_root}")
41
+
42
+ scanned = cache.scan_project(verbose=verbose)
43
+ files = cache.get_cached_files()
44
+ elapsed = time.perf_counter() - start_time
45
+
46
+ py_count = sum(1 for f in files if f.suffix == ".py")
47
+ java_count = sum(1 for f in files if f.suffix == ".java")
48
+
49
+ typer.echo(f"✓ Indexed {scanned} files")
50
+ typer.echo(f"Total {len(files)} files (py={py_count}, java={java_count}) in {elapsed:.3f}s")
51
+ typer.echo(f"Database: {cache.db_path}")