diff --git a/cli.rkt b/cli.rkt index 192dacd..bea096a 100644 --- a/cli.rkt +++ b/cli.rkt @@ -24,7 +24,7 @@ resyntax resyntax/base resyntax/default-recommendations - resyntax/private/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,8 @@ 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 target-group (source-group-union-all (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 +330,8 @@ 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 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)) (define analysis diff --git a/cli.scrbl b/cli.scrbl index 436d52d..e3980b7 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{source group} describing which +files to operate on. See @secref["source-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..df16f71 100644 --- a/grimoire.scrbl +++ b/grimoire.scrbl @@ -13,4 +13,5 @@ programmatically on anything found here. @include-section[(lib "resyntax/grimoire/source.scrbl")] +@include-section[(lib "resyntax/grimoire/source-group.scrbl")] @include-section[(lib "resyntax/grimoire/syntax-path.scrbl")] diff --git a/grimoire/source-group.rkt b/grimoire/source-group.rkt new file mode 100644 index 0000000..1751c6c --- /dev/null +++ b/grimoire/source-group.rkt @@ -0,0 +1,363 @@ +#lang racket/base + + +(require racket/contract/base) + + +(provide + (contract-out + [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 (-> 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 + pkg/lib + racket/file + racket/match + racket/path + racket/sequence + racket/set + racket/string + rebellion/base/comparator + rebellion/base/range + rebellion/collection/entry + rebellion/collection/hash + rebellion/collection/list + rebellion/collection/range-set + rebellion/streaming/reducer + rebellion/streaming/transducer + resyntax/private/git + resyntax/private/logger + resyntax/grimoire/source) + + +(module+ test + (require (submod "..") + racket/file + racket/list + racket/system + rackunit)) + + +;@---------------------------------------------------------------------------------------------------- + + +(struct source-group () #:transparent) + + +(struct single-source-group source-group (path ranges) + #:transparent + #:guard (λ (path ranges _) (values (simple-form-path path) ranges))) + + +(struct directory-source-group source-group (path) + #:transparent + #:guard (λ (path _) (simple-form-path path))) + + +(struct package-source-group source-group (package-name) + #:transparent + #:guard (λ (package-name _) (string->immutable-string package-name))) + + +(struct git-repository-source-group source-group (repository-path ref) + #:transparent + #:guard + (λ (repository-path ref _) + (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-all groups) + (define combined + (for*/set ([group 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-union . groups) + (source-group-union-all groups)) + + +(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-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 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) + (list (entry path lines))] + [(directory-source-group path) + (for/list ([file (in-directory path)]) + (entry file all-lines))] + [(package-source-group package-name) + (define pkgdir (pkg-directory package-name)) + (unless pkgdir + (raise-user-error 'resyntax + "cannot analyze package ~a, it hasn't been installed" + package-name)) + (for/list ([file (in-directory (simple-form-path pkgdir))]) + (entry file all-lines))] + [(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)]) + (log-resyntax-debug "~a: modified lines: ~a" file lines) + ;; 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 (rkt-path? path) + (path-has-extension? path #".rkt")) + + +;; GitHub allows pull request reviews to include comments only on modified lines, plus the 3 lines +;; before and after any modified lines. +(define (expand-modified-line-set lines) + (define context-lines + (for/list ([line-range (in-range-set lines)]) + (range (range-bound-map (range-lower-bound line-range) (λ (x) (max 0 (- x 3)))) + (range-bound-map (range-upper-bound line-range) (λ (x) (+ x 3))) + #:comparator (range-comparator line-range)))) + (range-set-add-all lines context-lines)) + + +(define (range-bound-map bound f) + (if (unbounded? bound) + unbounded + (range-bound (f (range-bound-endpoint bound)) (range-bound-type bound)))) + + +(module+ test + + (test-case "single-source-group" + (test-case "constructor and predicates" + (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-source-group test-file (range-set (closed-open-range 1 5 #:comparator natural<=>)))) + (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<=>))) + (delete-directory/files test-dir))) + + (test-case "directory-source-group" + (test-case "constructor and predicates" + (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")) + (define rkt-file1 (build-path test-dir "test1.rkt")) + (define rkt-file2 (build-path test-dir "test2.rkt")) + (define txt-file (build-path test-dir "test.txt")) + (call-with-output-file rkt-file1 + (λ (out) (displayln "#lang racket/base" out))) + (call-with-output-file rkt-file2 + (λ (out) (displayln "#lang racket" out))) + (call-with-output-file txt-file + (λ (out) (displayln "not racket" out))) + (define group (directory-source-group test-dir)) + (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))) + (delete-directory/files test-dir))) + + (test-case "package-source-group" + (test-case "constructor and predicates" + (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-source-group "rackunit")) + (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) + (check-true (path-has-extension? (source-path src) #".rkt")))) + + (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-group-resolve group))))) + + (test-case "git-repository-source-group" + (test-case "constructor and predicates" + (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 "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") + (fail "git init failed")) + (unless (system "git config user.email 'test@example.com'") + (fail "git config email failed")) + (unless (system "git config user.name 'Test User'") + (fail "git config name failed")) + (define test-file (build-path test-dir "test.rkt")) + (call-with-output-file test-file + (λ (out) (displayln "#lang racket/base\n(void)" out))) + (unless (system "git add test.rkt") + (fail "git add failed")) + (unless (system "git commit -q -m 'Initial commit'") + (fail "git commit failed")) + (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-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-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-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" + (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")) + (call-with-output-file test-file1 + (λ (out) (displayln "#lang racket/base" out))) + (call-with-output-file test-file2 + (λ (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-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))) + (check-true (hash-has-key? result (file-source test-file2))) + (delete-directory/files test-dir)) + + (test-case "combines ranges for same 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 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-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)) + (check-true (range-set-contains? combined-ranges 2)) + (check-true (range-set-contains? combined-ranges 5)) + (check-true (range-set-contains? combined-ranges 6)) + (delete-directory/files test-dir))) + + (test-case "rkt-path?" + (test-case "returns true for .rkt files" + (check-true (rkt-path? "/tmp/test.rkt"))) + + (test-case "returns false for non-.rkt files" + (check-false (rkt-path? "/tmp/test.txt")) + (check-false (rkt-path? "/tmp/test.scm")))) + + (test-case "range-bound-map" + (test-case "maps bounded endpoints" + (define bound (range-bound 5 inclusive)) + (define result (range-bound-map bound (λ (x) (* x 2)))) + (check-equal? (range-bound-endpoint result) 10) + (check-equal? (range-bound-type result) inclusive)) + + (test-case "preserves unbounded" + (define result (range-bound-map unbounded (λ (x) (* x 2)))) + (check-equal? result unbounded))) + + (test-case "expand-modified-line-set" + (define ranges (range-set (closed-open-range 4 6) (greater-than-range 15))) + (define expected (range-set (closed-open-range 1 9) (greater-than-range 12))) + (check-equal? (expand-modified-line-set ranges) expected))) diff --git a/grimoire/source-group.scrbl b/grimoire/source-group.scrbl new file mode 100644 index 0000000..b076929 --- /dev/null +++ b/grimoire/source-group.scrbl @@ -0,0 +1,146 @@ +#lang scribble/manual + + +@(require (for-label pkg/lib + racket/base + racket/contract/base + racket/path + racket/sequence + rebellion/collection/range-set + resyntax/grimoire/source-group + resyntax/grimoire/source)) + + +@title[#:tag "source-group"]{Source Groups} +@defmodule[resyntax/grimoire/source-group] + +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 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[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 + constructs these.} + + @item{@emph{Package groups}, constructed with @racket[package-source-group], containing every file + 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 @emph{local} Git repository that have changed relative to some base + 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.}] + +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, +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 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?]{ + 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].}] + + This operation is a convenience wrapper around @racket[source-group-union-all].} + + +@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?]) + 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 [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 [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 [repository-path path-string?] [base-ref string?]) + 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. + + 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. 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?]) + (hash/c file-source? immutable-range-set?)]{ + 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.} diff --git a/private/file-group.rkt b/private/file-group.rkt deleted file mode 100644 index e504394..0000000 --- a/private/file-group.rkt +++ /dev/null @@ -1,306 +0,0 @@ -#lang racket/base - - -(require racket/contract/base) - - -(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?)] - [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?)])) - - -(require fancy-app - guard - pkg/lib - racket/file - racket/match - racket/path - racket/sequence - racket/string - rebellion/base/comparator - rebellion/base/range - rebellion/collection/entry - rebellion/collection/hash - rebellion/collection/list - rebellion/collection/range-set - rebellion/streaming/reducer - rebellion/streaming/transducer - resyntax/private/git - resyntax/private/logger - resyntax/grimoire/source) - - -(module+ test - (require (submod "..") - racket/file - racket/list - racket/system - rackunit)) - - -;@---------------------------------------------------------------------------------------------------- - - -(struct file-portion (path lines) - #:transparent - #:guard (λ (path lines _) (values (simple-form-path path) lines))) - - -(struct file-group () #:transparent) - - -(struct single-file-group file-group (path ranges) - #:transparent - #:guard (λ (path ranges _) (values (simple-form-path path) ranges))) - - -(struct directory-file-group file-group (path) - #:transparent - #:guard (λ (path _) (simple-form-path path))) - - -(struct package-file-group file-group (package-name) - #:transparent - #:guard (λ (package-name _) (string->immutable-string package-name))) - - -(struct git-repository-file-group file-group (repository-path ref) - #:transparent - #:guard - (λ (repository-path ref _) - (values (simple-form-path repository-path) (string->immutable-string ref)))) - - -(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)) - - -(define (file-group-resolve group) - (define files - (match group - [(single-file-group path ranges) - (list (file-portion path ranges))] - [(directory-file-group path) - (for/list ([file (in-directory path)]) - (file-portion file (range-set (unbounded-range #:comparator natural<=>))))] - [(package-file-group package-name) - (define pkgdir (pkg-directory package-name)) - (unless pkgdir - (raise-user-error 'resyntax - "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<=>))))] - [(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)) - - -(define/guard (rkt-file? portion) - (path-has-extension? (file-portion-path portion) #".rkt")) - - -;; GitHub allows pull request reviews to include comments only on modified lines, plus the 3 lines -;; before and after any modified lines. -(define (expand-modified-line-set lines) - (define context-lines - (for/list ([line-range (in-range-set lines)]) - (range (range-bound-map (range-lower-bound line-range) (λ (x) (max 0 (- x 3)))) - (range-bound-map (range-upper-bound line-range) (λ (x) (+ x 3))) - #:comparator (range-comparator line-range)))) - (range-set-add-all lines context-lines)) - - -(define (range-bound-map bound f) - (if (unbounded? bound) - unbounded - (range-bound (f (range-bound-endpoint bound)) (range-bound-type bound)))) - - -(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<=>)))) - (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<=>)))) - - (test-case "file-group-resolve 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<=>))) - (delete-directory/files test-dir))) - - (test-case "directory-file-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"))) - - (test-case "file-group-resolve 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")) - (define txt-file (build-path test-dir "test.txt")) - (call-with-output-file rkt-file1 - (λ (out) (displayln "#lang racket/base" out))) - (call-with-output-file rkt-file2 - (λ (out) (displayln "#lang racket" out))) - (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)) - (delete-directory/files test-dir))) - - (test-case "package-file-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")) - - (test-case "file-group-resolve 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 group (package-file-group "this-package-does-not-exist-xyz")) - (check-exn exn:fail:user? - (λ () (file-group-resolve group))))) - - (test-case "git-repository-file-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")) - - (test-case "file-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") - (fail "git init failed")) - (unless (system "git config user.email 'test@example.com'") - (fail "git config email failed")) - (unless (system "git config user.name 'Test User'") - (fail "git config name failed")) - (define test-file (build-path test-dir "test.rkt")) - (call-with-output-file test-file - (λ (out) (displayln "#lang racket/base\n(void)" out))) - (unless (system "git add test.rkt") - (fail "git add failed")) - (unless (system "git commit -q -m 'Initial commit'") - (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 portions (file-group-resolve group)) - (check-true (list? portions)) - (check-true (> (length portions) 0)) - (check-true (andmap file-portion? portions))) - (delete-directory/files test-dir))) - - (test-case "file-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")) - (define test-file2 (build-path test-dir "test2.rkt")) - (call-with-output-file test-file1 - (λ (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))) - (check-true (hash? result)) - (check-equal? (hash-count result) 2) - (check-true (hash-has-key? result (file-source test-file1))) - (check-true (hash-has-key? result (file-source test-file2))) - (delete-directory/files test-dir)) - - (test-case "combines ranges for same 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 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))) - (check-equal? (hash-count result) 1) - (define combined-ranges (hash-ref result (file-source test-file))) - (check-true (range-set-contains? combined-ranges 1)) - (check-true (range-set-contains? combined-ranges 2)) - (check-true (range-set-contains? combined-ranges 5)) - (check-true (range-set-contains? combined-ranges 6)) - (delete-directory/files test-dir))) - - (test-case "rkt-file?" - (test-case "returns true for .rkt files" - (define portion (file-portion "/tmp/test.rkt" (range-set #:comparator natural<=>))) - (check-true (rkt-file? portion))) - - (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)))) - - (test-case "range-bound-map" - (test-case "maps bounded endpoints" - (define bound (range-bound 5 inclusive)) - (define result (range-bound-map bound (λ (x) (* x 2)))) - (check-equal? (range-bound-endpoint result) 10) - (check-equal? (range-bound-type result) inclusive)) - - (test-case "preserves unbounded" - (define result (range-bound-map unbounded (λ (x) (* x 2)))) - (check-equal? result unbounded))) - - (test-case "expand-modified-line-set" - (define ranges (range-set (closed-open-range 4 6) (greater-than-range 15))) - (define expected (range-set (closed-open-range 1 9) (greater-than-range 12))) - (check-equal? (expand-modified-line-set ranges) expected)))