diff --git a/grimoire.scrbl b/grimoire.scrbl index df16f71..d6ff83e 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/private/string-replacement.rkt b/grimoire/string-replacement.rkt similarity index 76% rename from private/string-replacement.rkt rename to grimoire/string-replacement.rkt index 376eef4..72ad087 100644 --- a/private/string-replacement.rkt +++ b/grimoire/string-replacement.rkt @@ -15,20 +15,20 @@ #: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 + [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?)] @@ -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?) @@ -119,7 +118,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)) @@ -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)])) @@ -236,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-apply-replacement) + (name string-replacement-new-text) "string is not long enough" "string" immutable-original-string "string length" original-length @@ -261,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)) @@ -271,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 @@ -293,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") @@ -311,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") @@ -320,32 +323,71 @@ (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-replacement-apply:" (λ () (string-replacement-apply replacement "abc"))) + (check-exn #rx"string is not long enough" + (λ () (string-replacement-apply replacement "abc"))))) + + (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-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]) + (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-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 +;; 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-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 @@ -358,7 +400,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))))) @@ -445,7 +487,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") @@ -455,7 +497,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") @@ -465,7 +507,36 @@ #: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 "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") @@ -475,7 +546,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 "))))) @@ -488,7 +559,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 "))))) @@ -501,7 +572,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 "))))) @@ -514,7 +585,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 "))))) @@ -524,13 +595,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" @@ -538,13 +609,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" @@ -552,13 +623,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" @@ -566,13 +637,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 new file mode 100644 index 0000000..4184b8c --- /dev/null +++ b/grimoire/string-replacement.scrbl @@ -0,0 +1,283 @@ +#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{string 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 @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)]. @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. 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?]{ + A predicate that recognizes @tech{string replacements}.} + + +@defproc[(string-replacement + [#:start start natural?] + [#:end end natural?] + [#: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 + @racket[start]. + + 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. + + 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?]{ + 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. 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 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?]{ + 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. This is + always equal to @racket[string-replacement-start] subtracted from + @racket[string-replacement-new-end].} + + +@defproc[(string-replacement-contents [replacement string-replacement?]) + (listof string-piece?)]{ + Returns the @tech{string 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. + + This is used by Resyntax to determine whether or not a replacement preserves comments: if a + 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?] + [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 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?] + [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?]). + + 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, 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 + 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]. + + 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.} + + +@defproc[(string-replacement-focus + [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 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 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. + + 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-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 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-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[(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-replacement-apply], and overwrites the file with the result.} + + +@section{String Pieces} + +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{string pieces}. Implies @racket[string-piece?].} + + +@defproc[(inserted-string [contents string?]) inserted-string?]{ + Constructs a @tech{string 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{string pieces}. Implies @racket[string-piece?].} + + +@defproc[(copied-string [start natural?] [end natural?]) copied-string?]{ + 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].} + + +@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[(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/main.rkt b/main.rkt index c103158..cfafdeb 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 @@ -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 1c21667..639f74f 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 @@ -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 cb804a7..7b8aa47 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)) @@ -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 6d084af..f13af69 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 @@ -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)) @@ -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 diff --git a/refactoring-rules.scrbl b/refactoring-rules.scrbl index 41ed603..81a651c 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)]{ diff --git a/test/private/rackunit.rkt b/test/private/rackunit.rkt index 3eb4dbe..0abe5d3 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