okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnfiles: paths globs from the search root without rg's ./ prefix, and returns paths without itf5b76fe

main
32.4 KiB947 linesraw
1"""Bounded reads, exact edits, and ripgrep-backed search over workspace files.
2
3Every external command runs through the supervised `run` job plugin, so this
4module never spawns a process of its own and every child stays in a job's group.
5"""
6
7from __future__ import annotations
8
9import asyncio
10import codecs
11import difflib
12import fnmatch
13import json
14import os
15import re
16import shlex
17import stat
18import tempfile
19from dataclasses import dataclass
20from io import BufferedReader
21from pathlib import Path
22from collections.abc import Awaitable, Callable, Generator
23from typing import Generic, Iterable, Sequence, TypeVar
24
25from albedo_api import PythonApi, ReadyList, Text
26import albedo_trace
27from albedo_plugins import run as jobs
28
29READ_LIMIT = 16_000
30READ_CHUNK_BYTES = 64 * 1024
31LINE_SEPARATORS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029"
32LINE_BOUNDARIES = re.compile(rf"\r\n|[{re.escape(LINE_SEPARATORS)}]")
33SEARCH_TIMEOUT = 30
34LINE_WIDTH = 160
35CANDIDATES = 4
36LISTED = 10
37CONTEXT = 2
38PREVIEW_LINES = 2
39WITHHELD = 3
40
41
42@dataclass(frozen=True)
43class Match:
44    """One matching line, or with `context=` a surrounding line that prints
45    grep-style as `path-line- text`."""
46
47    path: str
48    line: int
49    text: str
50    context: bool = False
51
52    def __str__(self) -> str:
53        mark = "-" if self.context else ":"
54        return f"{self.path}{mark}{self.line}{mark} {self.text}"
55
56    def to_dict(self) -> dict[str, str | int | bool]:
57        fields: dict[str, str | int | bool] = {
58            "path": self.path,
59            "line": self.line,
60            "text": self.text,
61        }
62        if self.context:
63            fields["context"] = True
64        return fields
65
66
67class Rows(ReadyList):
68    """A list that prints one row per line, so a REPL result reads like output.
69    Like Text, it may be awaited or used directly."""
70
71    def __init__(
72        self, items: Iterable[object] = (), truncated: bool = False, note: str = ""
73    ) -> None:
74        super().__init__(items)
75        self.truncated = truncated
76        self.note = note
77
78    def __repr__(self) -> str:
79        if not self:
80            return f"[]\n{self.note}" if self.note else "[]"
81        body = "\n".join(str(item) for item in self)
82        return body + ("\n[truncated]" if self.truncated else "")
83
84    __str__ = __repr__
85
86
87Result = TypeVar("Result")
88
89
90class Search(Generic[Result]):
91    """Work that runs a supervised job, so its result exists only once awaited:
92    `Search[Rows]` awaits to Rows. Using it without `await` explains that
93    instead of printing a coroutine."""
94
95    def __init__(
96        self, call: str, run: Callable[[], Awaitable[Result]], result: str = "rows"
97    ) -> None:
98        self._call = call
99        self._run = run
100        self._result = result
101
102    def __await__(self) -> Generator[object, None, Result]:
103        return self._run().__await__()
104
105    def __getitem__(self: Search[Rows], index: int | slice) -> Search[object]:
106        """`await files.find(...)[:10]` slices before it awaits; apply the
107        slice to the rows instead of failing on precedence."""
108
109        async def run() -> object:
110            rows = await self._run()
111            picked = rows[index]
112            if isinstance(rows, Rows) and isinstance(index, slice):
113                return Rows(picked, truncated=rows.truncated, note=rows.note)
114            return picked
115
116        return Search(self._call, run, self._result)
117
118    def _unawaited(self) -> TypeError:
119        return TypeError(
120            f"{self._call}(...) runs in the background; "
121            f"use `await {self._call}(...)` to get its {self._result}"
122        )
123
124    def __repr__(self) -> str:
125        return f"<{self._call}(...) has not run: use `await {self._call}(...)` for its {self._result}>"
126
127    __str__ = __repr__
128
129    def __iter__(self):
130        raise self._unawaited()
131
132    def __len__(self) -> int:
133        raise self._unawaited()
134
135
136async def _run(command: str) -> tuple[int | None, str]:
137    """One supervised shell job, awaited to completion, with its bounded output.
138    The plugin's own work, so neither traced as the cell's command nor refused."""
139    job = jobs.start(["/bin/sh", "-c", command], SEARCH_TIMEOUT, traced=False)
140    try:
141        await job
142        return job.exit_code, job.tail(jobs.preview_limit)
143    except asyncio.CancelledError:
144        await job.stop()
145        raise
146    finally:
147        if job.exit_code is not None or job.timed_out or job.termination is not None:
148            jobs.forget(job)
149
150
151def _numbered(number: int, lines: Sequence[str]) -> str:
152    text = lines[number - 1] if 0 < number <= len(lines) else ""
153    return f"{number:>6} | {text[:LINE_WIDTH]}"
154
155
156class _ReadWindow:
157    """Track selected lines, the current line, and the first rejected row."""
158
159    def __init__(self, start_line: int, last_line: int | None, max_chars: int):
160        self.start_line = start_line
161        self.last_line = last_line
162        self.max_chars = max_chars
163        self.lines: list[str] = []
164        self.used_chars = 0
165        self.stopped_line: int | None = None
166        self.stopped_row_chars = 0
167        self.number = 1
168        self.line_chars = 0
169        self.fragments: list[str] = []
170
171    def wants_line(self) -> bool:
172        return (
173            self.stopped_line is None
174            and self.number >= self.start_line
175            and (self.last_line is None or self.number <= self.last_line)
176        )
177
178    def extend(self, text: str, start: int, end: int) -> None:
179        self.line_chars += end - start
180        if self.wants_line():
181            row_chars = max(6, len(str(self.number))) + 3 + self.line_chars
182            if self.used_chars + row_chars + 1 <= self.max_chars:
183                if start < end:
184                    self.fragments.append(text[start:end])
185            else:
186                # Keep counting a giant line, but release its retained prefix.
187                self.fragments.clear()
188
189    def finish_line(self) -> None:
190        if self.wants_line():
191            row_chars = max(6, len(str(self.number))) + 3 + self.line_chars
192            if self.used_chars + row_chars + 1 > self.max_chars:
193                self.stopped_line = self.number
194                self.stopped_row_chars = row_chars
195            else:
196                self.lines.append("".join(self.fragments))
197                self.used_chars += row_chars + 1
198        self.number += 1
199        self.line_chars = 0
200        self.fragments.clear()
201
202
203def _scan_read(
204    source: BufferedReader, window: _ReadWindow, end_line: int | None
205) -> int:
206    chunk = source.read(READ_CHUNK_BYTES)
207    at_eof = len(chunk) < READ_CHUNK_BYTES or not source.peek(1)
208    text = chunk.decode("utf-8", errors="replace") if at_eof else ""
209    # Native splitting is cheap for a bounded ASCII file. Unicode uses spans
210    # instead, so dense replacement characters never build a list of substrings.
211    if at_eof and text.isascii():
212        lines = text.splitlines()
213        total = len(lines)
214        last = total if window.last_line is None else min(total, window.last_line)
215        window.lines = lines[window.start_line - 1 : last]
216        return total
217
218    decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
219    if not at_eof:
220        text = decoder.decode(chunk)
221    skip_lf = False
222    while True:
223        if skip_lf and text.startswith("\n"):
224            text = text[1:]
225        separator_count = 0
226        if any(separator in text for separator in LINE_SEPARATORS):
227            separator_count = sum(
228                text.count(separator) for separator in LINE_SEPARATORS
229            )
230            separator_count -= text.count("\r\n")
231        window_complete = window.stopped_line is not None or (
232            window.last_line is not None and window.number > window.last_line
233        )
234        if not separator_count:
235            window.extend(text, 0, len(text))
236        elif window_complete or window.number + separator_count < window.start_line:
237            window.number += separator_count
238            if end_line is not None and window.number > end_line:
239                return end_line
240            # Skipped lines need only an unfinished-line flag for EOF.
241            # A requested line begins after a separator resets this state.
242            window.line_chars = int(text[-1] not in LINE_SEPARATORS)
243        else:
244            position = 0
245            for boundary in LINE_BOUNDARIES.finditer(text):
246                window.extend(text, position, boundary.start())
247                window.finish_line()
248                if end_line is not None and window.number > end_line:
249                    return end_line
250                position = boundary.end()
251            window.extend(text, position, len(text))
252        skip_lf = text.endswith("\r")
253        if at_eof:
254            if window.line_chars:
255                window.finish_line()
256            return window.number - 1
257        chunk = source.read(READ_CHUNK_BYTES)
258        at_eof = not chunk
259        text = decoder.decode(chunk, final=at_eof)
260
261
262class Files:
263    """Workspace file access with the diagnostics an exact edit needs."""
264
265    def read(
266        self,
267        path: str,
268        start_line: int = 1,
269        end_line: int | None = None,
270        *,
271        limit: int | None = None,
272        max_chars: int = READ_LIMIT,
273    ) -> Text:
274        """Numbered lines from start_line through end_line, at most `limit` lines.
275
276        `limit` counts lines. `max_chars` is a separate character budget (at
277        most 200000) that keeps a huge window from flooding the context; when it
278        stops a read early, the result says so and names the line to resume
279        from. Lines are never shortened. The numbers are what `edit(line_hint=)`
280        takes.
281        """
282        if (
283            start_line < 1
284            or (end_line is not None and end_line < start_line)
285            or (limit is not None and limit < 1)
286            or not 0 < max_chars <= 200_000
287        ):
288            raise ValueError(
289                "start_line >= 1, end_line >= start_line, limit >= 1 line, "
290                "0 < max_chars <= 200000"
291            )
292        target = Path(path).expanduser()
293        if not target.is_file():
294            if target.is_dir():
295                raise IsADirectoryError(
296                    f"{path} is a directory; files.ls({path!r}) lists it"
297                )
298            raise FileNotFoundError(_missing(target, path))
299        last = end_line
300        if limit is not None:
301            limited_last = start_line + limit - 1
302            last = limited_last if last is None else min(last, limited_last)
303        window = _ReadWindow(start_line, last, max_chars)
304        with target.open("rb") as source:
305            total = _scan_read(source, window, end_line)
306        if start_line > total:
307            return Text(f"[{path} has {total} lines; nothing at line {start_line}]")
308        requested = total if end_line is None else min(end_line, total)
309        last = requested if last is None else min(last, requested)
310        body, used = window.lines, 0
311        number = window.stopped_line
312        row_chars = window.stopped_row_chars
313        for current, text in enumerate(body, start_line):
314            row = f"{current:>6} | {text}"
315            if used + len(row) + 1 > max_chars:
316                number, row_chars = current, len(row)
317                del body[current - start_line :]
318                break
319            used += len(row) + 1
320            body[current - start_line] = row
321        if number is not None:
322            if not body:
323                retry = (
324                    f"read it alone with start_line={number}, end_line={number}, max_chars={row_chars + 1}"
325                    if row_chars + 1 <= 200_000
326                    else "it exceeds the 200000-character maximum"
327                )
328                body.append(
329                    f"[line {number} is {row_chars} characters with its number, over "
330                    f"max_chars={max_chars}; {retry}]"
331                )
332            else:
333                body.append(
334                    f"[stopped at max_chars={max_chars} characters; lines {number}-{last} "
335                    f"not shown; read again with start_line={number}, or raise max_chars]"
336                )
337        elif last < requested:
338            body.append(
339                f"[limit={limit} lines reached; {requested - last} more through line "
340                f"{requested}; read again with start_line={last + 1}]"
341            )
342        result = "\n".join(body)
343        # Release numbered rows before Text copies the joined Unicode buffer.
344        body.clear()
345        return Text(result)
346
347    def ls(
348        self, path: str = ".", pattern: str | None = None, *, hidden: bool = False
349    ) -> Rows:
350        """One directory, directories suffixed with `/`."""
351        directory = Path(path).expanduser()
352        entries = []
353        for entry in sorted(directory.iterdir(), key=lambda item: item.name):
354            if not hidden and entry.name.startswith("."):
355                continue
356            if pattern and not fnmatch.fnmatch(entry.name, pattern):
357                continue
358            entries.append(entry.name + ("/" if entry.is_dir() else ""))
359        return Rows(entries)
360
361    def find(
362        self,
363        pattern: str,
364        path: str | Sequence[str] = ".",
365        *,
366        glob: str | Sequence[str] | None = None,
367        context: int = 0,
368        max_results: int = 50,
369        literal: bool = False,
370        case_sensitive: bool | None = None,
371        hidden: bool = False,
372        ignored: bool = False,
373    ) -> Search[Rows]:
374        """Content search through ripgrep when it is installed, else pure Python.
375        `path` may be one path or a list; `context=N` adds N lines around each
376        match. A missing path raises. Ignore files and hidden files are skipped
377        unless `ignored=True` / `hidden=True`; an empty result says what was
378        searched and skipped. Await it: `await files.find(pattern)`."""
379        if not 0 <= context <= 50:
380            raise ValueError("0 <= context <= 50")
381        return Search(
382            "files.find",
383            lambda: self._find(
384                pattern,
385                path,
386                glob,
387                context,
388                max_results,
389                literal,
390                case_sensitive,
391                hidden,
392                ignored,
393            ),
394        )
395
396    async def _find(
397        self,
398        pattern: str,
399        path: str | Sequence[str],
400        glob: str | Sequence[str] | None,
401        context: int,
402        max_results: int,
403        literal: bool,
404        case_sensitive: bool | None,
405        hidden: bool,
406        ignored: bool,
407    ) -> Rows:
408        albedo_trace.note("search", pattern)
409        targets = _targets(path)
410        _require_roots(targets)
411        if not _which("rg"):
412            return _fallback_find(
413                pattern,
414                targets,
415                glob,
416                context,
417                max_results,
418                literal,
419                case_sensitive,
420                hidden,
421            )
422        flags = []
423        if literal:
424            flags.append("-F")
425        if case_sensitive is True:
426            flags.append("-s")
427        elif case_sensitive is False:
428            flags.append("-i")
429        for value in _globs(glob):
430            flags += ["-g", value]
431        visible = [
432            *(["--hidden"] if hidden else []),
433            *(["--no-ignore"] if ignored else []),
434        ]
435
436        def search(*extra: str) -> str:
437            return " ".join(
438                shlex.quote(part)
439                for part in ["rg", *flags, *extra, "-e", pattern, *map(str, targets)]
440            )
441
442        per_match = 4 + 2 * context
443        status, output = await _run(
444            f"{search('--json', *visible, *(['-C', str(context)] if context else []))}"
445            f" | head -n {max(1, max_results) * per_match}"
446        )
447        if status not in (0, 1, None) and not output.strip():
448            raise RuntimeError(f"search failed: {output.strip()[:400]}")
449        results, matched, searched = [], 0, 0
450        for line in output.splitlines():
451            try:
452                event = json.loads(line)
453            except ValueError:
454                continue
455            kind = event.get("type")
456            if kind == "summary":
457                searched = event["data"]["stats"]["searches"]
458            if kind not in ("match", "context"):
459                continue
460            if kind == "match" and matched >= max_results:
461                return Rows(results, truncated=True)
462            data = event["data"]
463            results.append(
464                Match(
465                    data["path"]["text"],
466                    data["line_number"],
467                    data["lines"]["text"].rstrip("\r\n")[: LINE_WIDTH * 4],
468                    context=kind == "context",
469                )
470            )
471            matched += kind == "match"
472        if results:
473            return Rows(results)
474        skipped = [
475            *([] if hidden else ["hidden files"]),
476            *([] if ignored else [".gitignore/.ignore rules"]),
477        ]
478        withheld: list[str] = []
479        if skipped:
480            _, listing = await _run(
481                f"{search('-l', '--hidden', '--no-ignore')} | head -n {WITHHELD}"
482            )
483            withheld = listing.splitlines()
484        return Rows(
485            note=_empty_note(pattern, targets, searched, skipped, glob, withheld)
486        )
487
488    def paths(
489        self,
490        pattern: str | None = None,
491        path: str | Sequence[str] = ".",
492        *,
493        glob: str | Sequence[str] | None = None,
494        max_results: int = 100,
495        hidden: bool = False,
496    ) -> Search[Rows]:
497        """File names, not contents; the same ripgrep-or-Python split. A pattern
498        with *, ? or [ is a glob over names, or over the path from the search
499        root when it has a /; other text matches anywhere in the path.
500        `path` may be one path or a list. Await it: `await files.paths(pattern)`."""
501        return Search(
502            "files.paths", lambda: self._paths(pattern, path, glob, max_results, hidden)
503        )
504
505    async def _paths(
506        self,
507        pattern: str | None,
508        path: str | Sequence[str],
509        glob: str | Sequence[str] | None,
510        max_results: int,
511        hidden: bool,
512    ) -> Rows:
513        targets = _targets(path)
514        globs = _globs(glob)
515        if not _which("rg"):
516            return _fallback_paths(pattern, targets, globs, max_results, hidden)
517        flags = ["--files"]
518        if hidden:
519            flags.append("--hidden")
520        for value in globs:
521            flags += ["-g", value]
522        command = " ".join(
523            shlex.quote(part) for part in ["rg", *flags, *map(str, targets)]
524        )
525        _, output = await _run(command)
526        matches = _name_matcher(pattern)
527        # rg lists "./x" under ".", where a path from the root is just "x"
528        listed = (line.removeprefix("./") for line in output.splitlines() if line)
529        found = [line for line in listed if matches(line)]
530        return Rows(found[:max_results], truncated=len(found) > max_results)
531
532    def edit(
533        self, path: str, old_str: str, new_str: str, line_hint: int | None = None
534    ) -> Text:
535        """Replace one exact, unique string. A miss reports what is actually there."""
536        if not old_str:
537            raise ValueError("old_str must be non-empty")
538        if line_hint is not None and (
539            isinstance(line_hint, bool) or not isinstance(line_hint, int)
540        ):
541            raise ValueError(
542                f"line_hint must be a line number, not {type(line_hint).__name__}"
543            )
544        target = Path(path).expanduser()
545        if not target.exists():
546            raise FileNotFoundError(_missing(target, path))
547        target = target.resolve()
548        snapshot = _snapshot(target)
549        content = snapshot[0].decode("utf-8")
550        found = _occurrences(content, old_str)
551        if not found:
552            closest = _closest(content, old_str)
553            raise ValueError(
554                f"string not found in {path}"
555                + (
556                    "; the text is there, but whitespace differs (indentation, "
557                    "tabs, trailing spaces or line endings)"
558                    if _words(old_str) in _words(content) and _words(old_str)
559                    else ""
560                )
561                + (f"\n{closest}" if closest else "")
562            )
563        chosen = _choose(content, old_str, found, line_hint, path)
564        replacement = (
565            content[: chosen.index] + new_str + content[chosen.index + len(old_str) :]
566        ).encode("utf-8")
567        _replace(target, snapshot, replacement)
568        return Text(f"Edited {target}")
569
570    def write(self, path: str, content: str) -> Text:
571        """Create or replace a whole file. Use `edit` to change part of one."""
572        target = Path(path).expanduser()
573        target.parent.mkdir(parents=True, exist_ok=True)
574        target.write_text(content, encoding="utf-8")
575        return Text(f"Wrote {target.resolve()} ({len(content.encode())} bytes)")
576
577
578def _which(name: str) -> str | None:
579    for directory in os.environ.get("PATH", "").split(os.pathsep):
580        candidate = Path(directory) / name
581        if candidate.is_file() and os.access(candidate, os.X_OK):
582            return str(candidate)
583    return None
584
585
586def _require_roots(targets: list[Path]) -> None:
587    for target in targets:
588        if not target.exists():
589            raise FileNotFoundError(_missing(target, str(target)))
590
591
592def _empty_note(
593    pattern: str,
594    targets: list[Path],
595    searched: int,
596    skipped: list[str],
597    glob: str | Sequence[str] | None,
598    withheld: list[str],
599) -> str:
600    """Why a search came back empty: where it looked, what it left out, and
601    which skipped files would have matched."""
602    roots = ", ".join(str(target.resolve()) for target in targets)
603    left_out = [*skipped, *([f"files outside glob {_globs(glob)}"] if glob else [])]
604    note = f"no matches for {pattern!r} in {roots}: {searched} files searched"
605    if left_out:
606        note += f"; skipped {', '.join(left_out)}"
607    if withheld:
608        note += (
609            f"; it matches in skipped files ({', '.join(withheld)}): "
610            "rerun with hidden=True and/or ignored=True"
611        )
612    return note
613
614
615def _fallback_find(
616    pattern,
617    targets: list[Path],
618    glob,
619    context,
620    max_results,
621    literal,
622    case_sensitive,
623    hidden,
624) -> Rows:
625    rows, searched = _fallback_scan(
626        pattern, targets, glob, context, max_results, literal, case_sensitive, hidden
627    )
628    if rows:
629        return rows
630    withheld = []
631    if not hidden:
632        everything, _ = _fallback_scan(
633            pattern, targets, glob, 0, WITHHELD, literal, case_sensitive, True
634        )
635        withheld = [match.path for match in everything]
636    skipped = [] if hidden else ["hidden files"]
637    return Rows(note=_empty_note(pattern, targets, searched, skipped, glob, withheld))
638
639
640def _fallback_scan(
641    pattern,
642    targets: list[Path],
643    glob,
644    context,
645    max_results,
646    literal,
647    case_sensitive,
648    hidden,
649) -> tuple[Rows, int]:
650    flags = 0 if case_sensitive else re.IGNORECASE
651    matcher = re.compile(re.escape(pattern) if literal else pattern, flags)
652    globs = _globs(glob)
653    results, matched, searched = [], 0, 0
654    for target in targets:
655        for file in _walk(target, hidden):
656            if not _globbed(file, globs):
657                continue
658            try:
659                lines = file.read_text(encoding="utf-8", errors="replace").splitlines()
660            except OSError:
661                continue
662            searched += 1
663            hits = [
664                number for number, line in enumerate(lines, 1) if matcher.search(line)
665            ]
666            hit = set(hits)
667            shown = 0
668            for number in hits:
669                if matched >= max_results:
670                    return Rows(results, truncated=True), searched
671                for around in range(
672                    max(number - context, shown + 1),
673                    min(number + context, len(lines)) + 1,
674                ):
675                    results.append(
676                        Match(
677                            str(file),
678                            around,
679                            lines[around - 1][: LINE_WIDTH * 4],
680                            context=around not in hit,
681                        )
682                    )
683                    shown = around
684                matched += 1
685    return Rows(results), searched
686
687
688def _targets(path: str | Sequence[str]) -> list[Path]:
689    return [
690        Path(item).expanduser() for item in ([path] if isinstance(path, str) else path)
691    ]
692
693
694def _globs(glob: str | Sequence[str] | None) -> list[str]:
695    return [glob] if isinstance(glob, str) else list(glob or [])
696
697
698def _globbed(file: Path, globs: list[str]) -> bool:
699    """True when there is no glob, or one matches the path or the file name."""
700    return not globs or any(
701        fnmatch.fnmatch(str(file), value) or fnmatch.fnmatch(file.name, value)
702        for value in globs
703    )
704
705
706def _name_matcher(pattern: str | None) -> Callable[[str], bool]:
707    """A pattern with *, ? or [ is a glob over the file name (or the whole path
708    when it has a /); anything else matches as case-insensitive text anywhere
709    in the path, so "*.md" and "readme" both mean what they look like."""
710    if not pattern:
711        return lambda _path: True
712    folded = pattern.lower().removeprefix("./")
713    if any(char in pattern for char in "*?["):
714        return lambda path: fnmatch.fnmatch(
715            (path if "/" in pattern else Path(path).name).lower(), folded
716        )
717    return lambda path: folded in path.lower()
718
719
720def _fallback_paths(
721    pattern, targets: list[Path], globs: list[str], max_results, hidden
722) -> Rows:
723    matches = _name_matcher(pattern)
724    found = [
725        str(file)
726        for target in targets
727        for file in _walk(target, hidden)
728        if _globbed(file, globs) and matches(str(file))
729    ]
730    return Rows(found[:max_results], truncated=len(found) > max_results)
731
732
733def _walk(path: Path, hidden: bool):
734    if path.is_file():
735        yield path
736        return
737    for root, directories, names in os.walk(path):
738        if not hidden:
739            directories[:] = [name for name in directories if not name.startswith(".")]
740            names = [name for name in names if not name.startswith(".")]
741        for name in sorted(names):
742            yield Path(root) / name
743
744
745def _snapshot(target: Path) -> tuple[bytes, tuple[int, int, int, int, int]]:
746    """Bytes plus identity: an edit refuses to publish over a concurrent change."""
747    for _ in range(3):
748        before = target.stat()
749        data = target.read_bytes()
750        after = target.stat()
751        identity = (
752            after.st_dev,
753            after.st_ino,
754            stat.S_IMODE(after.st_mode),
755            after.st_size,
756            after.st_mtime_ns,
757        )
758        if identity == (
759            before.st_dev,
760            before.st_ino,
761            stat.S_IMODE(before.st_mode),
762            before.st_size,
763            before.st_mtime_ns,
764        ):
765            return data, identity
766    raise ValueError(f"file changed while reading {target}; refusing to edit it")
767
768
769def _replace(
770    target: Path,
771    snapshot: tuple[bytes, tuple[int, int, int, int, int]],
772    replacement: bytes,
773) -> None:
774    descriptor, name = tempfile.mkstemp(
775        dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
776    )
777    temporary = Path(name)
778    try:
779        with os.fdopen(descriptor, "wb") as stream:
780            os.fchmod(stream.fileno(), snapshot[1][2])
781            stream.write(replacement)
782            stream.flush()
783            os.fsync(stream.fileno())
784        if _snapshot(target) != snapshot:
785            raise ValueError(
786                f"file changed while editing {target}; refusing to overwrite concurrent changes"
787            )
788        os.replace(temporary, target)
789    finally:
790        try:
791            temporary.unlink()
792        except FileNotFoundError:
793            pass
794
795
796@dataclass(frozen=True)
797class _Occurrence:
798    index: int
799    start_line: int
800    end_line: int
801
802    @property
803    def label(self) -> str:
804        return (
805            str(self.start_line)
806            if self.start_line == self.end_line
807            else f"{self.start_line}-{self.end_line}"
808        )
809
810
811def _occurrences(content: str, needle: str) -> list[_Occurrence]:
812    found, span, start = [], needle.count("\n") + 1, 0
813    while True:
814        index = content.find(needle, start)
815        if index < 0:
816            return found
817        first = content.count("\n", 0, index) + 1
818        found.append(_Occurrence(index, first, first + span - 1))
819        start = index + len(needle)
820
821
822def _choose(
823    content: str,
824    old_str: str,
825    found: list[_Occurrence],
826    line_hint: int | None,
827    path: str,
828) -> _Occurrence:
829    if len(found) == 1:
830        return found[0]
831    listed = ", ".join(one.label for one in found[:LISTED]) + (
832        f", ... ({len(found)} total)" if len(found) > LISTED else ""
833    )
834    if line_hint is None:
835        raise ValueError(
836            f"found {len(found)} occurrences in {path}: lines {listed}. Nothing was changed. "
837            "Retry with line_hint=<a line inside the range you want>, or widen old_str until it is unique.\n"
838            + _candidates(content, found)
839        )
840    inside = next(
841        (one for one in found if one.start_line <= line_hint <= one.end_line), None
842    )
843    if inside is None:
844        raise ValueError(
845            f"line_hint={line_hint} is inside none of the {len(found)} occurrences in {path}: lines {listed}. "
846            f"Nothing was changed.\n{_candidates(content, found)}"
847        )
848    return inside
849
850
851def _candidates(content: str, found: list[_Occurrence]) -> str:
852    lines = content.splitlines()
853    blocks = []
854    for position, occurrence in enumerate(found[:CANDIDATES], 1):
855        matched = occurrence.end_line - occurrence.start_line + 1
856        preview = min(matched, PREVIEW_LINES)
857        first = max(1, occurrence.start_line - CONTEXT)
858        last = min(len(lines), occurrence.end_line + CONTEXT)
859        body = [
860            _numbered(number, lines) for number in range(first, occurrence.start_line)
861        ]
862        body += [
863            _numbered(number, lines)
864            for number in range(occurrence.start_line, occurrence.start_line + preview)
865        ]
866        if matched > preview:
867            body.append(f"{'...':>6} | ({matched - preview} further matching lines)")
868        body += [
869            _numbered(number, lines)
870            for number in range(occurrence.end_line + 1, last + 1)
871        ]
872        blocks.append(
873            f"candidate {position} of {len(found)}: lines {occurrence.label}\n"
874            + "\n".join(body)
875        )
876    if len(found) > CANDIDATES:
877        blocks.append(f"... {len(found) - CANDIDATES} further occurrences not shown")
878    return "\n\n".join(blocks)
879
880
881def _words(text: str) -> str:
882    """text with every run of whitespace as one space, to see a whitespace-only miss."""
883    return " ".join(text.split())
884
885
886def _closest(content: str, needle: str) -> str:
887    lines, needle_lines = content.splitlines(), needle.splitlines()
888    head = next((line.strip() for line in needle_lines if line.strip()), "")
889    if not lines or not head:
890        return ""
891    ranked = sorted(
892        (
893            (
894                difflib.SequenceMatcher(
895                    None, head, line.strip(), autojunk=False
896                ).ratio(),
897                index,
898            )
899            for index, line in enumerate(lines)
900            if line.strip()
901        ),
902        reverse=True,
903    )[:12]
904    width = max(1, len(needle_lines))
905    scored = []
906    for _, start in ranked:
907        end = min(len(lines), start + width)
908        current = "\n".join(lines[start:end]).strip()
909        scored.append(
910            (
911                difflib.SequenceMatcher(
912                    None, needle.strip(), current, autojunk=False
913                ).ratio(),
914                start,
915                end,
916            )
917        )
918    blocks = []
919    for score, start, end in sorted(scored, reverse=True):
920        if score < 0.25:
921            continue
922        body = "\n".join(_numbered(number + 1, lines) for number in range(start, end))
923        blocks.append(
924            f"closest candidate lines {start + 1}-{end} (similarity {score:.0%}):\n{body}"
925        )
926        if len(blocks) == 3:
927            break
928    return "\n\n".join(blocks)
929
930
931def _missing(target: Path, original: str) -> str:
932    message = f"{original} not found (cwd: {Path.cwd()})"
933    parent = target.parent
934    if not parent.is_dir():
935        return message
936    close = difflib.get_close_matches(
937        target.name, [entry.name for entry in parent.iterdir()], n=3, cutoff=0.35
938    )
939    return message + (
940        f"; nearby paths: {', '.join(str(parent / name) for name in close)}"
941        if close
942        else ""
943    )
944
945
946def setup(api: PythonApi) -> dict[str, object]:
947    return {"files": Files()}