From 1666b4e871a7309ba16a2be13eededec8d1f5f6a Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 16:09:52 -0700 Subject: [PATCH 01/10] Move file group API into grimoire --- cli.rkt | 2 +- {private => grimoire}/file-group.rkt | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {private => grimoire}/file-group.rkt (100%) diff --git a/cli.rkt b/cli.rkt index 192dacd..e17c6dd 100644 --- a/cli.rkt +++ b/cli.rkt @@ -24,7 +24,7 @@ resyntax resyntax/base resyntax/default-recommendations - resyntax/private/file-group + resyntax/grimoire/file-group resyntax/private/github resyntax/private/refactoring-result resyntax/grimoire/source diff --git a/private/file-group.rkt b/grimoire/file-group.rkt similarity index 100% rename from private/file-group.rkt rename to grimoire/file-group.rkt From 8409d61ca972ef3feb2ff6a340e6aac17cdfdfad Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 17:25:39 -0700 Subject: [PATCH 02/10] Remove the file portion API File portions were an intermediate pairing of a file path with a set of line numbers used during file group resolution. They predate the range set merging that file-groups-resolve performs, which makes them obsolete: resolution now produces entries mapping file sources to line range sets directly, and nothing outside the module ever used file portions. Removing them also removes a latent contract mismatch, since the file-portion constructor accepted mutable range sets while file-portion-lines promised immutable ones. Co-Authored-By: Claude Fable 5 --- grimoire/file-group.rkt | 105 ++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 57 deletions(-) diff --git a/grimoire/file-group.rkt b/grimoire/file-group.rkt index e504394..05a36d5 100644 --- a/grimoire/file-group.rkt +++ b/grimoire/file-group.rkt @@ -6,10 +6,6 @@ (provide (contract-out - [file-portion? (-> any/c boolean?)] - [file-portion (-> path-string? range-set? file-portion?)] - [file-portion-path (-> file-portion? complete-path?)] - [file-portion-lines (-> file-portion? immutable-range-set?)] [file-groups-resolve (-> (sequence/c file-group?) (hash/c file-source? immutable-range-set?))] [file-group? (-> any/c boolean?)] [single-file-group? (-> any/c boolean?)] @@ -23,7 +19,6 @@ (require fancy-app - guard pkg/lib racket/file racket/match @@ -54,11 +49,6 @@ ;@---------------------------------------------------------------------------------------------------- -(struct file-portion (path lines) - #:transparent - #:guard (λ (path lines _) (values (simple-form-path path) lines))) - - (struct file-group () #:transparent) @@ -84,22 +74,25 @@ (values (simple-form-path repository-path) (string->immutable-string ref)))) +(define all-lines (range-set (unbounded-range #:comparator natural<=>))) + + (define (file-groups-resolve groups) (transduce groups (append-mapping file-group-resolve) - (bisecting (λ (portion) (file-source (file-portion-path portion))) file-portion-lines) (grouping (make-fold-reducer range-set-add-all (range-set #:comparator natural<=>))) #:into into-hash)) +;; Resolves a single group into a list of entries mapping file sources to line range sets. (define (file-group-resolve group) - (define files + (define path-entries (match group - [(single-file-group path ranges) - (list (file-portion path ranges))] + [(single-file-group path lines) + (list (entry path lines))] [(directory-file-group path) (for/list ([file (in-directory path)]) - (file-portion file (range-set (unbounded-range #:comparator natural<=>))))] + (entry file all-lines))] [(package-file-group package-name) (define pkgdir (pkg-directory package-name)) (unless pkgdir @@ -107,18 +100,23 @@ "cannot analyze package ~a, it hasn't been installed" package-name)) (for/list ([file (in-directory (simple-form-path pkgdir))]) - (file-portion file (range-set (unbounded-range #:comparator natural<=>))))] + (entry file all-lines))] [(git-repository-file-group repository-path ref) (parameterize ([current-directory repository-path]) (define diff-lines (git-diff-modified-lines ref)) (for/list ([(file lines) (in-hash diff-lines)]) (log-resyntax-debug "~a: modified lines: ~a" file lines) - (file-portion file (expand-modified-line-set lines))))])) - (transduce files (filtering rkt-file?) #:into into-list)) + ;; Paths from the diff are relative to the repository, so they're resolved eagerly here + ;; while the current directory is still parameterized to the repository path. + (entry (simple-form-path file) (expand-modified-line-set lines))))])) + (transduce path-entries + (filtering (λ (e) (rkt-path? (entry-key e)))) + (mapping (λ (e) (entry (file-source (entry-key e)) (entry-value e)))) + #:into into-list)) -(define/guard (rkt-file? portion) - (path-has-extension? (file-portion-path portion) #".rkt")) +(define (rkt-path? path) + (path-has-extension? path #".rkt")) ;; GitHub allows pull request reviews to include comments only on modified lines, plus the 3 lines @@ -140,13 +138,6 @@ (module+ test - (test-case "file-portion" - (test-case "constructor normalizes paths" - (define portion (file-portion "/tmp/test.rkt" (range-set (closed-open-range 1 10 #:comparator natural<=>)))) - (check-true (file-portion? portion)) - (check-equal? (file-portion-path portion) (simple-form-path "/tmp/test.rkt")) - (check-equal? (file-portion-lines portion) (range-set (closed-open-range 1 10 #:comparator natural<=>))))) - (test-case "single-file-group" (test-case "constructor and predicates" (define group (single-file-group "/tmp/test.rkt" (range-set (closed-open-range 1 10 #:comparator natural<=>)))) @@ -155,16 +146,16 @@ (check-equal? (single-file-group-path group) (simple-form-path "/tmp/test.rkt")) (check-equal? (single-file-group-ranges group) (range-set (closed-open-range 1 10 #:comparator natural<=>)))) - (test-case "file-group-resolve returns single file" + (test-case "resolution returns single file" (define test-dir (make-temporary-directory "resyntax-test-~a")) (define test-file (build-path test-dir "test.rkt")) (call-with-output-file test-file (λ (out) (displayln "#lang racket/base" out))) (define group (single-file-group test-file (range-set (closed-open-range 1 5 #:comparator natural<=>)))) - (define portions (file-group-resolve group)) - (check-equal? (length portions) 1) - (check-equal? (file-portion-path (first portions)) (simple-form-path test-file)) - (check-equal? (file-portion-lines (first portions)) (range-set (closed-open-range 1 5 #:comparator natural<=>))) + (define resolved (file-groups-resolve (list group))) + (check-equal? (hash-count resolved) 1) + (check-equal? (hash-ref resolved (file-source test-file)) + (range-set (closed-open-range 1 5 #:comparator natural<=>))) (delete-directory/files test-dir))) (test-case "directory-file-group" @@ -174,7 +165,7 @@ (check-true (file-group? group)) (check-equal? (directory-file-group-path group) (simple-form-path "/tmp"))) - (test-case "file-group-resolve returns only .rkt files" + (test-case "resolution returns only .rkt files" (define test-dir (make-temporary-directory "resyntax-test-~a")) (define rkt-file1 (build-path test-dir "test1.rkt")) (define rkt-file2 (build-path test-dir "test2.rkt")) @@ -186,9 +177,10 @@ (call-with-output-file txt-file (λ (out) (displayln "not racket" out))) (define group (directory-file-group test-dir)) - (define portions (file-group-resolve group)) - (check-equal? (length portions) 2) - (check-true (andmap (λ (p) (path-has-extension? (file-portion-path p) #".rkt")) portions)) + (define resolved (file-groups-resolve (list group))) + (check-equal? (hash-count resolved) 2) + (check-true (hash-has-key? resolved (file-source rkt-file1))) + (check-true (hash-has-key? resolved (file-source rkt-file2))) (delete-directory/files test-dir))) (test-case "package-file-group" @@ -198,18 +190,19 @@ (check-true (file-group? group)) (check-equal? (package-file-group-package-name group) "rackunit")) - (test-case "file-group-resolve returns files from installed package" + (test-case "resolution returns files from installed package" (define group (package-file-group "rackunit")) - (define portions (file-group-resolve group)) - (check-true (list? portions)) - (check-true (andmap file-portion? portions)) - (check-true (andmap (λ (p) (path-has-extension? (file-portion-path p) #".rkt")) portions)) - (check-true (> (length portions) 0))) - - (test-case "file-group-resolve raises error for non-existent package" + (define resolved (file-groups-resolve (list group))) + (check-true (hash? resolved)) + (check-true (> (hash-count resolved) 0)) + (for ([src (in-hash-keys resolved)]) + (check-pred file-source? src) + (check-true (path-has-extension? (source-path src) #".rkt")))) + + (test-case "resolution raises error for non-existent package" (define group (package-file-group "this-package-does-not-exist-xyz")) (check-exn exn:fail:user? - (λ () (file-group-resolve group))))) + (λ () (file-groups-resolve (list group)))))) (test-case "git-repository-file-group" (test-case "constructor and predicates" @@ -238,10 +231,11 @@ (call-with-output-file test-file #:exists 'append (λ (out) (displayln "(define x 1)" out))) (define group (git-repository-file-group test-dir "HEAD")) - (define portions (file-group-resolve group)) - (check-true (list? portions)) - (check-true (> (length portions) 0)) - (check-true (andmap file-portion? portions))) + (define resolved (file-groups-resolve (list group))) + (check-true (hash? resolved)) + (check-true (> (hash-count resolved) 0)) + (for ([src (in-hash-keys resolved)]) + (check-pred file-source? src))) (delete-directory/files test-dir))) (test-case "file-groups-resolve" @@ -278,16 +272,13 @@ (check-true (range-set-contains? combined-ranges 6)) (delete-directory/files test-dir))) - (test-case "rkt-file?" + (test-case "rkt-path?" (test-case "returns true for .rkt files" - (define portion (file-portion "/tmp/test.rkt" (range-set #:comparator natural<=>))) - (check-true (rkt-file? portion))) - + (check-true (rkt-path? "/tmp/test.rkt"))) + (test-case "returns false for non-.rkt files" - (define portion1 (file-portion "/tmp/test.txt" (range-set #:comparator natural<=>))) - (define portion2 (file-portion "/tmp/test.scm" (range-set #:comparator natural<=>))) - (check-false (rkt-file? portion1)) - (check-false (rkt-file? portion2)))) + (check-false (rkt-path? "/tmp/test.txt")) + (check-false (rkt-path? "/tmp/test.scm")))) (test-case "range-bound-map" (test-case "maps bounded endpoints" From 331eace6fe4aa3e0595967405dc6f9b6ba1bcab3 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 17:27:25 -0700 Subject: [PATCH 03/10] First draft of file groups API docs Co-Authored-By: Claude --- cli.scrbl | 4 ++ grimoire.scrbl | 1 + grimoire/file-group.scrbl | 105 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 grimoire/file-group.scrbl diff --git a/cli.scrbl b/cli.scrbl index 436d52d..21a8ea9 100644 --- a/cli.scrbl +++ b/cli.scrbl @@ -16,6 +16,10 @@ 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. +Each of the target flags accepted by these commands constructs a @tech{file group} describing which +files to operate on. See @secref["file-group"] in The Resyntax Grimoire for the API underlying these +flags. + @section[#:tag "install"]{Installation} diff --git a/grimoire.scrbl b/grimoire.scrbl index 9c4a837..3cfb178 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -14,3 +14,4 @@ programmatically on anything found here. @include-section[(lib "resyntax/grimoire/source.scrbl")] @include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] +@include-section[(lib "resyntax/grimoire/file-group.scrbl")] diff --git a/grimoire/file-group.scrbl b/grimoire/file-group.scrbl new file mode 100644 index 0000000..22c8a13 --- /dev/null +++ b/grimoire/file-group.scrbl @@ -0,0 +1,105 @@ +#lang scribble/manual + + +@(require (for-label pkg/lib + racket/base + racket/contract/base + racket/path + racket/sequence + rebellion/collection/range-set + resyntax/grimoire/file-group + resyntax/grimoire/source)) + + +@title[#:tag "file-group"]{File Groups} +@defmodule[resyntax/grimoire/file-group] + +A @deftech{file group} is a specification of a set of files that Resyntax should analyze, along with +which lines within those files Resyntax is allowed to suggest changes to. File groups come in four +kinds, each corresponding to one of the target flags accepted by the @seclink["cli"]{command-line + interface}: + +@itemlist[ + @item{@emph{Single-file groups}, constructed with @racket[single-file-group], containing one file + restricted to a given set of lines. The @exec{--file} flag constructs these, with all lines + allowed.} + + @item{@emph{Directory groups}, constructed with @racket[directory-file-group], containing every + file within a directory, including files in subdirectories. The @exec{--directory} flag + constructs these.} + + @item{@emph{Package groups}, constructed with @racket[package-file-group], containing every file + of an installed Racket package. The @exec{--package} flag constructs these.} + + @item{@emph{Git repository groups}, constructed with @racket[git-repository-file-group], + containing the files of a Git repository that have changed relative to some base reference. The + @exec{--local-git-repository} flag constructs these.}] + +A file group is only a description: it must be @emph{resolved} with @racket[file-groups-resolve] to +produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the +filesystem, the package catalog, or the Git repository is actually consulted. + + +@defproc[(file-group? [v any/c]) boolean?]{ + A predicate that recognizes @tech{file groups} of any kind.} + + +@defproc[(single-file-group? [v any/c]) boolean?]{ + A predicate that recognizes single-file groups.} + + +@defproc[(single-file-group [path path-string?] [lines immutable-range-set?]) + single-file-group?]{ + Constructs a @tech{file group} containing only the file at @racket[path], with suggestions + restricted to the line numbers in @racket[lines]. The path is normalized with + @racket[simple-form-path] upon construction.} + + +@defproc[(directory-file-group? [v any/c]) boolean?]{ + A predicate that recognizes directory groups.} + + +@defproc[(directory-file-group [path path-string?]) directory-file-group?]{ + Constructs a @tech{file group} containing every file within the directory at @racket[path], + including files within subdirectories, with all lines of each file eligible for suggestions. The + path is normalized with @racket[simple-form-path] upon construction.} + + +@defproc[(package-file-group? [v any/c]) boolean?]{ + A predicate that recognizes package groups.} + + +@defproc[(package-file-group [package-name string?]) package-file-group?]{ + Constructs a @tech{file group} containing every file of the installed Racket package named + @racket[package-name], with all lines of each file eligible for suggestions. The package's + installation directory is located with @racket[pkg-directory] during resolution, and resolution + raises a user error if no such package is installed.} + + +@defproc[(git-repository-file-group? [v any/c]) boolean?]{ + A predicate that recognizes Git repository groups.} + + +@defproc[(git-repository-file-group [repository-path path-string?] [base-ref string?]) + git-repository-file-group?]{ + Constructs a @tech{file group} containing the files of the Git repository at + @racket[repository-path] that have changed relative to @racket[base-ref], as determined by + @exec{git diff} during resolution. The repository path is normalized with + @racket[simple-form-path] upon construction. + + Only the modified lines of each changed file are eligible for suggestions, expanded to include the + three lines before and after each modified region. The three-line margin matches what GitHub + allows in pull request reviews: comments may only be placed on modified lines and the three lines + of context surrounding them, so suggestions within the margin can still be posted as review + comments.} + + +@defproc[(file-groups-resolve [groups (sequence/c file-group?)]) + (hash/c file-source? immutable-range-set?)]{ + Resolves each @tech{file group} in @racket[groups] into concrete files, returning a hash whose + keys are @racket[file-source?] values and whose values are the line numbers eligible for + suggestions in each file. When multiple groups include the same file, their line sets are unioned. + + Resolution discards all files that don't have the @exec{.rkt} extension. This is where the + @seclink["cli"]{command-line interface}'s restriction to @exec{.rkt} files is implemented.} + From 375b7a45834451e8d1b95c8703534c8e61c9e40b Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 20:24:33 -0700 Subject: [PATCH 04/10] Rename file groups to source groups Co-Authored-By: Claude --- cli.rkt | 22 ++-- cli.scrbl | 4 +- grimoire.scrbl | 2 +- grimoire/{file-group.rkt => source-group.rkt} | 124 +++++++++--------- .../{file-group.scrbl => source-group.scrbl} | 56 ++++---- 5 files changed, 104 insertions(+), 104 deletions(-) rename grimoire/{file-group.rkt => source-group.rkt} (70%) rename grimoire/{file-group.scrbl => source-group.scrbl} (61%) diff --git a/cli.rkt b/cli.rkt index e17c6dd..de8c881 100644 --- a/cli.rkt +++ b/cli.rkt @@ -24,7 +24,7 @@ resyntax resyntax/base resyntax/default-recommendations - resyntax/grimoire/file-group + resyntax/grimoire/source-group resyntax/private/github resyntax/private/refactoring-result resyntax/grimoire/source @@ -71,17 +71,17 @@ ("--file" filepath "A file to analyze." - (vector-builder-add targets (single-file-group filepath all-lines))) + (vector-builder-add targets (single-source-group filepath all-lines))) ("--directory" dirpath "A directory to analyze, including subdirectories." - (vector-builder-add targets (directory-file-group dirpath))) + (vector-builder-add targets (directory-source-group dirpath))) ("--package" pkgname "An installed package to analyze." - (vector-builder-add targets (package-file-group pkgname))) + (vector-builder-add targets (package-source-group pkgname))) ("--local-git-repository" repopath baseref @@ -89,7 +89,7 @@ path to the root of a Git repository, and the baseref argument is a Git reference (in the form \ \"remotename/branchname\") to use as the base state of the repository. Any files that have been \ changed relative to baseref are analyzed." - (vector-builder-add targets (git-repository-file-group repopath baseref))) + (vector-builder-add targets (git-repository-source-group repopath baseref))) #:once-each @@ -158,17 +158,17 @@ determined by the GITHUB_REPOSITORY and GITHUB_REF environment variables." #:multi - ("--file" filepath "A file to fix." (add-target! (single-file-group filepath all-lines))) + ("--file" filepath "A file to fix." (add-target! (single-source-group filepath all-lines))) ("--directory" dirpath "A directory to fix, including subdirectories." - (add-target! (directory-file-group dirpath))) + (add-target! (directory-source-group dirpath))) ("--package" pkgname "An installed package to fix." - (add-target! (package-file-group pkgname))) + (add-target! (package-source-group pkgname))) ("--local-git-repository" repopath baseref @@ -176,7 +176,7 @@ determined by the GITHUB_REPOSITORY and GITHUB_REF environment variables." path to the root of a Git repository, and the baseref argument is a Git reference (in the form \ \"remotename/branchname\") to use as the base state of the repository. Any files that have been \ changed relative to baseref are analyzed and fixed." - (add-target! (git-repository-file-group repopath baseref))) + (add-target! (git-repository-source-group repopath baseref))) #:once-each @@ -284,7 +284,7 @@ For help on these, use 'analyze --help' or 'fix --help'." (define (resyntax-analyze-run) (define options (resyntax-analyze-parse-command-line)) - (define sources (file-groups-resolve (resyntax-analyze-options-targets options))) + (define sources (source-groups-resolve (resyntax-analyze-options-targets options))) (define analysis (resyntax-analyze-all sources #:suite (resyntax-analyze-options-suite options) @@ -329,7 +329,7 @@ For help on these, use 'analyze --help' or 'fix --help'." (define options (resyntax-fix-parse-command-line)) (define fix-method (resyntax-fix-options-fix-method options)) (define output-format (resyntax-fix-options-output-format options)) - (define sources (file-groups-resolve (resyntax-fix-options-targets options))) + (define sources (source-groups-resolve (resyntax-fix-options-targets options))) (define max-modified-files (resyntax-fix-options-max-modified-files options)) (define max-modified-lines (resyntax-fix-options-max-modified-lines options)) (define analysis diff --git a/cli.scrbl b/cli.scrbl index 21a8ea9..e3980b7 100644 --- a/cli.scrbl +++ b/cli.scrbl @@ -16,8 +16,8 @@ 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. -Each of the target flags accepted by these commands constructs a @tech{file group} describing which -files to operate on. See @secref["file-group"] in The Resyntax Grimoire for the API underlying these +Each of the target flags accepted by these commands constructs a @tech{source group} describing which +files to operate on. See @secref["source-group"] in The Resyntax Grimoire for the API underlying these flags. diff --git a/grimoire.scrbl b/grimoire.scrbl index 3cfb178..0f3318a 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -14,4 +14,4 @@ programmatically on anything found here. @include-section[(lib "resyntax/grimoire/source.scrbl")] @include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] -@include-section[(lib "resyntax/grimoire/file-group.scrbl")] +@include-section[(lib "resyntax/grimoire/source-group.scrbl")] diff --git a/grimoire/file-group.rkt b/grimoire/source-group.rkt similarity index 70% rename from grimoire/file-group.rkt rename to grimoire/source-group.rkt index 05a36d5..12c7a18 100644 --- a/grimoire/file-group.rkt +++ b/grimoire/source-group.rkt @@ -6,16 +6,16 @@ (provide (contract-out - [file-groups-resolve (-> (sequence/c file-group?) (hash/c file-source? immutable-range-set?))] - [file-group? (-> any/c boolean?)] - [single-file-group? (-> any/c boolean?)] - [single-file-group (-> path-string? immutable-range-set? single-file-group?)] - [directory-file-group? (-> any/c boolean?)] - [directory-file-group (-> path-string? directory-file-group?)] - [package-file-group? (-> any/c boolean?)] - [package-file-group (-> string? package-file-group?)] - [git-repository-file-group? (-> any/c boolean?)] - [git-repository-file-group (-> path-string? string? git-repository-file-group?)])) + [source-groups-resolve (-> (sequence/c source-group?) (hash/c file-source? immutable-range-set?))] + [source-group? (-> any/c boolean?)] + [single-source-group? (-> any/c boolean?)] + [single-source-group (-> path-string? immutable-range-set? single-source-group?)] + [directory-source-group? (-> any/c boolean?)] + [directory-source-group (-> path-string? directory-source-group?)] + [package-source-group? (-> any/c boolean?)] + [package-source-group (-> string? package-source-group?)] + [git-repository-source-group? (-> any/c boolean?)] + [git-repository-source-group (-> path-string? string? git-repository-source-group?)])) (require fancy-app @@ -49,25 +49,25 @@ ;@---------------------------------------------------------------------------------------------------- -(struct file-group () #:transparent) +(struct source-group () #:transparent) -(struct single-file-group file-group (path ranges) +(struct single-source-group source-group (path ranges) #:transparent #:guard (λ (path ranges _) (values (simple-form-path path) ranges))) -(struct directory-file-group file-group (path) +(struct directory-source-group source-group (path) #:transparent #:guard (λ (path _) (simple-form-path path))) -(struct package-file-group file-group (package-name) +(struct package-source-group source-group (package-name) #:transparent #:guard (λ (package-name _) (string->immutable-string package-name))) -(struct git-repository-file-group file-group (repository-path ref) +(struct git-repository-source-group source-group (repository-path ref) #:transparent #:guard (λ (repository-path ref _) @@ -77,23 +77,23 @@ (define all-lines (range-set (unbounded-range #:comparator natural<=>))) -(define (file-groups-resolve groups) +(define (source-groups-resolve groups) (transduce groups - (append-mapping file-group-resolve) + (append-mapping source-group-resolve) (grouping (make-fold-reducer range-set-add-all (range-set #:comparator natural<=>))) #:into into-hash)) ;; Resolves a single group into a list of entries mapping file sources to line range sets. -(define (file-group-resolve group) +(define (source-group-resolve group) (define path-entries (match group - [(single-file-group path lines) + [(single-source-group path lines) (list (entry path lines))] - [(directory-file-group path) + [(directory-source-group path) (for/list ([file (in-directory path)]) (entry file all-lines))] - [(package-file-group package-name) + [(package-source-group package-name) (define pkgdir (pkg-directory package-name)) (unless pkgdir (raise-user-error 'resyntax @@ -101,7 +101,7 @@ package-name)) (for/list ([file (in-directory (simple-form-path pkgdir))]) (entry file all-lines))] - [(git-repository-file-group repository-path ref) + [(git-repository-source-group repository-path ref) (parameterize ([current-directory repository-path]) (define diff-lines (git-diff-modified-lines ref)) (for/list ([(file lines) (in-hash diff-lines)]) @@ -138,32 +138,32 @@ (module+ test - (test-case "single-file-group" + (test-case "single-source-group" (test-case "constructor and predicates" - (define group (single-file-group "/tmp/test.rkt" (range-set (closed-open-range 1 10 #:comparator natural<=>)))) - (check-true (single-file-group? group)) - (check-true (file-group? group)) - (check-equal? (single-file-group-path group) (simple-form-path "/tmp/test.rkt")) - (check-equal? (single-file-group-ranges group) (range-set (closed-open-range 1 10 #:comparator natural<=>)))) + (define group (single-source-group "/tmp/test.rkt" (range-set (closed-open-range 1 10 #:comparator natural<=>)))) + (check-true (single-source-group? group)) + (check-true (source-group? group)) + (check-equal? (single-source-group-path group) (simple-form-path "/tmp/test.rkt")) + (check-equal? (single-source-group-ranges group) (range-set (closed-open-range 1 10 #:comparator natural<=>)))) (test-case "resolution returns single file" (define test-dir (make-temporary-directory "resyntax-test-~a")) (define test-file (build-path test-dir "test.rkt")) (call-with-output-file test-file (λ (out) (displayln "#lang racket/base" out))) - (define group (single-file-group test-file (range-set (closed-open-range 1 5 #:comparator natural<=>)))) - (define resolved (file-groups-resolve (list group))) + (define group (single-source-group test-file (range-set (closed-open-range 1 5 #:comparator natural<=>)))) + (define resolved (source-groups-resolve (list group))) (check-equal? (hash-count resolved) 1) (check-equal? (hash-ref resolved (file-source test-file)) (range-set (closed-open-range 1 5 #:comparator natural<=>))) (delete-directory/files test-dir))) - (test-case "directory-file-group" + (test-case "directory-source-group" (test-case "constructor and predicates" - (define group (directory-file-group "/tmp")) - (check-true (directory-file-group? group)) - (check-true (file-group? group)) - (check-equal? (directory-file-group-path group) (simple-form-path "/tmp"))) + (define group (directory-source-group "/tmp")) + (check-true (directory-source-group? group)) + (check-true (source-group? group)) + (check-equal? (directory-source-group-path group) (simple-form-path "/tmp"))) (test-case "resolution returns only .rkt files" (define test-dir (make-temporary-directory "resyntax-test-~a")) @@ -176,23 +176,23 @@ (λ (out) (displayln "#lang racket" out))) (call-with-output-file txt-file (λ (out) (displayln "not racket" out))) - (define group (directory-file-group test-dir)) - (define resolved (file-groups-resolve (list group))) + (define group (directory-source-group test-dir)) + (define resolved (source-groups-resolve (list group))) (check-equal? (hash-count resolved) 2) (check-true (hash-has-key? resolved (file-source rkt-file1))) (check-true (hash-has-key? resolved (file-source rkt-file2))) (delete-directory/files test-dir))) - (test-case "package-file-group" + (test-case "package-source-group" (test-case "constructor and predicates" - (define group (package-file-group "rackunit")) - (check-true (package-file-group? group)) - (check-true (file-group? group)) - (check-equal? (package-file-group-package-name group) "rackunit")) + (define group (package-source-group "rackunit")) + (check-true (package-source-group? group)) + (check-true (source-group? group)) + (check-equal? (package-source-group-package-name group) "rackunit")) (test-case "resolution returns files from installed package" - (define group (package-file-group "rackunit")) - (define resolved (file-groups-resolve (list group))) + (define group (package-source-group "rackunit")) + (define resolved (source-groups-resolve (list group))) (check-true (hash? resolved)) (check-true (> (hash-count resolved) 0)) (for ([src (in-hash-keys resolved)]) @@ -200,19 +200,19 @@ (check-true (path-has-extension? (source-path src) #".rkt")))) (test-case "resolution raises error for non-existent package" - (define group (package-file-group "this-package-does-not-exist-xyz")) + (define group (package-source-group "this-package-does-not-exist-xyz")) (check-exn exn:fail:user? - (λ () (file-groups-resolve (list group)))))) + (λ () (source-groups-resolve (list group)))))) - (test-case "git-repository-file-group" + (test-case "git-repository-source-group" (test-case "constructor and predicates" - (define group (git-repository-file-group "/tmp" "HEAD")) - (check-true (git-repository-file-group? group)) - (check-true (file-group? group)) - (check-equal? (git-repository-file-group-repository-path group) (simple-form-path "/tmp")) - (check-equal? (git-repository-file-group-ref group) "HEAD")) + (define group (git-repository-source-group "/tmp" "HEAD")) + (check-true (git-repository-source-group? group)) + (check-true (source-group? group)) + (check-equal? (git-repository-source-group-repository-path group) (simple-form-path "/tmp")) + (check-equal? (git-repository-source-group-ref group) "HEAD")) - (test-case "file-group-resolve with git repository" + (test-case "source-group-resolve with git repository" (define test-dir (make-temporary-directory "resyntax-test-git-~a")) (parameterize ([current-directory test-dir]) (unless (system "git init -q") @@ -230,15 +230,15 @@ (fail "git commit failed")) (call-with-output-file test-file #:exists 'append (λ (out) (displayln "(define x 1)" out))) - (define group (git-repository-file-group test-dir "HEAD")) - (define resolved (file-groups-resolve (list group))) + (define group (git-repository-source-group test-dir "HEAD")) + (define resolved (source-groups-resolve (list group))) (check-true (hash? resolved)) (check-true (> (hash-count resolved) 0)) (for ([src (in-hash-keys resolved)]) (check-pred file-source? src))) (delete-directory/files test-dir))) - (test-case "file-groups-resolve" + (test-case "source-groups-resolve" (test-case "resolves multiple groups into hash" (define test-dir (make-temporary-directory "resyntax-test-~a")) (define test-file1 (build-path test-dir "test1.rkt")) @@ -247,9 +247,9 @@ (λ (out) (displayln "#lang racket/base" out))) (call-with-output-file test-file2 (λ (out) (displayln "#lang racket" out))) - (define group1 (single-file-group test-file1 (range-set (closed-open-range 1 5 #:comparator natural<=>)))) - (define group2 (single-file-group test-file2 (range-set (closed-open-range 3 8 #:comparator natural<=>)))) - (define result (file-groups-resolve (list group1 group2))) + (define group1 (single-source-group test-file1 (range-set (closed-open-range 1 5 #:comparator natural<=>)))) + (define group2 (single-source-group test-file2 (range-set (closed-open-range 3 8 #:comparator natural<=>)))) + (define result (source-groups-resolve (list group1 group2))) (check-true (hash? result)) (check-equal? (hash-count result) 2) (check-true (hash-has-key? result (file-source test-file1))) @@ -261,9 +261,9 @@ (define test-file (build-path test-dir "test.rkt")) (call-with-output-file test-file (λ (out) (displayln "#lang racket/base" out))) - (define group1 (single-file-group test-file (range-set (closed-open-range 1 3 #:comparator natural<=>)))) - (define group2 (single-file-group test-file (range-set (closed-open-range 5 7 #:comparator natural<=>)))) - (define result (file-groups-resolve (list group1 group2))) + (define group1 (single-source-group test-file (range-set (closed-open-range 1 3 #:comparator natural<=>)))) + (define group2 (single-source-group test-file (range-set (closed-open-range 5 7 #:comparator natural<=>)))) + (define result (source-groups-resolve (list group1 group2))) (check-equal? (hash-count result) 1) (define combined-ranges (hash-ref result (file-source test-file))) (check-true (range-set-contains? combined-ranges 1)) diff --git a/grimoire/file-group.scrbl b/grimoire/source-group.scrbl similarity index 61% rename from grimoire/file-group.scrbl rename to grimoire/source-group.scrbl index 22c8a13..eb831e8 100644 --- a/grimoire/file-group.scrbl +++ b/grimoire/source-group.scrbl @@ -7,82 +7,82 @@ racket/path racket/sequence rebellion/collection/range-set - resyntax/grimoire/file-group + resyntax/grimoire/source-group resyntax/grimoire/source)) -@title[#:tag "file-group"]{File Groups} -@defmodule[resyntax/grimoire/file-group] +@title[#:tag "source-group"]{Source Groups} +@defmodule[resyntax/grimoire/source-group] -A @deftech{file group} is a specification of a set of files that Resyntax should analyze, along with +A @deftech{source group} is a specification of a set of files that Resyntax should analyze, along with which lines within those files Resyntax is allowed to suggest changes to. File groups come in four kinds, each corresponding to one of the target flags accepted by the @seclink["cli"]{command-line interface}: @itemlist[ - @item{@emph{Single-file groups}, constructed with @racket[single-file-group], containing one file + @item{@emph{Single-source groups}, constructed with @racket[single-source-group], containing one file restricted to a given set of lines. The @exec{--file} flag constructs these, with all lines allowed.} - @item{@emph{Directory groups}, constructed with @racket[directory-file-group], containing every + @item{@emph{Directory groups}, constructed with @racket[directory-source-group], containing every file within a directory, including files in subdirectories. The @exec{--directory} flag constructs these.} - @item{@emph{Package groups}, constructed with @racket[package-file-group], containing every file + @item{@emph{Package groups}, constructed with @racket[package-source-group], containing every file of an installed Racket package. The @exec{--package} flag constructs these.} - @item{@emph{Git repository groups}, constructed with @racket[git-repository-file-group], + @item{@emph{Git repository groups}, constructed with @racket[git-repository-source-group], containing the files of a Git repository that have changed relative to some base reference. The @exec{--local-git-repository} flag constructs these.}] -A file group is only a description: it must be @emph{resolved} with @racket[file-groups-resolve] to +A source group is only a description: it must be @emph{resolved} with @racket[source-groups-resolve] to produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the filesystem, the package catalog, or the Git repository is actually consulted. -@defproc[(file-group? [v any/c]) boolean?]{ - A predicate that recognizes @tech{file groups} of any kind.} +@defproc[(source-group? [v any/c]) boolean?]{ + A predicate that recognizes @tech{source groups} of any kind.} -@defproc[(single-file-group? [v any/c]) boolean?]{ - A predicate that recognizes single-file groups.} +@defproc[(single-source-group? [v any/c]) boolean?]{ + A predicate that recognizes single-source groups.} -@defproc[(single-file-group [path path-string?] [lines immutable-range-set?]) - single-file-group?]{ - Constructs a @tech{file group} containing only the file at @racket[path], with suggestions +@defproc[(single-source-group [path path-string?] [lines immutable-range-set?]) + single-source-group?]{ + Constructs a @tech{source group} containing only the file at @racket[path], with suggestions restricted to the line numbers in @racket[lines]. The path is normalized with @racket[simple-form-path] upon construction.} -@defproc[(directory-file-group? [v any/c]) boolean?]{ +@defproc[(directory-source-group? [v any/c]) boolean?]{ A predicate that recognizes directory groups.} -@defproc[(directory-file-group [path path-string?]) directory-file-group?]{ - Constructs a @tech{file group} containing every file within the directory at @racket[path], +@defproc[(directory-source-group [path path-string?]) directory-source-group?]{ + Constructs a @tech{source group} containing every file within the directory at @racket[path], including files within subdirectories, with all lines of each file eligible for suggestions. The path is normalized with @racket[simple-form-path] upon construction.} -@defproc[(package-file-group? [v any/c]) boolean?]{ +@defproc[(package-source-group? [v any/c]) boolean?]{ A predicate that recognizes package groups.} -@defproc[(package-file-group [package-name string?]) package-file-group?]{ - Constructs a @tech{file group} containing every file of the installed Racket package named +@defproc[(package-source-group [package-name string?]) package-source-group?]{ + Constructs a @tech{source group} containing every file of the installed Racket package named @racket[package-name], with all lines of each file eligible for suggestions. The package's installation directory is located with @racket[pkg-directory] during resolution, and resolution raises a user error if no such package is installed.} -@defproc[(git-repository-file-group? [v any/c]) boolean?]{ +@defproc[(git-repository-source-group? [v any/c]) boolean?]{ A predicate that recognizes Git repository groups.} -@defproc[(git-repository-file-group [repository-path path-string?] [base-ref string?]) - git-repository-file-group?]{ - Constructs a @tech{file group} containing the files of the Git repository at +@defproc[(git-repository-source-group [repository-path path-string?] [base-ref string?]) + git-repository-source-group?]{ + Constructs a @tech{source group} containing the files of the Git repository at @racket[repository-path] that have changed relative to @racket[base-ref], as determined by @exec{git diff} during resolution. The repository path is normalized with @racket[simple-form-path] upon construction. @@ -94,9 +94,9 @@ filesystem, the package catalog, or the Git repository is actually consulted. comments.} -@defproc[(file-groups-resolve [groups (sequence/c file-group?)]) +@defproc[(source-groups-resolve [groups (sequence/c source-group?)]) (hash/c file-source? immutable-range-set?)]{ - Resolves each @tech{file group} in @racket[groups] into concrete files, returning a hash whose + Resolves each @tech{source group} in @racket[groups] into concrete files, returning a hash whose keys are @racket[file-source?] values and whose values are the line numbers eligible for suggestions in each file. When multiple groups include the same file, their line sets are unioned. From ac5f907b98ce602b3b86a74927e29b0b1d3cc3c7 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 20:49:52 -0700 Subject: [PATCH 05/10] Rework overview docs for source groups Also, move the source group section to just after the source section in the grimoire. --- grimoire.scrbl | 2 +- grimoire/source-group.scrbl | 44 +++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/grimoire.scrbl b/grimoire.scrbl index 0f3318a..df16f71 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -13,5 +13,5 @@ programmatically on anything found here. @include-section[(lib "resyntax/grimoire/source.scrbl")] -@include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] @include-section[(lib "resyntax/grimoire/source-group.scrbl")] +@include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index eb831e8..4a015a7 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -14,30 +14,46 @@ @title[#:tag "source-group"]{Source Groups} @defmodule[resyntax/grimoire/source-group] -A @deftech{source group} is a specification of a set of files that Resyntax should analyze, along with -which lines within those files Resyntax is allowed to suggest changes to. File groups come in four -kinds, each corresponding to one of the target flags accepted by the @seclink["cli"]{command-line - interface}: +A @deftech{source group} is a specification of what @tech{source code} Resyntax should analyze, along +with which lines within those sources Resyntax is allowed to suggest changes to. Source groups come in +four kinds, each corresponding to one of the target flags accepted by the +@seclink["cli"]{command-line interface}: @itemlist[ - @item{@emph{Single-source groups}, constructed with @racket[single-source-group], containing one file + @item{@emph{Single sources}, constructed with @racket[single-source-group], containing one file restricted to a given set of lines. The @exec{--file} flag constructs these, with all lines - allowed.} + allowed. Note that the CLI doesn't include a way to specify which lines should be modified at this + time, despite the fact that the @racket[single-source-group] constructor accepts that information. + The only difference between a single source group and a @racket[source?] value is that the source + group may contain information about which lines to analyze.} @item{@emph{Directory groups}, constructed with @racket[directory-source-group], containing every - file within a directory, including files in subdirectories. The @exec{--directory} flag + source file within a directory, including files in subdirectories. The @exec{--directory} flag constructs these.} @item{@emph{Package groups}, constructed with @racket[package-source-group], containing every file - of an installed Racket package. The @exec{--package} flag constructs these.} + of a @emph{locally installed} Racket package. The @exec{--package} flag constructs these. This does + @bold{not} refer to remote packages on the package catalog; Resyntax cannot analyze a package unless + it's currently installed.} @item{@emph{Git repository groups}, constructed with @racket[git-repository-source-group], - containing the files of a Git repository that have changed relative to some base reference. The - @exec{--local-git-repository} flag constructs these.}] - -A source group is only a description: it must be @emph{resolved} with @racket[source-groups-resolve] to -produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the -filesystem, the package catalog, or the Git repository is actually consulted. + containing the files of a @emph{local} Git repository that have changed relative to some base + reference. The @exec{--local-git-repository} flag constructs these. Like package groups, Resyntax + can only install Git repositories that have already been cloned onto the current machine. Git + repository groups are the only source groups that take advantage of Resyntax's ability to restrict + which lines are analyzed --- only the lines actually touched in the diff against the specified base + reference will be included.}] + +A source group is only a description: it must be @emph{resolved} with @racket[source-groups-resolve] +to produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the +filesystem, the local package system, or the local Git repository is actually consulted. Resolution +does not consult external networked sources; only local information is considered. After resolution, +Resyntax "locks in" the set of sources it's editing. If, after this point, new files are added to a +directory group (or a similar edit is made to the files described by a different kind of source group) +they will be ignored by Resyntax. However, edits to files that @emph{were} included in the source set, +but which Resyntax has @emph{not} started to analyze, will be perceived by Resyntax. This is because +source group resolution does not read the @emph{contents} of each source file into memory yet. That +occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. @defproc[(source-group? [v any/c]) boolean?]{ From a9b01fc36ef2c9dbf4aecc984325d9941aaea6bd Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 21:58:07 -0700 Subject: [PATCH 06/10] Fix inaccuracies in the source group overview prose - "install" should have been "analyze" for Git repositories - The diff-based line restriction includes a margin of context lines, not just the touched lines - Single-source groups correspond to file sources specifically, not source? values in general (which include string sources) - Make the bullet labels parallel Co-Authored-By: Claude Fable 5 --- grimoire/source-group.scrbl | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index 4a015a7..8000f0b 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -20,12 +20,12 @@ four kinds, each corresponding to one of the target flags accepted by the @seclink["cli"]{command-line interface}: @itemlist[ - @item{@emph{Single sources}, constructed with @racket[single-source-group], containing one file - restricted to a given set of lines. The @exec{--file} flag constructs these, with all lines + @item{@emph{Single-source groups}, constructed with @racket[single-source-group], containing one + file restricted to a given set of lines. The @exec{--file} flag constructs these, with all lines allowed. Note that the CLI doesn't include a way to specify which lines should be modified at this time, despite the fact that the @racket[single-source-group] constructor accepts that information. - The only difference between a single source group and a @racket[source?] value is that the source - group may contain information about which lines to analyze.} + The only difference between a single-source group and a @racket[file-source?] value is that the + source group may contain information about which lines to analyze.} @item{@emph{Directory groups}, constructed with @racket[directory-source-group], containing every source file within a directory, including files in subdirectories. The @exec{--directory} flag @@ -38,11 +38,12 @@ four kinds, each corresponding to one of the target flags accepted by the @item{@emph{Git repository groups}, constructed with @racket[git-repository-source-group], containing the files of a @emph{local} Git repository that have changed relative to some base - reference. The @exec{--local-git-repository} flag constructs these. Like package groups, Resyntax - can only install Git repositories that have already been cloned onto the current machine. Git - repository groups are the only source groups that take advantage of Resyntax's ability to restrict - which lines are analyzed --- only the lines actually touched in the diff against the specified base - reference will be included.}] + reference. The @exec{--local-git-repository} flag constructs these. As with package groups, + Resyntax can only analyze Git repositories that have already been cloned onto the current machine. + Git repository groups are the only source groups that take advantage of Resyntax's ability to + restrict which lines are analyzed --- only the lines actually touched in the diff against the + specified base reference, plus a small margin of surrounding context lines (see + @racket[git-repository-source-group]), will be included.}] A source group is only a description: it must be @emph{resolved} with @racket[source-groups-resolve] to produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the From c100fa48c807ca3714e6932e63cf73c954e2d8a0 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 22:10:23 -0700 Subject: [PATCH 07/10] Make source groups unionable Source groups now form a commutative monoid: source-group-union combines any number of groups into one, and empty-source-group is the identity element. Since unioning is also idempotent, they're in fact a bounded join-semilattice. The laws hold up to equal?, which is achieved by an internal union-source-group struct that holds a normalized set of non-union subgroups: flattening makes union associative, set semantics make it commutative and idempotent, and collapsing singleton and empty sets makes the identity law hold exactly rather than up to wrapping. Consequently, source-groups-resolve is now source-group-resolve and takes a single group; the CLI unions its target flags into one group before resolving. Co-Authored-By: Claude Fable 5 --- cli.rkt | 8 +++- grimoire/source-group.rkt | 91 ++++++++++++++++++++++++++++++------- grimoire/source-group.scrbl | 41 ++++++++++++++--- 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/cli.rkt b/cli.rkt index de8c881..a638ec5 100644 --- a/cli.rkt +++ b/cli.rkt @@ -284,7 +284,9 @@ For help on these, use 'analyze --help' or 'fix --help'." (define (resyntax-analyze-run) (define options (resyntax-analyze-parse-command-line)) - (define sources (source-groups-resolve (resyntax-analyze-options-targets options))) + (define target-group + (apply source-group-union (vector->list (resyntax-analyze-options-targets options)))) + (define sources (source-group-resolve target-group)) (define analysis (resyntax-analyze-all sources #:suite (resyntax-analyze-options-suite options) @@ -329,7 +331,9 @@ For help on these, use 'analyze --help' or 'fix --help'." (define options (resyntax-fix-parse-command-line)) (define fix-method (resyntax-fix-options-fix-method options)) (define output-format (resyntax-fix-options-output-format options)) - (define sources (source-groups-resolve (resyntax-fix-options-targets options))) + (define target-group + (apply source-group-union (vector->list (resyntax-fix-options-targets options)))) + (define sources (source-group-resolve target-group)) (define max-modified-files (resyntax-fix-options-max-modified-files options)) (define max-modified-lines (resyntax-fix-options-max-modified-lines options)) (define analysis diff --git a/grimoire/source-group.rkt b/grimoire/source-group.rkt index 12c7a18..c400f82 100644 --- a/grimoire/source-group.rkt +++ b/grimoire/source-group.rkt @@ -6,8 +6,10 @@ (provide (contract-out - [source-groups-resolve (-> (sequence/c source-group?) (hash/c file-source? immutable-range-set?))] [source-group? (-> any/c boolean?)] + [empty-source-group source-group?] + [source-group-union (-> source-group? ... source-group?)] + [source-group-resolve (-> source-group? (hash/c file-source? immutable-range-set?))] [single-source-group? (-> any/c boolean?)] [single-source-group (-> path-string? immutable-range-set? single-source-group?)] [directory-source-group? (-> any/c boolean?)] @@ -23,7 +25,7 @@ racket/file racket/match racket/path - racket/sequence + racket/set racket/string rebellion/base/comparator rebellion/base/range @@ -74,18 +76,45 @@ (values (simple-form-path repository-path) (string->immutable-string ref)))) +;; A union of any number of the other kinds of source groups. Union groups are always normalized: +;; the subgroups set never contains union groups, so that equal? on source groups treats +;; source-group-union as commutative, associative, and idempotent, with empty-source-group as the +;; identity element. +(struct union-source-group source-group (subgroups) #:transparent) + + +(define empty-source-group (union-source-group (set))) + + +(define (source-group-union . groups) + (define combined + (for*/set ([group (in-list groups)] + [basic (in-set (source-group-basic-subgroups group))]) + basic)) + (cond + [(set-empty? combined) empty-source-group] + [(equal? (set-count combined) 1) (set-first combined)] + [else (union-source-group combined)])) + + +(define (source-group-basic-subgroups group) + (match group + [(union-source-group subgroups) subgroups] + [_ (set group)])) + + (define all-lines (range-set (unbounded-range #:comparator natural<=>))) -(define (source-groups-resolve groups) - (transduce groups - (append-mapping source-group-resolve) +(define (source-group-resolve group) + (transduce (source-group-basic-subgroups group) + (append-mapping basic-source-group-entries) (grouping (make-fold-reducer range-set-add-all (range-set #:comparator natural<=>))) #:into into-hash)) -;; Resolves a single group into a list of entries mapping file sources to line range sets. -(define (source-group-resolve group) +;; Resolves a single non-union group into a list of entries mapping file sources to line range sets. +(define (basic-source-group-entries group) (define path-entries (match group [(single-source-group path lines) @@ -152,7 +181,7 @@ (call-with-output-file test-file (λ (out) (displayln "#lang racket/base" out))) (define group (single-source-group test-file (range-set (closed-open-range 1 5 #:comparator natural<=>)))) - (define resolved (source-groups-resolve (list group))) + (define resolved (source-group-resolve group)) (check-equal? (hash-count resolved) 1) (check-equal? (hash-ref resolved (file-source test-file)) (range-set (closed-open-range 1 5 #:comparator natural<=>))) @@ -177,7 +206,7 @@ (call-with-output-file txt-file (λ (out) (displayln "not racket" out))) (define group (directory-source-group test-dir)) - (define resolved (source-groups-resolve (list group))) + (define resolved (source-group-resolve group)) (check-equal? (hash-count resolved) 2) (check-true (hash-has-key? resolved (file-source rkt-file1))) (check-true (hash-has-key? resolved (file-source rkt-file2))) @@ -192,7 +221,7 @@ (test-case "resolution returns files from installed package" (define group (package-source-group "rackunit")) - (define resolved (source-groups-resolve (list group))) + (define resolved (source-group-resolve group)) (check-true (hash? resolved)) (check-true (> (hash-count resolved) 0)) (for ([src (in-hash-keys resolved)]) @@ -202,7 +231,7 @@ (test-case "resolution raises error for non-existent package" (define group (package-source-group "this-package-does-not-exist-xyz")) (check-exn exn:fail:user? - (λ () (source-groups-resolve (list group)))))) + (λ () (source-group-resolve group))))) (test-case "git-repository-source-group" (test-case "constructor and predicates" @@ -231,15 +260,45 @@ (call-with-output-file test-file #:exists 'append (λ (out) (displayln "(define x 1)" out))) (define group (git-repository-source-group test-dir "HEAD")) - (define resolved (source-groups-resolve (list group))) + (define resolved (source-group-resolve group)) (check-true (hash? resolved)) (check-true (> (hash-count resolved) 0)) (for ([src (in-hash-keys resolved)]) (check-pred file-source? src))) (delete-directory/files test-dir))) - (test-case "source-groups-resolve" - (test-case "resolves multiple groups into hash" + (test-case "source-group-union" + + (define g1 (directory-source-group "/tmp/dir1")) + (define g2 (package-source-group "some-package")) + (define g3 (single-source-group "/tmp/foo.rkt" (range-set #:comparator natural<=>))) + + (test-case "commutative" + (check-equal? (source-group-union g1 g2) (source-group-union g2 g1))) + + (test-case "associative" + (check-equal? (source-group-union (source-group-union g1 g2) g3) + (source-group-union g1 (source-group-union g2 g3)))) + + (test-case "empty group is the identity" + (check-equal? (source-group-union g1 empty-source-group) g1) + (check-equal? (source-group-union empty-source-group g1) g1)) + + (test-case "idempotent" + (check-equal? (source-group-union g1 g1) g1)) + + (test-case "no groups produce the empty group" + (check-equal? (source-group-union) empty-source-group) + (check-equal? (source-group-union empty-source-group empty-source-group) empty-source-group)) + + (test-case "unioning a single group produces that group" + (check-equal? (source-group-union g1) g1))) + + (test-case "source-group-resolve" + (test-case "resolving the empty group produces an empty hash" + (check-equal? (source-group-resolve empty-source-group) (hash))) + + (test-case "resolves unioned groups into hash" (define test-dir (make-temporary-directory "resyntax-test-~a")) (define test-file1 (build-path test-dir "test1.rkt")) (define test-file2 (build-path test-dir "test2.rkt")) @@ -249,7 +308,7 @@ (λ (out) (displayln "#lang racket" out))) (define group1 (single-source-group test-file1 (range-set (closed-open-range 1 5 #:comparator natural<=>)))) (define group2 (single-source-group test-file2 (range-set (closed-open-range 3 8 #:comparator natural<=>)))) - (define result (source-groups-resolve (list group1 group2))) + (define result (source-group-resolve (source-group-union group1 group2))) (check-true (hash? result)) (check-equal? (hash-count result) 2) (check-true (hash-has-key? result (file-source test-file1))) @@ -263,7 +322,7 @@ (λ (out) (displayln "#lang racket/base" out))) (define group1 (single-source-group test-file (range-set (closed-open-range 1 3 #:comparator natural<=>)))) (define group2 (single-source-group test-file (range-set (closed-open-range 5 7 #:comparator natural<=>)))) - (define result (source-groups-resolve (list group1 group2))) + (define result (source-group-resolve (source-group-union group1 group2))) (check-equal? (hash-count result) 1) (define combined-ranges (hash-ref result (file-source test-file))) (check-true (range-set-contains? combined-ranges 1)) diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index 8000f0b..c4bd7be 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -5,7 +5,6 @@ racket/base racket/contract/base racket/path - racket/sequence rebellion/collection/range-set resyntax/grimoire/source-group resyntax/grimoire/source)) @@ -45,7 +44,12 @@ four kinds, each corresponding to one of the target flags accepted by the specified base reference, plus a small margin of surrounding context lines (see @racket[git-repository-source-group]), will be included.}] -A source group is only a description: it must be @emph{resolved} with @racket[source-groups-resolve] +Additionally, any number of source groups can be combined into a single group with +@racket[source-group-union]. This is how the command-line interface handles multiple target flags: +each flag becomes a source group, and all of them are unioned into one group describing the entire +analysis. + +A source group is only a description: it must be @emph{resolved} with @racket[source-group-resolve] to produce the actual @tech{source code} values that Resyntax analyzes. Resolution is when the filesystem, the local package system, or the local Git repository is actually consulted. Resolution does not consult external networked sources; only local information is considered. After resolution, @@ -61,6 +65,31 @@ occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. A predicate that recognizes @tech{source groups} of any kind.} +@defthing[empty-source-group source-group?]{ + The empty @tech{source group}, which specifies no sources at all. Resolving it produces an empty + hash, and unioning it with any other source group has no effect --- it is the identity element of + @racket[source-group-union].} + + +@defproc[(source-group-union [group source-group?] ...) source-group?]{ + Combines each @racket[group] into a single @tech{source group} specifying all of their sources. + Given no groups, the result is @racket[empty-source-group]. + + Unioning is commutative, associative, and idempotent, and @racket[empty-source-group] is its + identity element: source groups form a commutative monoid under union (in fact, a bounded + join-semilattice, thanks to idempotence). These laws hold up to @racket[equal?]: + + @itemlist[ + @item{@racket[(source-group-union _g1 _g2)] is always @racket[equal?] to + @racket[(source-group-union _g2 _g1)].} + + @item{@racket[(source-group-union (source-group-union _g1 _g2) _g3)] is always @racket[equal?] to + @racket[(source-group-union _g1 (source-group-union _g2 _g3))].} + + @item{@racket[(source-group-union _g _g)] and @racket[(source-group-union _g empty-source-group)] + are both always @racket[equal?] to @racket[_g].}]} + + @defproc[(single-source-group? [v any/c]) boolean?]{ A predicate that recognizes single-source groups.} @@ -111,11 +140,11 @@ occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. comments.} -@defproc[(source-groups-resolve [groups (sequence/c source-group?)]) +@defproc[(source-group-resolve [group source-group?]) (hash/c file-source? immutable-range-set?)]{ - Resolves each @tech{source group} in @racket[groups] into concrete files, returning a hash whose - keys are @racket[file-source?] values and whose values are the line numbers eligible for - suggestions in each file. When multiple groups include the same file, their line sets are unioned. + Resolves @racket[group] into concrete files, returning a hash whose keys are @racket[file-source?] + values and whose values are the line numbers eligible for suggestions in each file. When the same + file is included multiple times by a unioned group, its line sets are unioned. Resolution discards all files that don't have the @exec{.rkt} extension. This is where the @seclink["cli"]{command-line interface}'s restriction to @exec{.rkt} files is implemented.} From 065664a0c4757b67b7cdcd8efaae8644a29ce173 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 22:17:35 -0700 Subject: [PATCH 08/10] Add source-group-union-all and hide kind-specific predicates source-group-union-all accepts any sequence of source groups, so callers with collections don't need apply and list coercions; the varargs source-group-union is now a convenience wrapper around it. The CLI unions its vector of target flags with it directly. The kind-specific predicates are no longer exported, since source groups shouldn't be discriminated by kind, and the constructors now just promise source-group? values. Co-Authored-By: Claude Fable 5 --- cli.rkt | 6 ++---- grimoire/source-group.rkt | 29 ++++++++++++++++++----------- grimoire/source-group.scrbl | 32 +++++++++++++------------------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/cli.rkt b/cli.rkt index a638ec5..bea096a 100644 --- a/cli.rkt +++ b/cli.rkt @@ -284,8 +284,7 @@ For help on these, use 'analyze --help' or 'fix --help'." (define (resyntax-analyze-run) (define options (resyntax-analyze-parse-command-line)) - (define target-group - (apply source-group-union (vector->list (resyntax-analyze-options-targets options)))) + (define target-group (source-group-union-all (resyntax-analyze-options-targets options))) (define sources (source-group-resolve target-group)) (define analysis (resyntax-analyze-all sources @@ -331,8 +330,7 @@ For help on these, use 'analyze --help' or 'fix --help'." (define options (resyntax-fix-parse-command-line)) (define fix-method (resyntax-fix-options-fix-method options)) (define output-format (resyntax-fix-options-output-format options)) - (define target-group - (apply source-group-union (vector->list (resyntax-fix-options-targets options)))) + (define target-group (source-group-union-all (resyntax-fix-options-targets options))) (define sources (source-group-resolve target-group)) (define max-modified-files (resyntax-fix-options-max-modified-files options)) (define max-modified-lines (resyntax-fix-options-max-modified-lines options)) diff --git a/grimoire/source-group.rkt b/grimoire/source-group.rkt index c400f82..1751c6c 100644 --- a/grimoire/source-group.rkt +++ b/grimoire/source-group.rkt @@ -9,15 +9,12 @@ [source-group? (-> any/c boolean?)] [empty-source-group source-group?] [source-group-union (-> source-group? ... source-group?)] + [source-group-union-all (-> (sequence/c source-group?) source-group?)] [source-group-resolve (-> source-group? (hash/c file-source? immutable-range-set?))] - [single-source-group? (-> any/c boolean?)] - [single-source-group (-> path-string? immutable-range-set? single-source-group?)] - [directory-source-group? (-> any/c boolean?)] - [directory-source-group (-> path-string? directory-source-group?)] - [package-source-group? (-> any/c boolean?)] - [package-source-group (-> string? package-source-group?)] - [git-repository-source-group? (-> any/c boolean?)] - [git-repository-source-group (-> path-string? string? git-repository-source-group?)])) + [single-source-group (-> path-string? immutable-range-set? source-group?)] + [directory-source-group (-> path-string? source-group?)] + [package-source-group (-> string? source-group?)] + [git-repository-source-group (-> path-string? string? source-group?)])) (require fancy-app @@ -25,6 +22,7 @@ racket/file racket/match racket/path + racket/sequence racket/set racket/string rebellion/base/comparator @@ -86,9 +84,9 @@ (define empty-source-group (union-source-group (set))) -(define (source-group-union . groups) +(define (source-group-union-all groups) (define combined - (for*/set ([group (in-list groups)] + (for*/set ([group groups] [basic (in-set (source-group-basic-subgroups group))]) basic)) (cond @@ -97,6 +95,10 @@ [else (union-source-group combined)])) +(define (source-group-union . groups) + (source-group-union-all groups)) + + (define (source-group-basic-subgroups group) (match group [(union-source-group subgroups) subgroups] @@ -292,7 +294,12 @@ (check-equal? (source-group-union empty-source-group empty-source-group) empty-source-group)) (test-case "unioning a single group produces that group" - (check-equal? (source-group-union g1) g1))) + (check-equal? (source-group-union g1) g1)) + + (test-case "source-group-union-all accepts any sequence and agrees with source-group-union" + (check-equal? (source-group-union-all (list g1 g2)) (source-group-union g1 g2)) + (check-equal? (source-group-union-all (vector g1 g2)) (source-group-union g1 g2)) + (check-equal? (source-group-union-all '()) empty-source-group))) (test-case "source-group-resolve" (test-case "resolving the empty group produces an empty hash" diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index c4bd7be..84e99f4 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -5,6 +5,7 @@ racket/base racket/contract/base racket/path + racket/sequence rebellion/collection/range-set resyntax/grimoire/source-group resyntax/grimoire/source)) @@ -87,47 +88,40 @@ occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. @racket[(source-group-union _g1 (source-group-union _g2 _g3))].} @item{@racket[(source-group-union _g _g)] and @racket[(source-group-union _g empty-source-group)] - are both always @racket[equal?] to @racket[_g].}]} + are both always @racket[equal?] to @racket[_g].}] + This operation is a convenience wrapper around @racket[source-group-union-all].} -@defproc[(single-source-group? [v any/c]) boolean?]{ - A predicate that recognizes single-source groups.} + +@defproc[(source-group-union-all [groups (sequence/c source-group?)]) source-group?]{ + Combines every source group in @racket[groups] into a single @tech{source group}, exactly as + @racket[source-group-union] does for its arguments, but accepting the groups as a single sequence + of any kind. An empty sequence produces @racket[empty-source-group]. This is how the + @seclink["cli"]{command-line interface} combines its collection of target flags into one group.} @defproc[(single-source-group [path path-string?] [lines immutable-range-set?]) - single-source-group?]{ + source-group?]{ Constructs a @tech{source group} containing only the file at @racket[path], with suggestions restricted to the line numbers in @racket[lines]. The path is normalized with @racket[simple-form-path] upon construction.} -@defproc[(directory-source-group? [v any/c]) boolean?]{ - A predicate that recognizes directory groups.} - - -@defproc[(directory-source-group [path path-string?]) directory-source-group?]{ +@defproc[(directory-source-group [path path-string?]) source-group?]{ Constructs a @tech{source group} containing every file within the directory at @racket[path], including files within subdirectories, with all lines of each file eligible for suggestions. The path is normalized with @racket[simple-form-path] upon construction.} -@defproc[(package-source-group? [v any/c]) boolean?]{ - A predicate that recognizes package groups.} - - -@defproc[(package-source-group [package-name string?]) package-source-group?]{ +@defproc[(package-source-group [package-name string?]) source-group?]{ Constructs a @tech{source group} containing every file of the installed Racket package named @racket[package-name], with all lines of each file eligible for suggestions. The package's installation directory is located with @racket[pkg-directory] during resolution, and resolution raises a user error if no such package is installed.} -@defproc[(git-repository-source-group? [v any/c]) boolean?]{ - A predicate that recognizes Git repository groups.} - - @defproc[(git-repository-source-group [repository-path path-string?] [base-ref string?]) - git-repository-source-group?]{ + source-group?]{ Constructs a @tech{source group} containing the files of the Git repository at @racket[repository-path] that have changed relative to @racket[base-ref], as determined by @exec{git diff} during resolution. The repository path is normalized with From 47cdfc7afb96b2a3fd32a8717f85d12c5eb77775 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 23:08:33 -0700 Subject: [PATCH 09/10] Change wording and expound a bit --- grimoire/source-group.scrbl | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index 84e99f4..df2e09c 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -55,11 +55,11 @@ to produce the actual @tech{source code} values that Resyntax analyzes. Resoluti filesystem, the local package system, or the local Git repository is actually consulted. Resolution does not consult external networked sources; only local information is considered. After resolution, Resyntax "locks in" the set of sources it's editing. If, after this point, new files are added to a -directory group (or a similar edit is made to the files described by a different kind of source group) -they will be ignored by Resyntax. However, edits to files that @emph{were} included in the source set, -but which Resyntax has @emph{not} started to analyze, will be perceived by Resyntax. This is because -source group resolution does not read the @emph{contents} of each source file into memory yet. That -occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. +directory group (or a similar addition is made to the files described by a different kind of source +group) they will be ignored by Resyntax. However, edits to the @emph{contents} of files that were +included in the source set, but which Resyntax has @emph{not} started to analyze, will be perceived by +Resyntax. This is because source group resolution does not read the contents of each source file into +memory yet. That occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. @defproc[(source-group? [v any/c]) boolean?]{ @@ -131,7 +131,9 @@ occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. three lines before and after each modified region. The three-line margin matches what GitHub allows in pull request reviews: comments may only be placed on modified lines and the three lines of context surrounding them, so suggestions within the margin can still be posted as review - comments.} + comments. More generally, a three-line margin is the standard default behavior of the unified diff + format that many Unix tools interoperate with, particularly the + @hyperlink["https://en.wikipedia.org/wiki/Diff"]{diff} tool.} @defproc[(source-group-resolve [group source-group?]) @@ -142,4 +144,3 @@ occurs at a later step, on a per-file basis, as Resyntax is analyzing each file. Resolution discards all files that don't have the @exec{.rkt} extension. This is where the @seclink["cli"]{command-line interface}'s restriction to @exec{.rkt} files is implemented.} - From ecb837529f67f3891aa019f59c2c391ff08b77d0 Mon Sep 17 00:00:00 2001 From: Jack Firth Date: Sat, 4 Jul 2026 23:28:38 -0700 Subject: [PATCH 10/10] Rephrase --- grimoire/source-group.scrbl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl index df2e09c..b076929 100644 --- a/grimoire/source-group.scrbl +++ b/grimoire/source-group.scrbl @@ -131,9 +131,9 @@ memory yet. That occurs at a later step, on a per-file basis, as Resyntax is ana three lines before and after each modified region. The three-line margin matches what GitHub allows in pull request reviews: comments may only be placed on modified lines and the three lines of context surrounding them, so suggestions within the margin can still be posted as review - comments. More generally, a three-line margin is the standard default behavior of the unified diff - format that many Unix tools interoperate with, particularly the - @hyperlink["https://en.wikipedia.org/wiki/Diff"]{diff} tool.} + comments. More generally, a three-line margin is the default amount of extra context that many Unix + tools choose when interoperating via the unified diff format, particularly the + @hyperlink["https://en.wikipedia.org/wiki/Diff#Unified_format"]{diff} tool.} @defproc[(source-group-resolve [group source-group?])