kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/hooks.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Run user-configured shell commands on lifecycle events.
|
|
2
|
+
|
|
3
|
+
Commands live under the ``"hooks"`` block of the config, keyed by event name
|
|
4
|
+
(see :data:`kiwimatecoder.config.HOOK_EVENTS`). Each command runs with
|
|
5
|
+
``shell=True`` in the session workspace and receives context through the
|
|
6
|
+
environment (``KIWI_EVENT``, ``KIWI_WORKSPACE``, and for tool events
|
|
7
|
+
``KIWI_TOOL_NAME`` / ``KIWI_TOOL_OK`` / ``KIWI_TOOL_ARGS``).
|
|
8
|
+
|
|
9
|
+
Hooks are deliberately best-effort: a timeout, a non-zero exit, or a broken
|
|
10
|
+
command is reported as a :class:`HookResult` and never raises into the caller.
|
|
11
|
+
``pre_tool`` hook failures are surfaced via :attr:`HookResult.blocked` so the
|
|
12
|
+
agent can stop the action.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.markup import escape
|
|
26
|
+
|
|
27
|
+
from kiwimatecoder import config
|
|
28
|
+
from kiwimatecoder.redaction import redact
|
|
29
|
+
|
|
30
|
+
HOOK_TIMEOUT_SECONDS = 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class HookResult:
|
|
35
|
+
"""Outcome of running one configured hook command."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
command: str
|
|
39
|
+
exit_code: int
|
|
40
|
+
output: str
|
|
41
|
+
timed_out: bool = False
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return self.exit_code == 0 and not self.timed_out
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def blocked(self) -> bool:
|
|
49
|
+
"""Whether this result should stop the action (``pre_tool`` hooks)."""
|
|
50
|
+
return not self.ok
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _workspace(session: Any) -> Path:
|
|
54
|
+
root = getattr(session, "workspace_root", None)
|
|
55
|
+
return Path(root) if root is not None else Path.cwd()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _as_text(value: Any) -> str:
|
|
59
|
+
if value is None:
|
|
60
|
+
return ""
|
|
61
|
+
if isinstance(value, bytes):
|
|
62
|
+
return value.decode("utf-8", "replace")
|
|
63
|
+
return str(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _combine_output(stdout: Any, stderr: Any) -> str:
|
|
67
|
+
return _as_text(stdout) + _as_text(stderr)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hook_environment(
|
|
71
|
+
event: str,
|
|
72
|
+
workspace: Path,
|
|
73
|
+
tool_name: str,
|
|
74
|
+
tool_args: dict[str, Any] | None,
|
|
75
|
+
ok: bool | None,
|
|
76
|
+
duration_ms: int | None,
|
|
77
|
+
) -> dict[str, str]:
|
|
78
|
+
env = dict(os.environ)
|
|
79
|
+
env["KIWI_EVENT"] = event
|
|
80
|
+
env["KIWI_WORKSPACE"] = str(workspace)
|
|
81
|
+
if tool_name:
|
|
82
|
+
env["KIWI_TOOL_NAME"] = str(tool_name)
|
|
83
|
+
if ok is not None:
|
|
84
|
+
env["KIWI_TOOL_OK"] = "true" if ok else "false"
|
|
85
|
+
if tool_args is not None:
|
|
86
|
+
try:
|
|
87
|
+
encoded = json.dumps(tool_args, default=str, ensure_ascii=False)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
encoded = str(tool_args)
|
|
90
|
+
env["KIWI_TOOL_ARGS"] = redact(encoded)
|
|
91
|
+
if duration_ms is not None:
|
|
92
|
+
env["KIWI_TOOL_DURATION_MS"] = str(duration_ms)
|
|
93
|
+
return env
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_one(
|
|
97
|
+
event: str,
|
|
98
|
+
command: str,
|
|
99
|
+
workspace: Path,
|
|
100
|
+
env: dict[str, str],
|
|
101
|
+
console: Console | None,
|
|
102
|
+
) -> HookResult:
|
|
103
|
+
try:
|
|
104
|
+
completed = subprocess.run(
|
|
105
|
+
command,
|
|
106
|
+
shell=True,
|
|
107
|
+
cwd=str(workspace),
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=HOOK_TIMEOUT_SECONDS,
|
|
111
|
+
env=env,
|
|
112
|
+
)
|
|
113
|
+
result = HookResult(
|
|
114
|
+
name=event,
|
|
115
|
+
command=command,
|
|
116
|
+
exit_code=completed.returncode,
|
|
117
|
+
output=_combine_output(completed.stdout, completed.stderr),
|
|
118
|
+
)
|
|
119
|
+
except subprocess.TimeoutExpired as exc:
|
|
120
|
+
output = _combine_output(exc.stdout, exc.stderr)
|
|
121
|
+
result = HookResult(
|
|
122
|
+
name=event,
|
|
123
|
+
command=command,
|
|
124
|
+
exit_code=-1,
|
|
125
|
+
output=output or f"timed out after {HOOK_TIMEOUT_SECONDS}s",
|
|
126
|
+
timed_out=True,
|
|
127
|
+
)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
result = HookResult(
|
|
130
|
+
name=event,
|
|
131
|
+
command=command,
|
|
132
|
+
exit_code=-1,
|
|
133
|
+
output=f"could not run hook: {exc}",
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc: # defensive: a hook must never break the caller
|
|
136
|
+
result = HookResult(
|
|
137
|
+
name=event,
|
|
138
|
+
command=command,
|
|
139
|
+
exit_code=-1,
|
|
140
|
+
output=f"hook crashed: {exc!r}",
|
|
141
|
+
)
|
|
142
|
+
if console is not None:
|
|
143
|
+
status = "ok" if result.ok else f"exit {result.exit_code}"
|
|
144
|
+
console.print(
|
|
145
|
+
f"[dim]hook {escape(event)} \\[{status}]: {escape(redact(command))}[/dim]"
|
|
146
|
+
)
|
|
147
|
+
output = result.output.strip()
|
|
148
|
+
if output:
|
|
149
|
+
console.print(redact(output), style="dim", markup=False, highlight=False)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_hooks(
|
|
154
|
+
event: str,
|
|
155
|
+
*,
|
|
156
|
+
session: Any = None,
|
|
157
|
+
console: Console | None = None,
|
|
158
|
+
tool_name: str = "",
|
|
159
|
+
tool_args: dict[str, Any] | None = None,
|
|
160
|
+
ok: bool | None = None,
|
|
161
|
+
duration_ms: int | None = None,
|
|
162
|
+
cfg: dict[str, Any] | None = None,
|
|
163
|
+
) -> list[HookResult]:
|
|
164
|
+
"""Run every command configured for ``event``; never raises."""
|
|
165
|
+
commands = config.get_hooks(cfg).get(event, [])
|
|
166
|
+
if not commands:
|
|
167
|
+
return []
|
|
168
|
+
workspace = _workspace(session)
|
|
169
|
+
env = _hook_environment(event, workspace, tool_name, tool_args, ok, duration_ms)
|
|
170
|
+
return [_run_one(event, command, workspace, env, console) for command in commands]
|
kiwimatecoder/hunks.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Pure helpers for parsing unified diffs and applying hunk selections.
|
|
2
|
+
|
|
3
|
+
The approval UI can offer per-hunk review of a diff; this module turns a diff
|
|
4
|
+
into :class:`Hunk` records and rebuilds file content from only the selected
|
|
5
|
+
hunks. It performs no I/O and never touches the filesystem.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
16
|
+
|
|
17
|
+
# The body of a hunk is built from these prefix characters; ``\`` marks a GNU
|
|
18
|
+
# "no newline at end of file" line.
|
|
19
|
+
_BODY_PREFIXES = (" ", "+", "-", "\\")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Hunk:
|
|
24
|
+
"""One ``@@`` hunk of a unified diff."""
|
|
25
|
+
|
|
26
|
+
index: int
|
|
27
|
+
header: str
|
|
28
|
+
lines: tuple[str, ...]
|
|
29
|
+
old_start: int
|
|
30
|
+
old_count: int
|
|
31
|
+
new_start: int
|
|
32
|
+
new_count: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def split_hunks(diff_text: str) -> list[Hunk]:
|
|
36
|
+
"""Parse ``diff_text`` into 1-based-indexed hunks.
|
|
37
|
+
|
|
38
|
+
Returns an empty list when the text carries no parseable unified-diff
|
|
39
|
+
hunks (for example the ``+++ create`` preview used for new files).
|
|
40
|
+
"""
|
|
41
|
+
hunks: list[Hunk] = []
|
|
42
|
+
body: list[str] = []
|
|
43
|
+
current: tuple[str, int, int, int, int] | None = None
|
|
44
|
+
|
|
45
|
+
def flush() -> None:
|
|
46
|
+
nonlocal current, body
|
|
47
|
+
if current is None:
|
|
48
|
+
return
|
|
49
|
+
header, old_start, old_count, new_start, new_count = current
|
|
50
|
+
hunks.append(
|
|
51
|
+
Hunk(
|
|
52
|
+
index=len(hunks) + 1,
|
|
53
|
+
header=header,
|
|
54
|
+
lines=tuple(body),
|
|
55
|
+
old_start=old_start,
|
|
56
|
+
old_count=old_count,
|
|
57
|
+
new_start=new_start,
|
|
58
|
+
new_count=new_count,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
body = []
|
|
62
|
+
|
|
63
|
+
for line in diff_text.splitlines(keepends=True):
|
|
64
|
+
match = _HEADER_RE.match(line)
|
|
65
|
+
if match:
|
|
66
|
+
flush()
|
|
67
|
+
current = (
|
|
68
|
+
line.rstrip("\n"),
|
|
69
|
+
int(match.group(1)),
|
|
70
|
+
int(match.group(2) or 1),
|
|
71
|
+
int(match.group(3)),
|
|
72
|
+
int(match.group(4) or 1),
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
if current is not None and line[:1] in _BODY_PREFIXES:
|
|
76
|
+
body.append(line)
|
|
77
|
+
flush()
|
|
78
|
+
return hunks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_body(
|
|
82
|
+
body: Sequence[str],
|
|
83
|
+
old_lines: Sequence[str],
|
|
84
|
+
old_index: int,
|
|
85
|
+
old_count: int,
|
|
86
|
+
) -> list[tuple[str, str]] | None:
|
|
87
|
+
"""Split physical body lines into ``(prefix, content)`` pairs.
|
|
88
|
+
|
|
89
|
+
``difflib`` does not always terminate a diff line with a newline, so a
|
|
90
|
+
physical line can glue several logical segments together (for example
|
|
91
|
+
``-old+new`` at end of file). Context and deletion segments are anchored
|
|
92
|
+
against the original lines, which disambiguates glued additions.
|
|
93
|
+
"""
|
|
94
|
+
out: list[tuple[str, str]] = []
|
|
95
|
+
|
|
96
|
+
def walk(line_no: int, position: int, old_i: int) -> int | None:
|
|
97
|
+
if line_no == len(body):
|
|
98
|
+
return old_i
|
|
99
|
+
raw = body[line_no]
|
|
100
|
+
if position == len(raw):
|
|
101
|
+
return walk(line_no + 1, 0, old_i)
|
|
102
|
+
char = raw[position]
|
|
103
|
+
if char == "\n":
|
|
104
|
+
return walk(line_no, position + 1, old_i)
|
|
105
|
+
if char in (" ", "-"):
|
|
106
|
+
if old_i >= len(old_lines):
|
|
107
|
+
return None
|
|
108
|
+
expected = old_lines[old_i]
|
|
109
|
+
end = position + 1 + len(expected)
|
|
110
|
+
if raw[position + 1 : end] != expected:
|
|
111
|
+
return None
|
|
112
|
+
out.append((char, expected))
|
|
113
|
+
result = walk(line_no, end, old_i + 1)
|
|
114
|
+
if result is not None:
|
|
115
|
+
return result
|
|
116
|
+
out.pop()
|
|
117
|
+
return None
|
|
118
|
+
if char == "+":
|
|
119
|
+
candidates = [len(raw)]
|
|
120
|
+
for candidate in range(position + 1, len(raw)):
|
|
121
|
+
if raw[candidate] not in (" ", "-") or old_i >= len(old_lines):
|
|
122
|
+
continue
|
|
123
|
+
expected = old_lines[old_i]
|
|
124
|
+
end = candidate + 1 + len(expected)
|
|
125
|
+
if raw[candidate + 1 : end] == expected:
|
|
126
|
+
candidates.append(candidate)
|
|
127
|
+
for candidate in sorted(set(candidates), reverse=True):
|
|
128
|
+
out.append(("+", raw[position + 1 : candidate]))
|
|
129
|
+
result = walk(line_no, candidate, old_i)
|
|
130
|
+
if result is not None:
|
|
131
|
+
return result
|
|
132
|
+
out.pop()
|
|
133
|
+
return None
|
|
134
|
+
if char == "\\":
|
|
135
|
+
if out:
|
|
136
|
+
prefix, content = out[-1]
|
|
137
|
+
out[-1] = (prefix, content.rstrip("\n"))
|
|
138
|
+
result = walk(line_no, len(raw), old_i)
|
|
139
|
+
if result is not None:
|
|
140
|
+
return result
|
|
141
|
+
out[-1] = (prefix, content)
|
|
142
|
+
return None
|
|
143
|
+
return walk(line_no, len(raw), old_i)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
consumed = walk(0, 0, old_index)
|
|
147
|
+
if consumed is None or consumed != old_index + old_count:
|
|
148
|
+
return None
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _naive_segments(body: Sequence[str]) -> list[tuple[str, str]]:
|
|
153
|
+
"""Best-effort parse for diffs whose old lines are not consistent."""
|
|
154
|
+
segments: list[tuple[str, str]] = []
|
|
155
|
+
for line in body:
|
|
156
|
+
if line.startswith("\\"):
|
|
157
|
+
continue
|
|
158
|
+
if line[:1] in (" ", "+", "-"):
|
|
159
|
+
segments.append((line[:1], line[1:]))
|
|
160
|
+
return segments
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _creation_target(diff_text: str) -> str | None:
|
|
164
|
+
"""Rebuild the content targeted by a ``+++ create`` preview, if any."""
|
|
165
|
+
created = False
|
|
166
|
+
lines: list[str] = []
|
|
167
|
+
for line in diff_text.splitlines():
|
|
168
|
+
if line.startswith("+++ create "):
|
|
169
|
+
created = True
|
|
170
|
+
continue
|
|
171
|
+
if created and line.startswith("+"):
|
|
172
|
+
lines.append(line[1:])
|
|
173
|
+
if not created:
|
|
174
|
+
return None
|
|
175
|
+
return "\n".join(lines)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def apply_selected_hunks(
|
|
179
|
+
old_text: str,
|
|
180
|
+
diff_text: str,
|
|
181
|
+
selected: Sequence[int] | None,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Return ``old_text`` with only the selected 1-based hunks applied.
|
|
184
|
+
|
|
185
|
+
``selected=None`` applies every hunk. When the diff has no parseable hunks
|
|
186
|
+
(such as a ``+++ create`` preview) any non-empty selection (or ``None``)
|
|
187
|
+
yields the target content and an empty selection yields ``old_text``.
|
|
188
|
+
"""
|
|
189
|
+
old_lines = old_text.splitlines(keepends=True)
|
|
190
|
+
parsed = split_hunks(diff_text)
|
|
191
|
+
if not parsed:
|
|
192
|
+
if selected is None or len(selected) > 0:
|
|
193
|
+
target = _creation_target(diff_text)
|
|
194
|
+
if target is not None:
|
|
195
|
+
return target
|
|
196
|
+
return old_text
|
|
197
|
+
|
|
198
|
+
chosen = None if selected is None else set(selected)
|
|
199
|
+
result: list[str] = []
|
|
200
|
+
cursor = 0
|
|
201
|
+
for hunk in parsed:
|
|
202
|
+
start = max(cursor, hunk.old_start - 1)
|
|
203
|
+
if start > cursor:
|
|
204
|
+
result.extend(old_lines[cursor:start])
|
|
205
|
+
cursor = start
|
|
206
|
+
selected_hunk = chosen is None or hunk.index in chosen
|
|
207
|
+
if selected_hunk:
|
|
208
|
+
segments = _parse_body(hunk.lines, old_lines, cursor, hunk.old_count)
|
|
209
|
+
if segments is None:
|
|
210
|
+
segments = _naive_segments(hunk.lines)
|
|
211
|
+
result.extend(
|
|
212
|
+
content for prefix, content in segments if prefix in (" ", "+")
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
result.extend(old_lines[cursor : cursor + hunk.old_count])
|
|
216
|
+
cursor += hunk.old_count
|
|
217
|
+
result.extend(old_lines[cursor:])
|
|
218
|
+
return "".join(result)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
HunkSelection = tuple[int, ...] | Literal["all", "none"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_hunk_selection(answer: str, count: int) -> HunkSelection | None:
|
|
225
|
+
"""Parse a user's hunk selection.
|
|
226
|
+
|
|
227
|
+
Accepts ``all``/``none``, comma- or space-separated indices (``1,3``) and
|
|
228
|
+
inclusive ranges (``1-2``). Returns ``None`` for an invalid answer.
|
|
229
|
+
"""
|
|
230
|
+
text = answer.strip().lower()
|
|
231
|
+
if not text:
|
|
232
|
+
return None
|
|
233
|
+
if text in ("all", "a", "*"):
|
|
234
|
+
return "all"
|
|
235
|
+
if text in ("none", "n"):
|
|
236
|
+
return "none"
|
|
237
|
+
|
|
238
|
+
selected: set[int] = set()
|
|
239
|
+
for part in re.split(r"[,\s]+", text):
|
|
240
|
+
if not part:
|
|
241
|
+
continue
|
|
242
|
+
if "-" in part:
|
|
243
|
+
pieces = part.split("-")
|
|
244
|
+
if len(pieces) != 2 or not all(piece.isdigit() for piece in pieces):
|
|
245
|
+
return None
|
|
246
|
+
low, high = int(pieces[0]), int(pieces[1])
|
|
247
|
+
if low > high:
|
|
248
|
+
return None
|
|
249
|
+
candidates: Sequence[int] = range(low, high + 1)
|
|
250
|
+
elif part.isdigit():
|
|
251
|
+
candidates = (int(part),)
|
|
252
|
+
else:
|
|
253
|
+
return None
|
|
254
|
+
for index in candidates:
|
|
255
|
+
if not 1 <= index <= count:
|
|
256
|
+
return None
|
|
257
|
+
selected.add(index)
|
|
258
|
+
if not selected:
|
|
259
|
+
return None
|
|
260
|
+
return tuple(sorted(selected))
|