codebase-navigator 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.
- codebase_navigator/__init__.py +6 -0
- codebase_navigator/ask.py +412 -0
- codebase_navigator/cli.py +230 -0
- codebase_navigator/config.py +102 -0
- codebase_navigator/extractor.py +281 -0
- codebase_navigator/index.py +279 -0
- codebase_navigator/ipc.py +221 -0
- codebase_navigator/tags.py +198 -0
- codebase_navigator/watcher.py +134 -0
- codebase_navigator-0.1.0.dist-info/METADATA +114 -0
- codebase_navigator-0.1.0.dist-info/RECORD +13 -0
- codebase_navigator-0.1.0.dist-info/WHEEL +4 -0
- codebase_navigator-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Unix domain socket IPC server and client for fast semantic querying via cn watch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import socket
|
|
8
|
+
import socketserver
|
|
9
|
+
import threading
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .index import VectorIndex
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _IPCRequestHandler(socketserver.StreamRequestHandler):
|
|
17
|
+
"""Handles single client connection requests over Unix Domain Socket."""
|
|
18
|
+
|
|
19
|
+
server: _IPCUnixStreamServer
|
|
20
|
+
|
|
21
|
+
def handle(self):
|
|
22
|
+
for line in self.rfile:
|
|
23
|
+
line = line.strip()
|
|
24
|
+
if not line:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
req = json.loads(line.decode("utf-8"))
|
|
28
|
+
action = req.get("action")
|
|
29
|
+
if action == "ping":
|
|
30
|
+
resp = {"status": "ok", "pong": True}
|
|
31
|
+
elif action == "search":
|
|
32
|
+
query = req.get("query", "")
|
|
33
|
+
limit = int(req.get("limit", 5))
|
|
34
|
+
doc_type = req.get("type", "all")
|
|
35
|
+
with self.server.lock:
|
|
36
|
+
results = self.server.index.search(query, limit=limit, doc_type=doc_type)
|
|
37
|
+
resp = {"status": "ok", "results": results}
|
|
38
|
+
elif action == "status":
|
|
39
|
+
with self.server.lock:
|
|
40
|
+
meta = self.server.index.load_meta()
|
|
41
|
+
chunk_count = sum(m.get("chunks", 0) for m in meta.values())
|
|
42
|
+
resp = {
|
|
43
|
+
"status": "ok",
|
|
44
|
+
"files_count": len(meta),
|
|
45
|
+
"chunk_count": chunk_count,
|
|
46
|
+
"cache_dir": str(self.server.index.cache_dir),
|
|
47
|
+
}
|
|
48
|
+
else:
|
|
49
|
+
resp = {"status": "error", "error": f"Unknown action: {action}"}
|
|
50
|
+
except Exception as e:
|
|
51
|
+
resp = {"status": "error", "error": str(e)}
|
|
52
|
+
|
|
53
|
+
response_bytes = json.dumps(resp).encode("utf-8") + b"\n"
|
|
54
|
+
self.wfile.write(response_bytes)
|
|
55
|
+
self.wfile.flush()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _IPCUnixStreamServer(socketserver.ThreadingUnixStreamServer):
|
|
59
|
+
daemon_threads = True
|
|
60
|
+
allow_reuse_address = True
|
|
61
|
+
|
|
62
|
+
def __init__(self, server_address: str, RequestHandlerClass, index: VectorIndex, lock: threading.Lock):
|
|
63
|
+
self.index = index
|
|
64
|
+
self.lock = lock
|
|
65
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IPCServer:
|
|
69
|
+
"""Unix Domain Socket server hosting in-memory index for fast search queries."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, socket_path: Path, index: VectorIndex, lock: threading.Lock | None = None):
|
|
72
|
+
self.socket_path = socket_path
|
|
73
|
+
self.index = index
|
|
74
|
+
self.lock = lock or threading.Lock()
|
|
75
|
+
self._server: _IPCUnixStreamServer | None = None
|
|
76
|
+
self._thread: threading.Thread | None = None
|
|
77
|
+
|
|
78
|
+
def start(self):
|
|
79
|
+
"""Start socket server in a background thread.
|
|
80
|
+
|
|
81
|
+
Raises RuntimeError if another active daemon is already listening on this socket.
|
|
82
|
+
Cleans up stale socket files from prior crashes automatically.
|
|
83
|
+
"""
|
|
84
|
+
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
if self.socket_path.exists():
|
|
86
|
+
# Check if another process is actively listening on this socket
|
|
87
|
+
status = ping_socket(self.socket_path, timeout=0.5)
|
|
88
|
+
if status is not None:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"Another cn watch instance is already running on {self.socket_path}"
|
|
91
|
+
)
|
|
92
|
+
# Socket file exists but no process is listening -> stale socket from prior crash
|
|
93
|
+
try:
|
|
94
|
+
self.socket_path.unlink()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
self._server = _IPCUnixStreamServer(
|
|
99
|
+
str(self.socket_path),
|
|
100
|
+
_IPCRequestHandler,
|
|
101
|
+
index=self.index,
|
|
102
|
+
lock=self.lock,
|
|
103
|
+
)
|
|
104
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
105
|
+
self._thread.start()
|
|
106
|
+
|
|
107
|
+
def stop(self):
|
|
108
|
+
"""Stop socket server and clean up socket file."""
|
|
109
|
+
if self._server:
|
|
110
|
+
try:
|
|
111
|
+
self._server.shutdown()
|
|
112
|
+
self._server.server_close()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
self._server = None
|
|
116
|
+
if self.socket_path.exists():
|
|
117
|
+
try:
|
|
118
|
+
self.socket_path.unlink()
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def query_socket(
|
|
124
|
+
socket_path: Path,
|
|
125
|
+
query: str,
|
|
126
|
+
limit: int = 5,
|
|
127
|
+
doc_type: str = "all",
|
|
128
|
+
timeout: float = 3.0,
|
|
129
|
+
) -> list[dict[str, Any]] | None:
|
|
130
|
+
"""Query the running cn watch daemon via Unix Domain Socket.
|
|
131
|
+
|
|
132
|
+
Returns search results if successful, or None if socket is unavailable/unresponsive.
|
|
133
|
+
Automatically unlinks stale socket files from dead daemons.
|
|
134
|
+
"""
|
|
135
|
+
if not socket_path.exists():
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
139
|
+
sock.settimeout(timeout)
|
|
140
|
+
try:
|
|
141
|
+
sock.connect(str(socket_path))
|
|
142
|
+
req = {
|
|
143
|
+
"action": "search",
|
|
144
|
+
"query": query,
|
|
145
|
+
"limit": limit,
|
|
146
|
+
"type": doc_type,
|
|
147
|
+
}
|
|
148
|
+
payload = json.dumps(req).encode("utf-8") + b"\n"
|
|
149
|
+
sock.sendall(payload)
|
|
150
|
+
|
|
151
|
+
# Read response line
|
|
152
|
+
buffer = b""
|
|
153
|
+
while b"\n" not in buffer:
|
|
154
|
+
chunk = sock.recv(4096)
|
|
155
|
+
if not chunk:
|
|
156
|
+
break
|
|
157
|
+
buffer += chunk
|
|
158
|
+
|
|
159
|
+
if not buffer:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
line = buffer.split(b"\n")[0]
|
|
163
|
+
resp = json.loads(line.decode("utf-8"))
|
|
164
|
+
if resp.get("status") == "ok":
|
|
165
|
+
return resp.get("results")
|
|
166
|
+
return None
|
|
167
|
+
except ConnectionRefusedError:
|
|
168
|
+
# Socket file exists but no process is listening -> stale socket from prior crash
|
|
169
|
+
try:
|
|
170
|
+
socket_path.unlink()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
return None
|
|
174
|
+
except (OSError, socket.error, json.JSONDecodeError, TimeoutError):
|
|
175
|
+
return None
|
|
176
|
+
finally:
|
|
177
|
+
sock.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ping_socket(socket_path: Path, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
181
|
+
"""Check if cn watch daemon is active and return its status info.
|
|
182
|
+
|
|
183
|
+
Automatically unlinks stale socket files from dead daemons.
|
|
184
|
+
"""
|
|
185
|
+
if not socket_path.exists():
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
189
|
+
sock.settimeout(timeout)
|
|
190
|
+
try:
|
|
191
|
+
sock.connect(str(socket_path))
|
|
192
|
+
req = {"action": "status"}
|
|
193
|
+
payload = json.dumps(req).encode("utf-8") + b"\n"
|
|
194
|
+
sock.sendall(payload)
|
|
195
|
+
|
|
196
|
+
buffer = b""
|
|
197
|
+
while b"\n" not in buffer:
|
|
198
|
+
chunk = sock.recv(4096)
|
|
199
|
+
if not chunk:
|
|
200
|
+
break
|
|
201
|
+
buffer += chunk
|
|
202
|
+
|
|
203
|
+
if not buffer:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
line = buffer.split(b"\n")[0]
|
|
207
|
+
resp = json.loads(line.decode("utf-8"))
|
|
208
|
+
if resp.get("status") == "ok":
|
|
209
|
+
return resp
|
|
210
|
+
return None
|
|
211
|
+
except ConnectionRefusedError:
|
|
212
|
+
# Socket file exists but no process is listening -> stale socket from prior crash
|
|
213
|
+
try:
|
|
214
|
+
socket_path.unlink()
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
return None
|
|
218
|
+
except (OSError, socket.error, json.JSONDecodeError, TimeoutError):
|
|
219
|
+
return None
|
|
220
|
+
finally:
|
|
221
|
+
sock.close()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Git-aware ctags indexing and symbol lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config import CODE_EXTENSIONS, DOC_EXTENSIONS, IGNORE_DIR_NAMES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_available_files(folder: Path) -> tuple[list[Path], list[Path]]:
|
|
15
|
+
"""Discover all Git-tracked and unignored source and documentation files."""
|
|
16
|
+
code_files: list[Path] = []
|
|
17
|
+
doc_files: list[Path] = []
|
|
18
|
+
|
|
19
|
+
# 1. Try Git-based discovery
|
|
20
|
+
try:
|
|
21
|
+
res = subprocess.run(
|
|
22
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
23
|
+
cwd=folder,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=True,
|
|
27
|
+
)
|
|
28
|
+
for line in res.stdout.splitlines():
|
|
29
|
+
fpath = folder / line.strip()
|
|
30
|
+
if fpath.is_file():
|
|
31
|
+
ext = fpath.suffix.lower()
|
|
32
|
+
if ext in CODE_EXTENSIONS:
|
|
33
|
+
code_files.append(fpath)
|
|
34
|
+
elif ext in DOC_EXTENSIONS:
|
|
35
|
+
doc_files.append(fpath)
|
|
36
|
+
return sorted(code_files), sorted(doc_files)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
# 2. Fallback to filesystem walk
|
|
41
|
+
for root, dirs, files in os.walk(folder):
|
|
42
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIR_NAMES and not d.startswith(".")]
|
|
43
|
+
for file in files:
|
|
44
|
+
if file.startswith("."):
|
|
45
|
+
continue
|
|
46
|
+
fpath = Path(root) / file
|
|
47
|
+
ext = fpath.suffix.lower()
|
|
48
|
+
if ext in CODE_EXTENSIONS:
|
|
49
|
+
code_files.append(fpath)
|
|
50
|
+
elif ext in DOC_EXTENSIONS:
|
|
51
|
+
doc_files.append(fpath)
|
|
52
|
+
|
|
53
|
+
return sorted(code_files), sorted(doc_files)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TagsManager:
|
|
57
|
+
"""Manages generation, updates, and symbol lookups from .tags files."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, folder: Path):
|
|
60
|
+
self.folder = folder
|
|
61
|
+
self.tag_file = folder / ".tags"
|
|
62
|
+
|
|
63
|
+
def generate(self) -> tuple[bool, str]:
|
|
64
|
+
"""Generate or regenerate .tags for all source files in the folder."""
|
|
65
|
+
code_files, _ = get_available_files(self.folder)
|
|
66
|
+
if not code_files:
|
|
67
|
+
return False, "No source files found to index"
|
|
68
|
+
|
|
69
|
+
rel_paths = []
|
|
70
|
+
for p in code_files:
|
|
71
|
+
try:
|
|
72
|
+
rel_paths.append(str(p.relative_to(self.folder)))
|
|
73
|
+
except ValueError:
|
|
74
|
+
rel_paths.append(str(p))
|
|
75
|
+
|
|
76
|
+
input_data = "\n".join(rel_paths) + "\n"
|
|
77
|
+
cmd = [
|
|
78
|
+
"ctags",
|
|
79
|
+
"-L", "-",
|
|
80
|
+
"-f", str(self.tag_file),
|
|
81
|
+
"--fields=+n+K",
|
|
82
|
+
"--sort=yes",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
res = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
input=input_data,
|
|
89
|
+
cwd=self.folder,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
if res.returncode != 0:
|
|
95
|
+
return False, f"ctags error ({res.returncode}): {res.stderr.strip()}"
|
|
96
|
+
if self.tag_file.exists():
|
|
97
|
+
size_mb = self.tag_file.stat().st_size / (1024 * 1024)
|
|
98
|
+
return True, f"Indexed {len(code_files)} source files ({size_mb:.2f} MB)"
|
|
99
|
+
return False, "Tag file was not created"
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
return False, "ctags binary not found on PATH"
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return False, f"Error generating tags: {e}"
|
|
104
|
+
|
|
105
|
+
def find_tag_file(self) -> Path | None:
|
|
106
|
+
"""Find .tags in folder or climb parent directories."""
|
|
107
|
+
curr = self.folder
|
|
108
|
+
for parent in [curr, *curr.parents]:
|
|
109
|
+
tf = parent / ".tags"
|
|
110
|
+
if tf.exists():
|
|
111
|
+
return tf
|
|
112
|
+
return self.tag_file if self.tag_file.exists() else None
|
|
113
|
+
|
|
114
|
+
def lookup_symbol(
|
|
115
|
+
self,
|
|
116
|
+
pattern: str,
|
|
117
|
+
exact: bool = False,
|
|
118
|
+
limit: int = 20,
|
|
119
|
+
) -> list[dict[str, Any]]:
|
|
120
|
+
"""Look up symbols matching a regex or exact string."""
|
|
121
|
+
tag_files: list[Path] = []
|
|
122
|
+
tf = self.find_tag_file()
|
|
123
|
+
if tf:
|
|
124
|
+
tag_files.append(tf)
|
|
125
|
+
|
|
126
|
+
# Also search child folders' .tags
|
|
127
|
+
for p in self.folder.rglob(".tags"):
|
|
128
|
+
if p not in tag_files:
|
|
129
|
+
tag_files.append(p)
|
|
130
|
+
|
|
131
|
+
if not tag_files:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
results: list[dict[str, Any]] = []
|
|
135
|
+
seen_keys: set[tuple[str, str, int]] = set()
|
|
136
|
+
|
|
137
|
+
for tag_file in tag_files:
|
|
138
|
+
try:
|
|
139
|
+
regex = re.compile(pattern if not exact else f"^{re.escape(pattern)}$", re.IGNORECASE)
|
|
140
|
+
with open(tag_file, "r", encoding="utf-8", errors="replace") as f:
|
|
141
|
+
for line in f:
|
|
142
|
+
if line.startswith("!_"):
|
|
143
|
+
continue
|
|
144
|
+
parts = line.split("\t")
|
|
145
|
+
if not parts:
|
|
146
|
+
continue
|
|
147
|
+
sym = parts[0]
|
|
148
|
+
if regex.search(sym):
|
|
149
|
+
parsed = self._parse_tag_line(line, tag_file.parent)
|
|
150
|
+
if parsed:
|
|
151
|
+
key = (parsed["symbol"], parsed["path"], parsed["line"])
|
|
152
|
+
if key not in seen_keys:
|
|
153
|
+
seen_keys.add(key)
|
|
154
|
+
results.append(parsed)
|
|
155
|
+
if len(results) >= limit:
|
|
156
|
+
return results
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
def _parse_tag_line(self, line: str, base_dir: Path) -> dict[str, Any] | None:
|
|
163
|
+
if not line or line.startswith("!_"):
|
|
164
|
+
return None
|
|
165
|
+
parts = line.rstrip("\r\n").split("\t")
|
|
166
|
+
if len(parts) < 3:
|
|
167
|
+
return None
|
|
168
|
+
sym = parts[0]
|
|
169
|
+
fpath = parts[1]
|
|
170
|
+
pattern_or_line = parts[2]
|
|
171
|
+
|
|
172
|
+
kind = "symbol"
|
|
173
|
+
line_no = 1
|
|
174
|
+
for field in parts[3:]:
|
|
175
|
+
if field.startswith("line:"):
|
|
176
|
+
try:
|
|
177
|
+
line_no = int(field[5:])
|
|
178
|
+
except ValueError:
|
|
179
|
+
pass
|
|
180
|
+
elif field.startswith("kind:"):
|
|
181
|
+
kind = field[5:]
|
|
182
|
+
elif len(field) == 1:
|
|
183
|
+
kind = field
|
|
184
|
+
|
|
185
|
+
abs_p = (base_dir / fpath).resolve()
|
|
186
|
+
try:
|
|
187
|
+
rel_p = str(abs_p.relative_to(self.folder))
|
|
188
|
+
except ValueError:
|
|
189
|
+
rel_p = fpath
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"symbol": sym,
|
|
193
|
+
"path": rel_p,
|
|
194
|
+
"abs_path": str(abs_p),
|
|
195
|
+
"line": line_no,
|
|
196
|
+
"kind": kind,
|
|
197
|
+
"preview": pattern_or_line.strip("/^$"),
|
|
198
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Filesystem watcher for live ctags and LanceDB vector re-indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from watchfiles import Change, DefaultFilter, watch
|
|
10
|
+
|
|
11
|
+
from .config import CODE_EXTENSIONS, DOC_EXTENSIONS, IGNORE_DIR_NAMES, get_socket_path
|
|
12
|
+
from .index import VectorIndex
|
|
13
|
+
from .ipc import IPCServer, ping_socket
|
|
14
|
+
from .tags import TagsManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourceFilter(DefaultFilter):
|
|
18
|
+
"""Filter that includes only recognized source/doc extensions and ignores build/git artifacts."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, folder: Path):
|
|
21
|
+
super().__init__(
|
|
22
|
+
ignore_dirs=tuple(IGNORE_DIR_NAMES),
|
|
23
|
+
ignore_entity_patterns=(r"^\..*", r".*\.tags$", r".*tags$", r".*\.sock$"),
|
|
24
|
+
)
|
|
25
|
+
self.folder = folder
|
|
26
|
+
|
|
27
|
+
def __call__(self, change: Change, path: str) -> bool:
|
|
28
|
+
if not super().__call__(change, path):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
p = Path(path)
|
|
32
|
+
if p.name.startswith("."):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
for part in p.parts:
|
|
36
|
+
if part in IGNORE_DIR_NAMES or part.startswith("."):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
ext = p.suffix.lower()
|
|
40
|
+
return ext in CODE_EXTENSIONS or ext in DOC_EXTENSIONS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DirectoryWatcher:
|
|
44
|
+
"""Watches folder, serves IPC Unix socket, and keeps .tags and LanceDB vector embeddings up to date."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
folder: Path,
|
|
49
|
+
debounce_ms: int = 1000,
|
|
50
|
+
custom_index_dir: str | None = None,
|
|
51
|
+
):
|
|
52
|
+
self.folder = folder
|
|
53
|
+
self.debounce_ms = debounce_ms
|
|
54
|
+
self.tags_mgr = TagsManager(folder)
|
|
55
|
+
self.index = VectorIndex(folder, custom_index_dir)
|
|
56
|
+
self.socket_path = get_socket_path(folder, custom_index_dir)
|
|
57
|
+
self.index_lock = threading.Lock()
|
|
58
|
+
self.ipc_server = IPCServer(self.socket_path, self.index, lock=self.index_lock)
|
|
59
|
+
|
|
60
|
+
def start(self):
|
|
61
|
+
"""Run blocking live watcher loop and IPC server."""
|
|
62
|
+
# Early check: if an active instance is already watching, report and exit gracefully
|
|
63
|
+
if self.socket_path.exists():
|
|
64
|
+
active = ping_socket(self.socket_path, timeout=0.5)
|
|
65
|
+
if active is not None:
|
|
66
|
+
print(f"ā ļø Another cn watch instance is already running for: {self.folder}")
|
|
67
|
+
print(f" Active socket: {self.socket_path}")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
print(f"š Starting cn watch for: {self.folder}")
|
|
71
|
+
print(" Performing initial sync...")
|
|
72
|
+
ok, msg = self.tags_mgr.generate()
|
|
73
|
+
print(f" .tags: {msg}")
|
|
74
|
+
|
|
75
|
+
with self.index_lock:
|
|
76
|
+
u_files, u_chunks, p_files = self.index.sync()
|
|
77
|
+
print(f" LanceDB: {u_files} files updated ({u_chunks} chunks), {p_files} pruned.")
|
|
78
|
+
print(f" Index location: {self.index.cache_dir}")
|
|
79
|
+
|
|
80
|
+
self.ipc_server.start()
|
|
81
|
+
print(f" š IPC Socket: {self.socket_path}")
|
|
82
|
+
print("š Watching for file changes (Ctrl+C to stop)...\n")
|
|
83
|
+
|
|
84
|
+
source_filter = SourceFilter(self.folder)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
for changes in watch(
|
|
88
|
+
self.folder,
|
|
89
|
+
watch_filter=source_filter,
|
|
90
|
+
debounce=self.debounce_ms,
|
|
91
|
+
step=50,
|
|
92
|
+
):
|
|
93
|
+
code_changed = False
|
|
94
|
+
doc_changed = False
|
|
95
|
+
affected_files: list[Path] = []
|
|
96
|
+
|
|
97
|
+
for change_type, change_path in changes:
|
|
98
|
+
p = Path(change_path)
|
|
99
|
+
ext = p.suffix.lower()
|
|
100
|
+
if ext in CODE_EXTENSIONS:
|
|
101
|
+
code_changed = True
|
|
102
|
+
affected_files.append(p)
|
|
103
|
+
elif ext in DOC_EXTENSIONS:
|
|
104
|
+
doc_changed = True
|
|
105
|
+
affected_files.append(p)
|
|
106
|
+
|
|
107
|
+
if not affected_files:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
t0 = time.time()
|
|
111
|
+
ts_str = time.strftime("%H:%M:%S")
|
|
112
|
+
|
|
113
|
+
# Update tags if source code changed
|
|
114
|
+
if code_changed:
|
|
115
|
+
ok, msg = self.tags_mgr.generate()
|
|
116
|
+
print(f"[{ts_str}] š·ļø Tags updated: {msg}")
|
|
117
|
+
|
|
118
|
+
# Update LanceDB embeddings incrementally
|
|
119
|
+
total_chunks = 0
|
|
120
|
+
for fpath in affected_files:
|
|
121
|
+
try:
|
|
122
|
+
with self.index_lock:
|
|
123
|
+
n_chunks = self.index.update_single_file(fpath)
|
|
124
|
+
total_chunks += n_chunks
|
|
125
|
+
except Exception as e:
|
|
126
|
+
print(f"[{ts_str}] ā ļø Error updating embeddings for {fpath.name}: {e}")
|
|
127
|
+
|
|
128
|
+
dt = (time.time() - t0) * 1000
|
|
129
|
+
print(f"[{ts_str}] ā” Synced {len(affected_files)} file(s) ({total_chunks} chunks) in {dt:.0f}ms")
|
|
130
|
+
|
|
131
|
+
except KeyboardInterrupt:
|
|
132
|
+
print("\nš cn watch stopped.")
|
|
133
|
+
finally:
|
|
134
|
+
self.ipc_server.stop()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codebase-navigator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast Git-aware ctags indexing, live watchers, and LanceDB semantic search for developers
|
|
5
|
+
Author: Nigel Choi
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: lancedb>=0.17.0
|
|
9
|
+
Requires-Dist: numpy>=1.24.0
|
|
10
|
+
Requires-Dist: pyarrow>=14.0.0
|
|
11
|
+
Requires-Dist: sentence-transformers>=3.0.0
|
|
12
|
+
Requires-Dist: torch
|
|
13
|
+
Requires-Dist: watchfiles>=0.24.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# codebase-navigator
|
|
17
|
+
|
|
18
|
+
Developer tools for ultra-fast codebase navigation, Git-aware ctags indexing, live watchers, and LanceDB semantic search.
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Using nix
|
|
23
|
+
|
|
24
|
+
Run `cn` instantly without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Run directly from GitHub
|
|
28
|
+
nix run github:9gel/codebase-navigator -- sync
|
|
29
|
+
|
|
30
|
+
# Run help or any command
|
|
31
|
+
nix run github:9gel/codebase-navigator -- --help
|
|
32
|
+
nix run github:9gel/codebase-navigator -- search "authentication flow"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Using uvx
|
|
36
|
+
|
|
37
|
+
You can run `cn` using `uvx` (the tool runner from [uv](https://docs.astral.sh/uv/)):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Directly from the Git repository:
|
|
41
|
+
uvx --from git+https://github.com/9gel/codebase-navigator.git cn --help
|
|
42
|
+
|
|
43
|
+
# Once published to PyPI:
|
|
44
|
+
uvx codebase-navigator --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> **Note:** `cn tags` requires `universal-ctags` and `git` to be installed on your system.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- š·ļø **Git-Aware `.tags` Generation**: Uses `universal-ctags` to index genuine source code while completely ignoring huge data dumps, JSON caches, `.git`, `node_modules`, and build artifacts.
|
|
52
|
+
- š§ **LanceDB Semantic & Hybrid Search**: Vector search powered by `sentence-transformers/all-MiniLM-L6-v2` with hybrid phrase/title match boosting for markdown documentation, glossary terms, and code comments.
|
|
53
|
+
- ā” **Strict Offline Mode**: Runs 100% locally from disk cache with zero HuggingFace network requests or unauthenticated token warnings.
|
|
54
|
+
- š **Live File Watcher**: Automatically re-indexes `.tags` and incrementally updates LanceDB embeddings on every save with sub-second debounce.
|
|
55
|
+
- š **Clickable GitHub Markdown Links**: Returns results formatted as `[file:Lstart-Lend](file:///abs_path#Lstart-Lend)`.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
### Nix Flakes
|
|
60
|
+
|
|
61
|
+
Add `codebase-navigator` to your `flake.nix`:
|
|
62
|
+
|
|
63
|
+
```nix
|
|
64
|
+
{
|
|
65
|
+
inputs = {
|
|
66
|
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
67
|
+
codebase-navigator = {
|
|
68
|
+
url = "github:9gel/codebase-navigator";
|
|
69
|
+
inputs.nixpkgs.follows = "nixpkgs";
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
outputs = { self, nixpkgs, codebase-navigator, ... }:
|
|
74
|
+
let
|
|
75
|
+
system = "x86_64-linux"; # or "aarch64-darwin", etc.
|
|
76
|
+
pkgs = nixpkgs.legacyPackages.''${system};
|
|
77
|
+
in
|
|
78
|
+
{
|
|
79
|
+
# Add to environment packages or devShells:
|
|
80
|
+
devShells.''${system}.default = pkgs.mkShell {
|
|
81
|
+
packages = [
|
|
82
|
+
codebase-navigator.packages.''${system}.default
|
|
83
|
+
];
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or install it to your user profile:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
nix profile install github:9gel/codebase-navigator
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## CLI Commands
|
|
96
|
+
|
|
97
|
+
The unified `cn` command provides all indexing and search tools:
|
|
98
|
+
|
|
99
|
+
| Command | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `cn search <query> [folder]` | Semantic & hybrid search in markdown docs and code comments |
|
|
102
|
+
| `cn tags <symbol> [folder]` | Fast symbol definition lookup in `.tags` |
|
|
103
|
+
| `cn sync [folder] [--force]` | Synchronize `.tags` and LanceDB vector embeddings |
|
|
104
|
+
| `cn watch [folder]` | Live filesystem watcher for automatic re-indexing |
|
|
105
|
+
| `cn status [folder]` | Inspect index and `.tags` status |
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
Requires Nix and `direnv`:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
direnv allow
|
|
113
|
+
uv run pytest
|
|
114
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
codebase_navigator/__init__.py,sha256=e7d60tG-SgwP_05N7qBz7iyG2OSr0KyOUUaZ7P9SWLI,181
|
|
2
|
+
codebase_navigator/ask.py,sha256=1MKE-Hk92xw3kEXlDS_rSAZbfP7J0ona0i6EvozaXOw,14115
|
|
3
|
+
codebase_navigator/cli.py,sha256=UASkNs7RKf_6D89bgtGBy3mvIgureE6daQ9Bc55vlYs,9610
|
|
4
|
+
codebase_navigator/config.py,sha256=UkXpAvyWUgnlCHTYPgIhVBupNX1zHcdHJPuiGT21nIw,3629
|
|
5
|
+
codebase_navigator/extractor.py,sha256=kGy13gpDltum5n1rW_xuXF6Sw7lSoTYpD_-VxAOlJTM,11282
|
|
6
|
+
codebase_navigator/index.py,sha256=rVl9ZVsdCmMhSCIw3ttvZy5-11tzPWdtlUM7Wgnx7PQ,9582
|
|
7
|
+
codebase_navigator/ipc.py,sha256=EmlxaZ7KabIUMSXrONVfME3BB4sFJHXEYwVloa9iurM,7346
|
|
8
|
+
codebase_navigator/tags.py,sha256=HH-Vk3DLKIPFukAD1daagNUg8I4HS28MydG3dA-rzyg,6620
|
|
9
|
+
codebase_navigator/watcher.py,sha256=6ukf8qVUXqbeku51uykeg2SdNjFd8Z-F-jTEHDUgKYc,4882
|
|
10
|
+
codebase_navigator-0.1.0.dist-info/METADATA,sha256=HbreUNr6yu-mw5V2KGDqIP8X8Ujg1XYNLx-mcsWYjKA,3485
|
|
11
|
+
codebase_navigator-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
codebase_navigator-0.1.0.dist-info/entry_points.txt,sha256=K0dpqCSx-geADaO6s8rUvlZ76bVhqDA8YdBDUPX-_LA,51
|
|
13
|
+
codebase_navigator-0.1.0.dist-info/RECORD,,
|