ihmt-memory 1.2.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.
ihmt/chunkers/code.py ADDED
@@ -0,0 +1,572 @@
1
+ """Syntax-aware splitting for source code.
2
+
3
+ The guarantee this module provides: **a class, method, function or import block
4
+ is never cut in half**. Python is handled with the stdlib :mod:`ast`; the
5
+ C-family (Java, JavaScript/TypeScript, C, C++, C#, Go, Rust, PHP, Swift, Kotlin)
6
+ is handled by :class:`BraceScanner`, a character-level scanner that tracks brace
7
+ depth while correctly skipping comments and string literals.
8
+
9
+ Chunk text stays byte-identical to the source: concatenating the chunks of a
10
+ file reproduces that file exactly. When a method must be lifted out of an
11
+ oversized class, the enclosing class header travels in ``Block.context`` (and
12
+ therefore in the leaf metadata) rather than being spliced into the content.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import re
19
+ from typing import Dict, List, Optional, Tuple
20
+
21
+ from ..textutils import truncate
22
+ from .base import Block, Chunk, Chunker, join_lines, split_lines
23
+
24
+ #: File extension -> language identifier.
25
+ LANGUAGE_BY_EXTENSION: Dict[str, str] = {
26
+ ".py": "python",
27
+ ".pyi": "python",
28
+ ".java": "java",
29
+ ".js": "javascript",
30
+ ".mjs": "javascript",
31
+ ".jsx": "javascript",
32
+ ".ts": "typescript",
33
+ ".tsx": "typescript",
34
+ ".c": "c",
35
+ ".h": "c",
36
+ ".cc": "cpp",
37
+ ".cpp": "cpp",
38
+ ".cxx": "cpp",
39
+ ".hpp": "cpp",
40
+ ".hh": "cpp",
41
+ ".cs": "csharp",
42
+ ".go": "go",
43
+ ".rs": "rust",
44
+ ".kt": "kotlin",
45
+ ".swift": "swift",
46
+ ".php": "php",
47
+ ".scala": "scala",
48
+ }
49
+
50
+ #: Languages driven by the brace scanner.
51
+ BRACE_LANGUAGES = frozenset(LANGUAGE_BY_EXTENSION.values()) - {"python"}
52
+
53
+ _TYPE_DECL_RE = re.compile(
54
+ r"\b(class|interface|enum|record|struct|namespace|trait|protocol|impl)\s+([A-Za-z_][\w$]*)"
55
+ )
56
+ _FUNC_DECL_RE = re.compile(r"\b(?:function|fn|func|sub)\s+([A-Za-z_][\w$]*)")
57
+ _METHOD_DECL_RE = re.compile(r"([A-Za-z_][\w$]*)\s*\([^;{()]*\)\s*(?:const\b)?[^;{]*\{")
58
+ _IMPORT_RE = re.compile(r"^\s*(?:import\b|#include\b|using\b|package\b|from\b|require\b|export\s+\*)")
59
+ _ANNOTATION_RE = re.compile(r"^\s*[@\[]")
60
+
61
+ #: Block kinds that may be split one level deeper when oversized.
62
+ _CONTAINER_KINDS = frozenset({"class", "interface", "enum", "record", "struct", "namespace", "impl", "trait"})
63
+
64
+
65
+ class BraceScanner:
66
+ """Finds top-level declaration boundaries in C-family source.
67
+
68
+ The scanner is deliberately lexical rather than a full parser: it only needs
69
+ to know where a top-level ``{...}`` block closes, where a top-level
70
+ statement ends in ``;``, and where a preprocessor line ends — while never
71
+ being fooled by braces inside comments or string literals.
72
+ """
73
+
74
+ def __init__(self, text: str) -> None:
75
+ self.text = text
76
+ self._ends: Optional[List[int]] = None
77
+ self._line_depths: Dict[int, int] = {}
78
+
79
+ def unit_end_lines(self) -> List[int]:
80
+ """Return the 1-based line numbers where top-level units end."""
81
+ if self._ends is None:
82
+ self._ends = self._scan()
83
+ return self._ends
84
+
85
+ def depth_at_line_ends(self) -> Dict[int, int]:
86
+ """Brace depth at the end of each 1-based line.
87
+
88
+ A chunk boundary at depth 0 sits between top-level declarations; at
89
+ depth 1 it sits between the members of a class. Anything deeper would
90
+ mean a method was cut in half — which the chunker never does, and which
91
+ the test suite asserts against.
92
+ """
93
+ self.unit_end_lines()
94
+ return dict(self._line_depths)
95
+
96
+ def _scan(self) -> List[int]:
97
+ """Single pass: records unit boundaries and per-line brace depth."""
98
+ text = self.text
99
+ length = len(text)
100
+ ends: List[int] = []
101
+ index = 0
102
+ line = 1
103
+ depth = 0
104
+ has_content = False
105
+ at_line_start = True
106
+
107
+ def close_unit(at: int) -> None:
108
+ nonlocal has_content
109
+ if not ends or ends[-1] != at:
110
+ ends.append(at)
111
+ has_content = False
112
+
113
+ def span_lines(from_line: int, to_line: int) -> None:
114
+ """Record the (unchanged) depth of lines crossed by a skip."""
115
+ for crossed in range(from_line, to_line):
116
+ self._line_depths[crossed] = depth
117
+
118
+ while index < length:
119
+ char = text[index]
120
+ nxt = text[index + 1] if index + 1 < length else ""
121
+
122
+ if char == "\n":
123
+ self._line_depths[line] = depth
124
+ line += 1
125
+ index += 1
126
+ at_line_start = True
127
+ continue
128
+
129
+ if char in " \t\r":
130
+ index += 1
131
+ continue
132
+
133
+ # ---- comments ------------------------------------------------
134
+ if char == "/" and nxt == "/":
135
+ index = self._skip_to_eol(index)
136
+ continue
137
+ if char == "/" and nxt == "*":
138
+ previous_line = line
139
+ index, line = self._skip_block_comment(index, line)
140
+ span_lines(previous_line, line)
141
+ continue
142
+
143
+ # ---- preprocessor (C/C++): the line itself is the unit --------
144
+ if char == "#" and at_line_start and depth == 0:
145
+ end = self._skip_to_eol(index)
146
+ consumed = text[index:end]
147
+ line_end = line + consumed.count("\n")
148
+ close_unit(line_end)
149
+ span_lines(line, line_end)
150
+ line = line_end
151
+ index = end
152
+ at_line_start = False
153
+ continue
154
+
155
+ # ---- string literals -----------------------------------------
156
+ if text.startswith('"""', index):
157
+ previous_line = line
158
+ index, line = self._skip_delimited(index + 3, line, '"""')
159
+ span_lines(previous_line, line)
160
+ has_content = True
161
+ at_line_start = False
162
+ continue
163
+ if char in "\"'`":
164
+ previous_line = line
165
+ index, line = self._skip_string(index, line, char)
166
+ span_lines(previous_line, line)
167
+ has_content = True
168
+ at_line_start = False
169
+ continue
170
+
171
+ # ---- structure -----------------------------------------------
172
+ at_line_start = False
173
+ if char == "{":
174
+ depth += 1
175
+ has_content = True
176
+ elif char == "}":
177
+ depth = max(0, depth - 1)
178
+ if depth == 0:
179
+ # Absorb a trailing ';' (C++ `};`) into the same unit.
180
+ close_unit(line)
181
+ elif char == ";" and depth == 0:
182
+ if has_content:
183
+ close_unit(line)
184
+ else:
185
+ has_content = True
186
+ index += 1
187
+
188
+ total_lines = self.text.count("\n") + (0 if self.text.endswith("\n") else 1)
189
+ self._line_depths.setdefault(max(line, 1), depth)
190
+ if has_content or not ends:
191
+ if total_lines and (not ends or ends[-1] < total_lines):
192
+ close_unit(max(total_lines, 1))
193
+ return ends
194
+
195
+ # ------------------------------------------------------------- scanning
196
+ def _skip_to_eol(self, index: int) -> int:
197
+ """Skip to the end of the logical line, honouring ``\\`` continuations."""
198
+ text = self.text
199
+ while index < len(text):
200
+ if text[index] == "\\" and index + 1 < len(text) and text[index + 1] == "\n":
201
+ index += 2
202
+ continue
203
+ if text[index] == "\n":
204
+ return index
205
+ index += 1
206
+ return index
207
+
208
+ def _skip_block_comment(self, index: int, line: int) -> Tuple[int, int]:
209
+ end = self.text.find("*/", index + 2)
210
+ end = len(self.text) if end == -1 else end + 2
211
+ return end, line + self.text.count("\n", index, end)
212
+
213
+ def _skip_string(self, index: int, line: int, quote: str) -> Tuple[int, int]:
214
+ text = self.text
215
+ cursor = index + 1
216
+ while cursor < len(text):
217
+ char = text[cursor]
218
+ if char == "\\":
219
+ cursor += 2
220
+ continue
221
+ if char == "\n":
222
+ line += 1
223
+ # An unterminated single-line literal: stop at the newline
224
+ # rather than swallowing the rest of the file.
225
+ if quote != "`":
226
+ return cursor + 1, line
227
+ elif char == quote:
228
+ return cursor + 1, line
229
+ cursor += 1
230
+ return cursor, line
231
+
232
+ def _skip_delimited(self, index: int, line: int, closing: str) -> Tuple[int, int]:
233
+ end = self.text.find(closing, index)
234
+ end = len(self.text) if end == -1 else end + len(closing)
235
+ return end, line + self.text.count("\n", index, end)
236
+
237
+
238
+ class CodeAwareChunker(Chunker):
239
+ """Splits source files on syntactic boundaries only."""
240
+
241
+ name = "code"
242
+
243
+ def __init__(self, config, language: Optional[str] = None) -> None:
244
+ super().__init__(config)
245
+ self.language = language
246
+
247
+ # ------------------------------------------------------------------ api
248
+ @property
249
+ def symbol_mode(self) -> bool:
250
+ """One leaf per class member instead of packed runs of blocks."""
251
+ return getattr(self.config, "code_chunk_mode", "pack") == "symbol"
252
+
253
+ def _split_threshold(self) -> int:
254
+ """Size above which a class is split into its members.
255
+
256
+ In ``pack`` mode only oversized classes are split (block integrity
257
+ first). In ``symbol`` mode any class with more than a couple of small
258
+ members is, so that a lookup can return a single method and a project
259
+ map can list them; tiny types (records, interfaces) stay whole.
260
+ """
261
+ if not self.symbol_mode:
262
+ return self.config.max_tokens
263
+ return max(60, 2 * max(24, self.config.target_tokens // 4))
264
+
265
+ def chunk(self, text: str, *, source: str = "") -> List[Chunk]: # type: ignore[override]
266
+ """Split code on syntactic boundaries; see :meth:`Chunker.chunk`."""
267
+ if not text.strip():
268
+ return []
269
+ blocks = self.blocks(text, source=source)
270
+ return self.pack_each(blocks) if self.symbol_mode else self.pack(blocks)
271
+
272
+ @staticmethod
273
+ def language_for(source: str) -> Optional[str]:
274
+ """Map a filename/extension to a language identifier."""
275
+ if not source:
276
+ return None
277
+ suffix = source[source.rfind(".") :].lower() if "." in source else ""
278
+ return LANGUAGE_BY_EXTENSION.get(suffix)
279
+
280
+ def blocks(self, text: str, *, source: str = "") -> List[Block]:
281
+ """Detect indivisible code blocks, choosing the strategy per language."""
282
+ language = self.language or self.language_for(source) or self._sniff(text)
283
+ if language == "python":
284
+ try:
285
+ blocks = self._python_blocks(text)
286
+ except (SyntaxError, ValueError, RecursionError):
287
+ # Broken or partial source still deserves structural chunking:
288
+ # degrade to the lexical scanner, never to a blind character cut.
289
+ blocks = self._brace_blocks(text, language="python")
290
+ else:
291
+ blocks = self._brace_blocks(text, language=language or "unknown")
292
+
293
+ if not blocks:
294
+ blocks = self._fallback_blocks(text)
295
+ for block in blocks:
296
+ block.tags = [t for t in block.tags if t] or block.tags
297
+ if language:
298
+ block.tags.append(f"lang:{language}")
299
+ return blocks
300
+
301
+ @staticmethod
302
+ def _sniff(text: str) -> Optional[str]:
303
+ """Guess the language of a snippet with no filename."""
304
+ head = text[:4000]
305
+ if re.search(r"^\s*(def|class)\s+\w+.*:\s*$", head, re.MULTILINE) and "{" not in head[:200]:
306
+ return "python"
307
+ if re.search(r"\bpublic\s+(final\s+|abstract\s+)?class\b|^\s*package\s+[\w.]+;", head, re.MULTILINE):
308
+ return "java"
309
+ if re.search(r"#include\s*[<\"]", head):
310
+ return "cpp"
311
+ if re.search(r"\b(function|const|let|=>)\b", head):
312
+ return "javascript"
313
+ return None
314
+
315
+ # --------------------------------------------------------------- python
316
+ def _python_blocks(self, text: str) -> List[Block]:
317
+ """Block detection driven by the Python AST.
318
+
319
+ Consecutive imports and consecutive simple statements are coalesced;
320
+ every class and function becomes its own block, decorators included.
321
+ Blocks tile the file with no gaps, so comments and blank lines between
322
+ definitions are always carried by the following block.
323
+ """
324
+ tree = ast.parse(text)
325
+ lines = split_lines(text)
326
+ if not tree.body:
327
+ return self._fallback_blocks(text)
328
+
329
+ units: List[Dict[str, object]] = []
330
+ for node in tree.body:
331
+ start = node.lineno
332
+ for decorator in getattr(node, "decorator_list", []) or []:
333
+ start = min(start, decorator.lineno)
334
+ end = getattr(node, "end_lineno", None) or start
335
+
336
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
337
+ kind, name = "imports", "imports"
338
+ elif isinstance(node, ast.ClassDef):
339
+ kind, name = "class", node.name
340
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
341
+ kind, name = "function", node.name
342
+ else:
343
+ kind, name = "module", "module level"
344
+
345
+ mergeable = kind in ("imports", "module")
346
+ if units and mergeable and units[-1]["kind"] == kind:
347
+ units[-1]["end"] = max(int(units[-1]["end"]), end)
348
+ units[-1]["nodes"].append(node) # type: ignore[union-attr]
349
+ else:
350
+ units.append({"start": start, "end": end, "kind": kind, "name": name, "nodes": [node]})
351
+
352
+ blocks: List[Block] = []
353
+ cursor = 1
354
+ for unit in units:
355
+ end = int(unit["end"])
356
+ kind = str(unit["kind"])
357
+ name = str(unit["name"])
358
+ title = name if kind in ("imports", "module") else f"{kind} {name}"
359
+ tags = [f"{kind}:{name}"] if kind not in ("imports", "module") else [kind]
360
+ block = Block(
361
+ text=join_lines(lines, cursor, end),
362
+ title=title,
363
+ start_line=cursor,
364
+ end_line=end,
365
+ kind=kind,
366
+ tags=tags,
367
+ )
368
+ if kind == "class" and block.tokens > self._split_threshold():
369
+ node = unit["nodes"][0] # type: ignore[index]
370
+ blocks.extend(self._split_python_class(lines, block, node)) # type: ignore[arg-type]
371
+ else:
372
+ blocks.append(block)
373
+ cursor = end + 1
374
+
375
+ if cursor <= len(lines) and blocks:
376
+ trailing = join_lines(lines, cursor, len(lines))
377
+ blocks[-1].text += trailing
378
+ blocks[-1].end_line = len(lines)
379
+ return blocks
380
+
381
+ def _split_python_class(self, lines: List[str], block: Block, node: ast.ClassDef) -> List[Block]:
382
+ """Split an oversized class into header + one block per member."""
383
+ members = [
384
+ m
385
+ for m in node.body
386
+ if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
387
+ ]
388
+ if not members:
389
+ return [block]
390
+
391
+ def member_start(member: ast.AST) -> int:
392
+ start = member.lineno # type: ignore[attr-defined]
393
+ for decorator in getattr(member, "decorator_list", []) or []:
394
+ start = min(start, decorator.lineno)
395
+ return start
396
+
397
+ header_end = member_start(members[0]) - 1
398
+ context = truncate(join_lines(lines, node.lineno, min(header_end, node.lineno + 2)), 200)
399
+ out: List[Block] = []
400
+ if header_end >= block.start_line:
401
+ out.append(
402
+ Block(
403
+ text=join_lines(lines, block.start_line, header_end),
404
+ title=f"class {node.name} (header)",
405
+ start_line=block.start_line,
406
+ end_line=header_end,
407
+ kind="class-header",
408
+ tags=[f"class:{node.name}"],
409
+ )
410
+ )
411
+ cursor = max(header_end + 1, block.start_line)
412
+ for index, member in enumerate(members):
413
+ end = getattr(member, "end_lineno", None) or member_start(member)
414
+ if index == len(members) - 1:
415
+ end = block.end_line
416
+ out.append(
417
+ Block(
418
+ text=join_lines(lines, cursor, end),
419
+ title=f"{node.name}.{getattr(member, 'name', 'member')}",
420
+ start_line=cursor,
421
+ end_line=end,
422
+ kind="method",
423
+ tags=[f"class:{node.name}", f"method:{getattr(member, 'name', '')}"],
424
+ context=context,
425
+ )
426
+ )
427
+ cursor = end + 1
428
+ return out
429
+
430
+ # ---------------------------------------------------------- brace family
431
+ def _brace_blocks(self, text: str, *, language: str) -> List[Block]:
432
+ """Block detection for C-family syntax via :class:`BraceScanner`."""
433
+ lines = split_lines(text)
434
+ if not lines:
435
+ return []
436
+ ends = BraceScanner(text).unit_end_lines()
437
+ if not ends:
438
+ return self._fallback_blocks(text)
439
+
440
+ blocks: List[Block] = []
441
+ cursor = 1
442
+ for end in ends:
443
+ end = min(end, len(lines))
444
+ if end < cursor:
445
+ continue
446
+ body = join_lines(lines, cursor, end)
447
+ kind, name = self._identify(body)
448
+ block = Block(
449
+ text=body,
450
+ title=f"{kind} {name}".strip() if name else kind,
451
+ start_line=cursor,
452
+ end_line=end,
453
+ kind=kind,
454
+ tags=[f"{kind}:{name}"] if name else [kind],
455
+ )
456
+ if kind in _CONTAINER_KINDS and block.tokens > self._split_threshold():
457
+ blocks.extend(self._split_container(lines, block, name))
458
+ else:
459
+ blocks.append(block)
460
+ cursor = end + 1
461
+
462
+ if cursor <= len(lines):
463
+ if blocks:
464
+ blocks[-1].text += join_lines(lines, cursor, len(lines))
465
+ blocks[-1].end_line = len(lines)
466
+ else: # pragma: no cover - defensive
467
+ blocks.append(
468
+ Block(
469
+ text=join_lines(lines, cursor, len(lines)),
470
+ title="source",
471
+ start_line=cursor,
472
+ end_line=len(lines),
473
+ kind="segment",
474
+ )
475
+ )
476
+ return blocks
477
+
478
+ def _split_container(self, lines: List[str], block: Block, container: str) -> List[Block]:
479
+ """Split an oversized class/struct/namespace into its members.
480
+
481
+ The body is re-scanned with the same brace logic, so members are cut at
482
+ their own closing braces and stay intact.
483
+ """
484
+ open_line = self._find_body_open_line(lines, block)
485
+ if open_line is None or open_line >= block.end_line:
486
+ return [block]
487
+
488
+ body_start = open_line + 1
489
+ body_end = block.end_line - 1
490
+ if body_end < body_start:
491
+ return [block]
492
+
493
+ body_text = join_lines(lines, body_start, body_end)
494
+ inner_ends = BraceScanner(body_text).unit_end_lines()
495
+ if len(inner_ends) < 2:
496
+ return [block]
497
+
498
+ # The context is the declaration itself, not whatever documentation
499
+ # precedes it: a long Javadoc would otherwise fill the budget and hide
500
+ # the very line a reader needs ("class InventoryService {").
501
+ context = truncate(join_lines(lines, max(block.start_line, open_line - 2), open_line), 200)
502
+ out = [
503
+ Block(
504
+ text=join_lines(lines, block.start_line, open_line),
505
+ title=f"{block.kind} {container} (header)".strip(),
506
+ start_line=block.start_line,
507
+ end_line=open_line,
508
+ kind=f"{block.kind}-header",
509
+ tags=[f"{block.kind}:{container}"] if container else [block.kind],
510
+ )
511
+ ]
512
+ cursor = body_start
513
+ for offset in inner_ends:
514
+ end = min(body_start + offset - 1, body_end)
515
+ if end < cursor:
516
+ continue
517
+ member_text = join_lines(lines, cursor, end)
518
+ _, member_name = self._identify(member_text)
519
+ out.append(
520
+ Block(
521
+ text=member_text,
522
+ title=f"{container}.{member_name}" if member_name else f"{container} member",
523
+ start_line=cursor,
524
+ end_line=end,
525
+ kind="method",
526
+ tags=[t for t in (f"{block.kind}:{container}", f"method:{member_name}") if t],
527
+ context=context,
528
+ )
529
+ )
530
+ cursor = end + 1
531
+ # The closing brace of the container tails the last member.
532
+ if cursor <= block.end_line:
533
+ out[-1].text += join_lines(lines, cursor, block.end_line)
534
+ out[-1].end_line = block.end_line
535
+ return out
536
+
537
+ @staticmethod
538
+ def _find_body_open_line(lines: List[str], block: Block) -> Optional[int]:
539
+ """Line number of the ``{`` that opens a container's body."""
540
+ for line_no in range(block.start_line, block.end_line + 1):
541
+ stripped = lines[line_no - 1]
542
+ if "{" in stripped and not stripped.lstrip().startswith(("//", "*", "/*")):
543
+ return line_no
544
+ return None
545
+
546
+ @staticmethod
547
+ def _identify(body: str) -> Tuple[str, str]:
548
+ """Infer ``(kind, name)`` from the declaration text of a block."""
549
+ significant = [
550
+ line.strip()
551
+ for line in body.splitlines()
552
+ if line.strip()
553
+ and not line.strip().startswith(("//", "*", "/*", "*/"))
554
+ and not _ANNOTATION_RE.match(line)
555
+ ]
556
+ head = " ".join(significant[:4])
557
+ if not head:
558
+ return "comment", ""
559
+ if _IMPORT_RE.match(significant[0]) or significant[0].startswith("#"):
560
+ return "imports", ""
561
+ type_match = _TYPE_DECL_RE.search(head)
562
+ if type_match:
563
+ return type_match.group(1), type_match.group(2)
564
+ func_match = _FUNC_DECL_RE.search(head)
565
+ if func_match:
566
+ return "function", func_match.group(1)
567
+ method_match = _METHOD_DECL_RE.search(head)
568
+ if method_match:
569
+ return "method", method_match.group(1)
570
+ if "=" in head or head.endswith(";"):
571
+ return "declaration", ""
572
+ return "block", ""
@@ -0,0 +1,19 @@
1
+ """Fallback splitter for material with no recognizable structure.
2
+
3
+ Behaves like the narrative splitter with scene detection disabled: paragraphs
4
+ stay atomic, and the only cut inside a paragraph is the sentence-boundary split
5
+ of an over-long one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .narrative import NarrativeChunker
11
+
12
+
13
+ class GenericChunker(NarrativeChunker):
14
+ """Paragraph-atomic splitter used when no domain-specific rule applies."""
15
+
16
+ name = "generic"
17
+
18
+ def __init__(self, config) -> None:
19
+ super().__init__(config, detect_scenes=False)