char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
3c21631
main
1// A file's change as the view draws it: numbered lines, unchanged stretches folded, and what changed within lines. 2 3import type { FileDiff , Sent } from "./types.ts" ; 4 5/** One line of a change, numbered on the sides it is on. */ 6export type Line = { 7mark :" " | "-" | "+" ; 8/** Without its mark; a carriage return stays, for the view to show. */ 9text :string ; 10old :number | null ; 11new :number | null ; 12/** Where it is among the lines of each side the change holds, which are what is highlighted. */ 13at :{ old :number | null ; new :number | null }; 14/** `false` for the last line of a file that ends without one. */ 15newline :boolean ; 16}; 17 18export type Piece = 19| { kind :"lines" ; lines :Line [] } 20/** Unchanged lines, folded until they are opened. */ 21| { kind :"fold" ; lines :Line [] } 22/** Lines the patch left out: how many there are, when that is known. */ 23| { kind :"gap" ; count :number }; 24 25/** A row of the side-by-side view. */ 26export type Pair = { old :Line | null ; new :Line | null }; 27 28/** Character ranges, `[start, end)`. */ 29export type Range = readonly [ number , number ]; 30 31/** Added and removed lines, excluding context and patch metadata. */ 32export function lineCounts ( file :FileDiff ) { 33let additions = 0 ; 34let removals = 0 ; 35for ( const hunk of file . hunks ) { 36for ( const line of hunk . lines ) { 37if ( line [ 0 ] === "+" ) additions ++ ; 38else if ( line [ 0 ] === "-" ) removals ++ ; 39} 40} 41return { additions, removals}; 42} 43 44/** Every line `file`'s hunks hold, in order, and each side's text, for highlighting. */ 45export function linesOf ( file :FileDiff ) { 46const sides = { old :[] as string [], new :[] as string [] }; 47const hunks = file . hunks . map (( hunk ) => { 48let old = hunk . old ; 49let neu = hunk . new ; 50const lines :Line [] = []; 51for ( const raw of hunk . lines ) { 52const mark = raw [ 0 ]; 53if ( mark === "\\" ) { 54const last = lines . at ( - 1 ); 55if ( last ) last . newline = false ; 56continue ; 57} 58if ( mark !== " " && mark !== "-" && mark !== "+" ) continue ; 59const text = raw . slice ( 1 ); 60const onOld = mark !== "+" ; 61const onNew = mark !== "-" ; 62lines . push ({ 63 mark, 64 text, 65old :onOld ?old ++ :null , 66new :onNew ?neu ++ :null , 67at :{ 68old :onOld ?sides . old . push ( text . replace ( / \r$ / , "" )) - 1 :null , 69new :onNew ?sides . new . push ( text . replace ( / \r$ / , "" )) - 1 :null , 70}, 71newline :true , 72}); 73} 74return { hunk, lines, end :{ old, new :neu } }; 75}); 76return { hunks, sides}; 77} 78 79/** 80* `file`'s lines with each stretch of more than a few unchanged lines folded, but for `context` lines next to each 81* change; and gaps where the patch leaves lines out. 82*/ 83export function pieces ( file :FileDiff , context = 3 ) :Piece [] { 84const out :Piece [] = []; 85const shown = ( lines :Line []) => { 86const last = out . at ( - 1 ); 87if ( lines . length === 0 ) return ; 88if ( last ?. kind === "lines" ) last . lines . push ( ...lines ); 89else out . push ({ kind :"lines" , lines}); 90}; 91let previous :{ old :number ; new :number } = { old :1 , new :1 }; 92for ( const { hunk, lines, end} of linesOf ( file ). hunks ) { 93const missing = Math . max ( hunk . old - previous . old , hunk . new - previous . new ); 94if ( missing > 0 ) out . push ({ kind :"gap" , count :missing }); 95previous = end ; 96let i = 0 ; 97while ( i < lines . length ) { 98let j = i ; 99while ( j < lines . length && lines [ j ]. mark === " " ) j ++ ; 100if ( j === i ) { 101while ( j < lines . length && lines [ j ]. mark !== " " ) j ++ ; 102shown ( lines . slice ( i , j )); 103i = j ; 104continue ; 105} 106const before = i === 0 ?0 :context ; 107const after = j === lines . length ?0 :context ; 108// folding away less than this saves nothing 109if ( j - i - before - after < 4 ) shown ( lines . slice ( i , j )); 110else { 111shown ( lines . slice ( i , i + before )); 112out . push ({ kind :"fold" , lines :lines . slice ( i + before , j - after ) }); 113shown ( lines . slice ( j - after , j )); 114} 115i = j ; 116} 117} 118return out ; 119} 120 121/** Rows side by side: unchanged lines beside themselves, and each run of removals beside the additions that follow. */ 122export function pairs ( lines :readonly Line []) :Pair [] { 123const out :Pair [] = []; 124let i = 0 ; 125while ( i < lines . length ) { 126if ( lines [ i ]. mark === " " ) { 127out . push ({ old :lines [ i ], new :lines [ i ] }); 128i ++ ; 129continue ; 130} 131const removed :Line [] = []; 132const added :Line [] = []; 133while ( i < lines . length && lines [ i ]. mark !== " " ) { 134( lines [ i ]. mark === "-" ?removed :added ). push ( lines [ i ]); 135i ++ ; 136} 137for ( let k = 0 ; k < Math . max ( removed . length , added . length ); k ++ ) { 138out . push ({ old :removed [ k ] ?? null , new :added [ k ] ?? null }); 139} 140} 141return out ; 142} 143 144const TOKEN = / [\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s] / gu ; 145 146/** 147* What changed between two versions of a line, word by word, as ranges in each; `null` when they share too little for 148* that to say more than the whole line changing. 149*/ 150export function wordChanges ( a :string , b :string ) :{ old :Range []; new :Range [] } | null { 151const x = [ ...a . matchAll ( TOKEN )]; 152const y = [ ...b . matchAll ( TOKEN )]; 153if ( x . length * y . length > 250_000 ) return null ; 154// longest common subsequence of tokens, from the ends back 155const width = y . length + 1 ; 156const table = new Uint16Array (( x . length + 1 ) * width ); 157for ( let i = x . length - 1 ; i >= 0 ; i -- ) { 158for ( let j = y . length - 1 ; j >= 0 ; j -- ) { 159table [ i * width + j ] = x [ i ][ 0 ] === y [ j ][ 0 ] 160 ?table [( i + 1 ) * width + j + 1 ] + 1 161 :Math . max ( table [( i + 1 ) * width + j ], table [ i * width + j + 1 ]); 162} 163} 164const kept = { old :new Set < number >(), new :new Set < number >() }; 165let shared = 0 ; 166for ( let i = 0 , j = 0 ; i < x . length && j < y . length ;) { 167if ( x [ i ][ 0 ] === y [ j ][ 0 ]) { 168kept . old . add ( i ++ ); 169kept . new . add ( j ++ ); 170shared += x [ i - 1 ][ 0 ]. trim () ?x [ i - 1 ][ 0 ]. length :0 ; 171} else if ( table [( i + 1 ) * width + j ] >= table [ i * width + j + 1 ]) i ++ ; 172else j ++ ; 173} 174const visible = Math . max ( a . replace ( / \s / g , "" ). length , b . replace ( / \s / g , "" ). length ); 175if ( visible > 0 && shared / visible < 0.4 ) return null ; 176const ranges = ( tokens :RegExpExecArray [], kept :Set < number >) => { 177const out :[ number , number ][] = []; 178for ( const [ i , token ] of tokens . entries ()) { 179if ( kept . has ( i )) continue ; 180const last = out . at ( - 1 ); 181if ( last && last [ 1 ] === token . index ) last [ 1 ] += token [ 0 ]. length ; 182else out . push ([ token . index , token . index + token [ 0 ]. length ]); 183} 184return out ; 185}; 186return { old :ranges ( x , kept . old ), new :ranges ( y , kept . new ) }; 187} 188 189/** Whitespace at the end of a line, before any carriage return. */ 190export function trailing ( text :string ) :Range | null { 191const match = text . match ( / [ \t]+(?=\r?$) / ); 192return match ?[ match . index ! , match . index ! + match [ 0 ]. length ] :null ; 193} 194 195/** A short, stable fingerprint of a text. */ 196function fingerprint ( text :string ) { 197let h1 = 0xdeadbeef ; 198let h2 = 0x41c6ce57 ; 199for ( let i = 0 ; i < text . length ; i ++ ) { 200const c = text . charCodeAt ( i ); 201h1 = Math . imul ( h1 ^ c , 2654435761 ); 202h2 = Math . imul ( h2 ^ c , 1597334677 ); 203} 204h1 = Math . imul ( h1 ^ ( h1 >>> 16 ), 2246822507 ) ^ Math . imul ( h2 ^ ( h2 >>> 13 ), 3266489909 ); 205h2 = Math . imul ( h2 ^ ( h2 >>> 16 ), 2246822507 ) ^ Math . imul ( h1 ^ ( h1 >>> 13 ), 3266489909 ); 206return ( 4294967296 * ( 2097151 & h2 ) + ( h1 >>> 0 )). toString ( 36 ); 207} 208 209/** A file's change by its path and a fingerprint of all of it, so marks of having viewed it lapse once it changes again. */ 210export const fileKey = ( file :FileDiff ) => ` ${ file . path } \0 ${ fingerprint ( JSON . stringify ( file ))} ` ; 211 212/** `files` for a view that has the changes `known` names already, which so cost it only their keys. */ 213export function sending ( files :readonly FileDiff [], known :readonly string []) :Sent [] { 214const has = new Set ( known ); 215return files . map (( file ) => { 216const key = fileKey ( file ); 217return { key, diff :has . has ( key ) ?null :file }; 218}); 219}