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