From 54f3745e4eb46a6b22230e85d13ef8aa3543d997 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 10:50:15 -0700 Subject: [PATCH 01/12] Move `resyntax/private/syntax-path` into grimoire Co-Authored-By: Claude Fable 5 --- .../analyzers/function-expression-analyzer.rkt | 2 +- default-recommendations/analyzers/identifier-usage.rkt | 2 +- default-recommendations/analyzers/ignored-result-values.rkt | 2 +- default-recommendations/analyzers/variable-mutability.rkt | 2 +- {private => grimoire}/syntax-path.rkt | 0 private/analysis.rkt | 2 +- private/syntax-delta.rkt | 2 +- private/syntax-movement.rkt | 2 +- private/syntax-neighbors.rkt | 2 +- private/syntax-property-bundle.rkt | 2 +- test/private/rackunit.rkt | 2 +- 11 files changed, 10 insertions(+), 10 deletions(-) rename {private => grimoire}/syntax-path.rkt (100%) diff --git a/default-recommendations/analyzers/function-expression-analyzer.rkt b/default-recommendations/analyzers/function-expression-analyzer.rkt index d64254e3..e5d935fe 100644 --- a/default-recommendations/analyzers/function-expression-analyzer.rkt +++ b/default-recommendations/analyzers/function-expression-analyzer.rkt @@ -12,7 +12,7 @@ (require racket/stream rebellion/streaming/transducer resyntax/private/analyzer - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/parse) diff --git a/default-recommendations/analyzers/identifier-usage.rkt b/default-recommendations/analyzers/identifier-usage.rkt index de46499e..5d990dcc 100644 --- a/default-recommendations/analyzers/identifier-usage.rkt +++ b/default-recommendations/analyzers/identifier-usage.rkt @@ -15,7 +15,7 @@ rebellion/streaming/transducer resyntax/default-recommendations/analyzers/private/expanded-id-table resyntax/private/analyzer - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/id-table diff --git a/default-recommendations/analyzers/ignored-result-values.rkt b/default-recommendations/analyzers/ignored-result-values.rkt index 156e4cf8..c2944ecf 100644 --- a/default-recommendations/analyzers/ignored-result-values.rkt +++ b/default-recommendations/analyzers/ignored-result-values.rkt @@ -11,7 +11,7 @@ (require racket/stream resyntax/private/analyzer - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/parse) diff --git a/default-recommendations/analyzers/variable-mutability.rkt b/default-recommendations/analyzers/variable-mutability.rkt index 78d1dd4a..1647ca9b 100644 --- a/default-recommendations/analyzers/variable-mutability.rkt +++ b/default-recommendations/analyzers/variable-mutability.rkt @@ -16,7 +16,7 @@ rebellion/streaming/transducer resyntax/default-recommendations/analyzers/private/expanded-id-table resyntax/private/analyzer - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/id-table diff --git a/private/syntax-path.rkt b/grimoire/syntax-path.rkt similarity index 100% rename from private/syntax-path.rkt rename to grimoire/syntax-path.rkt diff --git a/private/analysis.rkt b/private/analysis.rkt index dc944de3..484a830a 100644 --- a/private/analysis.rkt +++ b/private/analysis.rkt @@ -48,7 +48,7 @@ resyntax/private/string-indent resyntax/private/syntax-movement resyntax/private/syntax-neighbors - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/parse) diff --git a/private/syntax-delta.rkt b/private/syntax-delta.rkt index 5589928f..080c3150 100644 --- a/private/syntax-delta.rkt +++ b/private/syntax-delta.rkt @@ -21,7 +21,7 @@ rebellion/collection/entry rebellion/collection/sorted-map rebellion/streaming/transducer - resyntax/private/syntax-path) + resyntax/grimoire/syntax-path) (module+ test diff --git a/private/syntax-movement.rkt b/private/syntax-movement.rkt index 340e03a5..abf15f10 100644 --- a/private/syntax-movement.rkt +++ b/private/syntax-movement.rkt @@ -17,7 +17,7 @@ rebellion/collection/sorted-set rebellion/streaming/transducer resyntax/private/syntax-neighbors - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-traversal syntax/parse) diff --git a/private/syntax-neighbors.rkt b/private/syntax-neighbors.rkt index 953c087b..9b7f9404 100644 --- a/private/syntax-neighbors.rkt +++ b/private/syntax-neighbors.rkt @@ -27,7 +27,7 @@ racket/struct racket/syntax-srcloc resyntax/private/logger - resyntax/private/syntax-path + resyntax/grimoire/syntax-path syntax/parse syntax/parse/experimental/template) diff --git a/private/syntax-property-bundle.rkt b/private/syntax-property-bundle.rkt index e4549c11..09b4b675 100644 --- a/private/syntax-property-bundle.rkt +++ b/private/syntax-property-bundle.rkt @@ -37,7 +37,7 @@ rebellion/collection/sorted-map rebellion/streaming/reducer rebellion/streaming/transducer - resyntax/private/syntax-path) + resyntax/grimoire/syntax-path) (module+ test diff --git a/test/private/rackunit.rkt b/test/private/rackunit.rkt index b99c4ec4..3eb4dbee 100644 --- a/test/private/rackunit.rkt +++ b/test/private/rackunit.rkt @@ -41,7 +41,7 @@ resyntax/grimoire/source resyntax/private/string-indent resyntax/private/string-replacement - resyntax/private/syntax-path + resyntax/grimoire/syntax-path resyntax/private/syntax-property-bundle resyntax/private/syntax-traversal syntax/modread From 44d8418ff366f40bd25af0eaed7aeb0e70efa2e3 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 10:50:15 -0700 Subject: [PATCH 02/12] Document `resyntax/grimoire/syntax-path` Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/grimoire.scrbl b/grimoire.scrbl index 30327d31..56a39474 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -4,9 +4,13 @@ @(require (for-label racket/base racket/contract/base racket/path + racket/sequence + racket/treelist + rebellion/base/comparator rebellion/base/immutable-string rebellion/collection/range-set resyntax/grimoire/source + resyntax/grimoire/syntax-path syntax/modread)) @@ -180,3 +184,171 @@ This applies to both modified and unmodified file sources. @racketmodname[syntax-color/module-lexer] API. @bold{Warning: the positions are zero-based}, unlike the one-based positions returned from @racket[syntax-position]. Additionally, positions are in terms of @emph{characters} and not @emph{bytes}.} + + +@section{Syntax Paths} +@defmodule[resyntax/grimoire/syntax-path] + +A @deftech{syntax path} identifies the location of a subform within a syntax object. Syntax paths +are sequences of zero-based indices: the empty path refers to an entire syntax object, and a path +whose first element is @racket[_i] refers to a location within the syntax object's @racket[_i]-th +child. Syntax paths are similar in spirit to filesystem paths, and they print in a filesystem-like +notation --- the path @racket[(syntax-path (list 1 2 3))] prints as +@racketresultfont{#}. Resyntax uses syntax paths to refer to subforms of a +program in a way that doesn't depend on source location information or syntax object identity. + +The children of a syntax object are determined by the shape of its datum: + +@itemlist[ + @item{The children of a proper list are its elements, in order.} + + @item{Improper lists are @emph{flattened}: the children are the flattened elements, with the + trailing atom counting as the final child. For example, @racket[#'(a b . c)] has three children, + and @racket[#'c] is the child at index @racket[2]. Dotted forms that flatten to proper lists, such + as @racket[#'(a . (b c))], have the same children as their proper equivalents.} + + @item{The children of a vector are its elements, in order.} + + @item{A box has a single child, its contents, at index @racket[0].} + + @item{The children of a prefab struct are its fields, in order.} + + @item{Hash datums @emph{cannot} be traversed by syntax paths. Operations that would need to + traverse a hash raise contract errors instead.} + + @item{All other datums have no children.}] + + +@subsection{Basic Syntax Path Operations} + + +@defproc[(syntax-path? [v any/c]) boolean?]{ + A predicate that recognizes @tech{syntax paths}.} + + +@defproc[(empty-syntax-path? [v any/c]) boolean?]{ + A predicate that recognizes the empty @tech{syntax path}. Implies @racket[syntax-path?].} + + +@defproc[(nonempty-syntax-path? [v any/c]) boolean?]{ + A predicate that recognizes nonempty @tech{syntax paths}. Implies @racket[syntax-path?].} + + +@defthing[empty-syntax-path syntax-path?]{ + The empty @tech{syntax path}, which refers to an entire syntax object rather than to any subform + within it.} + + +@defproc[(syntax-path [elements (sequence/c exact-nonnegative-integer?)]) syntax-path?]{ + Constructs a @tech{syntax path} from a sequence of zero-based child indices.} + + +@defproc[(syntax-path-elements [path syntax-path?]) (treelist/c exact-nonnegative-integer?)]{ + Returns the child indices that make up @racket[path], as a @tech[#:doc '(lib + "scribblings/reference/reference.scrbl")]{treelist}.} + + +@defproc[(syntax-path-add [path syntax-path?] [element exact-nonnegative-integer?]) + syntax-path?]{ + Extends @racket[path] with one additional child index. The resulting path refers to the + @racket[element]-th child of the subform that @racket[path] refers to.} + + +@defproc[(syntax-path-parent [path nonempty-syntax-path?]) syntax-path?]{ + Returns the path to the form that encloses the subform @racket[path] refers to.} + + +@defproc[(syntax-path-last-element [path nonempty-syntax-path?]) exact-nonnegative-integer?]{ + Returns the final child index of @racket[path], which is the position of the subform that + @racket[path] refers to within its enclosing form.} + + +@defproc[(syntax-path-next-neighbor [path syntax-path?]) (or/c syntax-path? #false)]{ + Returns the path to the sibling immediately following @racket[path] within its enclosing form, or + @racket[#false] if @racket[path] is empty (the root of a syntax object has no siblings). Note that + this is pure path arithmetic: the returned path is not guaranteed to actually exist within any + particular syntax object.} + + +@defproc[(syntax-path-neighbors? [leading-path syntax-path?] [trailing-path syntax-path?]) + boolean?]{ + Returns @racket[#true] if @racket[leading-path] and @racket[trailing-path] refer to immediately + adjacent siblings, meaning they share the same parent path and @racket[trailing-path]'s final + child index is one greater than @racket[leading-path]'s.} + + +@defproc[(syntax-path-remove-prefix [path syntax-path?] [prefix syntax-path?]) syntax-path?]{ + Returns @racket[path] with the leading elements of @racket[prefix] removed, producing a path + relative to the subform that @racket[prefix] refers to. Raises a contract error if @racket[path] + does not start with @racket[prefix].} + + +@defthing[syntax-path<=> (comparator/c syntax-path?)]{ + A comparator that orders @tech{syntax paths} lexicographically by their child indices. A path that + is a prefix of another path sorts before it, so ancestors precede their descendants and sorting a + collection of paths produces a depth-first preorder traversal.} + + +@defproc[(syntax-path->string [path syntax-path?]) immutable-string?]{ + Returns a string notation for @racket[path] in which each child index is preceded by a slash, like + a filesystem path. The empty path is rendered as @racket["/"].} + + +@defproc[(string->syntax-path [str string?]) syntax-path?]{ + Parses @racket[str] as a @tech{syntax path}. This is the inverse of @racket[syntax-path->string]. + The string must start with a slash, must not end with a slash (except for the root path + @racket["/"]), and must contain only slash-separated nonnegative integers. Raises a contract error + otherwise.} + + +@subsection{Operating on Syntax Objects with Syntax Paths} + + +@defproc[(syntax-ref [stx syntax?] [path syntax-path?]) syntax?]{ + Returns the subform of @racket[stx] that @racket[path] refers to. The empty path returns + @racket[stx] itself. Raises a contract error if @racket[path] is inconsistent with the shape of + @racket[stx].} + + +@defproc[(syntax-contains-path? [stx syntax?] [path syntax-path?]) boolean?]{ + Returns @racket[#true] if @racket[path] refers to a subform of @racket[stx], meaning + @racket[syntax-ref] would succeed.} + + +@defproc[(syntax-set [stx syntax?] [path syntax-path?] [new-subform syntax?]) syntax?]{ + Returns a copy of @racket[stx] in which the subform that @racket[path] refers to is replaced with + @racket[new-subform]. Passing the empty path returns @racket[new-subform] itself. The lexical + context, source locations, and syntax properties of the enclosing forms are preserved.} + + +@defproc[(syntax-remove-splice [stx syntax?] + [path nonempty-syntax-path?] + [children-count exact-nonnegative-integer?]) + syntax?]{ + Returns a copy of @racket[stx] with @racket[children-count] consecutive children removed from the + form enclosing @racket[path], starting with the child that @racket[path] refers to. The enclosing + form must be a proper list. Removing zero children returns @racket[stx] unchanged.} + + +@defproc[(syntax-insert-splice [stx syntax?] + [path nonempty-syntax-path?] + [new-children (sequence/c syntax?)]) + syntax?]{ + Returns a copy of @racket[stx] with each syntax object in @racket[new-children] inserted as + consecutive children of the form enclosing @racket[path], such that the first inserted child is + located at @racket[path]. Existing children at or after @racket[path] are shifted over. The + enclosing form must be a proper list. Inserting an empty sequence returns @racket[stx] unchanged.} + + +@defproc[(syntax-label-paths [stx syntax?] [property-name symbol?]) syntax?]{ + Returns a copy of @racket[stx] in which every subform, including @racket[stx] itself, has a + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax property} named + @racket[property-name] whose value is that subform's @tech{syntax path} within @racket[stx]. + Subforms of hash datums are not labeled, as syntax paths cannot refer to them.} + + +@defproc[(in-syntax-paths [stx syntax?] [#:base-path base-path syntax-path? empty-syntax-path]) + (sequence/c syntax-path?)]{ + Returns a sequence of the @tech{syntax paths} of every subform in @racket[stx], in depth-first + preorder. Each returned path is prefixed with @racket[base-path], so the first path in the + sequence is always @racket[base-path] itself, referring to @racket[stx].} From 66201bed0509b62f1e28ee55d7dbcfdd042dc5a6 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 15:59:06 -0700 Subject: [PATCH 03/12] Rename empty/nonempty syntax paths to root/child The empty syntax path is now called the root syntax path, matching filesystem terminology, and nonempty paths are now called child paths, since every path other than the root refers to a child of some enclosing form. Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 40 ++++++----- grimoire/syntax-path.rkt | 108 ++++++++++++++--------------- private/analysis.rkt | 12 ++-- private/syntax-delta.rkt | 4 +- private/syntax-movement.rkt | 4 +- private/syntax-property-bundle.rkt | 20 +++--- 6 files changed, 95 insertions(+), 93 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 56a39474..4947fd7f 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -190,9 +190,9 @@ This applies to both modified and unmodified file sources. @defmodule[resyntax/grimoire/syntax-path] A @deftech{syntax path} identifies the location of a subform within a syntax object. Syntax paths -are sequences of zero-based indices: the empty path refers to an entire syntax object, and a path -whose first element is @racket[_i] refers to a location within the syntax object's @racket[_i]-th -child. Syntax paths are similar in spirit to filesystem paths, and they print in a filesystem-like +are sequences of zero-based indices: the @emph{root} path, which contains no indices, refers to an +entire syntax object, and a path whose first element is @racket[_i] refers to a location within the +syntax object's @racket[_i]-th child. Syntax paths are similar in spirit to filesystem paths, and they print in a filesystem-like notation --- the path @racket[(syntax-path (list 1 2 3))] prints as @racketresultfont{#}. Resyntax uses syntax paths to refer to subforms of a program in a way that doesn't depend on source location information or syntax object identity. @@ -226,17 +226,18 @@ The children of a syntax object are determined by the shape of its datum: A predicate that recognizes @tech{syntax paths}.} -@defproc[(empty-syntax-path? [v any/c]) boolean?]{ - A predicate that recognizes the empty @tech{syntax path}. Implies @racket[syntax-path?].} +@defproc[(root-syntax-path? [v any/c]) boolean?]{ + A predicate that recognizes the root @tech{syntax path}. Implies @racket[syntax-path?].} -@defproc[(nonempty-syntax-path? [v any/c]) boolean?]{ - A predicate that recognizes nonempty @tech{syntax paths}. Implies @racket[syntax-path?].} +@defproc[(child-syntax-path? [v any/c]) boolean?]{ + A predicate that recognizes @tech{syntax paths} that refer to a child of some enclosing form --- + that is, any path except the root. Implies @racket[syntax-path?].} -@defthing[empty-syntax-path syntax-path?]{ - The empty @tech{syntax path}, which refers to an entire syntax object rather than to any subform - within it.} +@defthing[root-syntax-path syntax-path?]{ + The root @tech{syntax path}, which contains no indices and refers to an entire syntax object + rather than to any subform within it.} @defproc[(syntax-path [elements (sequence/c exact-nonnegative-integer?)]) syntax-path?]{ @@ -254,18 +255,19 @@ The children of a syntax object are determined by the shape of its datum: @racket[element]-th child of the subform that @racket[path] refers to.} -@defproc[(syntax-path-parent [path nonempty-syntax-path?]) syntax-path?]{ +@defproc[(syntax-path-parent [path child-syntax-path?]) syntax-path?]{ Returns the path to the form that encloses the subform @racket[path] refers to.} -@defproc[(syntax-path-last-element [path nonempty-syntax-path?]) exact-nonnegative-integer?]{ +@defproc[(syntax-path-last-element [path child-syntax-path?]) exact-nonnegative-integer?]{ Returns the final child index of @racket[path], which is the position of the subform that @racket[path] refers to within its enclosing form.} @defproc[(syntax-path-next-neighbor [path syntax-path?]) (or/c syntax-path? #false)]{ Returns the path to the sibling immediately following @racket[path] within its enclosing form, or - @racket[#false] if @racket[path] is empty (the root of a syntax object has no siblings). Note that + @racket[#false] if @racket[path] is the root path (the root of a syntax object has no siblings). + Note that this is pure path arithmetic: the returned path is not guaranteed to actually exist within any particular syntax object.} @@ -291,7 +293,7 @@ The children of a syntax object are determined by the shape of its datum: @defproc[(syntax-path->string [path syntax-path?]) immutable-string?]{ Returns a string notation for @racket[path] in which each child index is preceded by a slash, like - a filesystem path. The empty path is rendered as @racket["/"].} + a filesystem path. The root path is rendered as @racket["/"].} @defproc[(string->syntax-path [str string?]) syntax-path?]{ @@ -305,7 +307,7 @@ The children of a syntax object are determined by the shape of its datum: @defproc[(syntax-ref [stx syntax?] [path syntax-path?]) syntax?]{ - Returns the subform of @racket[stx] that @racket[path] refers to. The empty path returns + Returns the subform of @racket[stx] that @racket[path] refers to. The root path returns @racket[stx] itself. Raises a contract error if @racket[path] is inconsistent with the shape of @racket[stx].} @@ -317,12 +319,12 @@ The children of a syntax object are determined by the shape of its datum: @defproc[(syntax-set [stx syntax?] [path syntax-path?] [new-subform syntax?]) syntax?]{ Returns a copy of @racket[stx] in which the subform that @racket[path] refers to is replaced with - @racket[new-subform]. Passing the empty path returns @racket[new-subform] itself. The lexical + @racket[new-subform]. Passing the root path returns @racket[new-subform] itself. The lexical context, source locations, and syntax properties of the enclosing forms are preserved.} @defproc[(syntax-remove-splice [stx syntax?] - [path nonempty-syntax-path?] + [path child-syntax-path?] [children-count exact-nonnegative-integer?]) syntax?]{ Returns a copy of @racket[stx] with @racket[children-count] consecutive children removed from the @@ -331,7 +333,7 @@ The children of a syntax object are determined by the shape of its datum: @defproc[(syntax-insert-splice [stx syntax?] - [path nonempty-syntax-path?] + [path child-syntax-path?] [new-children (sequence/c syntax?)]) syntax?]{ Returns a copy of @racket[stx] with each syntax object in @racket[new-children] inserted as @@ -347,7 +349,7 @@ The children of a syntax object are determined by the shape of its datum: Subforms of hash datums are not labeled, as syntax paths cannot refer to them.} -@defproc[(in-syntax-paths [stx syntax?] [#:base-path base-path syntax-path? empty-syntax-path]) +@defproc[(in-syntax-paths [stx syntax?] [#:base-path base-path syntax-path? root-syntax-path]) (sequence/c syntax-path?)]{ Returns a sequence of the @tech{syntax paths} of every subform in @racket[stx], in depth-first preorder. Each returned path is prefixed with @racket[base-path], so the first path in the diff --git a/grimoire/syntax-path.rkt b/grimoire/syntax-path.rkt index 9a012a4d..6adb3287 100644 --- a/grimoire/syntax-path.rkt +++ b/grimoire/syntax-path.rkt @@ -8,14 +8,14 @@ (contract-out [syntax-path? (-> any/c boolean?)] [syntax-path<=> (comparator/c syntax-path?)] - [empty-syntax-path? (-> any/c boolean?)] - [nonempty-syntax-path? (-> any/c boolean?)] - [empty-syntax-path syntax-path?] + [root-syntax-path? (-> any/c boolean?)] + [child-syntax-path? (-> any/c boolean?)] + [root-syntax-path syntax-path?] [syntax-path (-> (sequence/c exact-nonnegative-integer?) syntax-path?)] [syntax-path-elements (-> syntax-path? (treelist/c exact-nonnegative-integer?))] - [syntax-path-parent (-> nonempty-syntax-path? syntax-path?)] + [syntax-path-parent (-> child-syntax-path? syntax-path?)] [syntax-path-next-neighbor (-> syntax-path? (or/c syntax-path? #false))] - [syntax-path-last-element (-> nonempty-syntax-path? exact-nonnegative-integer?)] + [syntax-path-last-element (-> child-syntax-path? exact-nonnegative-integer?)] [syntax-path-add (-> syntax-path? exact-nonnegative-integer? syntax-path?)] [syntax-path-remove-prefix (-> syntax-path? syntax-path? syntax-path?)] [syntax-path-neighbors? (-> syntax-path? syntax-path? boolean?)] @@ -24,8 +24,8 @@ [syntax-ref (-> syntax? syntax-path? syntax?)] [syntax-contains-path? (-> syntax? syntax-path? boolean?)] [syntax-set (-> syntax? syntax-path? syntax? syntax?)] - [syntax-remove-splice (-> syntax? nonempty-syntax-path? exact-nonnegative-integer? syntax?)] - [syntax-insert-splice (-> syntax? nonempty-syntax-path? (sequence/c syntax?) syntax?)] + [syntax-remove-splice (-> syntax? child-syntax-path? exact-nonnegative-integer? syntax?)] + [syntax-insert-splice (-> syntax? child-syntax-path? (sequence/c syntax?) syntax?)] [syntax-label-paths (-> syntax? symbol? syntax?)] [in-syntax-paths (->* (syntax?) (#:base-path syntax-path?) (sequence/c syntax-path?))])) @@ -73,16 +73,16 @@ (write-string ">" out))]) -(define empty-syntax-path (syntax-path (treelist))) +(define root-syntax-path (syntax-path (treelist))) (module+ test (test-case "syntax-path custom printing" - (test-case "empty path prints as #" - (check-equal? (~a empty-syntax-path) "#") - (check-equal? (~v empty-syntax-path) "#") - (check-equal? (~s empty-syntax-path) "#")) + (test-case "root path prints as #" + (check-equal? (~a root-syntax-path) "#") + (check-equal? (~v root-syntax-path) "#") + (check-equal? (~s root-syntax-path) "#")) (test-case "single element path prints compactly" (define path (syntax-path (list 0))) @@ -99,26 +99,26 @@ (check-equal? (~a path) "#")))) -(define (empty-syntax-path? v) +(define (root-syntax-path? v) (and (syntax-path? v) (treelist-empty? (syntax-path-elements v)))) (module+ test - (test-case "empty-syntax-path?" - (check-true (empty-syntax-path? empty-syntax-path)) - (check-false (empty-syntax-path? (syntax-path (list 0)))) - (check-false (empty-syntax-path? 42)))) + (test-case "root-syntax-path?" + (check-true (root-syntax-path? root-syntax-path)) + (check-false (root-syntax-path? (syntax-path (list 0)))) + (check-false (root-syntax-path? 42)))) -(define (nonempty-syntax-path? v) +(define (child-syntax-path? v) (and (syntax-path? v) (not (treelist-empty? (syntax-path-elements v))))) (module+ test - (test-case "nonempty-syntax-path?" - (check-false (nonempty-syntax-path? empty-syntax-path)) - (check-true (nonempty-syntax-path? (syntax-path (list 0)))) - (check-false (nonempty-syntax-path? 42)))) + (test-case "child-syntax-path?" + (check-false (child-syntax-path? root-syntax-path)) + (check-true (child-syntax-path? (syntax-path (list 0)))) + (check-false (child-syntax-path? 42)))) @@ -130,7 +130,7 @@ (module+ test (test-case "syntax-path-add" - (check-equal? (syntax-path-add empty-syntax-path 0) (syntax-path (list 0))) + (check-equal? (syntax-path-add root-syntax-path 0) (syntax-path (list 0))) (check-equal? (syntax-path-add (syntax-path (list 0)) 1) (syntax-path (list 0 1))))) @@ -141,11 +141,11 @@ (module+ test (test-case "syntax-path-parent" - (check-equal? (syntax-path-parent (syntax-path (list 0))) empty-syntax-path) + (check-equal? (syntax-path-parent (syntax-path (list 0))) root-syntax-path) (check-equal? (syntax-path-parent (syntax-path (list 0 1))) (syntax-path (list 0))) - (check-exn exn:fail:contract? (λ () (syntax-path-parent empty-syntax-path))) - (check-exn #rx"expected: nonempty-syntax-path?" (λ () (syntax-path-parent empty-syntax-path))))) + (check-exn exn:fail:contract? (λ () (syntax-path-parent root-syntax-path))) + (check-exn #rx"expected: child-syntax-path?" (λ () (syntax-path-parent root-syntax-path))))) (define/guard (syntax-path-next-neighbor path) @@ -159,8 +159,8 @@ (module+ test (test-case "syntax-path-next-neighbor" - (test-case "empty path" - (check-false (syntax-path-next-neighbor empty-syntax-path))) + (test-case "root path" + (check-false (syntax-path-next-neighbor root-syntax-path))) (test-case "first child" (define path (syntax-path (list 0))) @@ -197,9 +197,9 @@ (module+ test (test-case "syntax-path-remove-prefix" - (test-case "remove empty" + (test-case "remove root prefix" (define path (syntax-path (list 1 2 3))) - (check-equal? (syntax-path-remove-prefix path empty-syntax-path) path)) + (check-equal? (syntax-path-remove-prefix path root-syntax-path) path)) (test-case "remove one elem" (define path (syntax-path (list 1 2 3))) @@ -223,14 +223,14 @@ (check-equal? (syntax-path-last-element (syntax-path (list 0))) 0) (check-equal? (syntax-path-last-element (syntax-path (list 0 1))) 1) - (check-exn exn:fail:contract? (λ () (syntax-path-last-element empty-syntax-path))) - (check-exn #rx"expected: nonempty-syntax-path?" - (λ () (syntax-path-last-element empty-syntax-path))))) + (check-exn exn:fail:contract? (λ () (syntax-path-last-element root-syntax-path))) + (check-exn #rx"expected: child-syntax-path?" + (λ () (syntax-path-last-element root-syntax-path))))) (define (syntax-path-neighbors? leading-path trailing-path) - (and (nonempty-syntax-path? leading-path) - (nonempty-syntax-path? trailing-path) + (and (child-syntax-path? leading-path) + (child-syntax-path? trailing-path) (equal? (syntax-path-parent leading-path) (syntax-path-parent trailing-path)) (syntax-path-element-neighbors? (syntax-path-last-element leading-path) (syntax-path-last-element trailing-path)))) @@ -253,8 +253,8 @@ (module+ test (test-case "syntax-path->string" - (test-case "empty path" - (check-equal? (syntax-path->string empty-syntax-path) "/")) + (test-case "root path" + (check-equal? (syntax-path->string root-syntax-path) "/")) (test-case "single element" (check-equal? (syntax-path->string (syntax-path (list 0))) "/0")) @@ -278,7 +278,7 @@ "syntax path string must not end with / (except for root path)" "given" str)) (cond - [(equal? str "/") empty-syntax-path] + [(equal? str "/") root-syntax-path] [else (define parts (string-split (substring str 1) "/")) (define numbers @@ -298,8 +298,8 @@ (module+ test (test-case "string->syntax-path" - (test-case "empty path" - (check-equal? (string->syntax-path "/") empty-syntax-path)) + (test-case "root path" + (check-equal? (string->syntax-path "/") root-syntax-path)) (test-case "single element" (check-equal? (string->syntax-path "/0") (syntax-path (list 0)))) @@ -371,9 +371,9 @@ (module+ test (test-case "round-trip conversion" - (test-case "empty path" - (check-equal? (string->syntax-path (syntax-path->string empty-syntax-path)) - empty-syntax-path)) + (test-case "root path" + (check-equal? (string->syntax-path (syntax-path->string root-syntax-path)) + root-syntax-path)) (test-case "single element path" (define path (syntax-path (list 5))) @@ -522,9 +522,9 @@ (module+ test (test-case "syntax-ref" - (test-case "empty path" + (test-case "root path" (define stx #'a) - (define actual (syntax-ref stx empty-syntax-path)) + (define actual (syntax-ref stx root-syntax-path)) (check-equal? actual stx)) (test-case "list element path" @@ -654,9 +654,9 @@ (module+ test (test-case "syntax-contains-path?" - (test-case "empty path on any syntax" + (test-case "root path on any syntax" (define stx #'a) - (check-true (syntax-contains-path? stx empty-syntax-path))) + (check-true (syntax-contains-path? stx root-syntax-path))) (test-case "valid path in list" (define stx #'(a b c)) @@ -822,9 +822,9 @@ (define new-subform #'FOO) - (test-case "empty path" + (test-case "root path" (define stx #'a) - (define actual (syntax-set stx empty-syntax-path new-subform)) + (define actual (syntax-set stx root-syntax-path new-subform)) (check-equal? actual new-subform)) (test-case "list element path" @@ -927,10 +927,10 @@ (check-exn exn:fail? (λ () (syntax-remove-splice stx (syntax-path (list 0)) 1)))) - (test-case "error on empty path with non-zero count" + (test-case "error on root path with non-zero count" (define stx #'(a b c)) (check-exn exn:fail:contract? - (λ () (syntax-remove-splice stx empty-syntax-path 1)))) + (λ () (syntax-remove-splice stx root-syntax-path 1)))) (test-case "error on non-list target" (define stx #'#(a b c)) @@ -989,10 +989,10 @@ (define actual (syntax-insert-splice stx (syntax-path (list 0)) (list #'x))) (check-equal? (syntax->datum actual) '(x))) - (test-case "error on empty path" + (test-case "error on root path" (define stx #'(a b c)) (check-exn exn:fail:contract? - (λ () (syntax-insert-splice stx empty-syntax-path (list #'x))))) + (λ () (syntax-insert-splice stx root-syntax-path (list #'x))))) (test-case "error on non-list target" (define stx #'#(a b c)) @@ -1228,7 +1228,7 @@ [_ (in-list (list))])) -(define (in-syntax-paths stx #:base-path [base-path empty-syntax-path]) +(define (in-syntax-paths stx #:base-path [base-path root-syntax-path]) (in-generator (let traverse ([stx stx] [parent-elems (syntax-path-elements base-path)]) diff --git a/private/analysis.rkt b/private/analysis.rkt index 484a830a..2b9af382 100644 --- a/private/analysis.rkt +++ b/private/analysis.rkt @@ -333,7 +333,7 @@ (λ (expanded) (syntax-property-bundle ;; Valid path - the root - (syntax-property-entry empty-syntax-path 'valid-prop #true) + (syntax-property-entry root-syntax-path 'valid-prop #true) ;; Invalid path - way out of bounds (syntax-property-entry (syntax-path (list 999)) 'invalid-prop #true) ;; Another invalid path @@ -350,7 +350,7 @@ (check-true (syntax-property-bundle? props)) ;; The valid property at the root should be present - (define root-props (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define root-props (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref root-props 'valid-prop #false) #true) ;; The invalid properties should NOT be present @@ -370,7 +370,7 @@ ;; Sleep for 200ms - longer than a 100ms timeout (sleep 0.2) (syntax-property-bundle - (syntax-property-entry empty-syntax-path 'slow-prop #true))))) + (syntax-property-entry root-syntax-path 'slow-prop #true))))) ;; Run analysis with the slow analyzer and a short timeout - should timeout and not crash (define analysis (source-analyze test-source #:analyzers (list slow-analyzer) #:timeout-ms 100)) @@ -381,7 +381,7 @@ ;; Check that no properties were added from the timed-out analyzer (define props (source-code-analysis-added-syntax-properties analysis)) (check-true (syntax-property-bundle? props)) - (define root-props (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define root-props (syntax-property-bundle-get-immediate-properties props root-syntax-path)) ;; The slow-prop should NOT be present since the analyzer timed out (check-false (hash-has-key? root-props 'slow-prop))) @@ -395,7 +395,7 @@ (λ (expanded) ;; This should complete quickly, well within the timeout (syntax-property-bundle - (syntax-property-entry empty-syntax-path 'fast-prop #true))))) + (syntax-property-entry root-syntax-path 'fast-prop #true))))) ;; Run analysis with the fast analyzer - should complete successfully (define analysis (source-analyze test-source #:analyzers (list fast-analyzer) #:timeout-ms 10000)) @@ -406,7 +406,7 @@ ;; Check that properties were added from the successful analyzer (define props (source-code-analysis-added-syntax-properties analysis)) (check-true (syntax-property-bundle? props)) - (define root-props (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define root-props (syntax-property-bundle-get-immediate-properties props root-syntax-path)) ;; The fast-prop should be present since the analyzer completed successfully (check-equal? (hash-ref root-props 'fast-prop #false) #true)) diff --git a/private/syntax-delta.rkt b/private/syntax-delta.rkt index 080c3150..9f65d8fc 100644 --- a/private/syntax-delta.rkt +++ b/private/syntax-delta.rkt @@ -80,8 +80,8 @@ (define disjoint? (cond [(equal? children-count 0) #true] - [(empty-syntax-path? start) #false] - [(empty-syntax-path? next-start) #false] + [(root-syntax-path? start) #false] + [(root-syntax-path? next-start) #false] [(equal? (syntax-path-parent start) (syntax-path-parent next-start)) (<= (syntax-path-last-element start) (syntax-path-last-element next-start))] [else #true])) diff --git a/private/syntax-movement.rkt b/private/syntax-movement.rkt index abf15f10..56473068 100644 --- a/private/syntax-movement.rkt +++ b/private/syntax-movement.rkt @@ -70,8 +70,8 @@ #:key-comparator syntax-path<=> ; (module ...) - empty-syntax-path - (sorted-set empty-syntax-path (syntax-path (list 3)) #:comparator syntax-path<=>) + root-syntax-path + (sorted-set root-syntax-path (syntax-path (list 3)) #:comparator syntax-path<=>) ; module (syntax-path (list 0)) diff --git a/private/syntax-property-bundle.rkt b/private/syntax-property-bundle.rkt index 09b4b675..6f012695 100644 --- a/private/syntax-property-bundle.rkt +++ b/private/syntax-property-bundle.rkt @@ -204,7 +204,7 @@ (define/guard (syntax-property-bundle-get-all-properties prop-bundle path) - (guard (nonempty-syntax-path? path) #:else prop-bundle) + (guard (child-syntax-path? path) #:else prop-bundle) (define next-neighbor (syntax-path-next-neighbor path)) (define path-range (if next-neighbor @@ -246,7 +246,7 @@ (syntax-set stx path new-subform)) -(define (syntax-immediate-properties stx #:base-path [base-path empty-syntax-path]) +(define (syntax-immediate-properties stx #:base-path [base-path root-syntax-path]) (define keys (syntax-property-symbol-keys stx)) (transduce keys (mapping (λ (key) @@ -255,7 +255,7 @@ #:into into-syntax-property-bundle)) -(define (syntax-all-properties stx #:base-path [base-path empty-syntax-path]) +(define (syntax-all-properties stx #:base-path [base-path root-syntax-path]) (transduce (in-syntax-paths stx #:base-path base-path) (append-mapping (λ (path) @@ -272,7 +272,7 @@ (define stx #'(a (b c) d)) (define props (syntax-property-bundle - (syntax-property-entry empty-syntax-path 'size 3) + (syntax-property-entry root-syntax-path 'size 3) (syntax-property-entry (syntax-path (list 0)) 'headphone-shaped? #false) (syntax-property-entry (syntax-path (list 1)) 'size 2) (syntax-property-entry (syntax-path (list 1 0)) 'headphone-shaped? #true) @@ -298,14 +298,14 @@ (define stx #'(a b c)) (define props (syntax-immediate-properties stx)) (check-true (syntax-property-bundle? props)) - (check-equal? (syntax-property-bundle-get-immediate-properties props empty-syntax-path) + (check-equal? (syntax-property-bundle-get-immediate-properties props root-syntax-path) (hash))) (test-case "syntax with single property" (define stx (syntax-property #'(a b c) 'foo 42)) (define props (syntax-immediate-properties stx)) (check-true (syntax-property-bundle? props)) - (define immediate (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define immediate (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref immediate 'foo) 42)) (test-case "syntax with multiple properties" @@ -317,7 +317,7 @@ 'baz "hello")) (define props (syntax-immediate-properties stx)) (check-true (syntax-property-bundle? props)) - (define immediate (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define immediate (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref immediate 'foo) 42) (check-equal? (hash-ref immediate 'bar) #true) (check-equal? (hash-ref immediate 'baz) "hello")) @@ -329,7 +329,7 @@ (datum->syntax #f (list inner-stx #'y) #f) 'outer-prop 456)) (define props (syntax-immediate-properties outer-stx)) - (define immediate (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define immediate (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref immediate 'outer-prop #false) 456) (check-equal? (hash-ref immediate 'inner-prop #false) #false)))) @@ -347,7 +347,7 @@ (define stx (syntax-property #'(a b c) 'root-prop 42)) (define props (syntax-all-properties stx)) (check-true (syntax-property-bundle? props)) - (define root-props (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define root-props (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref root-props 'root-prop) 42)) (test-case "syntax with properties on multiple subforms" @@ -359,7 +359,7 @@ (define props (syntax-all-properties stx-with-root)) (check-true (syntax-property-bundle? props)) - (define root-props (syntax-property-bundle-get-immediate-properties props empty-syntax-path)) + (define root-props (syntax-property-bundle-get-immediate-properties props root-syntax-path)) (check-equal? (hash-ref root-props 'root-prop) 0) (define a-props (syntax-property-bundle-get-immediate-properties props (syntax-path (list 0)))) From 29f69779d4c17c761fbcc5b6b8cffec7209ed2e9 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 16:07:22 -0700 Subject: [PATCH 04/12] Clarify that syntax paths flatten all pair-based forms The previous wording implied flattening was specific to improper lists. In fact all pair-based shapes are canonicalized the same way regardless of how their pairs and syntax objects nest, as covered by the "improper lists - all produce same paths" tests. Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 4947fd7f..4d8552be 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -200,12 +200,12 @@ program in a way that doesn't depend on source location information or syntax ob The children of a syntax object are determined by the shape of its datum: @itemlist[ - @item{The children of a proper list are its elements, in order.} - - @item{Improper lists are @emph{flattened}: the children are the flattened elements, with the - trailing atom counting as the final child. For example, @racket[#'(a b . c)] has three children, - and @racket[#'c] is the child at index @racket[2]. Dotted forms that flatten to proper lists, such - as @racket[#'(a . (b c))], have the same children as their proper equivalents.} + @item{The children of a pair-based form are the elements of its fully @emph{flattened} shape, in + order, with the trailing atom of an improper list counting as the final child. How the underlying + pairs and syntax objects nest has no effect on paths: @racket[#'(a b c)], + @racket[#'(a . (b . (c . ())))], @racket[#'(a . (b c))], @racket[#'(a b . c)], and + @racket[#'(a . (b . c))] all have three children, and in each case the child at index @racket[2] + is @racket[#'c].} @item{The children of a vector are its elements, in order.} From b26f3ded16e6c51f71c0d9be01f46bfda49527c2 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 16:10:47 -0700 Subject: [PATCH 05/12] Say syntax paths normalize improper lists rather than flatten them "Flatten" evokes Racket's `flatten` function, which erases all tree structure including nested proper lists. Syntax paths only normalize improper lists into proper ones; nested forms remain distinct children. Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 4d8552be..9d0a094c 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -200,12 +200,13 @@ program in a way that doesn't depend on source location information or syntax ob The children of a syntax object are determined by the shape of its datum: @itemlist[ - @item{The children of a pair-based form are the elements of its fully @emph{flattened} shape, in - order, with the trailing atom of an improper list counting as the final child. How the underlying - pairs and syntax objects nest has no effect on paths: @racket[#'(a b c)], - @racket[#'(a . (b . (c . ())))], @racket[#'(a . (b c))], @racket[#'(a b . c)], and - @racket[#'(a . (b . c))] all have three children, and in each case the child at index @racket[2] - is @racket[#'c].} + @item{The children of a pair-based form are the elements of its @emph{normalized} shape: the + proper list obtained by treating the trailing atom of an improper list, if any, as a final + element. Only the form's own pair structure is normalized --- nested forms remain distinct + children --- but how the underlying pairs and syntax objects nest has no effect on paths: + @racket[#'(a b c)], @racket[#'(a . (b . (c . ())))], @racket[#'(a . (b c))], @racket[#'(a b . c)], + and @racket[#'(a . (b . c))] all have three children, and in each case the child at index + @racket[2] is @racket[#'c].} @item{The children of a vector are its elements, in order.} From 1d7ee5bbaf60541af352c3cfbc8f496ed2a00d27 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 16:45:30 -0700 Subject: [PATCH 06/12] Expound on why hash literals break Resyntax --- grimoire.scrbl | 59 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 9d0a094c..7885d85e 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -34,8 +34,8 @@ In Resyntax, @deftech{source code} refers to @racket[source?] values, which come @item{@emph{Source strings}, constructed with @racket[string-source], which contain the source code directly as a string and don't exist anywhere on the local filesystem. (These are useful for - testing Resyntax, and other scenarios where Resyntax needs to operate on code that doesn't exist on - disk.)} + testing Resyntax, and other scenarios where Resyntax needs to operate on code that doesn't exist on + disk.)} @item{@emph{Modified sources}, constructed by passing another (unmodified) source to @racket[modified-source] along with a string representing what to replace the source's contents @@ -192,10 +192,11 @@ This applies to both modified and unmodified file sources. A @deftech{syntax path} identifies the location of a subform within a syntax object. Syntax paths are sequences of zero-based indices: the @emph{root} path, which contains no indices, refers to an entire syntax object, and a path whose first element is @racket[_i] refers to a location within the -syntax object's @racket[_i]-th child. Syntax paths are similar in spirit to filesystem paths, and they print in a filesystem-like -notation --- the path @racket[(syntax-path (list 1 2 3))] prints as +syntax object's @racket[_i]-th child. Syntax paths are similar in spirit to filesystem paths, and they +print in a filesystem-like notation --- the path @racket[(syntax-path (list 1 2 3))] prints as @racketresultfont{#}. Resyntax uses syntax paths to refer to subforms of a -program in a way that doesn't depend on source location information or syntax object identity. +program in a way that doesn't depend on exact source locations or syntax object identity, and which is +relatively stable when structure-preserving edits are made to source text. The children of a syntax object are determined by the shape of its datum: @@ -215,10 +216,52 @@ The children of a syntax object are determined by the shape of its datum: @item{The children of a prefab struct are its fields, in order.} @item{Hash datums @emph{cannot} be traversed by syntax paths. Operations that would need to - traverse a hash raise contract errors instead.} + traverse a hash raise contract errors instead. More about this constraint is explained below.} @item{All other datums have no children.}] +When Resyntax calls the Racket reader to turn @tech{source code} into unexpanded syntax objects using +@racket[source-read-syntax], Resyntax recurisely labels each syntax object with its +@deftech{original syntax path}. This label is attached to each syntax object via a +@tech{syntax property} named @racket['original-syntax-path]. + +As these syntax objects are expanded by the Racket macro expander and further manipulated by +@tech{refactoring rules}, subforms get transformed and moved, but they preserve their original syntax +properties. Resyntax then inspects these properties on the refactored syntax objects produced by rules +in order to determine where each syntax object originated @emph{structurally}, in addition to +inspecting source locations to determine where it originated @emph{textually}. Resyntax uses this +information to determine when refactored code contains unchanged subexpressions whose text should be +@emph{copied} from the input rather than reproduced wholesale and reformatted. + +Unchanged syntax objects have their original text copied by Resyntax, and if two unchanged syntax +objects start and end adjacent to each other (and without swapping their order) then the original text +between them is also copied. This allows Resyntax to minimize the amount of text that a refactoring +has to change. However, in order for this process to work, Resyntax makes a few assumptions about the +relationship between syntax paths and the source locations of reader-produced syntax objects. These +assumptions are: + +@itemlist[ + @item{If one syntax path is a prefix of another, then the source location of a syntax object + originally at the first path (the prefix path) should @emph{fully contain} the source location of a + syntax object originally at the second path. This need not be a @emph{proper subset} relation: it's + acceptable for the two syntax objects to have the exact same source location. That allowance doesn't + affect code written in @hash-lang[] @racketmodname[racket], but it matters for other languages which + sometimes use "invisible" wrappers around syntax objects to reflect grouping implied by how the text + was originally parsed by the language's reader.} + + @item{If two syntax paths are disjoint, meaning neither is a prefix of the other, then the source + locations of two syntax objects originally at those paths should contain no overlap (but they may + be adjacent). Furthermore, the lesser syntax path (in the sense of @racket[syntax-path<=>]) should + correspond to the source location that occurs earlier in the source code (by character position).}] + +It is the second of these two requirements that prevents Resyntax from traversing literal hash datums. +A hash datum such as @racket[#hash((a . 1) ("foo" . 2) ('b . 3))], when parsed by the Racket reader, +@bold{does not preserve source ordering in the resulting hash datum's iteration order}. Two literal +hash datums with the same keys will always iterate in the same order when inspected with +@racket[syntax-e]. As a result, Resyntax cannot uphold the assumptions it makes about source locations +and syntax paths, and cannot preserve text between adjacent hash entries correctly. For this reason, +Resyntax does not allow editing expressions inside hash datums. + @subsection{Basic Syntax Path Operations} @@ -246,8 +289,8 @@ The children of a syntax object are determined by the shape of its datum: @defproc[(syntax-path-elements [path syntax-path?]) (treelist/c exact-nonnegative-integer?)]{ - Returns the child indices that make up @racket[path], as a @tech[#:doc '(lib - "scribblings/reference/reference.scrbl")]{treelist}.} + Returns the child indices that make up @racket[path], as a + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{treelist}.} @defproc[(syntax-path-add [path syntax-path?] [element exact-nonnegative-integer?]) From 32aca56497fd921b91893da09b3821a82ef1d450 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 19:26:15 -0700 Subject: [PATCH 07/12] Polish and cross-link the original syntax path docs - Fix the broken syntax property tech reference and a typo - Fix the hash example: #hash keys are implicitly quoted, so ('b . 3) made the key the list (quote b) rather than the symbol b - Give the discussion its own linkable subsection and point it and the comment preservation section in the rules docs at each other - Document that source-read-syntax and source-expand attach the 'original-syntax-path property, and index that property key with indexed-racket so it shows up in docs search Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 25 ++++++++++++++++++------- refactoring-rules.scrbl | 4 +++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 7885d85e..a9af36a1 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -160,11 +160,15 @@ This applies to both modified and unmodified file sources. @defproc[(source-read-syntax [code source?]) syntax?]{ Reads @racket[code] as a syntax object, using the module reading parameterization to allow the - source's @hash-lang[] to control the reader.} + source's @hash-lang[] to control the reader. Every syntax object within the result is labeled with + its @tech{original syntax path} using the @racket['original-syntax-path] syntax property, as + described in @secref["original-syntax-paths"].} @defproc[(source-expand [code source?]) syntax?]{ - Reads @racket[code] and fully expands it, as in @racket[expand].} + Reads @racket[code] and fully expands it, as in @racket[expand]. Because the program is read with + @racket[source-read-syntax], its subforms are labeled with @racket['original-syntax-path] + properties before expansion occurs.} @defproc[(source-can-expand? [code source?]) boolean?]{ @@ -216,14 +220,19 @@ The children of a syntax object are determined by the shape of its datum: @item{The children of a prefab struct are its fields, in order.} @item{Hash datums @emph{cannot} be traversed by syntax paths. Operations that would need to - traverse a hash raise contract errors instead. More about this constraint is explained below.} + traverse a hash raise contract errors instead. More about this constraint is explained in + @secref["original-syntax-paths"].} @item{All other datums have no children.}] +@subsection[#:tag "original-syntax-paths"]{Original Syntax Paths and Formatting Preservation} + + When Resyntax calls the Racket reader to turn @tech{source code} into unexpanded syntax objects using -@racket[source-read-syntax], Resyntax recurisely labels each syntax object with its +@racket[source-read-syntax], Resyntax recursively labels each syntax object with its @deftech{original syntax path}. This label is attached to each syntax object via a -@tech{syntax property} named @racket['original-syntax-path]. +@tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax property} named +@indexed-racket['original-syntax-path]. As these syntax objects are expanded by the Racket macro expander and further manipulated by @tech{refactoring rules}, subforms get transformed and moved, but they preserve their original syntax @@ -231,7 +240,9 @@ properties. Resyntax then inspects these properties on the refactored syntax obj in order to determine where each syntax object originated @emph{structurally}, in addition to inspecting source locations to determine where it originated @emph{textually}. Resyntax uses this information to determine when refactored code contains unchanged subexpressions whose text should be -@emph{copied} from the input rather than reproduced wholesale and reformatted. +@emph{copied} from the input rather than reproduced wholesale and reformatted. This mechanism is what +powers the automatic comment preservation described in @secref["comment-preservation"], and the +template metafunctions described in that section give rule authors a way to guide it. Unchanged syntax objects have their original text copied by Resyntax, and if two unchanged syntax objects start and end adjacent to each other (and without swapping their order) then the original text @@ -255,7 +266,7 @@ assumptions are: correspond to the source location that occurs earlier in the source code (by character position).}] It is the second of these two requirements that prevents Resyntax from traversing literal hash datums. -A hash datum such as @racket[#hash((a . 1) ("foo" . 2) ('b . 3))], when parsed by the Racket reader, +A hash datum such as @racket[#hash((a . 1) ("foo" . 2) (b . 3))], when parsed by the Racket reader, @bold{does not preserve source ordering in the resulting hash datum's iteration order}. Two literal hash datums with the same keys will always iterate in the same order when inspected with @racket[syntax-e]. As a result, Resyntax cannot uphold the assumptions it makes about source locations diff --git a/refactoring-rules.scrbl b/refactoring-rules.scrbl index 4c498ec2..41ed6036 100644 --- a/refactoring-rules.scrbl +++ b/refactoring-rules.scrbl @@ -178,7 +178,7 @@ refactoring rules. (void)))} -@section{Exercising Fine Control Over Comments} +@section[#:tag "comment-preservation"]{Exercising Fine Control Over Comments} Writing a rule with @racket[define-refactoring-rule] is usually enough for Resyntax to handle @@ -248,6 +248,8 @@ Based on this observation, Resyntax decides to preserve whatever text was origin @racket[(foo ...)] and the nested @racket[or] expression. This mechanism, exposed via @racket[~replacement] and @racket[~splicing-replacement], offers a means for refactoring rules to guide Resyntax's internal comment preservation system when the default behavior is not sufficient. +For an explanation of how that system works under the hood, see @secref["original-syntax-paths"] in +The Resyntax Grimoire. @defform[#:kind "template metafunction" (~replacement replacement-form original) From f8a236bc9ef35ed6636cf0db874d202c45ce20cf Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 20:15:45 -0700 Subject: [PATCH 08/12] Clarify some syntax path API reference entries Most of the prose here looks good but there are some sneaky details I decided to call out. --- grimoire.scrbl | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index a9af36a1..0a86fc5d 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -225,8 +225,8 @@ The children of a syntax object are determined by the shape of its datum: @item{All other datums have no children.}] -@subsection[#:tag "original-syntax-paths"]{Original Syntax Paths and Formatting Preservation} +@subsection[#:tag "original-syntax-paths"]{Original Syntax Paths and Formatting Preservation} When Resyntax calls the Racket reader to turn @tech{source code} into unexpanded syntax objects using @racket[source-read-syntax], Resyntax recursively labels each syntax object with its @@ -311,7 +311,8 @@ Resyntax does not allow editing expressions inside hash datums. @defproc[(syntax-path-parent [path child-syntax-path?]) syntax-path?]{ - Returns the path to the form that encloses the subform @racket[path] refers to.} + Returns the path to the parent of @racket[path], which refers to the form that immediately encloses + the form referred to by @racket[path].} @defproc[(syntax-path-last-element [path child-syntax-path?]) exact-nonnegative-integer?]{ @@ -322,16 +323,18 @@ Resyntax does not allow editing expressions inside hash datums. @defproc[(syntax-path-next-neighbor [path syntax-path?]) (or/c syntax-path? #false)]{ Returns the path to the sibling immediately following @racket[path] within its enclosing form, or @racket[#false] if @racket[path] is the root path (the root of a syntax object has no siblings). - Note that - this is pure path arithmetic: the returned path is not guaranteed to actually exist within any - particular syntax object.} + Note that this is pure path arithmetic: the returned path is not guaranteed to actually exist within + any particular syntax object.} @defproc[(syntax-path-neighbors? [leading-path syntax-path?] [trailing-path syntax-path?]) boolean?]{ Returns @racket[#true] if @racket[leading-path] and @racket[trailing-path] refer to immediately adjacent siblings, meaning they share the same parent path and @racket[trailing-path]'s final - child index is one greater than @racket[leading-path]'s.} + child index is one greater than @racket[leading-path]'s. The order of @racket[leading-path] and + @racket[trailing-path] is significant: this operation does @emph{not} return @racket[#true] if + @racket[leading-path] and @racket[trailing-path] are adjacent siblings, but the @emph{trailing} path + comes first.} @defproc[(syntax-path-remove-prefix [path syntax-path?] [prefix syntax-path?]) syntax-path?]{ @@ -364,7 +367,9 @@ Resyntax does not allow editing expressions inside hash datums. @defproc[(syntax-ref [stx syntax?] [path syntax-path?]) syntax?]{ Returns the subform of @racket[stx] that @racket[path] refers to. The root path returns @racket[stx] itself. Raises a contract error if @racket[path] is inconsistent with the shape of - @racket[stx].} + @racket[stx], which can occur if @racket[path] refers to children of an atomic subform that has no + children, or if @racket[path] contains a child index that's too large for the number of children + actually contained by the parent subform of that index.} @defproc[(syntax-contains-path? [stx syntax?] [path syntax-path?]) boolean?]{ @@ -384,7 +389,9 @@ Resyntax does not allow editing expressions inside hash datums. syntax?]{ Returns a copy of @racket[stx] with @racket[children-count] consecutive children removed from the form enclosing @racket[path], starting with the child that @racket[path] refers to. The enclosing - form must be a proper list. Removing zero children returns @racket[stx] unchanged.} + form must be a proper list. Removing zero children returns @racket[stx] unchanged. Removing one child + removes @emph{only} the subform referred to by @racket[path]. If @racket[stx] does not contain + @racket[path] (in the sense of @racket[syntax-contains-path?]) a contract error is raised.} @defproc[(syntax-insert-splice [stx syntax?] @@ -393,19 +400,24 @@ Resyntax does not allow editing expressions inside hash datums. syntax?]{ Returns a copy of @racket[stx] with each syntax object in @racket[new-children] inserted as consecutive children of the form enclosing @racket[path], such that the first inserted child is - located at @racket[path]. Existing children at or after @racket[path] are shifted over. The - enclosing form must be a proper list. Inserting an empty sequence returns @racket[stx] unchanged.} + located at @racket[path]. Existing children at or after @racket[path] are shifted over --- + @racket[new-children] are inserted @emph{just before} @racket[path]. The enclosing form must be a + proper list. Inserting an empty sequence returns @racket[stx] unchanged.} @defproc[(syntax-label-paths [stx syntax?] [property-name symbol?]) syntax?]{ Returns a copy of @racket[stx] in which every subform, including @racket[stx] itself, has a @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax property} named @racket[property-name] whose value is that subform's @tech{syntax path} within @racket[stx]. - Subforms of hash datums are not labeled, as syntax paths cannot refer to them.} + Subforms of hash datums are not labeled, as syntax paths cannot refer to them --- see + @secref["original-syntax-paths"] for further explanation.} @defproc[(in-syntax-paths [stx syntax?] [#:base-path base-path syntax-path? root-syntax-path]) (sequence/c syntax-path?)]{ Returns a sequence of the @tech{syntax paths} of every subform in @racket[stx], in depth-first preorder. Each returned path is prefixed with @racket[base-path], so the first path in the - sequence is always @racket[base-path] itself, referring to @racket[stx].} + sequence is always @racket[base-path] itself. The @racket[base-path] argument is useful when + @racket[stx] is itself assumed to be a subform of some larger syntax object. In Resyntax's case, + that's usually the root syntax object for the entire source file as returned by + @racket[source-read-syntax].} From 587e672d61b30442f960fe10f2a85444f9a68bb6 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 20:21:15 -0700 Subject: [PATCH 09/12] Fix confusing Scribble rendering The `@racket[]` form performs some syntax object normalization of its own, so these four syntax objects weren't all rendering as distinct text in the final document. Using `@tt{}` instead of `@racket[]` fixes this (because `@tt` doesn't try to actually parse its input text in any way). --- grimoire.scrbl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 0a86fc5d..bc097855 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -209,7 +209,7 @@ The children of a syntax object are determined by the shape of its datum: proper list obtained by treating the trailing atom of an improper list, if any, as a final element. Only the form's own pair structure is normalized --- nested forms remain distinct children --- but how the underlying pairs and syntax objects nest has no effect on paths: - @racket[#'(a b c)], @racket[#'(a . (b . (c . ())))], @racket[#'(a . (b c))], @racket[#'(a b . c)], + @tt{#'(a b c)}, @tt{#'(a . (b . (c . ())))}, @tt{#'(a . (b c))}, @tt{#'(a b . c)}, and @racket[#'(a . (b . c))] all have three children, and in each case the child at index @racket[2] is @racket[#'c].} From e34f04e9f5e8299e15e3ba27b0fd86b66da58219 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 20:43:33 -0700 Subject: [PATCH 10/12] Reword stuff a little more --- grimoire.scrbl | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index bc097855..d1cce8f2 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -210,7 +210,7 @@ The children of a syntax object are determined by the shape of its datum: element. Only the form's own pair structure is normalized --- nested forms remain distinct children --- but how the underlying pairs and syntax objects nest has no effect on paths: @tt{#'(a b c)}, @tt{#'(a . (b . (c . ())))}, @tt{#'(a . (b c))}, @tt{#'(a b . c)}, - and @racket[#'(a . (b . c))] all have three children, and in each case the child at index + and @tt{#'(a . (b . c))} all have three children, and in each case the child at index @racket[2] is @racket[#'c].} @item{The children of a vector are its elements, in order.} @@ -331,10 +331,10 @@ Resyntax does not allow editing expressions inside hash datums. boolean?]{ Returns @racket[#true] if @racket[leading-path] and @racket[trailing-path] refer to immediately adjacent siblings, meaning they share the same parent path and @racket[trailing-path]'s final - child index is one greater than @racket[leading-path]'s. The order of @racket[leading-path] and - @racket[trailing-path] is significant: this operation does @emph{not} return @racket[#true] if - @racket[leading-path] and @racket[trailing-path] are adjacent siblings, but the @emph{trailing} path - comes first.} + child index is one greater than @racket[leading-path]'s. @bold{Warning:} the order of + @racket[leading-path] and @racket[trailing-path] is significant --- this operation returns + @racket[#false] if @racket[leading-path] and @racket[trailing-path] are adjacent siblings where the + @emph{trailing} path comes first.} @defproc[(syntax-path-remove-prefix [path syntax-path?] [prefix syntax-path?]) syntax-path?]{ @@ -418,6 +418,5 @@ Resyntax does not allow editing expressions inside hash datums. Returns a sequence of the @tech{syntax paths} of every subform in @racket[stx], in depth-first preorder. Each returned path is prefixed with @racket[base-path], so the first path in the sequence is always @racket[base-path] itself. The @racket[base-path] argument is useful when - @racket[stx] is itself assumed to be a subform of some larger syntax object. In Resyntax's case, - that's usually the root syntax object for the entire source file as returned by - @racket[source-read-syntax].} + @racket[stx] is itself a subform of some larger syntax object, such as the root syntax object for the + entire source file that @racket[stx] originates from. From 2af062f1559f84f711d5adeb45fd0610495b657f Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 20:44:21 -0700 Subject: [PATCH 11/12] Add a failing splice removal corner case test --- grimoire/syntax-path.rkt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/grimoire/syntax-path.rkt b/grimoire/syntax-path.rkt index 6adb3287..4d34b26e 100644 --- a/grimoire/syntax-path.rkt +++ b/grimoire/syntax-path.rkt @@ -917,6 +917,11 @@ (check-exn exn:fail? (λ () (syntax-remove-splice stx (syntax-path (list 1)) 10)))) + (test-case "remove zero children from out of bounds path - should still error" + (define stx #'(a b c)) + (define bad-path (syntax-path (list 42))) + (check-exn exn:fail? (λ () (syntax-remove-splice stx bad-path 0)))) + (test-case "nested list removal" (define stx #'(a (x y z) b)) (define actual (syntax-remove-splice stx (syntax-path (list 1 1)) 1)) From 186f7c94060ef888dc420143eaba0ed8bbd87b27 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Thu, 2 Jul 2026 20:52:21 -0700 Subject: [PATCH 12/12] Make syntax-remove-splice reject paths outside the syntax object The path is now validated with syntax-contains-path? before the empty-splice early return, so removing zero children at a nonexistent path raises a contract error instead of silently returning the syntax unchanged. This makes the grimoire's documented claim true. syntax-apply-delta now skips the removal step for pure insertions, since their start paths may point one past the end of the enclosing form, which is a valid insertion point but not a removable child. Also restores a closing brace that went missing from the in-syntax-paths documentation entry and broke the docs build. Co-Authored-By: Claude Fable 5 --- grimoire.scrbl | 2 +- grimoire/syntax-path.rkt | 5 +++++ private/syntax-delta.rkt | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index d1cce8f2..cf2da9f6 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -419,4 +419,4 @@ Resyntax does not allow editing expressions inside hash datums. preorder. Each returned path is prefixed with @racket[base-path], so the first path in the sequence is always @racket[base-path] itself. The @racket[base-path] argument is useful when @racket[stx] is itself a subform of some larger syntax object, such as the root syntax object for the - entire source file that @racket[stx] originates from. + entire source file that @racket[stx] originates from.} diff --git a/grimoire/syntax-path.rkt b/grimoire/syntax-path.rkt index 4d34b26e..bd7eb4b7 100644 --- a/grimoire/syntax-path.rkt +++ b/grimoire/syntax-path.rkt @@ -877,6 +877,11 @@ (define/guard (syntax-remove-splice stx path children-count) + (unless (syntax-contains-path? stx path) + (raise-arguments-error 'syntax-remove-splice + "syntax path does not exist within the given syntax object" + "syntax" stx + "path" path)) (guard (positive? children-count) #:else stx) (define parent (syntax-ref stx (syntax-path-parent path))) (define updated diff --git a/private/syntax-delta.rkt b/private/syntax-delta.rkt index 9f65d8fc..0e203da9 100644 --- a/private/syntax-delta.rkt +++ b/private/syntax-delta.rkt @@ -101,7 +101,12 @@ (match added [(new-syntax new-stx) new-stx] [(copied-syntax orig-path) (syntax-ref stx orig-path)]))) - (syntax-insert-splice (syntax-remove-splice stx start children-count) start new-stxs))) + ;; Pure insertions skip the removal step: their start path may be one past the end of the + ;; enclosing form, which syntax-remove-splice rejects because it only accepts paths of children + ;; that actually exist. + (define stx-with-removals + (if (zero? children-count) stx (syntax-remove-splice stx start children-count))) + (syntax-insert-splice stx-with-removals start new-stxs)))