From 1801a2a650ef8678857874df358f6d154bd3d499 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sun, 5 Jul 2026 21:06:11 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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))