From c23c766278b6347f5e5ab8bac3fc838c27a6065c Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 1 Jul 2026 21:53:48 -0700 Subject: [PATCH 1/5] Split documentation up into multiple files --- cli.scrbl | 94 +++++++ main.scrbl | 604 +--------------------------------------- refactoring-rules.scrbl | 359 ++++++++++++++++++++++++ testing.scrbl | 154 ++++++++++ 4 files changed, 614 insertions(+), 597 deletions(-) create mode 100644 cli.scrbl create mode 100644 refactoring-rules.scrbl create mode 100644 testing.scrbl diff --git a/cli.scrbl b/cli.scrbl new file mode 100644 index 00000000..b7dfd029 --- /dev/null +++ b/cli.scrbl @@ -0,0 +1,94 @@ +#lang scribble/manual + + +@(require (for-label racket/base + resyntax/base) + scribble/bnf) + + +@title[#:tag "cli"]{The Resyntax Command-Line Interface} + + +Resyntax provides a command-line @exec{resyntax} tool for analyzing and refactoring code. The tool has +two commands: @exec{resyntax analyze} for analyzing code without changing it, and @exec{resyntax fix} +for fixing code by applying Resyntax's suggestions. + +Note that at present, Resyntax is limited in what files it can fix. Resyntax only analyzes files with +the @exec{.rkt} extension where @tt{#lang racket/base} is the first line in file. + + +@section[#:tag "install"]{Installation} + +Use the Racket package manager to install Resyntax in the installation scope: + +@verbatim{ + % raco pkg install --installation resyntax +} + +The @exec{--installation} flag (shorthand for @exec{--scope installation}) installs packages for +all users of a Racket installation, ensuring @exec{resyntax} is in your @envvar{PATH}. + +e.g. +@verbatim{ + % resyntax analyze --file example.rkt + resyntax: --- analyzing code --- + resyntax: --- displaying results --- + % +} + + +@section{Running @exec{resyntax analyze}} + + +The @exec{resyntax analyze} command accepts flags for specifying what modules to analyze. After +analysis, suggestions are printed in the console. Any of the following flags can be specified any +number of times: + + +@itemlist[ + + @item{@exec{--file} @nonterm{file-path} --- A file to anaylze.} + + @item{@exec{--directory} @nonterm{directory-path} --- A directory to anaylze, including + subdirectories.} + + @item{@exec{--package} @nonterm{package-name} --- An installed package to analyze.} + + @item{@exec{--local-git-repository} @nonterm{repository-path} @nonterm{base-ref} --- A local Git + repository to analyze the changed files of. Only files which have changed relative to + @nonterm{base-ref} are analyzed. Base references must be given in the form + @exec{remotename/branchname}, for example @exec{origin/main} or @exec{upstream/my-feature-branch}.} + + @item{@exec{--refactoring-suite} @nonterm{module-path} @nonterm{suite-name} --- A + @tech{refactoring suite} to use instead of Resyntax's default recommendations. Custom refactoring + suites can be created with @racket[define-refactoring-suite].}] + + +@section{Running @exec{resyntax fix}} + + +The @exec{resyntax fix} command accepts the same flags as @exec{resyntax analyze} for specifying what +modules to fix. After analysis, fixes are applied and a summary is printed. + + +@itemlist[ + + @item{@exec{--file} @nonterm{file-path} --- A file to fix.} + + @item{@exec{--directory} @nonterm{directory-path} --- A directory to fix, including + subdirectories.} + + @item{@exec{--package} @nonterm{package-name} --- An installed package to fix.} + + @item{@exec{--local-git-repository} @nonterm{repository-path} @nonterm{base-ref} --- A local Git + repository to fix the changed files of. Only files which have changed relative to @nonterm{base-ref} + are fixed. Base references must be given in the form @exec{remotename/branchname}, for example + @exec{origin/main} or @exec{upstream/my-feature-branch}.} + + @item{@exec{--refactoring-suite} @nonterm{module-path} @nonterm{suite-name} --- A + @tech{refactoring suite} to use instead of Resyntax's default recommendations. Custom refactoring + suites can be created with @racket[define-refactoring-suite].}] + + +If two suggestions try to fix the same code, one of them will be rejected. At present, the best way to +handle overlapping fixes is to run Resyntax multiple times until no fixes are rejected. diff --git a/main.scrbl b/main.scrbl index d1e3c18d..51530882 100644 --- a/main.scrbl +++ b/main.scrbl @@ -1,22 +1,8 @@ #lang scribble/manual -@(require (for-label (except-in racket/base require) - resyntax/base - resyntax/default-recommendations - (except-in resyntax/test #%app #%module-begin) - syntax/parse - syntax/parse/define) - scribble/bnf - scribble/example - (submod resyntax/private/scribble-evaluator-factory doc)) - - -@(define make-evaluator - (make-module-sharing-evaluator-factory - #:public (list 'resyntax/base - 'syntax/parse) - #:private (list 'racket/base))) +@(require (for-label racket/base + syntax/parse)) @title{Resyntax} @@ -32,7 +18,7 @@ that improve code written in @racket[@#,hash-lang[] @#,racketmodname[racket]] or @(racketmod #:file "my-program.rkt" racket/base - + (define (swap x y) (let ([t (unbox x)]) (set-box! x (unbox y)) @@ -46,7 +32,7 @@ to the following: @(racketmod #:file "my-program.rkt" racket/base - + (define (swap x y) (define t (unbox x)) (set-box! x (unbox y)) @@ -59,582 +45,6 @@ To see a list of suggestions that Resyntax would apply, use @exec{resyntax analy @table-of-contents[] -@(define github-repository-url "https://github.com/jackfirth/resyntax/") - -@section[#:tag "install"]{Installation} - -Use the Racket package manager to install Resyntax in the installation scope: - -@verbatim{ - % raco pkg install --installation resyntax -} - -The @exec{--installation} flag (shorthand for @exec{--scope installation}) installs packages for -all users of a Racket installation, ensuring @exec{resyntax} is in your @envvar{PATH}. - -e.g. -@verbatim{ - % resyntax analyze --file example.rkt - resyntax: --- analyzing code --- - resyntax: --- displaying results --- - % -} - - -@section[#:tag "cli"]{The Resyntax Command-Line Interface} - - -Resyntax provides a command-line @exec{resyntax} tool for analyzing and refactoring code. The tool has -two commands: @exec{resyntax analyze} for analyzing code without changing it, and @exec{resyntax fix} -for fixing code by applying Resyntax's suggestions. - -Note that at present, Resyntax is limited in what files it can fix. Resyntax only analyzes files with -the @exec{.rkt} extension where @tt{#lang racket/base} is the first line in file. - - -@subsection{Running @exec{resyntax analyze}} - - -The @exec{resyntax analyze} command accepts flags for specifying what modules to analyze. After -analysis, suggestions are printed in the console. Any of the following flags can be specified any -number of times: - - -@itemlist[ - - @item{@exec{--file} @nonterm{file-path} --- A file to anaylze.} - - @item{@exec{--directory} @nonterm{directory-path} --- A directory to anaylze, including - subdirectories.} - - @item{@exec{--package} @nonterm{package-name} --- An installed package to analyze.} - - @item{@exec{--local-git-repository} @nonterm{repository-path} @nonterm{base-ref} --- A local Git - repository to analyze the changed files of. Only files which have changed relative to - @nonterm{base-ref} are analyzed. Base references must be given in the form - @exec{remotename/branchname}, for example @exec{origin/main} or @exec{upstream/my-feature-branch}.} - - @item{@exec{--refactoring-suite} @nonterm{module-path} @nonterm{suite-name} --- A - @tech{refactoring suite} to use instead of Resyntax's default recommendations. Custom refactoring - suites can be created with @racket[define-refactoring-suite].}] - - -@subsection{Running @exec{resyntax fix}} - - -The @exec{resyntax fix} command accepts the same flags as @exec{resyntax analyze} for specifying what -modules to fix. After analysis, fixes are applied and a summary is printed. - - -@itemlist[ - - @item{@exec{--file} @nonterm{file-path} --- A file to fix.} - - @item{@exec{--directory} @nonterm{directory-path} --- A directory to fix, including - subdirectories.} - - @item{@exec{--package} @nonterm{package-name} --- An installed package to fix.} - - @item{@exec{--local-git-repository} @nonterm{repository-path} @nonterm{base-ref} --- A local Git - repository to fix the changed files of. Only files which have changed relative to @nonterm{base-ref} - are fixed. Base references must be given in the form @exec{remotename/branchname}, for example - @exec{origin/main} or @exec{upstream/my-feature-branch}.} - - @item{@exec{--refactoring-suite} @nonterm{module-path} @nonterm{suite-name} --- A - @tech{refactoring suite} to use instead of Resyntax's default recommendations. Custom refactoring - suites can be created with @racket[define-refactoring-suite].}] - - -If two suggestions try to fix the same code, one of them will be rejected. At present, the best way to -handle overlapping fixes is to run Resyntax multiple times until no fixes are rejected. - - -@section{Refactoring Rules and Suites} -@defmodule[resyntax/base] - - -Resyntax derives its suggestions from @tech{refactoring rules}, which can be grouped into a -@deftech{refactoring suite}. Resyntax ships with a default refactoring suite consisting of many rules -that cover various scenarios related to Racket's standard libraries. However, you may also define your -own refactoring suite and rules using the forms below. Knowledge of Racket macros, and of -@racket[syntax-parse] in particular, is especially useful for understanding how to create effective -refactoring rules. - - -@defproc[(refactoring-rule? [v any/c]) boolean?]{ - A predicate that recognizes @tech{refactoring rules}.} - - -@defproc[(refactoring-suite? [v any/c]) boolean?]{ - A predicate that recognizes @tech{refactoring suites}.} - - -@defform[(define-refactoring-rule id - #:description description - parse-option ... - syntax-pattern - pattern-directive ... - template) - #:contracts ([description string?])]{ - - Defines a @tech{refactoring rule} named @racket[id]. Refactoring rules are defined in terms of - @racket[syntax-parse]. The rule matches syntax objects that match @racket[syntax-pattern], and - @racket[template] is a @racket[syntax] template that defines what the matched code is refactored - into. The message in @racket[description] is presented to the user when Resyntax makes a suggestion - based on the rule. Refactoring rules function roughly like macros defined with - @racket[define-syntax-parse-rule]. For example, here is a simple rule that flattens nested - @racket[or] expressions: - - @(examples - #:eval (make-evaluator) #:once - (define-refactoring-rule nested-or-to-flat-or - #:description "This nested `or` expression can be flattened." - #:literals (or) - (or a (or b c)) - (or a b c))) - - Like @racket[syntax-parse] and @racket[define-syntax-parse-rule], - @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{pattern directives} can be used to aid in - defining rules. Here is a rule that uses the @racket[#:when] directive to only refactor @racket[or] - expressions that have a duplicate condition: - - @(examples - #:eval (make-evaluator) #:once - (define-refactoring-rule or-with-duplicate-subterm - #:description "This `or` expression has a duplicate subterm." - #:literals (or) - (or before ... a:id between ... b:id after ...) - #:when (free-identifier=? #'a #'b) - (or before ... a between ... after ...)))} - -@defform[(define-definition-context-refactoring-rule id - #:description description - parse-option ... - syntax-pattern - pattern-directive ... - template) - #:contracts ([description string?])]{ - - Defines a @tech{refactoring rule} named @racket[id], like @racket[define-refactoring-rule], except - the rule is applied only in - @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{internal-definition contexts}. The given - @racket[syntax-pattern] must be a - @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{proper head pattern}, and it is expected to - match the entire sequence of body forms within the definition context. The output @racket[template] - of the rule should be a single syntax object containing a sequence of refactored body forms. Like - @racket[define-refactoring-rule], @racket[description] is used to generate a message presented to the - user, and both @racket[parse-option] and @racket[pattern-directive] function the same as they do in - @racket[syntax-parse]. For example, here is a simple rule that turns a series of @racket[define] - forms unpacking a 2D @racket[point] structure into a single @racket[match-define] form: - - @(examples - #:eval (make-evaluator) #:once - (eval:no-prompt - (struct point (x y) #:transparent)) - - (define-definition-context-refactoring-rule point-define-to-match-define - #:description "These definitions can be simplified with `match-define`." - #:literals (define point-x point-y) - (~seq body-before ... - (define x:id (point-x pt:id)) - (define y:id (point-y pt2:id)) - body-after ...) - #:when (free-identifier=? #'pt #'pt2) - (body-before ... - (match-define (point x y) pt) - body-after ...))) - - Note that by default Resyntax will try to reformat the entire context. To reformat just the forms - being modified, a few additional steps are required. First, use @racket[~replacement] (or - @racket[~splicing-replacement]) to annotate which subpart of the context is being replaced: - - @(examples - #:eval (make-evaluator) #:once - (define-definition-context-refactoring-rule point-define-to-match-define - #:description "These definitions can be simplified with `match-define`." - #:literals (define point-x point-y) - (~seq body-before ... - (~and x-def (define x:id (point-x pt:id))) - (~and y-def (define y:id (point-y pt2:id))) - body-after ...) - #:when (free-identifier=? #'pt #'pt2) - (body-before ... - (~replacement (match-define (point x y) pt) - #:original-splice (x-def y-def)) - body-after ...))) - - This ensures that Resyntax will preserve any comments at the end of @racket[body-before ...] and the - beginning of @racket[body-after ...]. However, that alone doesn't prevent Resyntax from reformatting - the whole context. To do that, use the @racket[~focus-replacement-on] metafunction, which tells - Resyntax that if @emph{only} the focused forms are changed, Resyntax should "shrink" the replacement - it generates down to just those forms and not reformat anything in the replacement syntax object - that's outside of the focused syntax: - - @(examples - #:eval (make-evaluator) #:once - (define-definition-context-refactoring-rule point-define-to-match-define - #:description "These definitions can be simplified with `match-define`." - #:literals (define point-x point-y) - (~seq body-before ... - (~and x-def (define x:id (point-x pt:id))) - (~and y-def (define y:id (point-y pt2:id))) - body-after ...) - #:when (free-identifier=? #'pt #'pt2) - (body-before ... - (~focus-replacement-on - (~replacement (match-define (point x y) pt) - #:original-splice (x-def y-def))) - body-after ...)))} - - -@defform[(define-refactoring-suite id rules-list suites-list) - - #:grammar - [(rules-list (code:line) - (code:line #:rules (rule ...))) - (suites-list (code:line) - (code:line #:suites (suite ...)))] - - #:contracts ([rule refactoring-rule?] - [suite refactoring-suite?])]{ - - Defines a @tech{refactoring suite} named @racket[id] containing each listed @racket[rule]. - Additionally, each @racket[suite] provided has its rules added to the newly defined suite. - - @(examples - #:eval (make-evaluator) #:once - (eval:alts - (define-refactoring-suite my-suite - #:rules (rule1 rule2 rule3) - #:suites (subsuite1 subsuite2)) - (void)))} - - -@subsection{Exercising Fine Control Over Comments} - - -Writing a rule with @racket[define-refactoring-rule] is usually enough for Resyntax to handle -commented code without issue, but in certain cases more precise control is desired. For instance, -consider the @racketidfont{nested-or-to-flat-or} rule from earlier: - -@(racketblock - (define-refactoring-rule nested-or-to-flat-or - #:description "This nested `or` expression can be flattened." - #:literals (or) - (or a (or b c)) - (or a b c))) - -As-is, this rule will @emph{fail} to refactor the following code: - -@(racketblock - (or (foo ...) - (code:comment @#,elem{If that doesn't work, fall back to other approaches}) - (or (bar ...) - (baz ...)))) - -Resyntax rejects the rule because applying it would produce this code, which loses the comment: - -@(racketblock - (or (foo ...) - (bar ...) - (baz ...))) - -Resyntax is unable to preserve the comment automatically. Resyntax can preserve some comments without -programmer effort, but only in specific circumstances: - -@itemlist[ - @item{Comments @emph{within} expressions that the rule left unchanged are preserved. If the comment - were inside @racket[(foo ...)], @racket[(bar ...)], or @racket[(baz ...)], it would have been kept.} - - @item{Comments @emph{between} unchanged expressions are similarly preserved. If the comment were - between @racket[(bar ...)] and @racket[(baz ...)], it would have been kept.}] - -To fix this issue, rule authors can inject some extra markup into their suggested replacements using -@tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunctions} provided by Resyntax. In -the case of @racketidfont{nested-or-to-flat-or}, we can use the @racket[~splicing-replacement] -metafunction to indicate that the nested @racket[or] expression should be considered @emph{replaced} -by its nested subterms: - -@(racketblock - (define-refactoring-rule nested-or-to-flat-or - #:description "This nested `or` expression can be flattened." - #:literals (or) - (or a (~and nested-or (or b c))) - #:with (nested-subterm ...) #'(~splicing-replacement (b c) #:original nested-or) - (or a nested-subterm ...))) - -This adds @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} to the nested -subterms that allow Resyntax to preserve the comment, producing this output: - -@(racketblock - (or (foo ...) - (code:comment @#,elem{If that doesn't work, fall back to other approaches}) - (bar ...) - (baz ...))) - -When Resyntax sees that the @racket[(bar ...)] nested subterm comes immediately after the -@racket[(foo ...)] subterm, it notices that @racket[(bar ...)] has been annotated with replacement -properties. Then Resyntax observes that @racket[(bar ...)] is the first expression of a sequence of -expressions that replaces the @racket[or] expression which originally followed @racket[(foo ...)]. -Based on this observation, Resyntax decides to preserve whatever text was originally between -@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. - -@defform[#:kind "template metafunction" - (~replacement replacement-form original) - #:grammar - ([original - (code:line #:original original-form) - (code:line #:original-splice (original-form ...))])]{ - A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in - @tech{refactoring rules}. The result of the metafunction is just the @racket[#'replacement-form] - syntax object, except with some - @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those - properties inform Resyntax that this syntax object should be considered a replacement for - @racket[original-form] (or in the splicing case, for the unparenthesized sequence - @racket[original-form ...]). Resyntax uses this information to preserve comments and formatting near - the original form(s).} - -@defform[#:kind "template metafunction" - (~splicing-replacement (replacement-form ...) - original) - #:grammar - ([original - (code:line #:original original-form) - (code:line #:original-splice (original-form ...))])]{ - A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in - @tech{refactoring rules}. The result of the metafunction is the syntax object - @racket[#'(replacement-form ...)], except with some - @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those - properties inform Resyntax that the replacement syntax objects --- as an unparenthesized sequence --- - should be considered a replacement for @racket[original-form] (or @racket[original-form ...]). - Resyntax uses this information to preserve comments and formatting near the original form(s).} - - -@subsection{Narrowing the Focus of Replacements} - -@defform[#:kind "template metafunction" - (~focus-replacement-on replacement-form)]{ - A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in - @tech{refactoring rules}. The result of the metafunction is just the @racket[#'replacement-form] - syntax object, except with some - @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those - properties inform Resyntax that the returned syntax object should be treated as the @emph{focus} of - the entire refactoring rule's generated replacement. When a refactoring rule produces a replacement - that has a focus, Resyntax checks that nothing outside the focus was modified. If this is the case, - then Resyntax will @emph{shrink} the replacement it generates to only touch the focus. Crucially, - this means Resyntax will @emph{only reformat the focused code}, not the entire generated replacement. - This metafunction is frequently used with @racket[define-definition-context-refactoring-rule], - because such rules often touch only a small series of forms in a much larger definition context.} - - -@subsection{Resyntax's Default Rules} -@defmodule[resyntax/default-recommendations] - - -@(define default-recommendations-directory-link - "https://github.com/jackfirth/resyntax/tree/master/default-recommendations") - -@defthing[default-recommendations refactoring-suite?]{ - The refactoring suite containing all of Resyntax's default refactoring rules. These rules are further - broken up into subsuites, with each subsuite corresponding to a module within the - @racketmodname[resyntax/default-recommendations] collection. For example, all of Resyntax's rules - related to @racket[for] loops are located in the - @racketmodfont{resyntax/default-recommendations/for-loop-shortcuts} module. See - @hyperlink[default-recommendations-directory-link]{this directory} for all of Resyntax's default - refactoring rules.} - - -@subsection{What Makes a Good Refactoring Rule?} - - -If you'd like to add a new @tech{refactoring rule} to Resyntax, there are a few guidelines to keep in -mind: - -@itemlist[ - - @item{Refactoring rules should be @emph{safe}. Resyntax shouldn't break users' code, and it shouldn't - require careful review to determine whether a suggestion from Resyntax is safe to apply. It's better - for a rule to never make suggestions than to occasionally make broken suggestions.} - - @item{Refactoring rules can be shown to many different developers in a wide variety of different - contexts. Therefore, it's important that Resyntax's default recommendations have some degree of - @emph{consensus} among the Racket community. Highly divisive suggestions that many developers - disagree with are not a good fit for Resyntax. Technology is social before it is technical: - discussing your rule with the Racket community prior to developing it is encouraged, especially if - it's likely to affect a lot of code. If necessary, consider narrowing the focus of your rule to just - the cases that everybody agrees are clear improvements.} - - @item{Refactoring rules should @emph{explain themselves}. The description of a refactoring rule (as - specified with the @racket[#:description] option) should state why the new code is an improvement - over the old code. Refactoring rule descriptions are shown to Resyntax users at the command line, in - GitHub pull request review comments, and in Git commit messages. The description is the only means - you have of explaining to a potentially confused stranger why Resyntax wants to change their code, - so make sure you use it!} - - @item{Refactoring rules should focus on cleaning up @emph{real-world code}. A refactoring rule that - suggests improvements to hypothetical code that no human would write in the first place is not - useful. Try to find examples of code "in the wild" that the rule would improve. The best candidates - for new rules tend to be rules that help Racketeers clean up and migrate old Scheme code that - doesn't take advantage of Racket's unique features and extensive standard library.} - - @item{Refactoring rules should try to preserve the @emph{intended behavior} of the refactored code, - but not necessarily the @emph{actual behavior}. For instance, a rule that changes how code handles - some edge case is acceptable if the original behavior of the code was likely confusing or surprising - to the developer who wrote it. This is a judgment call that requires understanding what the original - code communicates clearly and what it doesn't. A rule's @racket[#:description] is an excellent place - to draw attention to potentially surprising behavior changes.} - - @item{Refactoring rules should be @emph{self-contained}, meaning they can operate locally on a single - expression. Refactoring rules that require whole-program analysis are not a good fit for Resyntax, - nor are rules that require global knowledge of the whole codebase.}] - - -@section{Testing Refactoring Rules} -@defmodulelang[resyntax/test] - - -The @racketmodname[resyntax/test] language provides a convenient domain-specific language for testing -@tech{refactoring rules}. This language makes it easy to write comprehensive tests that verify -refactoring rules work correctly across a variety of inputs, and that they properly handle edge cases -without making unwanted transformations. - -@subsection{Basic Test Syntax} - -Tests are written using @racket[@#,hash-lang[] @#,racketmodname[resyntax/test]] and consist of a -series of @deftech{test statements}. Each statement begins with a keyword followed by a colon, and -the statement's body follows. Strings of code are written using @tech{code blocks}. Here's a simple -example: - -@verbatim{ - #lang resyntax/test - - require: my-rules my-suite - - header: - - #lang racket - - test: "my rule transforms code as expected" - - (old-pattern 1 2 3) - - (new-pattern 1 2 3) -} - - -@subsection{Code Blocks} - -A @deftech{code block} is a delimited section of Racket code used within @tech{test statements}. -There are two types of code blocks: - -@itemlist[ - - @item{@deftech{Single-line code blocks} are preceded by a single dash and a space (@litchar{- }).} - - @item{@deftech{Multi-line code blocks} are delimited by lines of at least three consecutive dashes - (@litchar{---}). As a convenience, two adjacent multi-line code blocks can be separated by a single - line of equals signs (@litchar{===}) instead of two lines of dashes.}] - -Code blocks are essentially string literals, and can contain code written in any language. For this -reason, it's common for Resyntax tests to include a @racket[header] test statement which specifies -what @hash-lang[] each code block in that file is written in. - - -@subsection{Test Statements} - -The @racketmodname[resyntax/test] language supports four types of @tech{test statements}: - -@itemlist[ - @item{@racket[require] statements for loading @tech{refactoring suites}} - @item{@racket[header] statements for defining common code used in all tests} - @item{@racket[test] and @racket[no-change-test] statements for defining individual test cases}] - - -@defform[#:kind "test statement" (require module-path suite-name)]{ - Loads the @tech{refactoring suite} named @racket[suite-name] from the module at - @racket[module-path]. The refactoring suite will be used in all tests defined in the surrounding - file. Multiple @racket[require] statements can be used to test rules from multiple different suites. - - @verbatim{ - #lang resyntax/test - - require: resyntax/default-recommendations list-shortcuts - require: my-custom-rules my-suite -}} - - -@defform[#:kind "test statement" (header code-block)]{ - Defines a @tech{code block} that will be prepended to every test case code block in the file. This is - useful for common setup code that all tests need, such as a @hash-lang[] line or library imports. - - @verbatim{ - #lang resyntax/test - - header: - -------------------- - #lang racket/base - (require racket/list) - -------------------- -}} - - -@defform[#:kind "test statement" - (test description-string - input-code-block ...+ - expected-code-block)]{ - Defines a test case named with the given @racket[description-string]. The test case checks that - Resyntax refactors each @racket[input-code-block] block into the final @racket[expected-code-block]: - - @verbatim{ - #lang resyntax/test - - test: "should rewrite old function to new function" - -------------------- - #lang racket - (old-function 1 2 3) - ==================== - #lang racket - (new-function 1 2 3) - -------------------- - - test: "should remove old-condition from and expressions" - - (and old-condition x) - - (and x old-condition) - - (and old-condition x old-condition) - - x - }} - -@defform[#:kind "test statement" (no-change-test description-string input-code-block)]{ - Defines a test case named with the given @racket[description-string]. The test checks that Resyntax - does @emph{not} make any changes to the @racket[input-code-block]: - - @verbatim{ - #lang resyntax/test - - no-change-test: "should not rewrite old function to new function in higher-order uses" - -------------------- - #lang racket - (map old-function (list 1 2 3)) - -------------------- -}} - - -@subsection{Running Resyntax Tests} - -Tests written in @racketmodname[resyntax/test] are integrated with RackUnit and can be run using -the standard @exec{raco test} command: - -@verbatim{ - % raco test my-rule-test.rkt - raco test: (submod "my-rule-test.rkt" test) - 5 tests passed -} - -Each @racket[test] statement becomes a RackUnit test case, and the entire test file becomes a module -with a single submodule named @racket[test]. Clicking the Run button in DrRacket will execute each -test in the file. Just like in RackUnit, failing tests are highlighted by DrRacket and print failure -messages. - -When executing tests, the @racketmodname[resyntax/test] language enables Resyntax's debug logging and -captures all of its logs. Failing test cases include the captured Resyntax logs in their printed -output. This output can be somewhat verbose, but it makes it much easier to tell why Resyntax did or -didn't refactor code and how Resyntax came to produce a malformed suggestion. +@include-section[(lib "resyntax/cli.scrbl")] +@include-section[(lib "resyntax/refactoring-rules.scrbl")] +@include-section[(lib "resyntax/testing.scrbl")] diff --git a/refactoring-rules.scrbl b/refactoring-rules.scrbl new file mode 100644 index 00000000..4c498ec2 --- /dev/null +++ b/refactoring-rules.scrbl @@ -0,0 +1,359 @@ +#lang scribble/manual + + +@(require (for-label racket/base + resyntax/base + resyntax/default-recommendations + syntax/parse + syntax/parse/define) + scribble/example + (submod resyntax/private/scribble-evaluator-factory doc)) + + +@(define make-evaluator + (make-module-sharing-evaluator-factory + #:public (list 'resyntax/base + 'syntax/parse) + #:private (list 'racket/base))) + + +@title[#:tag "refactoring-rules"]{Refactoring Rules and Suites} +@defmodule[resyntax/base] + + +Resyntax derives its suggestions from @tech{refactoring rules}, which can be grouped into a +@deftech{refactoring suite}. Resyntax ships with a default refactoring suite consisting of many rules +that cover various scenarios related to Racket's standard libraries. However, you may also define your +own refactoring suite and rules using the forms below. Knowledge of Racket macros, and of +@racket[syntax-parse] in particular, is especially useful for understanding how to create effective +refactoring rules. + + +@defproc[(refactoring-rule? [v any/c]) boolean?]{ + A predicate that recognizes @tech{refactoring rules}.} + + +@defproc[(refactoring-suite? [v any/c]) boolean?]{ + A predicate that recognizes @tech{refactoring suites}.} + + +@defform[(define-refactoring-rule id + #:description description + parse-option ... + syntax-pattern + pattern-directive ... + template) + #:contracts ([description string?])]{ + + Defines a @tech{refactoring rule} named @racket[id]. Refactoring rules are defined in terms of + @racket[syntax-parse]. The rule matches syntax objects that match @racket[syntax-pattern], and + @racket[template] is a @racket[syntax] template that defines what the matched code is refactored + into. The message in @racket[description] is presented to the user when Resyntax makes a suggestion + based on the rule. Refactoring rules function roughly like macros defined with + @racket[define-syntax-parse-rule]. For example, here is a simple rule that flattens nested + @racket[or] expressions: + + @(examples + #:eval (make-evaluator) #:once + (define-refactoring-rule nested-or-to-flat-or + #:description "This nested `or` expression can be flattened." + #:literals (or) + (or a (or b c)) + (or a b c))) + + Like @racket[syntax-parse] and @racket[define-syntax-parse-rule], + @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{pattern directives} can be used to aid in + defining rules. Here is a rule that uses the @racket[#:when] directive to only refactor @racket[or] + expressions that have a duplicate condition: + + @(examples + #:eval (make-evaluator) #:once + (define-refactoring-rule or-with-duplicate-subterm + #:description "This `or` expression has a duplicate subterm." + #:literals (or) + (or before ... a:id between ... b:id after ...) + #:when (free-identifier=? #'a #'b) + (or before ... a between ... after ...)))} + +@defform[(define-definition-context-refactoring-rule id + #:description description + parse-option ... + syntax-pattern + pattern-directive ... + template) + #:contracts ([description string?])]{ + + Defines a @tech{refactoring rule} named @racket[id], like @racket[define-refactoring-rule], except + the rule is applied only in + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{internal-definition contexts}. The given + @racket[syntax-pattern] must be a + @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{proper head pattern}, and it is expected to + match the entire sequence of body forms within the definition context. The output @racket[template] + of the rule should be a single syntax object containing a sequence of refactored body forms. Like + @racket[define-refactoring-rule], @racket[description] is used to generate a message presented to the + user, and both @racket[parse-option] and @racket[pattern-directive] function the same as they do in + @racket[syntax-parse]. For example, here is a simple rule that turns a series of @racket[define] + forms unpacking a 2D @racket[point] structure into a single @racket[match-define] form: + + @(examples + #:eval (make-evaluator) #:once + (eval:no-prompt + (struct point (x y) #:transparent)) + + (define-definition-context-refactoring-rule point-define-to-match-define + #:description "These definitions can be simplified with `match-define`." + #:literals (define point-x point-y) + (~seq body-before ... + (define x:id (point-x pt:id)) + (define y:id (point-y pt2:id)) + body-after ...) + #:when (free-identifier=? #'pt #'pt2) + (body-before ... + (match-define (point x y) pt) + body-after ...))) + + Note that by default Resyntax will try to reformat the entire context. To reformat just the forms + being modified, a few additional steps are required. First, use @racket[~replacement] (or + @racket[~splicing-replacement]) to annotate which subpart of the context is being replaced: + + @(examples + #:eval (make-evaluator) #:once + (define-definition-context-refactoring-rule point-define-to-match-define + #:description "These definitions can be simplified with `match-define`." + #:literals (define point-x point-y) + (~seq body-before ... + (~and x-def (define x:id (point-x pt:id))) + (~and y-def (define y:id (point-y pt2:id))) + body-after ...) + #:when (free-identifier=? #'pt #'pt2) + (body-before ... + (~replacement (match-define (point x y) pt) + #:original-splice (x-def y-def)) + body-after ...))) + + This ensures that Resyntax will preserve any comments at the end of @racket[body-before ...] and the + beginning of @racket[body-after ...]. However, that alone doesn't prevent Resyntax from reformatting + the whole context. To do that, use the @racket[~focus-replacement-on] metafunction, which tells + Resyntax that if @emph{only} the focused forms are changed, Resyntax should "shrink" the replacement + it generates down to just those forms and not reformat anything in the replacement syntax object + that's outside of the focused syntax: + + @(examples + #:eval (make-evaluator) #:once + (define-definition-context-refactoring-rule point-define-to-match-define + #:description "These definitions can be simplified with `match-define`." + #:literals (define point-x point-y) + (~seq body-before ... + (~and x-def (define x:id (point-x pt:id))) + (~and y-def (define y:id (point-y pt2:id))) + body-after ...) + #:when (free-identifier=? #'pt #'pt2) + (body-before ... + (~focus-replacement-on + (~replacement (match-define (point x y) pt) + #:original-splice (x-def y-def))) + body-after ...)))} + + +@defform[(define-refactoring-suite id rules-list suites-list) + + #:grammar + [(rules-list (code:line) + (code:line #:rules (rule ...))) + (suites-list (code:line) + (code:line #:suites (suite ...)))] + + #:contracts ([rule refactoring-rule?] + [suite refactoring-suite?])]{ + + Defines a @tech{refactoring suite} named @racket[id] containing each listed @racket[rule]. + Additionally, each @racket[suite] provided has its rules added to the newly defined suite. + + @(examples + #:eval (make-evaluator) #:once + (eval:alts + (define-refactoring-suite my-suite + #:rules (rule1 rule2 rule3) + #:suites (subsuite1 subsuite2)) + (void)))} + + +@section{Exercising Fine Control Over Comments} + + +Writing a rule with @racket[define-refactoring-rule] is usually enough for Resyntax to handle +commented code without issue, but in certain cases more precise control is desired. For instance, +consider the @racketidfont{nested-or-to-flat-or} rule from earlier: + +@(racketblock + (define-refactoring-rule nested-or-to-flat-or + #:description "This nested `or` expression can be flattened." + #:literals (or) + (or a (or b c)) + (or a b c))) + +As-is, this rule will @emph{fail} to refactor the following code: + +@(racketblock + (or (foo ...) + (code:comment @#,elem{If that doesn't work, fall back to other approaches}) + (or (bar ...) + (baz ...)))) + +Resyntax rejects the rule because applying it would produce this code, which loses the comment: + +@(racketblock + (or (foo ...) + (bar ...) + (baz ...))) + +Resyntax is unable to preserve the comment automatically. Resyntax can preserve some comments without +programmer effort, but only in specific circumstances: + +@itemlist[ + @item{Comments @emph{within} expressions that the rule left unchanged are preserved. If the comment + were inside @racket[(foo ...)], @racket[(bar ...)], or @racket[(baz ...)], it would have been kept.} + + @item{Comments @emph{between} unchanged expressions are similarly preserved. If the comment were + between @racket[(bar ...)] and @racket[(baz ...)], it would have been kept.}] + +To fix this issue, rule authors can inject some extra markup into their suggested replacements using +@tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunctions} provided by Resyntax. In +the case of @racketidfont{nested-or-to-flat-or}, we can use the @racket[~splicing-replacement] +metafunction to indicate that the nested @racket[or] expression should be considered @emph{replaced} +by its nested subterms: + +@(racketblock + (define-refactoring-rule nested-or-to-flat-or + #:description "This nested `or` expression can be flattened." + #:literals (or) + (or a (~and nested-or (or b c))) + #:with (nested-subterm ...) #'(~splicing-replacement (b c) #:original nested-or) + (or a nested-subterm ...))) + +This adds @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} to the nested +subterms that allow Resyntax to preserve the comment, producing this output: + +@(racketblock + (or (foo ...) + (code:comment @#,elem{If that doesn't work, fall back to other approaches}) + (bar ...) + (baz ...))) + +When Resyntax sees that the @racket[(bar ...)] nested subterm comes immediately after the +@racket[(foo ...)] subterm, it notices that @racket[(bar ...)] has been annotated with replacement +properties. Then Resyntax observes that @racket[(bar ...)] is the first expression of a sequence of +expressions that replaces the @racket[or] expression which originally followed @racket[(foo ...)]. +Based on this observation, Resyntax decides to preserve whatever text was originally between +@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. + +@defform[#:kind "template metafunction" + (~replacement replacement-form original) + #:grammar + ([original + (code:line #:original original-form) + (code:line #:original-splice (original-form ...))])]{ + A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in + @tech{refactoring rules}. The result of the metafunction is just the @racket[#'replacement-form] + syntax object, except with some + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those + properties inform Resyntax that this syntax object should be considered a replacement for + @racket[original-form] (or in the splicing case, for the unparenthesized sequence + @racket[original-form ...]). Resyntax uses this information to preserve comments and formatting near + the original form(s).} + +@defform[#:kind "template metafunction" + (~splicing-replacement (replacement-form ...) + original) + #:grammar + ([original + (code:line #:original original-form) + (code:line #:original-splice (original-form ...))])]{ + A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in + @tech{refactoring rules}. The result of the metafunction is the syntax object + @racket[#'(replacement-form ...)], except with some + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those + properties inform Resyntax that the replacement syntax objects --- as an unparenthesized sequence --- + should be considered a replacement for @racket[original-form] (or @racket[original-form ...]). + Resyntax uses this information to preserve comments and formatting near the original form(s).} + + +@section{Narrowing the Focus of Replacements} + +@defform[#:kind "template metafunction" + (~focus-replacement-on replacement-form)]{ + A @tech[#:doc '(lib "syntax/scribblings/syntax.scrbl")]{template metafunction} for use in + @tech{refactoring rules}. The result of the metafunction is just the @racket[#'replacement-form] + syntax object, except with some + @tech[#:doc '(lib "scribblings/reference/reference.scrbl")]{syntax properties} added. Those + properties inform Resyntax that the returned syntax object should be treated as the @emph{focus} of + the entire refactoring rule's generated replacement. When a refactoring rule produces a replacement + that has a focus, Resyntax checks that nothing outside the focus was modified. If this is the case, + then Resyntax will @emph{shrink} the replacement it generates to only touch the focus. Crucially, + this means Resyntax will @emph{only reformat the focused code}, not the entire generated replacement. + This metafunction is frequently used with @racket[define-definition-context-refactoring-rule], + because such rules often touch only a small series of forms in a much larger definition context.} + + +@section{Resyntax's Default Rules} +@defmodule[resyntax/default-recommendations] + + +@(define default-recommendations-directory-link + "https://github.com/jackfirth/resyntax/tree/master/default-recommendations") + +@defthing[default-recommendations refactoring-suite?]{ + The refactoring suite containing all of Resyntax's default refactoring rules. These rules are further + broken up into subsuites, with each subsuite corresponding to a module within the + @racketmodname[resyntax/default-recommendations] collection. For example, all of Resyntax's rules + related to @racket[for] loops are located in the + @racketmodfont{resyntax/default-recommendations/for-loop-shortcuts} module. See + @hyperlink[default-recommendations-directory-link]{this directory} for all of Resyntax's default + refactoring rules.} + + +@section{What Makes a Good Refactoring Rule?} + + +If you'd like to add a new @tech{refactoring rule} to Resyntax, there are a few guidelines to keep in +mind: + +@itemlist[ + + @item{Refactoring rules should be @emph{safe}. Resyntax shouldn't break users' code, and it shouldn't + require careful review to determine whether a suggestion from Resyntax is safe to apply. It's better + for a rule to never make suggestions than to occasionally make broken suggestions.} + + @item{Refactoring rules can be shown to many different developers in a wide variety of different + contexts. Therefore, it's important that Resyntax's default recommendations have some degree of + @emph{consensus} among the Racket community. Highly divisive suggestions that many developers + disagree with are not a good fit for Resyntax. Technology is social before it is technical: + discussing your rule with the Racket community prior to developing it is encouraged, especially if + it's likely to affect a lot of code. If necessary, consider narrowing the focus of your rule to just + the cases that everybody agrees are clear improvements.} + + @item{Refactoring rules should @emph{explain themselves}. The description of a refactoring rule (as + specified with the @racket[#:description] option) should state why the new code is an improvement + over the old code. Refactoring rule descriptions are shown to Resyntax users at the command line, in + GitHub pull request review comments, and in Git commit messages. The description is the only means + you have of explaining to a potentially confused stranger why Resyntax wants to change their code, + so make sure you use it!} + + @item{Refactoring rules should focus on cleaning up @emph{real-world code}. A refactoring rule that + suggests improvements to hypothetical code that no human would write in the first place is not + useful. Try to find examples of code "in the wild" that the rule would improve. The best candidates + for new rules tend to be rules that help Racketeers clean up and migrate old Scheme code that + doesn't take advantage of Racket's unique features and extensive standard library.} + + @item{Refactoring rules should try to preserve the @emph{intended behavior} of the refactored code, + but not necessarily the @emph{actual behavior}. For instance, a rule that changes how code handles + some edge case is acceptable if the original behavior of the code was likely confusing or surprising + to the developer who wrote it. This is a judgment call that requires understanding what the original + code communicates clearly and what it doesn't. A rule's @racket[#:description] is an excellent place + to draw attention to potentially surprising behavior changes.} + + @item{Refactoring rules should be @emph{self-contained}, meaning they can operate locally on a single + expression. Refactoring rules that require whole-program analysis are not a good fit for Resyntax, + nor are rules that require global knowledge of the whole codebase.}] diff --git a/testing.scrbl b/testing.scrbl new file mode 100644 index 00000000..73131087 --- /dev/null +++ b/testing.scrbl @@ -0,0 +1,154 @@ +#lang scribble/manual + + +@(require (for-label (except-in racket/base require) + (except-in resyntax/test #%app #%module-begin))) + + +@title[#:tag "testing"]{Testing Refactoring Rules} +@defmodulelang[resyntax/test] + + +The @racketmodname[resyntax/test] language provides a convenient domain-specific language for testing +@tech{refactoring rules}. This language makes it easy to write comprehensive tests that verify +refactoring rules work correctly across a variety of inputs, and that they properly handle edge cases +without making unwanted transformations. + +@section{Basic Test Syntax} + +Tests are written using @racket[@#,hash-lang[] @#,racketmodname[resyntax/test]] and consist of a +series of @deftech{test statements}. Each statement begins with a keyword followed by a colon, and +the statement's body follows. Strings of code are written using @tech{code blocks}. Here's a simple +example: + +@verbatim{ + #lang resyntax/test + + require: my-rules my-suite + + header: + - #lang racket + + test: "my rule transforms code as expected" + - (old-pattern 1 2 3) + - (new-pattern 1 2 3) +} + + +@section{Code Blocks} + +A @deftech{code block} is a delimited section of Racket code used within @tech{test statements}. +There are two types of code blocks: + +@itemlist[ + + @item{@deftech{Single-line code blocks} are preceded by a single dash and a space (@litchar{- }).} + + @item{@deftech{Multi-line code blocks} are delimited by lines of at least three consecutive dashes + (@litchar{---}). As a convenience, two adjacent multi-line code blocks can be separated by a single + line of equals signs (@litchar{===}) instead of two lines of dashes.}] + +Code blocks are essentially string literals, and can contain code written in any language. For this +reason, it's common for Resyntax tests to include a @racket[header] test statement which specifies +what @hash-lang[] each code block in that file is written in. + + +@section{Test Statements} + +The @racketmodname[resyntax/test] language supports four types of @tech{test statements}: + +@itemlist[ + @item{@racket[require] statements for loading @tech{refactoring suites}} + @item{@racket[header] statements for defining common code used in all tests} + @item{@racket[test] and @racket[no-change-test] statements for defining individual test cases}] + + +@defform[#:kind "test statement" (require module-path suite-name)]{ + Loads the @tech{refactoring suite} named @racket[suite-name] from the module at + @racket[module-path]. The refactoring suite will be used in all tests defined in the surrounding + file. Multiple @racket[require] statements can be used to test rules from multiple different suites. + + @verbatim{ + #lang resyntax/test + + require: resyntax/default-recommendations list-shortcuts + require: my-custom-rules my-suite +}} + + +@defform[#:kind "test statement" (header code-block)]{ + Defines a @tech{code block} that will be prepended to every test case code block in the file. This is + useful for common setup code that all tests need, such as a @hash-lang[] line or library imports. + + @verbatim{ + #lang resyntax/test + + header: + -------------------- + #lang racket/base + (require racket/list) + -------------------- +}} + + +@defform[#:kind "test statement" + (test description-string + input-code-block ...+ + expected-code-block)]{ + Defines a test case named with the given @racket[description-string]. The test case checks that + Resyntax refactors each @racket[input-code-block] block into the final @racket[expected-code-block]: + + @verbatim{ + #lang resyntax/test + + test: "should rewrite old function to new function" + -------------------- + #lang racket + (old-function 1 2 3) + ==================== + #lang racket + (new-function 1 2 3) + -------------------- + + test: "should remove old-condition from and expressions" + - (and old-condition x) + - (and x old-condition) + - (and old-condition x old-condition) + - x + }} + +@defform[#:kind "test statement" (no-change-test description-string input-code-block)]{ + Defines a test case named with the given @racket[description-string]. The test checks that Resyntax + does @emph{not} make any changes to the @racket[input-code-block]: + + @verbatim{ + #lang resyntax/test + + no-change-test: "should not rewrite old function to new function in higher-order uses" + -------------------- + #lang racket + (map old-function (list 1 2 3)) + -------------------- +}} + + +@section{Running Resyntax Tests} + +Tests written in @racketmodname[resyntax/test] are integrated with RackUnit and can be run using +the standard @exec{raco test} command: + +@verbatim{ + % raco test my-rule-test.rkt + raco test: (submod "my-rule-test.rkt" test) + 5 tests passed +} + +Each @racket[test] statement becomes a RackUnit test case, and the entire test file becomes a module +with a single submodule named @racket[test]. Clicking the Run button in DrRacket will execute each +test in the file. Just like in RackUnit, failing tests are highlighted by DrRacket and print failure +messages. + +When executing tests, the @racketmodname[resyntax/test] language enables Resyntax's debug logging and +captures all of its logs. Failing test cases include the captured Resyntax logs in their printed +output. This output can be somewhat verbose, but it makes it much easier to tell why Resyntax did or +didn't refactor code and how Resyntax came to produce a malformed suggestion. From 6b7dfa1bd0ea126d58b54da5f7898571b2b0de2d Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 1 Jul 2026 21:56:42 -0700 Subject: [PATCH 2/5] Move `resyntax/private/source` into grimoire --- base.rkt | 4 ++-- cli.rkt | 2 +- {private => grimoire}/source.rkt | 0 main.rkt | 2 +- private/analysis.rkt | 2 +- private/file-group.rkt | 2 +- private/github.rkt | 2 +- private/refactoring-result.rkt | 2 +- private/string-replacement.rkt | 2 +- private/syntax-replacement.rkt | 2 +- private/universal-tagged-syntax.rkt | 2 +- test/private/rackunit.rkt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) rename {private => grimoire}/source.rkt (100%) diff --git a/base.rkt b/base.rkt index 1d14d28f..bc86caaf 100644 --- a/base.rkt +++ b/base.rkt @@ -50,7 +50,7 @@ resyntax/default-recommendations/private/definition-context resyntax/private/analyzer resyntax/private/logger - resyntax/private/source + resyntax/grimoire/source resyntax/private/syntax-neighbors resyntax/private/syntax-replacement syntax/parse @@ -116,7 +116,7 @@ (define (refactoring-rule-refactor rule syntax source) ;; Before refactoring the input syntax, we create a new scope and add it. Combined with the code in - ;; resyntax/private/source which marks the original path of every syntax object before expansion, + ;; resyntax/grimoire/source which marks the original path of every syntax object before expansion, ;; this allows us to tell when two neighboring subforms within the output syntax object are ;; originally from the input and were originally next to each other in the input. This allows ;; Resyntax to preserve any formatting and comments between those two subform when rendering the diff --git a/cli.rkt b/cli.rkt index f1917b15..3148b981 100644 --- a/cli.rkt +++ b/cli.rkt @@ -27,7 +27,7 @@ resyntax/private/file-group resyntax/private/github resyntax/private/refactoring-result - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/syntax-replacement) diff --git a/private/source.rkt b/grimoire/source.rkt similarity index 100% rename from private/source.rkt rename to grimoire/source.rkt diff --git a/main.rkt b/main.rkt index a9db4ebb..01299e46 100644 --- a/main.rkt +++ b/main.rkt @@ -59,7 +59,7 @@ resyntax/private/line-replacement resyntax/private/logger resyntax/private/refactoring-result - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/string-replacement resyntax/private/syntax-property-bundle diff --git a/private/analysis.rkt b/private/analysis.rkt index 52ac154c..dc944de3 100644 --- a/private/analysis.rkt +++ b/private/analysis.rkt @@ -44,7 +44,7 @@ resyntax/private/analyzer resyntax/private/linemap resyntax/private/logger - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/syntax-movement resyntax/private/syntax-neighbors diff --git a/private/file-group.rkt b/private/file-group.rkt index 1ef5cb73..e5043943 100644 --- a/private/file-group.rkt +++ b/private/file-group.rkt @@ -40,7 +40,7 @@ rebellion/streaming/transducer resyntax/private/git resyntax/private/logger - resyntax/private/source) + resyntax/grimoire/source) (module+ test diff --git a/private/github.rkt b/private/github.rkt index dcfe4f1a..0e0aeaae 100644 --- a/private/github.rkt +++ b/private/github.rkt @@ -25,7 +25,7 @@ resyntax/private/line-replacement resyntax/private/refactoring-result resyntax/private/run-command - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/syntax-replacement) diff --git a/private/refactoring-result.rkt b/private/refactoring-result.rkt index 9b5d5edf..cb804a77 100644 --- a/private/refactoring-result.rkt +++ b/private/refactoring-result.rkt @@ -52,7 +52,7 @@ resyntax/private/line-replacement resyntax/private/linemap resyntax/private/logger - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-replacement resyntax/private/syntax-replacement (only-in racket/list first)) diff --git a/private/string-replacement.rkt b/private/string-replacement.rkt index ed411ed5..376eef4b 100644 --- a/private/string-replacement.rkt +++ b/private/string-replacement.rkt @@ -75,7 +75,7 @@ rebellion/streaming/transducer rebellion/type/record rebellion/type/tuple - resyntax/private/source) + resyntax/grimoire/source) (module+ test diff --git a/private/syntax-replacement.rkt b/private/syntax-replacement.rkt index 117466b2..6d084afc 100644 --- a/private/syntax-replacement.rkt +++ b/private/syntax-replacement.rkt @@ -44,7 +44,7 @@ rebellion/type/record resyntax/private/linemap resyntax/private/logger - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/string-replacement resyntax/private/syntax-neighbors diff --git a/private/universal-tagged-syntax.rkt b/private/universal-tagged-syntax.rkt index 1b52bf55..e08dc824 100644 --- a/private/universal-tagged-syntax.rkt +++ b/private/universal-tagged-syntax.rkt @@ -14,7 +14,7 @@ racket/mutability racket/port racket/syntax-srcloc - resyntax/private/source) + resyntax/grimoire/source) (module+ test diff --git a/test/private/rackunit.rkt b/test/private/rackunit.rkt index 424d4f49..cb2b8d96 100644 --- a/test/private/rackunit.rkt +++ b/test/private/rackunit.rkt @@ -38,7 +38,7 @@ resyntax/private/analyzer resyntax/private/logger resyntax/private/refactoring-result - resyntax/private/source + resyntax/grimoire/source resyntax/private/string-indent resyntax/private/string-replacement resyntax/private/syntax-path From 0d94b07e86c2cde533b5f48f0949a5ee925c4a4c Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 1 Jul 2026 23:04:22 -0700 Subject: [PATCH 3/5] Document `resyntax/grimoire/source` --- grimoire.scrbl | 202 +++++++++++++++++++++++++++++++++++++++++++++++++ main.scrbl | 1 + 2 files changed, 203 insertions(+) create mode 100644 grimoire.scrbl diff --git a/grimoire.scrbl b/grimoire.scrbl new file mode 100644 index 00000000..3245f0e5 --- /dev/null +++ b/grimoire.scrbl @@ -0,0 +1,202 @@ +#lang scribble/manual + + +@(require (for-label racket/base + racket/contract/base + racket/path + rebellion/base/immutable-string + rebellion/collection/range-set + resyntax/grimoire/source + syntax/modread)) + + +@title[#:tag "grimoire"]{The Resyntax Grimoire} + +Resyntax's implementation is complex. This document serves as a reference manual for many of the +internal libraries and abstractions contained within Resyntax. @bold{The APIs documented here are + unstable and not meant for public consumption at this time.} This grimoire is intended for those +seeking to understand how Resyntax operates under the hood. Danger awaits those who come to rely +programmatically on anything found here. + + +@section{Source Code} +@defmodule[resyntax/grimoire/source] + +In Resyntax, @deftech{source code} refers to @racket[source?] values, which come in three types: + +@itemlist[ + @item{@emph{Source files}, constructed with @racket[file-source], which don't contain the code + directly but refer to it by a local filesystem path.} + + @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.)} + + @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 + with. A modified source contains both it's new updated contents and a reference to the original + source.}] + +Resyntax's basic architecture is to recursively take sources of any kind as input, produce +@racket[modified-source?] values as output, then re-analyze the modified sources again until no +further modifications are desired. Then, Resyntax decides whether to commit those final modifications +to the filesystem (as in @tt{resyntax fix}) or merely display them to users (as in +@tt{resyntax analyze}). This recursive loop approach allows Resyntax to "look ahead" and produce a +stack of dependent changes to commit in series without actually mutating the files on disk. + + +@subsection{Basic Source Operations} + + +@defproc[(source? [v any/c]) boolean?]{ + A predicate that recognizes @tech{source code} values of any kind --- file, string, or modified.} + + +@defproc[(unmodified-source? [v any/c]) boolean?]{ + A predicate that recognizes @tech{source code} values that are @emph{not} modified sources, i.e. + either file sources or string sources.} + + +@defproc[(file-source? [v any/c]) boolean?]{ + A predicate that recognizes (unmodified) source files.} + + +@defproc[(file-source [path path-string?]) file-source?]{ + Constructs a source file that refers to the code stored on disk at @racket[path]. The path is + normalized with @racket[simple-form-path] upon construction.} + + +@defproc[(file-source-path [code file-source?]) path?]{ + Returns the filesystem path that @racket[code] refers to.} + + +@defproc[(string-source? [v any/c]) boolean?]{ + A predicate that recognizes source strings.} + + +@defproc[(string-source [contents string?]) string-source?]{ + Constructs a source string containing @racket[contents] directly.} + + +@defproc[(string-source-contents [code string-source?]) immutable-string?]{ + Returns the source code text contained within @racket[code]. Note that this is a distinct operation + from @racket[source->string] --- the latter returns the current source code text of @emph{any} source + code, which may involve reading files from disk. This operation, in contrast, cannot perform I/O + because it only operates on an unmodified @racket[string-source?].} + + +@defproc[(modified-source? [v any/c]) boolean?]{ + A predicate that recognizes modified sources. A modified source is always a wrapper around an + unmodified source plus a string containing the full replacement text that the modified source should + contain instead of what the original source contains.} + + +@defproc[(modified-source [original unmodified-source?] [new-contents string?]) modified-source?]{ + Constructs a modified source that replaces the contents of @racket[original] with + @racket[new-contents]. This represents a whole-file replacement --- the @emph{complete} contents of + @racket[original] are @emph{entirely} swapped out with @racket[new-contents]. Modified sources cannot + represent partial edits on its own.} + + +@defproc[(modified-source-contents [code modified-source?]) immutable-string?]{ + Returns the new, updated contents of @racket[code], ignoring whatever contents the original source + of @racket[code] had.} + + +@defproc[(modified-source-original [code modified-source?]) unmodified-source?]{ + Returns the original, unmodified source that @racket[code] was constructed from.} + + +@defproc[(source-name [code source?]) (or/c path? symbol?)]{ + Returns a name identifying @racket[code], suitable for use as a syntax object's source location + name. For file-based sources this is the source file's path, and for string-based sources this is the + symbol @racket['string]. Modified sources always have the same name as their original unmodified + sources.} + + +@defproc[(source-path [code source?]) (or/c path? #false)]{ + Returns the filesystem path of @racket[code], or @racket[#false] if @racket[code] is not + file-based. Modified sources are file-based if their original source is file-based.} + + +@defproc[(source-directory [code source?]) (or/c path? #false)]{ + Returns the directory containing @racket[code], or @racket[#false] if @racket[code] is not + file-based. Modified sources are file-based if their original source is file-based.} + + +@defproc[(source-original [code source?]) unmodified-source?]{ + Returns the original, unmodified source underlying @racket[code]. If @racket[code] is already + unmodified, it is returned as-is.} + + +@defproc[(source->string [code source?]) immutable-string?]{ + Returns the full text of @racket[code], reading it from the filesystem if necessary. For + @racket[modified-source?] values, this returns the new, updated text rather than the original + unmodified text.} + + +@defproc[(with-input-from-source [code source?] [proc (-> any)]) any]{ + Calls @racket[proc] with @racket[current-input-port] set to a freshly opened input port reading + the contents of @racket[code]. For unmodified file sources, this opens a file port. For modified + sources and string sources, this opens a string port without interacting with the filesystem.} + + +@subsection{Parsing, Expanding, and Compiling Sources} + + +The following operations allow treating @tech{source code} values as inputs to Racket's compiler. +For file sources, this is roughly the same as reading the file into a syntax object using +@racket[with-module-reading-parameterization] and expanding that syntax object using @racket[expand]. +However, string sources and modified sources behave slightly differently, especially with regard to +source location information on derived syntax objects: + +@itemlist[ + @item{A string source behaves as if it were an @emph{anonymous file} with no well-defined location on + the filesystem. Relative file path imports will not work correctly. Source location information will + still be present with line and column numbers, but will claim to be located in a source named + @racket['string] instead of a file.} + + @item{A modified source behaves the same as its wrapped unmodified source, except as if its contents + were completely replaced. Source location information will be present, @bold{but will not correspond + to positions within the original source} as they will instead refer to positions in the modified + contents. If the original source was a file source, accidentally using source locations from the + modified source to make edits to the original file will produce malformed changes.}] + +Note that while a file source is being read and expanded, the current directory is parameterized to +the file source's parent directory. This ensures that relative file path imports in source files can +still be resolved regardless of what the current directory is before the source is read or expanded. +This applies to both modified and unmodified file sources. + + +@defproc[(source-read-language [code source?]) (or/c module-path? #false)]{ + Detects the @hash-lang[] language of @racket[code] and returns the module path of that language. + Returns @racket[#false] if @racket[code] does not begin with a @hash-lang[] line.} + + +@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.} + + +@defproc[(source-expand [code source?]) syntax?]{ + Reads @racket[code] and fully expands it, as in @racket[expand].} + + +@defproc[(source-can-expand? [code source?]) boolean?]{ + Attempts to fully expand @racket[code], then returns @racket[#true] if expansion finished without + without raising an error and returns @racket[#false] otherwise.} + + +@defproc[(source-text-of [code source?] [stx syntax?]) immutable-string?]{ + Returns the source text within @racket[code] that produced @racket[stx], based on the source location + information attached to @racket[stx]. Raises a contract violation if @racket[stx] does not have + source location information.} + + +@defproc[(source-comment-locations [code source?]) immutable-range-set?]{ + Returns a range set containing the positions of all comments in @racket[code]. This is implemented + by looking up the lexer of the @hash-lang[] that @racket[code] is written in, using the + @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}.} diff --git a/main.scrbl b/main.scrbl index 51530882..db5d9e5b 100644 --- a/main.scrbl +++ b/main.scrbl @@ -48,3 +48,4 @@ To see a list of suggestions that Resyntax would apply, use @exec{resyntax analy @include-section[(lib "resyntax/cli.scrbl")] @include-section[(lib "resyntax/refactoring-rules.scrbl")] @include-section[(lib "resyntax/testing.scrbl")] +@include-section[(lib "resyntax/grimoire.scrbl")] From 18d4e1c9b003b786a36ae757242f6c945673abd8 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 1 Jul 2026 23:29:05 -0700 Subject: [PATCH 4/5] Parameterize current directory in source read/expand operations The grimoire documents that reading and expanding a file source parameterizes the current directory to the source's parent directory, but that parameterization only happened in source-analyze, not in the source module itself. Now source-read-syntax, source-read-language, and source-expand (and thus source-can-expand?) each parameterize the current directory for file-based sources, so relative module imports resolve correctly no matter the caller's working directory. The existing parameterization in source-analyze stays, since it calls expand directly rather than source-expand. Co-Authored-By: Claude Fable 5 --- grimoire/source.rkt | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/grimoire/source.rkt b/grimoire/source.rkt index a16631e8..caca2a35 100644 --- a/grimoire/source.rkt +++ b/grimoire/source.rkt @@ -50,6 +50,7 @@ (module+ test (require (submod "..") + racket/file rackunit)) @@ -103,11 +104,23 @@ (string->immutable-string (with-input-from-source code port->string))) +;; Parameterizes the current directory to the source's parent directory (for file-based sources) +;; while calling proc, so that reading and expanding sources can resolve relative module paths +;; regardless of what the current directory was beforehand. +(define (call-with-source-directory code proc) + (define dir (source-directory code)) + (if dir + (parameterize ([current-directory dir]) + (proc)) + (proc))) + + (define (source-read-syntax code) (define (read-from-input) (port-count-lines! (current-input-port)) (with-module-reading-parameterization read-syntax)) - (syntax-label-original-paths (with-input-from-source code read-from-input))) + (syntax-label-original-paths + (call-with-source-directory code (λ () (with-input-from-source code read-from-input))))) (define (source-read-language code) @@ -120,7 +133,8 @@ (parameterize ([current-reader-guard escape]) (read-syntax)) #false))))) - (define detected-lang (with-input-from-source code read-lang-from-input)) + (define detected-lang + (call-with-source-directory code (λ () (with-input-from-source code read-lang-from-input)))) (match detected-lang [(list 'submod path 'reader) path] [_ #false])) @@ -149,11 +163,26 @@ (define valid-mod (modified-source orig "#lang racket/base\n(define foo 43)")) (define invalid-mod (modified-source orig "#lang racket/base\n(if)")) (check-true (source-can-expand? valid-mod)) - (check-false (source-can-expand? invalid-mod)))) + (check-false (source-can-expand? invalid-mod))) + + (test-case "source-expand parameterizes the current directory for file sources" + ;; Expanding a file source with a relative import should succeed no matter what the current + ;; directory is. + (define dir (make-temporary-directory)) + (display-to-file "#lang racket/base\n(provide x)\n(define x 42)\n" (build-path dir "helper.rkt")) + (define program-path (build-path dir "program.rkt")) + (display-to-file "#lang racket/base\n(require \"helper.rkt\")\nx\n" program-path) + (define program-file-source (file-source program-path)) + (define program-modified-source + (modified-source program-file-source "#lang racket/base\n(require \"helper.rkt\")\n(void x)\n")) + (parameterize ([current-directory (find-system-path 'temp-dir)]) + (check-true (source-can-expand? program-file-source)) + (check-true (source-can-expand? program-modified-source))) + (delete-directory/files dir))) (define (source-expand code) - (expand (source-read-syntax code))) + (call-with-source-directory code (λ () (expand (source-read-syntax code))))) (define (source-can-expand? code) From a83c7f56a02acf198d5b8d00cdc8d5387a2c6d11 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Wed, 1 Jul 2026 23:34:32 -0700 Subject: [PATCH 5/5] Fix typos in documentation and CLI help text Co-Authored-By: Claude Fable 5 --- cli.rkt | 2 +- cli.scrbl | 6 +++--- grimoire.scrbl | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cli.rkt b/cli.rkt index 3148b981..fc642bef 100644 --- a/cli.rkt +++ b/cli.rkt @@ -75,7 +75,7 @@ ("--directory" dirpath - "A directory to anaylze, including subdirectories." + "A directory to analyze, including subdirectories." (vector-builder-add targets (directory-file-group dirpath))) ("--package" diff --git a/cli.scrbl b/cli.scrbl index b7dfd029..436d52da 100644 --- a/cli.scrbl +++ b/cli.scrbl @@ -14,7 +14,7 @@ two commands: @exec{resyntax analyze} for analyzing code without changing it, an for fixing code by applying Resyntax's suggestions. Note that at present, Resyntax is limited in what files it can fix. Resyntax only analyzes files with -the @exec{.rkt} extension where @tt{#lang racket/base} is the first line in file. +the @exec{.rkt} extension where @tt{#lang racket/base} is the first line in the file. @section[#:tag "install"]{Installation} @@ -47,9 +47,9 @@ number of times: @itemlist[ - @item{@exec{--file} @nonterm{file-path} --- A file to anaylze.} + @item{@exec{--file} @nonterm{file-path} --- A file to analyze.} - @item{@exec{--directory} @nonterm{directory-path} --- A directory to anaylze, including + @item{@exec{--directory} @nonterm{directory-path} --- A directory to analyze, including subdirectories.} @item{@exec{--package} @nonterm{package-name} --- An installed package to analyze.} diff --git a/grimoire.scrbl b/grimoire.scrbl index 3245f0e5..052fa062 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -35,7 +35,7 @@ In Resyntax, @deftech{source code} refers to @racket[source?] values, which come @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 - with. A modified source contains both it's new updated contents and a reference to the original + with. A modified source contains both its new updated contents and a reference to the original source.}] Resyntax's basic architecture is to recursively take sources of any kind as input, produce @@ -96,7 +96,7 @@ stack of dependent changes to commit in series without actually mutating the fil Constructs a modified source that replaces the contents of @racket[original] with @racket[new-contents]. This represents a whole-file replacement --- the @emph{complete} contents of @racket[original] are @emph{entirely} swapped out with @racket[new-contents]. Modified sources cannot - represent partial edits on its own.} + represent partial edits on their own.} @defproc[(modified-source-contents [code modified-source?]) immutable-string?]{ @@ -184,7 +184,7 @@ This applies to both modified and unmodified file sources. @defproc[(source-can-expand? [code source?]) boolean?]{ - Attempts to fully expand @racket[code], then returns @racket[#true] if expansion finished without + Attempts to fully expand @racket[code], then returns @racket[#true] if expansion finished without raising an error and returns @racket[#false] otherwise.}