diff --git a/base.rkt b/base.rkt index 1d14d28..bc86caa 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 f1917b1..fc642be 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) @@ -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 new file mode 100644 index 0000000..436d52d --- /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 the 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 analyze.} + + @item{@exec{--directory} @nonterm{directory-path} --- A directory to analyze, 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/grimoire.scrbl b/grimoire.scrbl new file mode 100644 index 0000000..052fa06 --- /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 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 +@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 their 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 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/private/source.rkt b/grimoire/source.rkt similarity index 81% rename from private/source.rkt rename to grimoire/source.rkt index a16631e..caca2a3 100644 --- a/private/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) diff --git a/main.rkt b/main.rkt index a9db4eb..01299e4 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/main.scrbl b/main.scrbl index d1e3c18..db5d9e5 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,7 @@ 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")] +@include-section[(lib "resyntax/grimoire.scrbl")] diff --git a/private/analysis.rkt b/private/analysis.rkt index 52ac154..dc944de 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 1ef5cb7..e504394 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 dcfe4f1..0e0aeaa 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 9b5d5ed..cb804a7 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 ed411ed..376eef4 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 117466b..6d084af 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 1b52bf5..e08dc82 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/refactoring-rules.scrbl b/refactoring-rules.scrbl new file mode 100644 index 0000000..4c498ec --- /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/test/private/rackunit.rkt b/test/private/rackunit.rkt index 424d4f4..cb2b8d9 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 diff --git a/testing.scrbl b/testing.scrbl new file mode 100644 index 0000000..7313108 --- /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.