From 1801a2a650ef8678857874df358f6d154bd3d499 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sun, 5 Jul 2026 21:06:11 -0700 Subject: [PATCH 1/8] Move `resyntax/private/string-replacement` into grimoire Co-Authored-By: Claude Fable 5 --- {private => grimoire}/string-replacement.rkt | 0 main.rkt | 2 +- private/line-replacement.rkt | 2 +- private/refactoring-result.rkt | 2 +- private/syntax-replacement.rkt | 2 +- test/private/rackunit.rkt | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename {private => grimoire}/string-replacement.rkt (100%) diff --git a/private/string-replacement.rkt b/grimoire/string-replacement.rkt similarity index 100% rename from private/string-replacement.rkt rename to grimoire/string-replacement.rkt diff --git a/main.rkt b/main.rkt index c1031589..430f4c0a 100644 --- a/main.rkt +++ b/main.rkt @@ -61,7 +61,7 @@ resyntax/private/refactoring-result resyntax/grimoire/source resyntax/private/string-indent - resyntax/private/string-replacement + resyntax/grimoire/string-replacement resyntax/private/syntax-property-bundle resyntax/private/syntax-range resyntax/private/syntax-replacement diff --git a/private/line-replacement.rkt b/private/line-replacement.rkt index 1c216674..27a58206 100644 --- a/private/line-replacement.rkt +++ b/private/line-replacement.rkt @@ -28,7 +28,7 @@ rebellion/streaming/transducer rebellion/type/record resyntax/private/linemap - resyntax/private/string-replacement) + resyntax/grimoire/string-replacement) (module+ test diff --git a/private/refactoring-result.rkt b/private/refactoring-result.rkt index cb804a77..29685156 100644 --- a/private/refactoring-result.rkt +++ b/private/refactoring-result.rkt @@ -53,7 +53,7 @@ resyntax/private/linemap resyntax/private/logger resyntax/grimoire/source - resyntax/private/string-replacement + resyntax/grimoire/string-replacement resyntax/private/syntax-replacement (only-in racket/list first)) diff --git a/private/syntax-replacement.rkt b/private/syntax-replacement.rkt index 6d084afc..0d474449 100644 --- a/private/syntax-replacement.rkt +++ b/private/syntax-replacement.rkt @@ -46,7 +46,7 @@ resyntax/private/logger resyntax/grimoire/source resyntax/private/string-indent - resyntax/private/string-replacement + resyntax/grimoire/string-replacement resyntax/private/syntax-neighbors (only-in resyntax/private/syntax-traversal syntax-search-everything) syntax/parse diff --git a/test/private/rackunit.rkt b/test/private/rackunit.rkt index 3eb4dbee..0abe5d35 100644 --- a/test/private/rackunit.rkt +++ b/test/private/rackunit.rkt @@ -40,7 +40,7 @@ resyntax/private/refactoring-result resyntax/grimoire/source resyntax/private/string-indent - resyntax/private/string-replacement + resyntax/grimoire/string-replacement resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal From 67e3d90a8d72ea7c882ba19bfe390f784890b789 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sun, 5 Jul 2026 21:06:11 -0700 Subject: [PATCH 2/8] Document `resyntax/grimoire/string-replacement` Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 1 + grimoire/string-replacement.scrbl | 201 ++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 grimoire/string-replacement.scrbl diff --git a/grimoire.scrbl b/grimoire.scrbl index df16f71a..d6ff83e5 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -15,3 +15,4 @@ programmatically on anything found here. @include-section[(lib "resyntax/grimoire/source.scrbl")] @include-section[(lib "resyntax/grimoire/source-group.scrbl")] @include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] +@include-section[(lib "resyntax/grimoire/string-replacement.scrbl")] diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl new file mode 100644 index 00000000..0ed6c101 --- /dev/null +++ b/grimoire/string-replacement.scrbl @@ -0,0 +1,201 @@ +#lang scribble/manual + + +@(require (for-label racket/base + racket/contract/base + racket/math + racket/sequence + rebellion/base/immutable-string + rebellion/collection/range-set + rebellion/streaming/reducer + rebellion/streaming/transducer + resyntax/grimoire/string-replacement)) + + +@title[#:tag "string-replacement"]{String Replacements} +@defmodule[resyntax/grimoire/string-replacement] + +A @deftech{string replacement} is a value describing an edit to a string. A replacement identifies a +region of the string to replace --- the characters between a start position and an end position --- +and describes the new contents of that region as a list of @tech{replacement pieces}. There are two +kinds of pieces: + +@itemlist[ + @item{@emph{Inserted strings}, constructed with @racket[inserted-string], containing brand new text + to insert.} + + @item{@emph{Copied strings}, constructed with @racket[copied-string], containing a range of + positions to copy from the original string. Copied pieces may copy from @emph{anywhere} in the + original string, not just from within the replaced region, so a replacement can move text around + in addition to inserting and deleting it.}] + +All positions are zero-based character offsets, and regions are half-open: a replacement from +position @racket[_start] to position @racket[_end] covers the characters at positions +@racket[_start] through @racket[(sub1 _end)]. + +String replacements are the final, lowest-level form that Resyntax's refactoring suggestions take +before being written to files. The distinction between inserted and copied pieces is the string-level +end of the formatting preservation mechanism described in @secref["original-syntax-paths"]: when +Resyntax decides that a piece of refactored code is unchanged from the original program, the +suggestion copies its text rather than regenerating it, and that decision ultimately takes the form +of a @racket[copied-string] piece. + + +@defproc[(string-replacement? [v any/c]) boolean?]{ + A predicate that recognizes @tech{string replacements}.} + + +@defproc[(string-replacement + [#:start start natural?] + [#:end end natural?] + [#:contents contents (sequence/c (or/c inserted-string? copied-string?))]) + string-replacement?]{ + Constructs a @tech{string replacement} that replaces the characters between @racket[start] and + @racket[end] with the given @racket[contents]. Raises a contract error if @racket[end] is before + @racket[start]. + + The contents are normalized during construction: pieces that span zero characters are dropped, + adjacent inserted strings are merged into one, and adjacent copied strings that copy contiguous + regions are merged into one. As a consequence, two replacements constructed from differently + divided piece lists describing the same content are @racket[equal?], and + @racket[string-replacement-contents] may return a different list than the one the replacement was + constructed with.} + + +@defproc[(string-replacement-start [replacement string-replacement?]) natural?]{ + Returns the position of the first character replaced by @racket[replacement].} + + +@defproc[(string-replacement-original-end [replacement string-replacement?]) natural?]{ + Returns the position just past the last character replaced by @racket[replacement], in terms of + positions within the @emph{original} string.} + + +@defproc[(string-replacement-original-span [replacement string-replacement?]) natural?]{ + Returns the number of characters of the original string that @racket[replacement] replaces.} + + +@defproc[(string-replacement-new-end [replacement string-replacement?]) natural?]{ + Returns the position just past the replaced region within the @emph{edited} string produced by + applying @racket[replacement], equal to the replacement's start position plus its new span.} + + +@defproc[(string-replacement-new-span [replacement string-replacement?]) natural?]{ + Returns the total number of characters that @racket[replacement]'s contents span, which is the + length of the text that the replaced region contains after the replacement is applied.} + + +@defproc[(string-replacement-contents [replacement string-replacement?]) + (listof (or/c inserted-string? copied-string?))]{ + Returns the @tech{replacement pieces} making up the new contents of @racket[replacement]'s replaced + region, in normalized form.} + + +@defproc[(string-replacement-preserved-locations [replacement string-replacement?]) range-set?]{ + Returns a range set of the positions in the original string whose characters are preserved by + @racket[replacement]: every position before the replaced region, every position after it, and every + position within the source range of one of the replacement's @racket[copied-string] pieces.} + + +@defproc[(string-replacement-overlaps? [replacement string-replacement?] + [other-replacement string-replacement?]) + boolean?]{ + Returns @racket[#true] if the replaced regions of @racket[replacement] and + @racket[other-replacement] overlap. Replacements whose regions merely touch at a boundary do not + overlap, since regions are half-open.} + + +@defproc[(string-replacement-union [replacement1 string-replacement?] + [replacement2 string-replacement?]) + string-replacement?]{ + Combines @racket[replacement1] and @racket[replacement2] into a single @tech{string replacement} + whose replaced region covers both of their regions. The text between the two regions is copied from + the original string unchanged. The order of the arguments doesn't matter. Raises a contract error + if the two replacements overlap (in the sense of @racket[string-replacement-overlaps?]).} + + +@defthing[union-into-string-replacement (reducer/c string-replacement? string-replacement?)]{ + A @tech[#:doc '(lib "rebellion/main.scrbl")]{reducer} that combines a sequence of pairwise + non-overlapping @tech{string replacements} into one, as with @racket[string-replacement-union], for + use with @racket[transduce]. The reduction starts from an empty replacement at position @racket[0], + so the combined replacement's region always starts at position @racket[0].} + + +@defproc[(string-replacement-normalize + [replacement string-replacement?] + [original-string string?] + [#:preserve-start preserve-start (or/c exact-nonnegative-integer? #false) #false] + [#:preserve-end preserve-end (or/c exact-nonnegative-integer? #false) #false]) + string-replacement?]{ + Returns a @tech{string replacement} equivalent to @racket[replacement] --- applying it to + @racket[original-string] produces the same result --- but whose replaced region is as small as + possible. Leading and trailing portions of the replacement that leave the original text unchanged + are trimmed away. If @racket[preserve-start] is provided, the normalized region is never trimmed + past it: the region always starts at or before @racket[preserve-start]. Likewise, if + @racket[preserve-end] is provided, the normalized region always ends at or after + @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are returned + unchanged.} + + +@defproc[(string-replacement-render [replacement string-replacement?] [original-string string?]) + immutable-string?]{ + Returns the new text of @racket[replacement]'s replaced region --- just the rendered contents, not + the entire edited string. The original string is needed to render the contents of + @racket[copied-string] pieces. Raises a contract error if @racket[original-string] is too short to + contain the positions that the replacement's copied pieces refer to.} + + +@defproc[(string-apply-replacement [string string?] [replacement string-replacement?]) + immutable-string?]{ + Applies @racket[replacement] to @racket[string], returning the entire edited string. Text outside + the replaced region is unchanged. Raises a contract error if @racket[string] is too short to + contain the positions that the replacement's copied pieces refer to.} + + +@defproc[(file-apply-string-replacement! [path path-string?] [replacement string-replacement?]) + void?]{ + Reads the file at @racket[path], applies @racket[replacement] to its contents as with + @racket[string-apply-replacement], and overwrites the file with the result.} + + +@section{Replacement Pieces} + +A @deftech{replacement piece} describes one segment of the new contents of a @tech{string + replacement}'s replaced region. + + +@defproc[(inserted-string? [v any/c]) boolean?]{ + A predicate that recognizes inserted-string @tech{replacement pieces}.} + + +@defproc[(inserted-string [contents string?]) inserted-string?]{ + Constructs a @tech{replacement piece} containing @racket[contents] as brand new text.} + + +@defproc[(inserted-string-contents [piece inserted-string?]) immutable-string?]{ + Returns the text that @racket[piece] inserts.} + + +@defproc[(copied-string? [v any/c]) boolean?]{ + A predicate that recognizes copied-string @tech{replacement pieces}.} + + +@defproc[(copied-string [start natural?] [end natural?]) copied-string?]{ + Constructs a @tech{replacement piece} that copies the characters between @racket[start] and + @racket[end] from the original string. The copied range may lie anywhere within the original + string, including entirely outside the replaced region. Raises a contract error if @racket[end] is + before @racket[start].} + + +@defproc[(copied-string-start [piece copied-string?]) natural?]{ + Returns the position of the first character that @racket[piece] copies.} + + +@defproc[(copied-string-end [piece copied-string?]) natural?]{ + Returns the position just past the last character that @racket[piece] copies.} + + +@defproc[(replacement-string-span [piece (or/c inserted-string? copied-string?)]) + exact-nonnegative-integer?]{ + Returns the number of characters that @racket[piece] spans: the length of an inserted string's + text, or the size of a copied string's range.} From 9f845c1ca33b14bb0b12cc2779a1b28468424092 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sun, 5 Jul 2026 21:35:43 -0700 Subject: [PATCH 3/8] Fix two error-reporting bugs in string replacements The string-too-short error in string-replacement-render was reported under the name string-apply-replacement, a copy-paste slip. Additionally, a replacement's required length now accounts for its end position, not just the positions its copied pieces refer to. Applying a replacement to a string shorter than the replaced region previously skipped the friendly error and failed inside make-string with a confusing contract violation. Co-Authored-By: Claude Fable 5 --- grimoire/string-replacement.rkt | 20 +++++++++++++++++--- grimoire/string-replacement.scrbl | 4 ++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/grimoire/string-replacement.rkt b/grimoire/string-replacement.rkt index 376eef4b..c827be7a 100644 --- a/grimoire/string-replacement.rkt +++ b/grimoire/string-replacement.rkt @@ -119,7 +119,7 @@ #:original-span (- end start) #:new-end (+ start new-span) #:new-span new-span - #:required-length (option-get max-end end) + #:required-length (max end (option-get max-end 0)) #:contents content-list)) @@ -242,7 +242,7 @@ (define original-length (string-length immutable-original-string)) (unless (>= original-length required-length) (raise-arguments-error - (name string-apply-replacement) + (name string-replacement-render) "string is not long enough" "string" immutable-original-string "string length" original-length @@ -320,7 +320,21 @@ (string-replacement #:start 0 #:end (string-length s) #:contents (list (inserted-string "hi there")))) - (check-equal? (string-apply-replacement s replacement) "hi there")))) + (check-equal? (string-apply-replacement s replacement) "hi there")) + + (test-case "string shorter than the replaced region" + (define replacement + (string-replacement #:start 0 #:end 10 #:contents (list (copied-string 0 2)))) + (check-exn #rx"string-apply-replacement:" (λ () (string-apply-replacement "abc" replacement))) + (check-exn #rx"string is not long enough" + (λ () (string-apply-replacement "abc" replacement))))) + + (test-case (name-string string-replacement-render) + (test-case "errors are reported under the right name" + (define replacement + (string-replacement #:start 0 #:end 2 #:contents (list (copied-string 10 20)))) + (check-exn #rx"string-replacement-render:" + (λ () (string-replacement-render replacement "ab")))))) (define (file-apply-string-replacement! path replacement) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index 0ed6c101..d7b33232 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -142,14 +142,14 @@ of a @racket[copied-string] piece. Returns the new text of @racket[replacement]'s replaced region --- just the rendered contents, not the entire edited string. The original string is needed to render the contents of @racket[copied-string] pieces. Raises a contract error if @racket[original-string] is too short to - contain the positions that the replacement's copied pieces refer to.} + contain the replaced region or the positions that the replacement's copied pieces refer to.} @defproc[(string-apply-replacement [string string?] [replacement string-replacement?]) immutable-string?]{ Applies @racket[replacement] to @racket[string], returning the entire edited string. Text outside the replaced region is unchanged. Raises a contract error if @racket[string] is too short to - contain the positions that the replacement's copied pieces refer to.} + contain the replaced region or the positions that the replacement's copied pieces refer to.} @defproc[(file-apply-string-replacement! [path path-string?] [replacement string-replacement?]) From acc36652a06a5f2ce64a1f4a493b20c6789cc776 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Mon, 6 Jul 2026 19:53:31 -0700 Subject: [PATCH 4/8] Introduce a string piece supertype The pieces making up a string replacement's contents are now called string pieces, with a string-piece struct supertype underneath both kinds and a public string-piece? predicate. The pieces aren't exclusively tied to replacements --- syntax-replacement uses their spans directly when narrowing replacement focus --- so the replacement-string-span operation is now string-piece-span, and contracts say string-piece? instead of enumerating the two kinds. Also converts copied-string from define-tuple-type to a plain struct, matching inserted-string. Co-Authored-By: Claude Fable 5 --- grimoire/string-replacement.rkt | 25 +++++++++++++----------- grimoire/string-replacement.scrbl | 32 +++++++++++++++++-------------- private/syntax-replacement.rkt | 4 ++-- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/grimoire/string-replacement.rkt b/grimoire/string-replacement.rkt index c827be7a..b824893d 100644 --- a/grimoire/string-replacement.rkt +++ b/grimoire/string-replacement.rkt @@ -15,17 +15,17 @@ #:chaperone (#:start [start natural?] #:end [end natural?] - #:contents [contents (sequence/c (or/c inserted-string? copied-string?))]) + #:contents [contents (sequence/c string-piece?)]) #:pre/name (start end) "end cannot be before start" (<= start end) [_ string-replacement?])] - [replacement-string-span (-> (or/c inserted-string? copied-string?) exact-nonnegative-integer?)] + [string-piece? (-> any/c boolean?)] + [string-piece-span (-> string-piece? exact-nonnegative-integer?)] [string-replacement-start (-> string-replacement? natural?)] [string-replacement-original-end (-> string-replacement? natural?)] [string-replacement-original-span (-> string-replacement? natural?)] [string-replacement-new-end (-> string-replacement? natural?)] [string-replacement-new-span (-> string-replacement? natural?)] - [string-replacement-contents - (-> string-replacement? (listof (or/c inserted-string? copied-string?)))] + [string-replacement-contents (-> string-replacement? (listof string-piece?))] [string-replacement-preserved-locations (-> string-replacement? range-set?)] [string-replacement-overlaps? (-> string-replacement? string-replacement? boolean?)] [string-replacement-normalize @@ -74,7 +74,6 @@ rebellion/streaming/reducer rebellion/streaming/transducer rebellion/type/record - rebellion/type/tuple resyntax/grimoire/source) @@ -98,7 +97,7 @@ #:result (reverse (append (if previous (list previous) (list)) accumulated))) ([piece contents] - #:when (positive? (replacement-string-span piece))) + #:when (positive? (string-piece-span piece))) (match (list previous piece) [(list #false _) (values accumulated piece)] [(list (inserted-string s1) (inserted-string s2)) @@ -107,7 +106,7 @@ #:when (equal? end1 start2) (values accumulated (copied-string start1 end2))] [(list _ _) (values (cons previous accumulated) piece)]))) - (define new-span (transduce content-list (mapping replacement-string-span) #:into into-sum)) + (define new-span (transduce content-list (mapping string-piece-span) #:into into-sum)) (define max-end (transduce content-list (filtering copied-string?) @@ -207,15 +206,19 @@ (string-replacement-range replacement) (string-replacement-range other-replacement))) -(struct inserted-string (contents) #:transparent +(struct string-piece () #:transparent) + + +(struct inserted-string string-piece (contents) #:transparent #:guard (λ (contents _) (string->immutable-string contents)) #:property prop:custom-print-quotable 'never) -(define-tuple-type copied-string (start end)) +(struct copied-string string-piece (start end) #:transparent + #:property prop:custom-print-quotable 'never) -(define (replacement-string-span piece) +(define (string-piece-span piece) (match piece [(inserted-string inserted-string) (string-length inserted-string)] [(copied-string start end) (- end start)])) @@ -372,7 +375,7 @@ (guarded-block (guard (< pos actual-start) #:else pieces) (guard-match (cons next-piece remaining) pieces #:else (list)) - (define piece-span (replacement-string-span next-piece)) + (define piece-span (string-piece-span next-piece)) (if (< (+ pos piece-span) actual-start) (loop remaining (+ pos piece-span)) (cons (replacement-string-drop-left next-piece (- actual-start pos)) remaining))))) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index d7b33232..79684452 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -17,8 +17,8 @@ A @deftech{string replacement} is a value describing an edit to a string. A replacement identifies a region of the string to replace --- the characters between a start position and an end position --- -and describes the new contents of that region as a list of @tech{replacement pieces}. There are two -kinds of pieces: +and describes the new contents of that region as a list of @tech{string pieces}. There are two kinds +of pieces: @itemlist[ @item{@emph{Inserted strings}, constructed with @racket[inserted-string], containing brand new text @@ -48,7 +48,7 @@ of a @racket[copied-string] piece. @defproc[(string-replacement [#:start start natural?] [#:end end natural?] - [#:contents contents (sequence/c (or/c inserted-string? copied-string?))]) + [#:contents contents (sequence/c string-piece?)]) string-replacement?]{ Constructs a @tech{string replacement} that replaces the characters between @racket[start] and @racket[end] with the given @racket[contents]. Raises a contract error if @racket[end] is before @@ -86,8 +86,8 @@ of a @racket[copied-string] piece. @defproc[(string-replacement-contents [replacement string-replacement?]) - (listof (or/c inserted-string? copied-string?))]{ - Returns the @tech{replacement pieces} making up the new contents of @racket[replacement]'s replaced + (listof string-piece?)]{ + Returns the @tech{string pieces} making up the new contents of @racket[replacement]'s replaced region, in normalized form.} @@ -158,18 +158,23 @@ of a @racket[copied-string] piece. @racket[string-apply-replacement], and overwrites the file with the result.} -@section{Replacement Pieces} +@section{String Pieces} -A @deftech{replacement piece} describes one segment of the new contents of a @tech{string - replacement}'s replaced region. +A @deftech{string piece} describes a segment of text, either brand new or copied from some original +string. String pieces primarily serve as the contents of a @tech{string replacement}'s replaced +region, but they are also occasionally useful on their own as standalone descriptions of text. + + +@defproc[(string-piece? [v any/c]) boolean?]{ + A predicate that recognizes @tech{string pieces} of either kind.} @defproc[(inserted-string? [v any/c]) boolean?]{ - A predicate that recognizes inserted-string @tech{replacement pieces}.} + A predicate that recognizes inserted-string @tech{string pieces}. Implies @racket[string-piece?].} @defproc[(inserted-string [contents string?]) inserted-string?]{ - Constructs a @tech{replacement piece} containing @racket[contents] as brand new text.} + Constructs a @tech{string piece} containing @racket[contents] as brand new text.} @defproc[(inserted-string-contents [piece inserted-string?]) immutable-string?]{ @@ -177,11 +182,11 @@ A @deftech{replacement piece} describes one segment of the new contents of a @te @defproc[(copied-string? [v any/c]) boolean?]{ - A predicate that recognizes copied-string @tech{replacement pieces}.} + A predicate that recognizes copied-string @tech{string pieces}. Implies @racket[string-piece?].} @defproc[(copied-string [start natural?] [end natural?]) copied-string?]{ - Constructs a @tech{replacement piece} that copies the characters between @racket[start] and + Constructs a @tech{string piece} that copies the characters between @racket[start] and @racket[end] from the original string. The copied range may lie anywhere within the original string, including entirely outside the replaced region. Raises a contract error if @racket[end] is before @racket[start].} @@ -195,7 +200,6 @@ A @deftech{replacement piece} describes one segment of the new contents of a @te Returns the position just past the last character that @racket[piece] copies.} -@defproc[(replacement-string-span [piece (or/c inserted-string? copied-string?)]) - exact-nonnegative-integer?]{ +@defproc[(string-piece-span [piece string-piece?]) exact-nonnegative-integer?]{ Returns the number of characters that @racket[piece] spans: the length of an inserted string's text, or the size of a copied string's range.} diff --git a/private/syntax-replacement.rkt b/private/syntax-replacement.rkt index 0d474449..40b24657 100644 --- a/private/syntax-replacement.rkt +++ b/private/syntax-replacement.rkt @@ -200,12 +200,12 @@ (+ start (for/sum ([piece (in-list contents-with-possible-focus)] #:break (focus? piece)) - (replacement-string-span piece)))) + (string-piece-span piece)))) (define focused-end (- end (for/sum ([piece (in-list (reverse contents-with-possible-focus))] #:break (focus? piece)) - (replacement-string-span piece)))) + (string-piece-span piece)))) (define raw-contents (append-map (λ (piece) (if (focus? piece) (focus-contents piece) (list piece))) contents-with-possible-focus)) From 4c9448dafc8587dd3e93a8e6122b96ebb06f5942 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 8 Jul 2026 23:47:17 -0700 Subject: [PATCH 5/8] Edit string replacement docs prose --- grimoire/string-replacement.scrbl | 116 +++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 18 deletions(-) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index 79684452..1c87dbdc 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -29,16 +29,27 @@ of pieces: original string, not just from within the replaced region, so a replacement can move text around in addition to inserting and deleting it.}] -All positions are zero-based character offsets, and regions are half-open: a replacement from +All positions are zero-based @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{character} +offsets, and regions are half-open: a replacement from position @racket[_start] to position @racket[_end] covers the characters at positions -@racket[_start] through @racket[(sub1 _end)]. +@racket[_start] through @racket[(sub1 _end)]. @bold{Beware that string replacements do NOT use + one-based position indices}, unlike positions in syntax object source locations such as those +returned by @racket[syntax-position]. + +When applied to a string, a string replacement first deletes everything in the range it describes. +Then it inserts and copies text into that range, according to the string replacement's piece list. +The replacement is applied atomically: character positions in copied strings always refer to the +positions of characters in the original, unedited string, prior to any deletions, insertions, or +copying operations being applied. String replacements are the final, lowest-level form that Resyntax's refactoring suggestions take before being written to files. The distinction between inserted and copied pieces is the string-level end of the formatting preservation mechanism described in @secref["original-syntax-paths"]: when Resyntax decides that a piece of refactored code is unchanged from the original program, the suggestion copies its text rather than regenerating it, and that decision ultimately takes the form -of a @racket[copied-string] piece. +of a @racket[copied-string] piece. This mechanism is also used by Resyntax to preserve comments where +possible and discard suggestions that can't preserve them; see +@racket[string-replacement-preserved-locations] for details. @defproc[(string-replacement? [v any/c]) boolean?]{ @@ -54,12 +65,23 @@ of a @racket[copied-string] piece. @racket[end] with the given @racket[contents]. Raises a contract error if @racket[end] is before @racket[start]. - The contents are normalized during construction: pieces that span zero characters are dropped, - adjacent inserted strings are merged into one, and adjacent copied strings that copy contiguous - regions are merged into one. As a consequence, two replacements constructed from differently - divided piece lists describing the same content are @racket[equal?], and + The contents are lightly normalized during construction: pieces that span zero characters are + dropped, adjacent inserted strings are merged into one, and adjacent copied strings that copy + contiguous regions are merged into one. As a consequence, two replacements constructed from + differently divided piece lists describing the same content are @racket[equal?], and @racket[string-replacement-contents] may return a different list than the one the replacement was - constructed with.} + constructed with. + + Beware that this light form of normalization, called + @deftech{replacement construction normalization}, does @bold{not} remove redundant no-op + @racket[copied-string] operations at the start or end of the replacement. This is in contrast to + the stricter @tech{replacement focusing normalization} implemented by + @racket[string-replacement-normalize]. For example, consider a replacement that starts at position + @racket[0], ends at position @racket[_n], and contains a single @racket[(copied-string 0 _n)] piece. + This replacement is functionally a no-op that makes no changes at all to the string. However, + construction normalization does not remove the @racket[copied-string] piece, even though it behaves + identically to an empty string replacement. Focusing normalization, on the other hand, @emph{does} + remove the copied string piece --- see @racket[string-replacement-normalize] for details.} @defproc[(string-replacement-start [replacement string-replacement?]) natural?]{ @@ -68,11 +90,14 @@ of a @racket[copied-string] piece. @defproc[(string-replacement-original-end [replacement string-replacement?]) natural?]{ Returns the position just past the last character replaced by @racket[replacement], in terms of - positions within the @emph{original} string.} + positions within the @emph{original} string. Note that this is a zero-indexed position, with zero + being before the first character.} @defproc[(string-replacement-original-span [replacement string-replacement?]) natural?]{ - Returns the number of characters of the original string that @racket[replacement] replaces.} + Returns the size in characters of the range of the original string that @racket[replacement] + replaces. This is always equal to @racket[string-replacement-start] subtracted from + @racket[string-replacement-original-end].} @defproc[(string-replacement-new-end [replacement string-replacement?]) natural?]{ @@ -82,7 +107,9 @@ of a @racket[copied-string] piece. @defproc[(string-replacement-new-span [replacement string-replacement?]) natural?]{ Returns the total number of characters that @racket[replacement]'s contents span, which is the - length of the text that the replaced region contains after the replacement is applied.} + length of the text that the replaced region contains after the replacement is applied. This is + always equal to @racket[string-replacement-start] subtracted from + @racket[string-replacement-new-end].} @defproc[(string-replacement-contents [replacement string-replacement?]) @@ -94,7 +121,11 @@ of a @racket[copied-string] piece. @defproc[(string-replacement-preserved-locations [replacement string-replacement?]) range-set?]{ Returns a range set of the positions in the original string whose characters are preserved by @racket[replacement]: every position before the replaced region, every position after it, and every - position within the source range of one of the replacement's @racket[copied-string] pieces.} + position within the source range of one of the replacement's @racket[copied-string] pieces. + + This is used by Resyntax to determine whether or not a replacement preserves comments: if a + replacement's @emph{unpreserved} locations has any overlap with @racket[source-comment-locations] for + the source being edited, then the replacement @emph{drops comments} and is discarded by Resyntax.} @defproc[(string-replacement-overlaps? [replacement string-replacement?] @@ -102,7 +133,13 @@ of a @racket[copied-string] piece. boolean?]{ Returns @racket[#true] if the replaced regions of @racket[replacement] and @racket[other-replacement] overlap. Replacements whose regions merely touch at a boundary do not - overlap, since regions are half-open.} + overlap, since regions are half-open character ranges. + + Overlap detection is conservatively imperfect. There exist string replacements that can be applied + together safely, but which @racket[string-replacement-overlaps?] reports @racket[#true] for. However, + there are @emph{never} cases where @racket[string-replacement-overlaps?] issues a false negative --- + only false positives. Further improvement here would likely require adjusting the representation of + string replacements using a range map structure instead of a single range and a list of pieces.} @defproc[(string-replacement-union [replacement1 string-replacement?] @@ -111,15 +148,49 @@ of a @racket[copied-string] piece. Combines @racket[replacement1] and @racket[replacement2] into a single @tech{string replacement} whose replaced region covers both of their regions. The text between the two regions is copied from the original string unchanged. The order of the arguments doesn't matter. Raises a contract error - if the two replacements overlap (in the sense of @racket[string-replacement-overlaps?]).} + if the two replacements overlap (in the sense of @racket[string-replacement-overlaps?]). + + A quirk of the current implementation is that empty replacements are not treated specially. The union + of an empty replacement at position @racket[0] and a replacement at position @racket[_n] will produce + an expanded replacement that starts at position @racket[0]. A no-op @racket[copied-string] piece that + copies the contents between @racket[0] and @racket[_n] will be included. This results in a + replacement that behaves identically, but which reports an earlier start position and larger spans + from @racket[string-replacement-original-span] and @racket[string-replacement-new-span]. Like the + deficiencies of @racket[string-replacement-overlaps?], this could be fixed by using a range map + representation for string replacements instead of a single range representation. + + Note that even for non-overlapping replacements, appying the union of two string replacements is + @bold{not} the same as applying one replacement and then applying the other. This is because of + copied strings --- the first replacement may change the size of the string, which can cause the + character positions referenced by copied string pieces in the second replacement to refer to + different text than it would have if the replacements were applied in the opposite order. This is + because although individual string replacements can be applied atomically, @emph{multiple} + replacements cannot. + + When Resyntax wants to apply multiple string replacements at once, it always combines their + replacements into a single union replacement. Conflicting replacements are dropped; rather than try + to apply them in a second stage, Resyntax throws them out and re-analyzes the entire modified source + file instead to generate fresh suggested replacements. As a result, Resyntax has to decide which + replacements it wants to apply in each analysis round @emph{before} it can begin the next analysis + round. The result of each analysis round edits the source code in-memory using + @racket[modified-source] so that Resyntax can interleave editing and analysis in this manner without + actually committing its edits to the filesystem.} @defthing[union-into-string-replacement (reducer/c string-replacement? string-replacement?)]{ A @tech[#:doc '(lib "rebellion/main.scrbl")]{reducer} that combines a sequence of pairwise non-overlapping @tech{string replacements} into one, as with @racket[string-replacement-union], for use with @racket[transduce]. The reduction starts from an empty replacement at position @racket[0], - so the combined replacement's region always starts at position @racket[0].} + so the combined replacement's region always starts at position @racket[0]. + Note that because of the quirk with how @racket[string-replacement-union] handles empty replacements, + this implies that a replacement produced by @racket[union-into-string-replacement] @bold{always} + starts at position @racket[0] and includes a large no-op @racket[copied-string] piece between + position @racket[0] and the lowest-position replacement that was included in the union.} + + +@; TODO: maybe this should be named string-replacement-normalize-by-focusing or something? it's weird +@; that there's two forms of normalization and none of the names draw attention to that. @defproc[(string-replacement-normalize [replacement string-replacement?] @@ -133,9 +204,18 @@ of a @racket[copied-string] piece. are trimmed away. If @racket[preserve-start] is provided, the normalized region is never trimmed past it: the region always starts at or before @racket[preserve-start]. Likewise, if @racket[preserve-end] is provided, the normalized region always ends at or after - @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are returned - unchanged.} - + @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are shrunk td + the smallest no-op replacements possible given the constraints of @racket[preserve-start] and + @racket[preserve-end], and shrunk to empty replacements starting at their original start if those + constraints are not provided. @;TODO: claude, double-check this behavior + + This form of normalization is called @deftech{replacement focusing normalization}, and is more + aggressive than the @tech{replacement construction normalization} performed automatically by + @racket[string-replacement]. This is the low-level mechanism used by Resyntax to implement the + replacement focusing behavior described in @secref[#|TODO: claude|#].} + +@;TODO: string-replacement-render + string-apply-replacement is confusing API split with weird naming. +@; should they be named differently? string-replacement-apply + -apply-partial maybe? @defproc[(string-replacement-render [replacement string-replacement?] [original-string string?]) immutable-string?]{ From e6d4e4cd75a00108a4c5529312f56df7873d43b9 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 8 Jul 2026 23:52:21 -0700 Subject: [PATCH 6/8] Fix typos and fill in the replacement focusing cross-reference The Narrowing the Focus of Replacements section now has a tag so the string-replacement-normalize docs can point at it. Co-Authored-By: Claude Fable 5 --- grimoire/string-replacement.scrbl | 11 ++++++----- refactoring-rules.scrbl | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index 1c87dbdc..e36665b8 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -124,8 +124,9 @@ possible and discard suggestions that can't preserve them; see position within the source range of one of the replacement's @racket[copied-string] pieces. This is used by Resyntax to determine whether or not a replacement preserves comments: if a - replacement's @emph{unpreserved} locations has any overlap with @racket[source-comment-locations] for - the source being edited, then the replacement @emph{drops comments} and is discarded by Resyntax.} + replacement's @emph{unpreserved} locations have any overlap with @racket[source-comment-locations] + for the source being edited, then the replacement @emph{drops comments} and is discarded by + Resyntax.} @defproc[(string-replacement-overlaps? [replacement string-replacement?] @@ -159,7 +160,7 @@ possible and discard suggestions that can't preserve them; see deficiencies of @racket[string-replacement-overlaps?], this could be fixed by using a range map representation for string replacements instead of a single range representation. - Note that even for non-overlapping replacements, appying the union of two string replacements is + Note that even for non-overlapping replacements, applying the union of two string replacements is @bold{not} the same as applying one replacement and then applying the other. This is because of copied strings --- the first replacement may change the size of the string, which can cause the character positions referenced by copied string pieces in the second replacement to refer to @@ -204,7 +205,7 @@ possible and discard suggestions that can't preserve them; see are trimmed away. If @racket[preserve-start] is provided, the normalized region is never trimmed past it: the region always starts at or before @racket[preserve-start]. Likewise, if @racket[preserve-end] is provided, the normalized region always ends at or after - @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are shrunk td + @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are shrunk to the smallest no-op replacements possible given the constraints of @racket[preserve-start] and @racket[preserve-end], and shrunk to empty replacements starting at their original start if those constraints are not provided. @;TODO: claude, double-check this behavior @@ -212,7 +213,7 @@ possible and discard suggestions that can't preserve them; see This form of normalization is called @deftech{replacement focusing normalization}, and is more aggressive than the @tech{replacement construction normalization} performed automatically by @racket[string-replacement]. This is the low-level mechanism used by Resyntax to implement the - replacement focusing behavior described in @secref[#|TODO: claude|#].} + replacement focusing behavior described in @secref["replacement-focusing"].} @;TODO: string-replacement-render + string-apply-replacement is confusing API split with weird naming. @; should they be named differently? string-replacement-apply + -apply-partial maybe? diff --git a/refactoring-rules.scrbl b/refactoring-rules.scrbl index 41ed6036..81a651c1 100644 --- a/refactoring-rules.scrbl +++ b/refactoring-rules.scrbl @@ -282,7 +282,7 @@ The Resyntax Grimoire. Resyntax uses this information to preserve comments and formatting near the original form(s).} -@section{Narrowing the Focus of Replacements} +@section[#:tag "replacement-focusing"]{Narrowing the Focus of Replacements} @defform[#:kind "template metafunction" (~focus-replacement-on replacement-form)]{ From fde1192da0b3e196126b1ec967c81932e40da27f Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 9 Jul 2026 00:15:38 -0700 Subject: [PATCH 7/8] Rename string replacement operations for clarity - string-replacement-normalize is now string-replacement-focus, tying it to the replacement focusing feature it implements and dissolving the confusing two-kinds-of-normalization vocabulary - string-apply-replacement is now string-replacement-apply, joining the string-replacement-* naming family and taking the replacement first like every other operation - string-replacement-render is now string-replacement-new-text, completing the accessor family: new-span is its length and new-end is where it ends - file-apply-string-replacement! is now string-replacement-apply-to-file! Co-Authored-By: Claude Fable 5 --- grimoire/string-replacement.rkt | 86 +++++++++++++++---------------- grimoire/string-replacement.scrbl | 57 ++++++++++---------- main.rkt | 2 +- private/line-replacement.rkt | 2 +- private/refactoring-result.rkt | 6 +-- private/syntax-replacement.rkt | 8 +-- 6 files changed, 79 insertions(+), 82 deletions(-) diff --git a/grimoire/string-replacement.rkt b/grimoire/string-replacement.rkt index b824893d..da70d1f9 100644 --- a/grimoire/string-replacement.rkt +++ b/grimoire/string-replacement.rkt @@ -28,7 +28,7 @@ [string-replacement-contents (-> string-replacement? (listof string-piece?))] [string-replacement-preserved-locations (-> string-replacement? range-set?)] [string-replacement-overlaps? (-> string-replacement? string-replacement? boolean?)] - [string-replacement-normalize + [string-replacement-focus (->* (string-replacement? string?) (#:preserve-start (or/c exact-nonnegative-integer? #false) #:preserve-end (or/c exact-nonnegative-integer? #false)) @@ -41,9 +41,9 @@ (not (string-replacement-overlaps? replacement1 replacement2)) [_ string-replacement?])] [union-into-string-replacement (reducer/c string-replacement? string-replacement?)] - [string-replacement-render (-> string-replacement? string? immutable-string?)] - [string-apply-replacement (-> string? string-replacement? immutable-string?)] - [file-apply-string-replacement! (-> path-string? string-replacement? void?)] + [string-replacement-new-text (-> string-replacement? string? immutable-string?)] + [string-replacement-apply (-> string-replacement? string? immutable-string?)] + [string-replacement-apply-to-file! (-> string-replacement? path-string? void?)] [inserted-string? (-> any/c boolean?)] [inserted-string (-> string? inserted-string?)] [inserted-string-contents (-> inserted-string? immutable-string?)] @@ -239,13 +239,13 @@ [(copied-string start end) (copied-string start (- end amount))])) -(define (string-replacement-render replacement original-string) +(define (string-replacement-new-text replacement original-string) (define immutable-original-string (string->immutable-string original-string)) (define required-length (string-replacement-required-length replacement)) (define original-length (string-length immutable-original-string)) (unless (>= original-length required-length) (raise-arguments-error - (name string-replacement-render) + (name string-replacement-new-text) "string is not long enough" "string" immutable-original-string "string length" original-length @@ -264,7 +264,7 @@ (string->immutable-string edited)) -(define (string-apply-replacement string replacement) +(define (string-replacement-apply replacement string) (define immutable-string (string->immutable-string string)) (define start (string-replacement-start replacement)) (define end (string-replacement-original-end replacement)) @@ -274,7 +274,7 @@ (define original-length (string-length immutable-string)) (unless (>= original-length required-length) (raise-arguments-error - (name string-apply-replacement) + (name string-replacement-apply) "string is not long enough" "string" immutable-string "string length" original-length @@ -296,7 +296,7 @@ (module+ test - (test-case (name-string string-apply-replacement) + (test-case (name-string string-replacement-apply) (test-case "replace middle part" (define s "good morning and hello world") @@ -314,8 +314,8 @@ (check-equal? (string-replacement-new-span replacement) 19) (check-equal? (string-replacement-length-change replacement) 2) (check-equal? (string-replacement-new-end replacement) 24) - (check-equal? (string-replacement-render replacement s) "evening and goodbye") - (check-equal? (string-apply-replacement s replacement) "good evening and goodbye world")) + (check-equal? (string-replacement-new-text replacement s) "evening and goodbye") + (check-equal? (string-replacement-apply replacement s) "good evening and goodbye world")) (test-case "replace entire string" (define s "good morning and hello world") @@ -323,46 +323,46 @@ (string-replacement #:start 0 #:end (string-length s) #:contents (list (inserted-string "hi there")))) - (check-equal? (string-apply-replacement s replacement) "hi there")) + (check-equal? (string-replacement-apply replacement s) "hi there")) (test-case "string shorter than the replaced region" (define replacement (string-replacement #:start 0 #:end 10 #:contents (list (copied-string 0 2)))) - (check-exn #rx"string-apply-replacement:" (λ () (string-apply-replacement "abc" replacement))) + (check-exn #rx"string-replacement-apply:" (λ () (string-replacement-apply replacement "abc"))) (check-exn #rx"string is not long enough" - (λ () (string-apply-replacement "abc" replacement))))) + (λ () (string-replacement-apply replacement "abc"))))) - (test-case (name-string string-replacement-render) + (test-case (name-string string-replacement-new-text) (test-case "errors are reported under the right name" (define replacement (string-replacement #:start 0 #:end 2 #:contents (list (copied-string 10 20)))) - (check-exn #rx"string-replacement-render:" - (λ () (string-replacement-render replacement "ab")))))) + (check-exn #rx"string-replacement-new-text:" + (λ () (string-replacement-new-text replacement "ab")))))) -(define (file-apply-string-replacement! path replacement) - (define replacement-text (string-apply-replacement (source->string (file-source path)) replacement)) +(define (string-replacement-apply-to-file! replacement path) + (define replacement-text (string-replacement-apply replacement (source->string (file-source path)))) (display-to-file replacement-text path #:mode 'text #:exists 'replace)) -(define/guard (string-replacement-normalize replacement original-string +(define/guard (string-replacement-focus replacement original-string #:preserve-start [preserve-start #false] #:preserve-end [preserve-end #false]) (define left-normalized - (string-replacement-left-normalize replacement original-string #:preserve-start preserve-start)) + (string-replacement-left-focus replacement original-string #:preserve-start preserve-start)) (define left-reversed (string-replacement-reverse left-normalized original-string)) (define reversed-original-string (string-reverse original-string)) (define reversed - (string-replacement-left-normalize + (string-replacement-left-focus left-reversed reversed-original-string #:preserve-start (and preserve-end (- (string-length original-string) preserve-end)))) (string-replacement-reverse reversed reversed-original-string)) -(define/guard (string-replacement-left-normalize replacement original-string +(define/guard (string-replacement-left-focus replacement original-string #:preserve-start [preserve-start #false]) - (define replaced (string-apply-replacement original-string replacement)) + (define replaced (string-replacement-apply replacement original-string)) (guard (not (equal? original-string replaced)) #:else replacement) (define actual-start (inexact->exact @@ -462,7 +462,7 @@ "good morning friend and hello world") 12)) - (test-case "string-replacement-normalize" + (test-case "string-replacement-focus" (test-case "empty replacement" (define s "good morning and hello world") @@ -472,7 +472,7 @@ #:start 5 #:end 5 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) replacement)) + (check-equal? (string-replacement-focus replacement s) replacement)) (test-case "insertion-only replacement" (define s "good morning and hello world") @@ -482,7 +482,7 @@ #:start 13 #:end 13 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) replacement)) + (check-equal? (string-replacement-focus replacement s) replacement)) (test-case "redundant copied string before insertion" (define s "good morning and hello world") @@ -492,7 +492,7 @@ #:start 5 #:end 13 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) + (check-equal? (string-replacement-focus replacement s) (string-replacement #:start 13 #:end 13 #:contents (list (inserted-string "friend "))))) @@ -505,7 +505,7 @@ #:start 5 #:end 13 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) + (check-equal? (string-replacement-focus replacement s) (string-replacement #:start 13 #:end 13 #:contents (list (inserted-string "friend "))))) @@ -518,7 +518,7 @@ #:start 13 #:end 16 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) + (check-equal? (string-replacement-focus replacement s) (string-replacement #:start 13 #:end 13 #:contents (list (inserted-string "friend "))))) @@ -531,7 +531,7 @@ #:start 13 #:end 16 #:contents replacement-pieces)) - (check-equal? (string-replacement-normalize replacement s) + (check-equal? (string-replacement-focus replacement s) (string-replacement #:start 13 #:end 13 #:contents (list (inserted-string "friend "))))) @@ -541,13 +541,13 @@ (define s2 "hello my little friend") (define replacement-pieces (list (inserted-string "my little"))) (define replacement (string-replacement #:start 6 #:end 12 #:contents replacement-pieces)) - (check-equal? (string-apply-replacement s replacement) s2) + (check-equal? (string-replacement-apply replacement s) s2) - (define normalized (string-replacement-normalize replacement s)) + (define normalized (string-replacement-focus replacement s)) (define expected (string-replacement #:start 9 #:end 12 #:contents (list (inserted-string "little")))) - (check-equal? (string-apply-replacement s expected) s2) + (check-equal? (string-replacement-apply expected s) s2) (check-equal? normalized expected)) (test-case "normalizing by left-trimming a single size-decreasing insertion" @@ -555,13 +555,13 @@ (define s2 "hello my big friend") (define replacement-pieces (list (inserted-string "my big"))) (define replacement (string-replacement #:start 6 #:end 15 #:contents replacement-pieces)) - (check-equal? (string-apply-replacement s replacement) s2) + (check-equal? (string-replacement-apply replacement s) s2) - (define normalized (string-replacement-normalize replacement s)) + (define normalized (string-replacement-focus replacement s)) (define expected (string-replacement #:start 9 #:end 15 #:contents (list (inserted-string "big")))) - (check-equal? (string-apply-replacement s expected) s2) + (check-equal? (string-replacement-apply expected s) s2) (check-equal? normalized expected)) (test-case "normalizing by right-trimming a single size-increasing insertion" @@ -569,13 +569,13 @@ (define s2 "hello my little friend") (define replacement-pieces (list (inserted-string "little friend"))) (define replacement (string-replacement #:start 9 #:end 19 #:contents replacement-pieces)) - (check-equal? (string-apply-replacement s replacement) s2) + (check-equal? (string-replacement-apply replacement s) s2) - (define normalized (string-replacement-normalize replacement s)) + (define normalized (string-replacement-focus replacement s)) (define expected (string-replacement #:start 9 #:end 12 #:contents (list (inserted-string "little")))) - (check-equal? (string-apply-replacement s expected) s2) + (check-equal? (string-replacement-apply expected s) s2) (check-equal? normalized expected)) (test-case "normalizing by right-trimming a single size-decreasing insertion" @@ -583,13 +583,13 @@ (define s2 "hello my big friend") (define replacement-pieces (list (inserted-string "big friend"))) (define replacement (string-replacement #:start 9 #:end 22 #:contents replacement-pieces)) - (check-equal? (string-apply-replacement s replacement) s2) + (check-equal? (string-replacement-apply replacement s) s2) - (define normalized (string-replacement-normalize replacement s)) + (define normalized (string-replacement-focus replacement s)) (define expected (string-replacement #:start 9 #:end 15 #:contents (list (inserted-string "big")))) - (check-equal? (string-apply-replacement s expected) s2) + (check-equal? (string-replacement-apply expected s) s2) (check-equal? normalized expected)))) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index e36665b8..bc993919 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -72,16 +72,15 @@ possible and discard suggestions that can't preserve them; see @racket[string-replacement-contents] may return a different list than the one the replacement was constructed with. - Beware that this light form of normalization, called - @deftech{replacement construction normalization}, does @bold{not} remove redundant no-op - @racket[copied-string] operations at the start or end of the replacement. This is in contrast to - the stricter @tech{replacement focusing normalization} implemented by - @racket[string-replacement-normalize]. For example, consider a replacement that starts at position - @racket[0], ends at position @racket[_n], and contains a single @racket[(copied-string 0 _n)] piece. - This replacement is functionally a no-op that makes no changes at all to the string. However, - construction normalization does not remove the @racket[copied-string] piece, even though it behaves - identically to an empty string replacement. Focusing normalization, on the other hand, @emph{does} - remove the copied string piece --- see @racket[string-replacement-normalize] for details.} + Beware that this normalization does @bold{not} remove redundant no-op @racket[copied-string] + pieces at the start or end of the replacement. That is the job of the stricter + @tech{replacement focusing} operation implemented by @racket[string-replacement-focus]. For + example, consider a replacement that starts at position @racket[0], ends at position @racket[_n], + and contains a single @racket[(copied-string 0 _n)] piece. This replacement is functionally a + no-op that makes no changes at all to the string. Construction does not remove the + @racket[copied-string] piece, even though it behaves identically to an empty string replacement. + Focusing the replacement, on the other hand, @emph{does} remove the copied string piece --- see + @racket[string-replacement-focus] for details.} @defproc[(string-replacement-start [replacement string-replacement?]) natural?]{ @@ -190,10 +189,7 @@ possible and discard suggestions that can't preserve them; see position @racket[0] and the lowest-position replacement that was included in the union.} -@; TODO: maybe this should be named string-replacement-normalize-by-focusing or something? it's weird -@; that there's two forms of normalization and none of the names draw attention to that. - -@defproc[(string-replacement-normalize +@defproc[(string-replacement-focus [replacement string-replacement?] [original-string string?] [#:preserve-start preserve-start (or/c exact-nonnegative-integer? #false) #false] @@ -202,41 +198,42 @@ possible and discard suggestions that can't preserve them; see Returns a @tech{string replacement} equivalent to @racket[replacement] --- applying it to @racket[original-string] produces the same result --- but whose replaced region is as small as possible. Leading and trailing portions of the replacement that leave the original text unchanged - are trimmed away. If @racket[preserve-start] is provided, the normalized region is never trimmed + are trimmed away. If @racket[preserve-start] is provided, the focused region is never trimmed past it: the region always starts at or before @racket[preserve-start]. Likewise, if - @racket[preserve-end] is provided, the normalized region always ends at or after + @racket[preserve-end] is provided, the focused region always ends at or after @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are shrunk to the smallest no-op replacements possible given the constraints of @racket[preserve-start] and @racket[preserve-end], and shrunk to empty replacements starting at their original start if those constraints are not provided. @;TODO: claude, double-check this behavior - This form of normalization is called @deftech{replacement focusing normalization}, and is more - aggressive than the @tech{replacement construction normalization} performed automatically by - @racket[string-replacement]. This is the low-level mechanism used by Resyntax to implement the - replacement focusing behavior described in @secref["replacement-focusing"].} - -@;TODO: string-replacement-render + string-apply-replacement is confusing API split with weird naming. -@; should they be named differently? string-replacement-apply + -apply-partial maybe? + This operation is called @deftech{replacement focusing}, and is more aggressive than the + normalization performed automatically by the @racket[string-replacement] constructor. This is the + low-level mechanism used by Resyntax to implement the replacement focusing behavior described in + @secref["replacement-focusing"].} -@defproc[(string-replacement-render [replacement string-replacement?] [original-string string?]) +@defproc[(string-replacement-new-text [replacement string-replacement?] [original-string string?]) immutable-string?]{ Returns the new text of @racket[replacement]'s replaced region --- just the rendered contents, not - the entire edited string. The original string is needed to render the contents of - @racket[copied-string] pieces. Raises a contract error if @racket[original-string] is too short to - contain the replaced region or the positions that the replacement's copied pieces refer to.} + the entire edited string. The length of the returned string is always equal to + @racket[string-replacement-new-span], and it's the text that occupies the region ending at + @racket[string-replacement-new-end] once the replacement is applied. The original string is needed + to render the contents of @racket[copied-string] pieces. Raises a contract error if + @racket[original-string] is too short to contain the replaced region or the positions that the + replacement's copied pieces refer to.} -@defproc[(string-apply-replacement [string string?] [replacement string-replacement?]) +@defproc[(string-replacement-apply [replacement string-replacement?] [string string?]) immutable-string?]{ Applies @racket[replacement] to @racket[string], returning the entire edited string. Text outside the replaced region is unchanged. Raises a contract error if @racket[string] is too short to contain the replaced region or the positions that the replacement's copied pieces refer to.} -@defproc[(file-apply-string-replacement! [path path-string?] [replacement string-replacement?]) +@defproc[(string-replacement-apply-to-file! [replacement string-replacement?] + [path path-string?]) void?]{ Reads the file at @racket[path], applies @racket[replacement] to its contents as with - @racket[string-apply-replacement], and overwrites the file with the result.} + @racket[string-replacement-apply], and overwrites the file with the result.} @section{String Pieces} diff --git a/main.rkt b/main.rkt index 430f4c0a..cfafdeb0 100644 --- a/main.rkt +++ b/main.rkt @@ -471,7 +471,7 @@ (grouping union-into-string-replacement) #:into into-hash)) (for ([(path replacement) (in-hash results-by-path)]) - (file-apply-string-replacement! path replacement))) + (string-replacement-apply-to-file! replacement path))) (define (substring-by-range str rng) diff --git a/private/line-replacement.rkt b/private/line-replacement.rkt index 27a58206..639f74f2 100644 --- a/private/line-replacement.rkt +++ b/private/line-replacement.rkt @@ -72,7 +72,7 @@ (define (string-replacement->line-replacement replacement original-string) - (define new-string (string-apply-replacement original-string replacement)) + (define new-string (string-replacement-apply replacement original-string)) (define orig-lmap (string-linemap original-string)) (define new-lmap (string-linemap new-string)) diff --git a/private/refactoring-result.rkt b/private/refactoring-result.rkt index 29685156..7b8aa47e 100644 --- a/private/refactoring-result.rkt +++ b/private/refactoring-result.rkt @@ -119,7 +119,7 @@ (mapping refactoring-result-string-replacement) #:into union-into-string-replacement)) (define base (refactoring-result-set-base-source result-set)) - (define new-contents (string-apply-replacement (source->string base) replacement)) + (define new-contents (string-replacement-apply replacement (source->string base))) (modified-source (source-original base) new-contents)) @@ -171,7 +171,7 @@ (define replacement (transduce (hash-ref new-committed-replacements source '()) #:into union-into-string-replacement)) - (values (source-path source) (string-apply-replacement old-contents replacement)))) + (values (source-path source) (string-replacement-apply replacement old-contents)))) (define description (refactoring-result-message (first rule-results))) (define num-fixes (length rule-results)) @@ -205,7 +205,7 @@ (define start (string-replacement-start replacement)) (define original-line (linemap-position-to-line lmap (add1 start))) (define original-column (- (add1 start) (linemap-position-to-start-of-line lmap (add1 start)))) - (define refactored-source-code (string-apply-replacement full-orig-code replacement)) + (define refactored-source-code (string-replacement-apply replacement full-orig-code)) (define new-code-string (substring refactored-source-code (string-replacement-start replacement) diff --git a/private/syntax-replacement.rkt b/private/syntax-replacement.rkt index 40b24657..f13af69e 100644 --- a/private/syntax-replacement.rkt +++ b/private/syntax-replacement.rkt @@ -222,7 +222,7 @@ [else (define normalized (if has-focus? - (string-replacement-normalize unformatted (source->string source) + (string-replacement-focus unformatted (source->string source) #:preserve-start focused-start #:preserve-end focused-end) unformatted)) @@ -244,7 +244,7 @@ ;; This function is the sole integration point between Resyntax and fmt. It is used by Resyntax to ;; format any refactored code initially generated by Resyntax. (define (string-replacement-format replacement original) - (define refactored-source-code (string-apply-replacement original replacement)) + (define refactored-source-code (string-replacement-apply replacement original)) (define start (string-replacement-start replacement)) (define end (string-replacement-new-end replacement)) (define changed-code-substring (substring refactored-source-code start end)) @@ -347,7 +347,7 @@ ;; exceed the desired line length. (parameterize ([current-width 102]) (define formatted (string-replacement-format replacement orig-code)) - (define result (string-apply-replacement orig-code formatted)) + (define result (string-replacement-apply formatted orig-code)) ;; The result should fit within a reasonable line length and shouldn't be multiline (check-false (string-contains? result "\n") "Result should remain on a single line") @@ -380,7 +380,7 @@ ;; Test with Racket's standard line width of 102 (parameterize ([current-width 102]) (define formatted (string-replacement-format replacement orig-line)) - (define result (string-apply-replacement orig-line formatted)) + (define result (string-replacement-apply formatted orig-line)) ;; If the result is a single line, it should not exceed 102 characters ;; If it's multi-line, each line should not exceed 102 characters From e6a040a71482ce35fe4e1cfdf7c1076653a56310 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 9 Jul 2026 00:32:29 -0700 Subject: [PATCH 8/8] Shrink no-op replacements when focusing string-replacement-focus previously returned replacements unchanged when they didn't alter the string at all, contradicting the documented behavior. Focusing a no-op replacement now shrinks it to the smallest no-op replacement satisfying the preservation constraints: the region between preserve-start and preserve-end (clamped to the original region) with a single copied piece, degenerating to an empty replacement at the constraint position (or at the original start when no constraints are given). Co-Authored-By: Claude Fable 5 --- grimoire/string-replacement.rkt | 54 +++++++++++++++++++++++++++++++ grimoire/string-replacement.scrbl | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/grimoire/string-replacement.rkt b/grimoire/string-replacement.rkt index da70d1f9..72ad0873 100644 --- a/grimoire/string-replacement.rkt +++ b/grimoire/string-replacement.rkt @@ -348,6 +348,11 @@ (define/guard (string-replacement-focus replacement original-string #:preserve-start [preserve-start #false] #:preserve-end [preserve-end #false]) + (guard (not (equal? (string-replacement-apply replacement original-string) original-string)) + #:else + (string-replacement-shrink-no-op replacement + #:preserve-start preserve-start + #:preserve-end preserve-end)) (define left-normalized (string-replacement-left-focus replacement original-string #:preserve-start preserve-start)) (define left-reversed (string-replacement-reverse left-normalized original-string)) @@ -360,6 +365,26 @@ (string-replacement-reverse reversed reversed-original-string)) +;; Shrinks a replacement that makes no changes down to the smallest no-op replacement that still +;; satisfies the preservation constraints: the shrunk region must start at or before preserve-start +;; and end at or after preserve-end, so the smallest choice is the region between them (clamped to +;; the original replaced region), or an empty region when a bound is absent. +(define (string-replacement-shrink-no-op replacement + #:preserve-start preserve-start + #:preserve-end preserve-end) + (define start (string-replacement-start replacement)) + (define end (string-replacement-original-end replacement)) + (define (clamp pos #:at-least [lower start]) (max lower (min pos end))) + (define new-start + (cond + [preserve-start (clamp preserve-start)] + [preserve-end (clamp preserve-end)] + [else start])) + (define new-end (if preserve-end (clamp preserve-end #:at-least new-start) new-start)) + (define contents (if (< new-start new-end) (list (copied-string new-start new-end)) (list))) + (string-replacement #:start new-start #:end new-end #:contents contents)) + + (define/guard (string-replacement-left-focus replacement original-string #:preserve-start [preserve-start #false]) (define replaced (string-replacement-apply replacement original-string)) @@ -484,6 +509,35 @@ #:contents replacement-pieces)) (check-equal? (string-replacement-focus replacement s) replacement)) + (test-case "complete no-op replacement shrinks to empty" + (define s "good morning and hello world") + (define replacement + (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13)))) + (check-equal? (string-replacement-apply replacement s) s) + (check-equal? (string-replacement-focus replacement s) + (string-replacement #:start 5 #:end 5 #:contents (list)))) + + (test-case "complete no-op replacement shrinks to the preserved region" + (define s "good morning and hello world") + (define replacement + (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13)))) + (check-equal? (string-replacement-focus replacement s #:preserve-start 7 #:preserve-end 9) + (string-replacement #:start 7 #:end 9 #:contents (list (copied-string 7 9))))) + + (test-case "complete no-op replacement with only preserve-start shrinks to empty" + (define s "good morning and hello world") + (define replacement + (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13)))) + (check-equal? (string-replacement-focus replacement s #:preserve-start 7) + (string-replacement #:start 7 #:end 7 #:contents (list)))) + + (test-case "complete no-op replacement with only preserve-end shrinks to empty" + (define s "good morning and hello world") + (define replacement + (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13)))) + (check-equal? (string-replacement-focus replacement s #:preserve-end 9) + (string-replacement #:start 9 #:end 9 #:contents (list)))) + (test-case "redundant copied string before insertion" (define s "good morning and hello world") (define replacement-pieces (list (copied-string 5 13) (inserted-string "friend "))) diff --git a/grimoire/string-replacement.scrbl b/grimoire/string-replacement.scrbl index bc993919..4184b8cc 100644 --- a/grimoire/string-replacement.scrbl +++ b/grimoire/string-replacement.scrbl @@ -204,7 +204,7 @@ possible and discard suggestions that can't preserve them; see @racket[preserve-end]. Replacements that don't change @racket[original-string] at all are shrunk to the smallest no-op replacements possible given the constraints of @racket[preserve-start] and @racket[preserve-end], and shrunk to empty replacements starting at their original start if those - constraints are not provided. @;TODO: claude, double-check this behavior + constraints are not provided. This operation is called @deftech{replacement focusing}, and is more aggressive than the normalization performed automatically by the @racket[string-replacement] constructor. This is the