llmcode-cli 1.0.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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
llm_code/tools/dump.py ADDED
@@ -0,0 +1,116 @@
1
+ """DAFC Dump -- concatenate repo source files for external LLM consumption."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ _SKIP_DIRS = frozenset({
8
+ ".git", "__pycache__", "node_modules", ".venv", "venv",
9
+ ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
10
+ "dist", "build", ".eggs",
11
+ })
12
+
13
+ _SKIP_EXTENSIONS = frozenset({
14
+ ".pyc", ".pyo", ".so", ".dll", ".dylib", ".exe",
15
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
16
+ ".woff", ".woff2", ".ttf", ".eot",
17
+ ".zip", ".tar", ".gz", ".bz2", ".xz",
18
+ ".db", ".sqlite", ".sqlite3",
19
+ ".bin", ".dat",
20
+ })
21
+
22
+ _MAX_SINGLE_FILE_BYTES = 50_000 # 50KB
23
+ _MAX_TOTAL_BYTES = 500_000 # 500KB
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DumpResult:
28
+ text: str
29
+ file_count: int
30
+ total_lines: int
31
+ estimated_tokens: int
32
+
33
+
34
+ def dump_codebase(
35
+ cwd: Path,
36
+ max_files: int = 200,
37
+ max_file_size: int = _MAX_SINGLE_FILE_BYTES,
38
+ max_total_size: int = _MAX_TOTAL_BYTES,
39
+ ) -> DumpResult:
40
+ """Walk cwd, concatenate source files into a single text dump.
41
+
42
+ Skips binary files, large files, and common non-source directories.
43
+ """
44
+ files: list[Path] = []
45
+ _collect_files(cwd, cwd, files, max_files, max_file_size)
46
+ files.sort(key=lambda p: str(p.relative_to(cwd)))
47
+
48
+ parts: list[str] = []
49
+ total_lines = 0
50
+ total_bytes = 0
51
+
52
+ for f in files:
53
+ if total_bytes >= max_total_size:
54
+ break
55
+ try:
56
+ content = f.read_text(encoding="utf-8", errors="strict")
57
+ except (UnicodeDecodeError, OSError):
58
+ continue # skip binary / unreadable
59
+
60
+ rel_path = str(f.relative_to(cwd))
61
+ total_lines += content.count("\n") + (1 if content and not content.endswith("\n") else 0)
62
+ total_bytes += len(content.encode("utf-8"))
63
+ parts.append(f"--- file: {rel_path} ---\n{content}\n")
64
+
65
+ text = "".join(parts)
66
+ file_count = len(parts)
67
+
68
+ return DumpResult(
69
+ text=text,
70
+ file_count=file_count,
71
+ total_lines=total_lines,
72
+ estimated_tokens=len(text) // 4,
73
+ )
74
+
75
+
76
+ def _collect_files(
77
+ base: Path,
78
+ current: Path,
79
+ out: list[Path],
80
+ limit: int,
81
+ max_file_size: int,
82
+ ) -> None:
83
+ """Recursively collect files, respecting skip rules and limits."""
84
+ if len(out) >= limit:
85
+ return
86
+
87
+ try:
88
+ entries = sorted(current.iterdir(), key=lambda p: p.name)
89
+ except PermissionError:
90
+ return
91
+
92
+ for entry in entries:
93
+ if len(out) >= limit:
94
+ return
95
+
96
+ if entry.is_dir():
97
+ # Skip known non-source dirs and hidden dirs
98
+ if entry.name in _SKIP_DIRS or entry.name.startswith("."):
99
+ continue
100
+ # Skip egg-info directories (*.egg-info pattern)
101
+ if entry.name.endswith(".egg-info"):
102
+ continue
103
+ _collect_files(base, entry, out, limit, max_file_size)
104
+ elif entry.is_file():
105
+ if entry.suffix.lower() in _SKIP_EXTENSIONS:
106
+ continue
107
+ if entry.stat().st_size > max_file_size:
108
+ continue
109
+ # Quick binary check: look for null bytes in first 512 bytes
110
+ try:
111
+ head = entry.read_bytes()[:512]
112
+ if b"\x00" in head:
113
+ continue # likely binary
114
+ except OSError:
115
+ continue
116
+ out.append(entry)
@@ -0,0 +1,282 @@
1
+ """EditFileTool — search-and-replace within an existing file."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from llm_code.runtime.file_protection import check_write
11
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
12
+ from llm_code.utils.errors import friendly_error
13
+ from llm_code.utils.text_normalize import normalize_for_match
14
+
15
+ if TYPE_CHECKING:
16
+ from llm_code.runtime.overlay import OverlayFS
17
+
18
+ _MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class EditApplyResult:
23
+ """Result of applying a search-and-replace edit to content."""
24
+
25
+ success: bool
26
+ new_content: str
27
+ replaced: int = 0
28
+ fuzzy_match: bool = False
29
+ error: str = ""
30
+
31
+
32
+ def _apply_edit(content: str, old: str, new: str, replace_all: bool = False) -> EditApplyResult:
33
+ """Apply search-and-replace to content string. Returns EditApplyResult."""
34
+ # --- Exact match ---
35
+ count = content.count(old)
36
+
37
+ if count == 0:
38
+ # --- Fuzzy match: quote normalization + trailing whitespace ---
39
+ norm_content = normalize_for_match(content)
40
+ norm_old = normalize_for_match(old)
41
+ norm_count = norm_content.count(norm_old)
42
+
43
+ if norm_count == 0:
44
+ return EditApplyResult(success=False, new_content=content, error=f"Text not found: {old!r}")
45
+
46
+ if replace_all:
47
+ new_content = _fuzzy_replace_all(content, norm_content, norm_old, new)
48
+ replaced = norm_count
49
+ else:
50
+ new_content = _fuzzy_replace_first(content, norm_content, norm_old, new)
51
+ replaced = 1
52
+
53
+ return EditApplyResult(success=True, new_content=new_content, replaced=replaced, fuzzy_match=True)
54
+
55
+ if replace_all:
56
+ new_content = content.replace(old, new)
57
+ replaced = count
58
+ else:
59
+ new_content = content.replace(old, new, 1)
60
+ replaced = 1
61
+
62
+ return EditApplyResult(success=True, new_content=new_content, replaced=replaced)
63
+
64
+
65
+ class EditFileInput(BaseModel):
66
+ path: str
67
+ old: str
68
+ new: str
69
+ replace_all: bool = False
70
+
71
+
72
+ class EditFileTool(Tool):
73
+ @property
74
+ def name(self) -> str:
75
+ return "edit_file"
76
+
77
+ @property
78
+ def description(self) -> str:
79
+ return "Search and replace text within a file."
80
+
81
+ @property
82
+ def input_schema(self) -> dict:
83
+ return {
84
+ "type": "object",
85
+ "properties": {
86
+ "path": {"type": "string", "description": "Absolute path to the file"},
87
+ "old": {"type": "string", "description": "Text to search for"},
88
+ "new": {"type": "string", "description": "Replacement text"},
89
+ "replace_all": {
90
+ "type": "boolean",
91
+ "description": "Replace all occurrences (default false)",
92
+ "default": False,
93
+ },
94
+ },
95
+ "required": ["path", "old", "new"],
96
+ }
97
+
98
+ @property
99
+ def required_permission(self) -> PermissionLevel:
100
+ return PermissionLevel.WORKSPACE_WRITE
101
+
102
+ @property
103
+ def input_model(self) -> type[EditFileInput]:
104
+ return EditFileInput
105
+
106
+ def execute(self, args: dict, overlay: "OverlayFS | None" = None) -> ToolResult:
107
+ path = pathlib.Path(args["path"])
108
+ old: str = args["old"]
109
+ new: str = args["new"]
110
+ replace_all: bool = bool(args.get("replace_all", False))
111
+
112
+ protection = check_write(str(path))
113
+ if not protection.allowed:
114
+ return ToolResult(output=protection.reason, is_error=True)
115
+ warning_prefix = f"[WARNING] {protection.reason}\n" if protection.severity == "warn" else ""
116
+
117
+ # File size guard (real FS only — overlay has no on-disk size)
118
+ if overlay is None:
119
+ if not path.exists():
120
+ return ToolResult(output=f"File not found: {path}", is_error=True)
121
+ # Single stat call — capture both size and mtime together.
122
+ st = path.stat()
123
+ if st.st_size > _MAX_FILE_BYTES:
124
+ return ToolResult(
125
+ output=f"File too large ({st.st_size} bytes, limit {_MAX_FILE_BYTES}): {path}",
126
+ is_error=True,
127
+ )
128
+ # Record mtime before read for conflict detection
129
+ mtime_before = st.st_mtime
130
+ try:
131
+ content = path.read_text()
132
+ except (PermissionError, OSError) as exc:
133
+ return ToolResult(output=friendly_error(exc, str(path)), is_error=True)
134
+ else:
135
+ try:
136
+ content = overlay.read(path)
137
+ except FileNotFoundError:
138
+ return ToolResult(output=f"File not found: {path}", is_error=True)
139
+ mtime_before = None
140
+
141
+ result = _apply_edit(content, old, new, replace_all)
142
+ if not result.success:
143
+ return ToolResult(
144
+ output=f"Text not found in {path}: {old!r}",
145
+ is_error=True,
146
+ )
147
+ new_content = result.new_content
148
+ replaced = result.replaced
149
+ fuzzy_match = result.fuzzy_match
150
+
151
+ # --- mtime conflict check (real FS only, before write) ---
152
+ if overlay is None and mtime_before is not None:
153
+ current_mtime = path.stat().st_mtime
154
+ if current_mtime != mtime_before:
155
+ return ToolResult(
156
+ output=f"File was modified externally since last read: {path}",
157
+ is_error=True,
158
+ )
159
+ path.write_text(new_content)
160
+ elif overlay is not None:
161
+ overlay.write(path, new_content)
162
+
163
+ # Generate structured diff
164
+ from llm_code.utils.diff import generate_diff, count_changes
165
+
166
+ hunks = generate_diff(content, new_content, path.name)
167
+ adds, dels = count_changes(hunks)
168
+
169
+ match_note = " (fuzzy match: quote normalization)" if fuzzy_match else ""
170
+ diff_parts = [warning_prefix + f"Replaced {replaced} occurrence(s) in {path}{match_note}"]
171
+ for line in old.splitlines()[:5]:
172
+ diff_parts.append(f"- {line}")
173
+ for line in new.splitlines()[:5]:
174
+ diff_parts.append(f"+ {line}")
175
+
176
+ return ToolResult(
177
+ output="\n".join(diff_parts),
178
+ metadata={
179
+ "diff": [h.to_dict() for h in hunks],
180
+ "additions": adds,
181
+ "deletions": dels,
182
+ },
183
+ )
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Fuzzy replacement helpers
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def _build_norm_to_orig_map(original: str) -> list[int]:
191
+ """Build a mapping from each normalised-string index to its original index.
192
+
193
+ normalize_for_match applies two transforms:
194
+ - normalize_quotes: length-preserving (1-to-1 character replacement)
195
+ - strip_trailing_whitespace: length-reducing (removes trailing spaces/tabs
196
+ per line, but keeps the newline)
197
+
198
+ We compute the map by stepping through the original character by character
199
+ and deciding whether each character survives into the normalised string.
200
+ """
201
+
202
+ # First pass: quote normalisation is 1-to-1 in length, so positions match.
203
+ # Second pass: trailing whitespace removal — skip chars that are spaces/tabs
204
+ # which trail before a newline or end-of-string.
205
+
206
+ # Pre-compute which original positions are stripped (trailing whitespace).
207
+ n = len(original)
208
+ stripped: list[bool] = [False] * n
209
+
210
+ # Walk each line and mark trailing spaces/tabs for removal.
211
+ i = 0
212
+ while i < n:
213
+ # Find end of line (next \n or end of string).
214
+ j = i
215
+ while j < n and original[j] != "\n":
216
+ j += 1
217
+ # j is now the position of \n or n.
218
+ # Walk backwards from j-1 while space or tab.
219
+ k = j - 1
220
+ while k >= i and original[k] in (" ", "\t"):
221
+ stripped[k] = True
222
+ k -= 1
223
+ i = j + 1 # skip past the \n
224
+
225
+ # Build the map: norm_idx -> orig_idx for surviving characters.
226
+ norm_to_orig: list[int] = []
227
+ for orig_idx in range(n):
228
+ if not stripped[orig_idx]:
229
+ norm_to_orig.append(orig_idx)
230
+
231
+ return norm_to_orig
232
+
233
+
234
+ def _fuzzy_replace_first(original: str, norm_original: str, norm_old: str, new: str) -> str:
235
+ """Replace the first occurrence of norm_old in the original string.
236
+
237
+ Uses the normalised strings to locate the span, then maps the normalised
238
+ positions back to the original content positions.
239
+ """
240
+ norm_idx = norm_original.find(norm_old)
241
+ if norm_idx == -1:
242
+ return original
243
+
244
+ norm_end = norm_idx + len(norm_old)
245
+ norm_to_orig = _build_norm_to_orig_map(original)
246
+
247
+ # Map normalised span to original span.
248
+ orig_start = norm_to_orig[norm_idx]
249
+ # norm_end may equal len(norm_original) when the match is at the very end.
250
+ if norm_end < len(norm_to_orig):
251
+ orig_end = norm_to_orig[norm_end]
252
+ else:
253
+ orig_end = len(original)
254
+
255
+ return original[:orig_start] + new + original[orig_end:]
256
+
257
+
258
+ def _fuzzy_replace_all(original: str, norm_original: str, norm_old: str, new: str) -> str:
259
+ """Replace all occurrences of norm_old in the original string."""
260
+ norm_to_orig = _build_norm_to_orig_map(original)
261
+ old_len = len(norm_old)
262
+
263
+ result_parts: list[str] = []
264
+ search_start_norm = 0
265
+ search_start_orig = 0
266
+
267
+ while True:
268
+ idx = norm_original.find(norm_old, search_start_norm)
269
+ if idx == -1:
270
+ result_parts.append(original[search_start_orig:])
271
+ break
272
+
273
+ norm_end = idx + old_len
274
+ orig_start = norm_to_orig[idx]
275
+ orig_end = norm_to_orig[norm_end] if norm_end < len(norm_to_orig) else len(original)
276
+
277
+ result_parts.append(original[search_start_orig:orig_start])
278
+ result_parts.append(new)
279
+ search_start_norm = norm_end
280
+ search_start_orig = orig_end
281
+
282
+ return "".join(result_parts)