• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jackfirth / resyntax / #460

09 Jul 2026 07:39AM UTC coverage: 94.119% (+0.1%) from 93.976%
#460

push

cover

web-flow
Document `resyntax/grimoire/string-replacement` (#790)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

271 of 275 new or added lines in 9 files covered. (98.55%)

16405 of 17430 relevant lines covered (94.12%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.33
/grimoire/string-replacement.rkt
1
#lang racket/base
1✔
2

3
;; A string replacement is an operation that can be applied to a string which can insert, delete, and
4
;; move around the contents of the string.
5

6

7
(require racket/contract/base)
1✔
8

9

10
(provide
1✔
11
 (contract-out
1✔
12
  [string-replacement? (-> any/c boolean?)]
1✔
13
  [string-replacement
1✔
14
   (->i
1✔
15
    #:chaperone
1✔
16
    (#:start [start natural?]
1✔
17
     #:end [end natural?]
1✔
18
     #:contents [contents (sequence/c string-piece?)])
1✔
19
    #:pre/name (start end) "end cannot be before start" (<= start end)
1✔
20
    [_ string-replacement?])]
1✔
21
  [string-piece? (-> any/c boolean?)]
1✔
22
  [string-piece-span (-> string-piece? exact-nonnegative-integer?)]
1✔
23
  [string-replacement-start (-> string-replacement? natural?)]
1✔
24
  [string-replacement-original-end (-> string-replacement? natural?)]
1✔
25
  [string-replacement-original-span (-> string-replacement? natural?)]
1✔
26
  [string-replacement-new-end (-> string-replacement? natural?)]
1✔
27
  [string-replacement-new-span (-> string-replacement? natural?)]
1✔
28
  [string-replacement-contents (-> string-replacement? (listof string-piece?))]
1✔
29
  [string-replacement-preserved-locations (-> string-replacement? range-set?)]
1✔
30
  [string-replacement-overlaps? (-> string-replacement? string-replacement? boolean?)]
1✔
31
  [string-replacement-focus
1✔
32
   (->* (string-replacement? string?)
1✔
33
        (#:preserve-start (or/c exact-nonnegative-integer? #false)
1✔
34
         #:preserve-end (or/c exact-nonnegative-integer? #false))
1✔
35
        string-replacement?)]
1✔
36
  [string-replacement-union
1✔
37
   (->i ([replacement1 string-replacement?]
1✔
38
         [replacement2 string-replacement?])
1✔
39
        #:pre/name (replacement1 replacement2)
1✔
40
        "replacements must not overlap"
1✔
41
        (not (string-replacement-overlaps? replacement1 replacement2))
1✔
42
        [_ string-replacement?])]
1✔
43
  [union-into-string-replacement (reducer/c string-replacement? string-replacement?)]
1✔
44
  [string-replacement-new-text (-> string-replacement? string? immutable-string?)]
1✔
45
  [string-replacement-apply (-> string-replacement? string? immutable-string?)]
1✔
46
  [string-replacement-apply-to-file! (-> string-replacement? path-string? void?)]
1✔
47
  [inserted-string? (-> any/c boolean?)]
1✔
48
  [inserted-string (-> string? inserted-string?)]
1✔
49
  [inserted-string-contents (-> inserted-string? immutable-string?)]
1✔
50
  [copied-string? (-> any/c boolean?)]
1✔
51
  [copied-string
1✔
52
   (->i
1✔
53
    #:chaperone
1✔
54
    ([start natural?]
1✔
55
     [end natural?])
1✔
56
    #:pre/name (start end) "end cannot be before start" (<= start end)
1✔
57
    [_ copied-string?])]
1✔
58
  [copied-string-start (-> copied-string? natural?)]
1✔
59
  [copied-string-end (-> copied-string? natural?)]))
1✔
60

61

62
(require guard
1✔
63
         racket/file
1✔
64
         racket/match
1✔
65
         racket/math
1✔
66
         racket/sequence
1✔
67
         rebellion/base/comparator
1✔
68
         rebellion/base/immutable-string
1✔
69
         rebellion/base/option
1✔
70
         rebellion/base/range
1✔
71
         rebellion/collection/list
1✔
72
         rebellion/collection/range-set
1✔
73
         rebellion/private/static-name
1✔
74
         rebellion/streaming/reducer
1✔
75
         rebellion/streaming/transducer
1✔
76
         rebellion/type/record
1✔
77
         resyntax/grimoire/source)
1✔
78

79

80
(module+ test
81
  (require rackunit
82
           (submod "..")))
83

84

85
;@----------------------------------------------------------------------------------------------------
86

87

88
(define-record-type string-replacement
1✔
89
  (start original-end original-span new-end new-span required-length contents)
1✔
90
  #:omit-root-binding)
1✔
91

92

93
(define (string-replacement #:start start #:end end #:contents contents)
1✔
94
  (define content-list
1✔
95
    (for/fold ([accumulated '()]
1✔
96
               [previous #false]
1✔
97
               #:result
1✔
98
               (reverse (append (if previous (list previous) (list)) accumulated)))
1✔
99
              ([piece contents]
1✔
100
               #:when (positive? (string-piece-span piece)))
1✔
101
      (match (list previous piece)
1✔
102
        [(list #false _) (values accumulated piece)]
1✔
103
        [(list (inserted-string s1) (inserted-string s2))
1✔
104
         (values accumulated (inserted-string (string-append s1 s2)))]
1✔
105
        [(list (copied-string start1 end1) (copied-string start2 end2))
1✔
106
         #:when (equal? end1 start2)
1✔
107
         (values accumulated (copied-string start1 end2))]
1✔
108
        [(list _ _) (values (cons previous accumulated) piece)])))
1✔
109
  (define new-span (transduce content-list (mapping string-piece-span) #:into into-sum))
1✔
110
  (define max-end
1✔
111
    (transduce content-list
1✔
112
               (filtering copied-string?)
1✔
113
               (mapping copied-string-end)
1✔
114
               #:into (into-max)))
1✔
115
  (constructor:string-replacement
1✔
116
   #:start start
1✔
117
   #:original-end end
1✔
118
   #:original-span (- end start)
1✔
119
   #:new-end (+ start new-span)
1✔
120
   #:new-span new-span
1✔
121
   #:required-length (max end (option-get max-end 0))
1✔
122
   #:contents content-list))
1✔
123

124

125
(module+ test
126
  (test-case "string-replacement constructor"
127

128
    (test-case "should merge insertions"
129
      (define initial-pieces
130
        (list (inserted-string "aaa") (inserted-string "bbb") (inserted-string "ccc")))
131
      (define expected-pieces (list (inserted-string "aaabbbccc")))
132
      (define replacement
133
        (string-replacement
134
         #:start 0
135
         #:end 10
136
         #:contents initial-pieces))
137
      (check-equal? (string-replacement-contents replacement) expected-pieces))
138

139
    (test-case "should merge adjacent copied pieces"
140
      (define initial-pieces
141
        (list (copied-string 2 5) (copied-string 5 7) (copied-string 7 9)))
142
      (define expected-pieces (list (copied-string 2 9)))
143
      (define replacement
144
        (string-replacement
145
         #:start 0
146
         #:end 10
147
         #:contents initial-pieces))
148
      (check-equal? (string-replacement-contents replacement) expected-pieces))
149

150
    (test-case "should not merge non-adjacent copied pieces"
151
      (define initial-pieces
152
        (list (copied-string 2 5) (copied-string 6 8) (copied-string 10 12)))
153
      (define replacement
154
        (string-replacement
155
         #:start 0
156
         #:end 10
157
         #:contents initial-pieces))
158
      (check-equal? (string-replacement-contents replacement) initial-pieces))
159

160
    (test-case "should not merge inserted pieces with copied pieces"
161
      (define initial-pieces
162
        (list (inserted-string "aaa") (copied-string 5 7) (inserted-string "bbb")))
163
      (define replacement
164
        (string-replacement
165
         #:start 0
166
         #:end 10
167
         #:contents initial-pieces))
168
      (check-equal? (string-replacement-contents replacement) initial-pieces))
169

170
    (test-case "should merge inserted pieces when before copied piece"
171
      (define initial-pieces
172
        (list (inserted-string "aaa") (inserted-string "bbb") (copied-string 5 7)))
173
      (define expected-pieces (list (inserted-string "aaabbb") (copied-string 5 7)))
174
      (define replacement
175
        (string-replacement
176
         #:start 0
177
         #:end 10
178
         #:contents initial-pieces))
179
      (check-equal? (string-replacement-contents replacement) expected-pieces))
180

181
    (test-case "should merge inserted pieces when after copied piece"
182
      (define initial-pieces
183
        (list (copied-string 5 7) (inserted-string "ccc") (inserted-string "ddd")))
184
      (define expected-pieces (list (copied-string 5 7) (inserted-string "cccddd")))
185
      (define replacement
186
        (string-replacement
187
         #:start 0
188
         #:end 10
189
         #:contents initial-pieces))
190
      (check-equal? (string-replacement-contents replacement) expected-pieces))))
191

192

193
(define (string-replacement-length-change replacement)
1✔
194
  (- (string-replacement-new-span replacement) (string-replacement-original-span replacement)))
1✔
195

196

197
(define (string-replacement-range replacement)
1✔
198
  (closed-open-range
1✔
199
   (string-replacement-start replacement)
1✔
200
   (string-replacement-original-end replacement)
1✔
201
   #:comparator natural<=>))
1✔
202

203

204
(define (string-replacement-overlaps? replacement other-replacement)
1✔
205
  (range-overlaps?
1✔
206
   (string-replacement-range replacement) (string-replacement-range other-replacement)))
1✔
207

208

209
(struct string-piece () #:transparent)
1✔
210

211

212
(struct inserted-string string-piece (contents) #:transparent
1✔
213
  #:guard (λ (contents _) (string->immutable-string contents))
1✔
214
  #:property prop:custom-print-quotable 'never)
1✔
215

216

217
(struct copied-string string-piece (start end) #:transparent
1✔
218
  #:property prop:custom-print-quotable 'never)
1✔
219

220

221
(define (string-piece-span piece)
1✔
222
  (match piece
1✔
223
    [(inserted-string inserted-string) (string-length inserted-string)]
1✔
224
    [(copied-string start end) (- end start)]))
1✔
225

226

227
(define (replacement-string-drop-left piece amount)
1✔
228
  (match piece
1✔
229
    [(inserted-string s)
1✔
230
     (inserted-string (string->immutable-string (substring s amount)))]
1✔
231
    [(copied-string start end) (copied-string (+ start amount) end)]))
1✔
232

233

234
(define (replacement-string-drop-right piece amount)
1✔
235
  (match piece
×
236
    [(inserted-string s)
×
237
     (define new-length (- (string-length s) amount))
×
238
     (inserted-string (string->immutable-string (substring s 0 new-length)))]
×
239
    [(copied-string start end) (copied-string start (- end amount))]))
×
240

241

242
(define (string-replacement-new-text replacement original-string)
1✔
243
  (define immutable-original-string (string->immutable-string original-string))
1✔
244
  (define required-length (string-replacement-required-length replacement))
1✔
245
  (define original-length (string-length immutable-original-string))
1✔
246
  (unless (>= original-length required-length)
1✔
247
    (raise-arguments-error
1✔
248
     (name string-replacement-new-text)
1✔
249
     "string is not long enough"
1✔
250
     "string" immutable-original-string
1✔
251
     "string length" original-length
1✔
252
     "required length" required-length))
1✔
253
  (define content-length (string-replacement-new-span replacement))
1✔
254
  (define edited (make-string content-length))
1✔
255
  (for/fold ([start 0] #:result (void))
1✔
256
            ([piece (in-list (string-replacement-contents replacement))])
1✔
257
    (match piece
1✔
258
      [(inserted-string inserted)
1✔
259
       (string-copy! edited start inserted)
1✔
260
       (+ start (string-length inserted))]
1✔
261
      [(copied-string copy-start copy-end)
1✔
262
       (string-copy! edited start immutable-original-string copy-start copy-end)
1✔
263
       (+ start (- copy-end copy-start))]))
1✔
264
  (string->immutable-string edited))
1✔
265

266

267
(define (string-replacement-apply replacement string)
1✔
268
  (define immutable-string (string->immutable-string string))
1✔
269
  (define start (string-replacement-start replacement))
1✔
270
  (define end (string-replacement-original-end replacement))
1✔
271
  (define new-end (string-replacement-new-end replacement))
1✔
272
  (define contents (string-replacement-contents replacement))
1✔
273
  (define required-length (string-replacement-required-length replacement))
1✔
274
  (define original-length (string-length immutable-string))
1✔
275
  (unless (>= original-length required-length)
1✔
276
    (raise-arguments-error
1✔
277
     (name string-replacement-apply)
1✔
278
     "string is not long enough"
1✔
279
     "string" immutable-string
1✔
280
     "string length" original-length
1✔
281
     "required length" required-length))
1✔
282
  (define new-length (+ original-length (string-replacement-length-change replacement)))
1✔
283
  (define edited (make-string new-length))
1✔
284
  (string-copy! edited 0 immutable-string 0 start)
1✔
285
  (for/fold ([start start] #:result (void))
1✔
286
            ([piece (in-list contents)])
1✔
287
    (match piece
1✔
288
      [(inserted-string inserted)
1✔
289
       (string-copy! edited start inserted)
1✔
290
       (+ start (string-length inserted))]
1✔
291
      [(copied-string copy-start copy-end)
1✔
292
       (string-copy! edited start immutable-string copy-start copy-end)
1✔
293
       (+ start (- copy-end copy-start))]))
1✔
294
  (string-copy! edited new-end immutable-string end)
1✔
295
  (string->immutable-string edited))
1✔
296

297

298
(module+ test
299
  (test-case (name-string string-replacement-apply)
300

301
    (test-case "replace middle part"
302
      (define s "good morning and hello world")
303
      (define replacement-pieces
304
        (list
305
         (inserted-string "evening")
306
         (copied-string 12 17)
307
         (inserted-string "goodbye")))
308
      (define replacement
309
        (string-replacement
310
         #:start 5
311
         #:end 22
312
         #:contents replacement-pieces))
313
      (check-equal? (string-replacement-original-span replacement) 17)
314
      (check-equal? (string-replacement-new-span replacement) 19)
315
      (check-equal? (string-replacement-length-change replacement) 2)
316
      (check-equal? (string-replacement-new-end replacement) 24)
317
      (check-equal? (string-replacement-new-text replacement s) "evening and goodbye")
318
      (check-equal? (string-replacement-apply replacement s) "good evening and goodbye world"))
319

320
    (test-case "replace entire string"
321
      (define s "good morning and hello world")
322
      (define replacement
323
        (string-replacement #:start 0
324
                            #:end (string-length s)
325
                            #:contents (list (inserted-string "hi there"))))
326
      (check-equal? (string-replacement-apply replacement s) "hi there"))
327

328
    (test-case "string shorter than the replaced region"
329
      (define replacement
330
        (string-replacement #:start 0 #:end 10 #:contents (list (copied-string 0 2))))
331
      (check-exn #rx"string-replacement-apply:" (λ () (string-replacement-apply replacement "abc")))
332
      (check-exn #rx"string is not long enough"
333
                 (λ () (string-replacement-apply replacement "abc")))))
334

335
  (test-case (name-string string-replacement-new-text)
336
    (test-case "errors are reported under the right name"
337
      (define replacement
338
        (string-replacement #:start 0 #:end 2 #:contents (list (copied-string 10 20))))
339
      (check-exn #rx"string-replacement-new-text:"
340
                 (λ () (string-replacement-new-text replacement "ab"))))))
341

342

343
(define (string-replacement-apply-to-file! replacement path)
1✔
NEW
344
  (define replacement-text (string-replacement-apply replacement (source->string (file-source path))))
×
345
  (display-to-file replacement-text path #:mode 'text #:exists 'replace))
×
346

347

348
(define/guard (string-replacement-focus replacement original-string
1✔
349
                                            #:preserve-start [preserve-start #false]
1✔
350
                                            #:preserve-end [preserve-end #false])
1✔
351
  (guard (not (equal? (string-replacement-apply replacement original-string) original-string))
1✔
352
    #:else
1✔
353
    (string-replacement-shrink-no-op replacement
1✔
354
                                     #:preserve-start preserve-start
1✔
355
                                     #:preserve-end preserve-end))
1✔
356
  (define left-normalized
1✔
357
    (string-replacement-left-focus replacement original-string #:preserve-start preserve-start))
1✔
358
  (define left-reversed (string-replacement-reverse left-normalized original-string))
1✔
359
  (define reversed-original-string (string-reverse original-string))
1✔
360
  (define reversed
1✔
361
    (string-replacement-left-focus
1✔
362
     left-reversed
1✔
363
     reversed-original-string
1✔
364
     #:preserve-start (and preserve-end (- (string-length original-string) preserve-end))))
1✔
365
  (string-replacement-reverse reversed reversed-original-string))
1✔
366

367

368
;; Shrinks a replacement that makes no changes down to the smallest no-op replacement that still
369
;; satisfies the preservation constraints: the shrunk region must start at or before preserve-start
370
;; and end at or after preserve-end, so the smallest choice is the region between them (clamped to
371
;; the original replaced region), or an empty region when a bound is absent.
372
(define (string-replacement-shrink-no-op replacement
1✔
373
                                         #:preserve-start preserve-start
1✔
374
                                         #:preserve-end preserve-end)
1✔
375
  (define start (string-replacement-start replacement))
1✔
376
  (define end (string-replacement-original-end replacement))
1✔
377
  (define (clamp pos #:at-least [lower start]) (max lower (min pos end)))
1✔
378
  (define new-start
1✔
379
    (cond
1✔
380
      [preserve-start (clamp preserve-start)]
1✔
381
      [preserve-end (clamp preserve-end)]
1✔
382
      [else start]))
1✔
383
  (define new-end (if preserve-end (clamp preserve-end #:at-least new-start) new-start))
1✔
384
  (define contents (if (< new-start new-end) (list (copied-string new-start new-end)) (list)))
1✔
385
  (string-replacement #:start new-start #:end new-end #:contents contents))
1✔
386

387

388
(define/guard (string-replacement-left-focus replacement original-string
1✔
389
                                                 #:preserve-start [preserve-start #false])
×
390
  (define replaced (string-replacement-apply replacement original-string))
1✔
391
  (guard (not (equal? original-string replaced)) #:else replacement)
×
392
  (define actual-start
1✔
393
    (inexact->exact
1✔
394
     (min (or preserve-start +inf.0)
1✔
395
          (string-diff-start original-string replaced)
1✔
396
          (string-replacement-original-end replacement))))
1✔
397
  (define left-trimmed-pieces
1✔
398
    (let loop ([pieces (string-replacement-contents replacement)]
1✔
399
               [pos (string-replacement-start replacement)])
1✔
400
      (guarded-block
1✔
401
        (guard (< pos actual-start) #:else pieces)
1✔
402
        (guard-match (cons next-piece remaining) pieces #:else (list))
×
403
        (define piece-span (string-piece-span next-piece))
1✔
404
        (if (< (+ pos piece-span) actual-start)
1✔
405
            (loop remaining (+ pos piece-span))
1✔
406
            (cons (replacement-string-drop-left next-piece (- actual-start pos)) remaining)))))
1✔
407
  (string-replacement #:start actual-start
1✔
408
                      #:end (string-replacement-original-end replacement)
1✔
409
                      #:contents left-trimmed-pieces))
1✔
410

411

412
(define (string-replacement-reverse replacement original-string)
1✔
413
  (define new-start (- (string-length original-string) (string-replacement-original-end replacement)))
1✔
414
  (define new-end (+ new-start (string-replacement-original-span replacement)))
1✔
415
  (define new-contents
1✔
416
    (reverse
1✔
417
     (for/list ([piece (in-list (string-replacement-contents replacement))])
1✔
418
       (match piece
1✔
419
         [(inserted-string s) (inserted-string (string-reverse s))]
1✔
420
         [(copied-string start end)
1✔
421
          (define new-start (- (string-length original-string) end))
1✔
422
          (define new-end (+ new-start (- end start)))
1✔
423
          (copied-string new-start new-end)]))))
1✔
424
  (string-replacement #:start new-start #:end new-end #:contents new-contents))
1✔
425

426

427
(define (string-reverse s)
1✔
428
  (list->string (reverse (string->list s))))
1✔
429

430

431
(define (string-diff-start original new)
1✔
432
  (let loop ([i 0])
1✔
433
    (cond
1✔
434
      [(or (>= i (string-length original)) (>= i (string-length new))) #false]
1✔
435
      [(equal? (string-ref original i) (string-ref new i)) (loop (add1 i))]
1✔
436
      [else i])))
1✔
437

438

439
(define (string-diff-end original new)
1✔
440
  (let loop ([i 0])
1✔
441
    (guarded-block
1✔
442
      (guard (and (< i (string-length original)) (< i (string-length new))) #:else #false)
1✔
443
      (define orig-index (- (string-length original) i 1))
1✔
444
      (define new-index (- (string-length new) i 1))
1✔
445
      (if (equal? (string-ref original orig-index) (string-ref new new-index))
1✔
446
          (loop (add1 i))
1✔
447
          (+ orig-index 1)))))
1✔
448

449

450
(module+ test
451

452
  (test-case "string-diff-start"
453
    (check-false (string-diff-start "aaaaaa" "aaaaaa"))
454
    (check-equal? (string-diff-start "aaaaaa" "baaaaa") 0)
455
    (check-equal? (string-diff-start "aaaaaa" "abaaaa") 1)
456
    (check-equal? (string-diff-start "aaaaaa" "aabaaa") 2)
457
    (check-equal? (string-diff-start "aaaaaa" "aaabaa") 3)
458
    (check-equal? (string-diff-start "aaaaaa" "aaaaba") 4)
459
    (check-equal? (string-diff-start "aaaaaa" "aaaaab") 5)
460
    (check-equal? (string-diff-start "aaaaaa" "b") 0)
461
    (check-equal? (string-diff-start "aaaaaa" "ab") 1)
462
    (check-equal? (string-diff-start "aaaaaa" "aab") 2)
463
    (check-equal? (string-diff-start "aaaaaa" "aaab") 3)
464
    (check-equal? (string-diff-start "aaaaaa" "aaaab") 4)
465
    (check-equal? (string-diff-start "aaaa" "abaaa") 1)
466
    (check-equal? (string-diff-start "aaaa" "aaaba") 3)
467
    (check-equal? (string-diff-start "good morning and hello world"
468
                                     "good morning friend and hello world")
469
                  13))
470

471
  (test-case "string-diff-end"
472
    (check-false (string-diff-end "aaaaaa" "aaaaaa"))
473
    (check-equal? (string-diff-end "aaaaaa" "baaaaa") 1)
474
    (check-equal? (string-diff-end "aaaaaa" "abaaaa") 2)
475
    (check-equal? (string-diff-end "aaaaaa" "aabaaa") 3)
476
    (check-equal? (string-diff-end "aaaaaa" "aaabaa") 4)
477
    (check-equal? (string-diff-end "aaaaaa" "aaaaba") 5)
478
    (check-equal? (string-diff-end "aaaaaa" "aaaaab") 6)
479
    (check-equal? (string-diff-end "aaaaaa" "baaaa") 2)
480
    (check-equal? (string-diff-end "aaaaaa" "baaa") 3)
481
    (check-equal? (string-diff-end "aaaaaa" "baa") 4)
482
    (check-equal? (string-diff-end "aaaaaa" "ba") 5)
483
    (check-equal? (string-diff-end "aaaaaa" "b") 6)
484
    (check-equal? (string-diff-end "aaaa" "abaaa") 1)
485
    (check-equal? (string-diff-end "aaaa" "aaaba") 3)
486
    (check-equal? (string-diff-end "good morning and hello world"
487
                                   "good morning friend and hello world")
488
                  12))
489

490
  (test-case "string-replacement-focus"
491

492
    (test-case "empty replacement"
493
      (define s "good morning and hello world")
494
      (define replacement-pieces (list))
495
      (define replacement
496
        (string-replacement
497
         #:start 5
498
         #:end 5
499
         #:contents replacement-pieces))
500
      (check-equal? (string-replacement-focus replacement s) replacement))
501

502
    (test-case "insertion-only replacement"
503
      (define s "good morning and hello world")
504
      (define replacement-pieces (list (inserted-string "friend ")))
505
      (define replacement
506
        (string-replacement
507
         #:start 13
508
         #:end 13
509
         #:contents replacement-pieces))
510
      (check-equal? (string-replacement-focus replacement s) replacement))
511

512
    (test-case "complete no-op replacement shrinks to empty"
513
      (define s "good morning and hello world")
514
      (define replacement
515
        (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13))))
516
      (check-equal? (string-replacement-apply replacement s) s)
517
      (check-equal? (string-replacement-focus replacement s)
518
                    (string-replacement #:start 5 #:end 5 #:contents (list))))
519

520
    (test-case "complete no-op replacement shrinks to the preserved region"
521
      (define s "good morning and hello world")
522
      (define replacement
523
        (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13))))
524
      (check-equal? (string-replacement-focus replacement s #:preserve-start 7 #:preserve-end 9)
525
                    (string-replacement #:start 7 #:end 9 #:contents (list (copied-string 7 9)))))
526

527
    (test-case "complete no-op replacement with only preserve-start shrinks to empty"
528
      (define s "good morning and hello world")
529
      (define replacement
530
        (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13))))
531
      (check-equal? (string-replacement-focus replacement s #:preserve-start 7)
532
                    (string-replacement #:start 7 #:end 7 #:contents (list))))
533

534
    (test-case "complete no-op replacement with only preserve-end shrinks to empty"
535
      (define s "good morning and hello world")
536
      (define replacement
537
        (string-replacement #:start 5 #:end 13 #:contents (list (copied-string 5 13))))
538
      (check-equal? (string-replacement-focus replacement s #:preserve-end 9)
539
                    (string-replacement #:start 9 #:end 9 #:contents (list))))
540

541
    (test-case "redundant copied string before insertion"
542
      (define s "good morning and hello world")
543
      (define replacement-pieces (list (copied-string 5 13) (inserted-string "friend ")))
544
      (define replacement
545
        (string-replacement
546
         #:start 5
547
         #:end 13
548
         #:contents replacement-pieces))
549
      (check-equal? (string-replacement-focus replacement s)
550
                    (string-replacement #:start 13
551
                                        #:end 13
552
                                        #:contents (list (inserted-string "friend ")))))
553

554
    (test-case "redundant inserted string before insertion"
555
      (define s "good morning and hello world")
556
      (define replacement-pieces (list (inserted-string "morning ") (inserted-string "friend ")))
557
      (define replacement
558
        (string-replacement
559
         #:start 5
560
         #:end 13
561
         #:contents replacement-pieces))
562
      (check-equal? (string-replacement-focus replacement s)
563
                    (string-replacement #:start 13
564
                                        #:end 13
565
                                        #:contents (list (inserted-string "friend ")))))
566

567
    (test-case "redundant copied string after insertion"
568
      (define s "good morning and hello world")
569
      (define replacement-pieces (list (inserted-string "friend ") (copied-string 13 16)))
570
      (define replacement
571
        (string-replacement
572
         #:start 13
573
         #:end 16
574
         #:contents replacement-pieces))
575
      (check-equal? (string-replacement-focus replacement s)
576
                    (string-replacement #:start 13
577
                                        #:end 13
578
                                        #:contents (list (inserted-string "friend ")))))
579

580
    (test-case "redundant inserted string after insertion"
581
      (define s "good morning and hello world")
582
      (define replacement-pieces (list (inserted-string "friend ") (inserted-string "and")))
583
      (define replacement
584
        (string-replacement
585
         #:start 13
586
         #:end 16
587
         #:contents replacement-pieces))
588
      (check-equal? (string-replacement-focus replacement s)
589
                    (string-replacement #:start 13
590
                                        #:end 13
591
                                        #:contents (list (inserted-string "friend ")))))
592

593
    (test-case "normalizing by left-trimming a single size-increasing insertion"
594
      (define s "hello my big friend")
595
      (define s2 "hello my little friend")
596
      (define replacement-pieces (list (inserted-string "my little")))
597
      (define replacement (string-replacement #:start 6 #:end 12 #:contents replacement-pieces))
598
      (check-equal? (string-replacement-apply replacement s) s2)
599

600
      (define normalized (string-replacement-focus replacement s))
601

602
      (define expected
603
        (string-replacement #:start 9 #:end 12 #:contents (list (inserted-string "little"))))
604
      (check-equal? (string-replacement-apply expected s) s2)
605
      (check-equal? normalized expected))
606

607
    (test-case "normalizing by left-trimming a single size-decreasing insertion"
608
      (define s "hello my little friend")
609
      (define s2 "hello my big friend")
610
      (define replacement-pieces (list (inserted-string "my big")))
611
      (define replacement (string-replacement #:start 6 #:end 15 #:contents replacement-pieces))
612
      (check-equal? (string-replacement-apply replacement s) s2)
613

614
      (define normalized (string-replacement-focus replacement s))
615

616
      (define expected
617
        (string-replacement #:start 9 #:end 15 #:contents (list (inserted-string "big"))))
618
      (check-equal? (string-replacement-apply expected s) s2)
619
      (check-equal? normalized expected))
620

621
    (test-case "normalizing by right-trimming a single size-increasing insertion"
622
      (define s "hello my big friend")
623
      (define s2 "hello my little friend")
624
      (define replacement-pieces (list (inserted-string "little friend")))
625
      (define replacement (string-replacement #:start 9 #:end 19 #:contents replacement-pieces))
626
      (check-equal? (string-replacement-apply replacement s) s2)
627

628
      (define normalized (string-replacement-focus replacement s))
629

630
      (define expected
631
        (string-replacement #:start 9 #:end 12 #:contents (list (inserted-string "little"))))
632
      (check-equal? (string-replacement-apply expected s) s2)
633
      (check-equal? normalized expected))
634

635
    (test-case "normalizing by right-trimming a single size-decreasing insertion"
636
      (define s "hello my little friend")
637
      (define s2 "hello my big friend")
638
      (define replacement-pieces (list (inserted-string "big friend")))
639
      (define replacement (string-replacement #:start 9 #:end 22 #:contents replacement-pieces))
640
      (check-equal? (string-replacement-apply replacement s) s2)
641

642
      (define normalized (string-replacement-focus replacement s))
643

644
      (define expected
645
        (string-replacement #:start 9 #:end 15 #:contents (list (inserted-string "big"))))
646
      (check-equal? (string-replacement-apply expected s) s2)
647
      (check-equal? normalized expected))))
648

649

650
(define/guard (string-replacement-union replacement1 replacement2)
1✔
651
  (guard (<= (string-replacement-start replacement1) (string-replacement-start replacement2)) #:else
1✔
652
    (string-replacement-union replacement2 replacement1))
×
653
  (define piece-between
1✔
654
    (copied-string
1✔
655
     (string-replacement-original-end replacement1) (string-replacement-start replacement2)))
1✔
656
  (string-replacement
1✔
657
   #:start (string-replacement-start replacement1)
1✔
658
   #:end (string-replacement-original-end replacement2)
1✔
659
   #:contents
1✔
660
   (append
1✔
661
    (string-replacement-contents replacement1)
1✔
662
    (list piece-between)
1✔
663
    (string-replacement-contents replacement2))))
1✔
664

665

666
(define union-into-string-replacement
1✔
667
  (make-fold-reducer
1✔
668
   string-replacement-union
1✔
669
   (string-replacement #:start 0 #:end 0 #:contents (list))
1✔
670
   #:name (name union-into-string-replacement)))
1✔
671

672

673
(module+ test
674
  (test-case (name-string string-replacement-union)
675
    (define s "hello world")
676
    (define r1 (string-replacement #:start 0 #:end 5 #:contents (list (inserted-string "goodbye"))))
677
    (define r2 (string-replacement #:start 6 #:end 11 #:contents (list (inserted-string "friend"))))
678
    (check-false (string-replacement-overlaps? r1 r2))
679
    (define union (string-replacement-union r1 r2))
680
    (define expected
681
      (string-replacement
682
       #:start 0
683
       #:end 11
684
       #:contents (list (inserted-string "goodbye") (copied-string 5 6) (inserted-string "friend"))))
685
    (check-equal? union expected)
686
    (check-equal? (transduce (list r1 r2) #:into union-into-string-replacement) expected)))
687

688

689
(define (string-replacement-preserved-locations replacement)
1✔
690
  (define before-replacement
1✔
691
    (less-than-range (string-replacement-start replacement) #:comparator natural<=>))
1✔
692
  (define after-replacement
1✔
693
    (at-least-range (string-replacement-original-end replacement) #:comparator natural<=>))
1✔
694
  (for/fold ([ranges (range-set before-replacement after-replacement)])
1✔
695
            ([piece (in-list (string-replacement-contents replacement))]
1✔
696
             #:when (copied-string? piece))
1✔
697
    (match-define (copied-string start end) piece)
1✔
698
    (range-set-add ranges (closed-open-range start end #:comparator natural<=>))))
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc