Repository navigation
Follow up #82: look up associations as Rails does, name constants through Object, and match packwerk on :: names and class_eval - #83
Merged
Conversation
packwerk names `class ::Burrito` inside `module Outer` `::Outer::::Burrito`, and so does pks. When no file defines a constant, both look for it in the file of its parent, which for `Outer::::Burrito` is `Outer::`. packwerk takes the parent with Ruby's `String#split`, which drops the empty name at the end, so `Outer::` has no parent, and packwerk finds no constant. Rust's `split` keeps it, so pks went on to `Outer`, and reported `::Outer::::Burrito` against the pack that defines `Outer`. In `module Aa; module Bb; class ::Cee`, packwerk goes from `Aa::Bb::` to `Aa`, where pks stopped at `Aa::Bb`. The Zeitwerk resolver now drops the empty names at the end too.
packwerk names `class ::Taco` at the top level of a file `::::Taco` among the file's own definitions, so a `Taco` or `::Taco` written later in the file, which it looks for as `::Taco`, isn't one of them, and is a reference. pks looked for `::` and the name, `::::Taco`, as well, so it took them for the file's own and dropped them. The packwerk parser now looks for the name alone. Only a definition written from the top level, at the top level of a file, has a name that starts with `::::`, so nothing else changes.
Rails looks up the class of `has_many :tacos` from the name of the model that declares it: in `Outer::Foo`, as `Outer::Foo::Taco`, `Outer::Taco`, then `Taco`. The experimental parser gave the reference the namespaces it's written in, so in `class Outer::Foo` pks looked for `::Outer::Foo::Taco`, then `::Taco`, and in `module Outer; class ::Foo` for `::Foo::Taco`, `::Outer::Taco`, then `::Taco`. The reference now has the namespaces of the model's name, `["Outer", "Foo"]` and `["Foo"]`, so the resolver looks where Rails does. Without `--experimental-parser`, pks looks it up as packwerk does, as before.
…ental-parser The constants of `Object` are the top level's, so `class Object::Bar` inside `module CoreExt` defines `::Bar`, as does `class Bar` inside a `class Object` at the top level of a file. The experimental parser named them `::CoreExt::Object::Bar` and `::Object::Bar`. `nest_constant_name` now drops a leading `::Object` from a name, and the experimental parser takes a class, module or constant defined as `Object::Bar` for `::Bar`. So both are named `::Bar`, and the experimental resolver finds `::Bar` for a reference written `::Object::Bar`. For a reference written `Object::Bar`, the resolver looks `Object` up as Ruby does, so inside `module CoreExt` it tries `::CoreExt::Object::Bar` before `::Bar`. The parser can't look it up for a definition, so it takes it that no namespace defines an `Object` of its own. A `class Object` inside a namespace is still the namespace's own, as Ruby makes it.
The association lookup and the names written through `Object` change what `--experimental-parser` finds. Without it, pks now matches packwerk around a name written from the top level, so `pks check` may report a violation it didn't, or no longer report one, and leave a todo stale.
ActiveRecord's `_klass` (reflection.rb) looks up the class of `belongs_to :company` in a model whose name ends in `Company` as `::Company` first, and from the model's name only if that isn't defined. a81a247 gave the reference the namespaces of the model's name, so with `--experimental-parser`, in `class Payroll::Company`, pks looked for `::Payroll::Company::Company`, then `::Payroll::Company`, and found the model itself, where it had found `::Company`. So it missed a violation it used to report. In `module Payroll; class Company`, it found the model itself before a81a247 too. When the association's class has the model's own last name, the reference is now to `::Company`, so pks finds what Rails does in both. Where no `::Company` is defined, Rails goes on to find `Payroll::Company::Company` or the model itself, and pks finds nothing, so it misses a reference only where a `Payroll::Company::Company` is defined. A test also covers a model in `module Outer; class Foo` and in `class Object::Foo`. In a module, as a concern, pks can't know the model that includes it, and takes the module's name, and `model_namespaces` now says so.
bc0ad33 said that only a definition written from the top level, at the top level of a file, has a name that starts with `::::`, so nothing else changes. That isn't so. The constants defined in such a class, as `::::Taco::Shell` in `class ::Taco; class Shell`, start with it too, as does the `::::Taco` pks takes from `class ::Taco::Shell`, and a `Taco` written inside `class ::Taco` was dropped the same way. pks now counts each of these as a reference, as packwerk 3.3.1 does, and the CHANGELOG says so. Tests cover a `Taco` inside `class ::Taco`, `::Taco = 1` at the top level of a file, and `class ::Burrito` in `module Outer` and `class ::Cee` in `module Aa; module Bb`, from the file to the references pks lists. e9a6092 said that for a reference written `Object::Bar`, the resolver looks `Object` up as Ruby does. Inside `module CoreExt` it tries `::CoreExt::Object::Bar` first, as Ruby would, but where `CoreExt` defines an `Object` with no `Bar`, Ruby raises a NameError, and pks goes on to `::Bar`, as it does for `Foo::Bar` when `CoreExt::Foo` has no `Bar`. New tests name `module Object::Bar`, the constants in `class Object::Bar`, `::Object::Object::BAR` and `ObjectSpace::Bar`, and find constants defined through `Object` from another pack. Comments now say that only the experimental parser and resolver drop a leading `::Object`, and that the empty names come off the end as in constant_resolver.
…ntal-parser With `--experimental-parser`, two things that pks looked up from one file depend on the rest of the app, so they now wait until every file is read. Rails looks up the class of an association in a module, as a concern's `included` block, from the name of each model that includes the module. a81a247 and 15b614c took the module's name for the model's. pks now records the `include` and `prepend` calls in each class and module, and looks up the class from the name of each class that includes the module, directly or through other modules, so one association can reference a class for each. Where no class includes the module, the module's name stands in, as before. When the class has the model's own name and no `::Taco` is defined, Rails goes on to look it up from the model's name, as `Outer::Taco::Taco`; 15b614c looked for `::Taco` only. From the model's name, Rails takes only a class of the very name it looks for (`compute_type` in inheritance.rb), and pks now does too. In a controller, inherited_resources looks up the class of `belongs_to` from the controller's namespaces, and one given as `class_name:` from the top level, so pks no longer looks in the controller itself, and an ActiveRecord model whose name ends in `Controller` is looked up as any other. `class Object::Charge` in `module Payments` defines `::Payments::Object::Charge` where the app has a `Payments::Object`, even one that defines nothing. e9a6092 took it for `::Charge` whatever the app defines. Where some namespace has its own `Object`, pks now reads a file that defines a constant through `Object` in a namespace again, knowing which namespaces do, and names it as Ruby does. Since `check-contents` reads its file from stdin, which can be read only once, pks keeps what it read there. Separately, Ruby finds a constant of `Kernel` or `BasicObject`, which `Object` includes and inherits from, from anywhere, after the top level's own, and the experimental resolver now looks there last.
packwerk names the reference that a class or module definition makes to itself from every class, module, `class_eval` block on a constant, and constant assigned `Class.new` or `Module.new` around it (`parent_module_name` in node_helpers.rb). pks named it from the classes and modules alone, so in `module Aa; Bb.class_eval do; class ::Cee` it left out `Bb`. c081104 made the Zeitwerk resolver drop the empty name at the end of a parent, as packwerk does, and that turned the gap into a regression there: pks found no reference where packwerk finds one to `Aa`, and in `module Aa; module Bb; Cc.class_eval do; class ::Dee` it found one to `Aa` where packwerk's is to `Aa::Bb`. The packwerk parser now keeps a second stack of names for this, with the receiver of `Foo.class_eval do` and the constant of `Foo = Class.new do`, and names the reference from it. A block that takes `_1` or `it`, which packwerk's parser makes a `numblock` or `itblock`, adds nothing, as in packwerk. Where packwerk can't name the class, in a block passed with `&.` or to `super`, pks no longer counts a reference either. The file's own definitions, and the namespaces each reference is looked up in, still come from the classes and modules alone, as in packwerk. On a copy of `simple_app` with 66 such files, list-references and packwerk 3.3.1 now differ on one line: a reference packwerk counts to the scope `Kk` of `Kk::Ll = 1`, which pks has never counted.
Object, and match packwerk on :: namesObject, and match packwerk on :: names and class_eval
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-ups to #82, found while reviewing it, and the gaps they turned up.
With
--experimental-parser, an association is looked up as Rails looks it up.has_many :tacosfrom the name of the model that declares it (ActiveRecord'scompute_type): inOuter::Foo, asOuter::Foo::Taco,Outer::Taco, thenTaco, taking only a class of that very name. pks gave the reference the namespaces it's written in, so inclass Outer::Fooit skippedOuter::Tacoand foundTaco, and inmodule Outer; class ::Fooit foundOuter::Taco, which Rails never tries.belongs_to :companyinPayroll::Company, Rails tries::Companyfirst, then the model's name (_klassin reflection.rb), and so does pks.includedblock, Rails looks up the class from the name of each model that includes the module. pks records theincludeandprependcalls in each class and module, and looks up the class from each class that includes the module, directly or through other modules. Where none does, the module's name stands in.belongs_to :tacofrom the controller's namespaces, and aclass_name:from the top level, and so does pks.With
--experimental-parser, a constant written throughObjectis named as Ruby names it.class Object::Barinsidemodule CoreExtdefines::Bar, and so doesclass Barinside a top-levelclass Object. pks named them::CoreExt::Object::Barand::Object::Bar, and now names them::Bar, unless the app has aCoreExt::Object, even an empty one, in which case it's::CoreExt::Object::Bar, as in Ruby. A reference written::Object::Barnow resolves to::Bar, and insidemodule CoreExtthe resolver tries::CoreExt::Object::BarforObject::Barbefore::Bar. Aclass Objectinside a namespace is still the namespace's own.With
--experimental-parser, the constants ofKernelandBasicObjectare found from anywhere, after the top level's own, as in Ruby:class BasicObject::Foois found asFoo.Without
--experimental-parser, pks matches packwerk around a name written from the top level, and inclass_evalandClass.newblocks. Checked against packwerk 3.3.1 and constant_resolver 0.3.0, run directly in Ruby:module Outer; class ::Burrito: both name the class::Outer::::Burrito. constant_resolver takes the parent ofOuter::::Burrito,Outer::, with Ruby'sString#split, which drops the empty name at the end, soOuter::has no parent and it finds nothing (constant_resolver.rb:163). Rust'ssplitkeeps it, so pks went on toOuterand reported the class againstOuter's pack. Inmodule Aa; module Bb; class ::Cee, the same split takes packwerk fromAa::Bb::toAa, where pks stopped atAa::Bb. The Zeitwerk resolver now drops the empty names at the end too.class ::Taco(or::Taco = 1) at the top level of a file, thenTaco.make: packwerk's own definitions for the file have::::Taco, so the laterTaco, looked for as::Taco, isn't local, and packwerk reports it. pks also looked for::plus the name, found::::Taco, and dropped it. The packwerk parser now looks for the name alone. The same goes for aTacowritten insideclass ::Taco, for constants defined in that class (::::Taco::Shell), and for the::::Tacopks takes fromclass ::Taco::Shell; each is now a reference, as in packwerk.Foo.class_eval doblock andFoo = Class.new doaround it (parent_module_name), somodule Aa; Bb.class_eval do; class ::Ceeis::Aa::Bb::::Cee, a reference toAa. pks left outBb, which thesplitchange above turned into a regression: it found no reference there. The packwerk parser now keeps the same names for this, including packwerk's quirks (blocks taking_1oritadd nothing; it names no class in a block passed with&.or tosuper).The CHANGELOG has an entry for each. Each can move or drop a violation, so a
package_todo.ymlentry can go stale untilpks update.How the experimental changes work
The experimental parser now leaves an association in a class or module, and the
include/prependcalls, in newProcessedFilefields (cached, empty ones left out of the cache file), andprocess_filesin experimental.rs turns them into references once every file is read and the resolver is built. A file that defines a constant throughObjectinside a namespace is flagged, and when the app has some namespace's ownObject, those files are read again knowing which.check-contentsreads its file from stdin, so stdin's contents are now kept after the first read.Test plan
cargo test(398 passed),cargo fmt --check, andcargo clippy --all-targets --all-features -- -Dwarnings.resolve_a_name_from_the_top_level_in_a_namespace(Zeitwerk resolver),test_list_references_to_a_class_defined_from_the_top_level_in_a_namespace,test_list_references_after_a_class_defined_from_the_top_levelandtest_list_references_after_a_constant_assigned_from_the_top_level: the::Burrito,::Cee,class ::Tacoand::Taco = 1cases, from the file to the references listed.class_defined_in_class_eval,classes_defined_in_blocks_and_assignments(36 cases, each checked against packwerk) andtest_list_references_to_a_class_defined_in_class_eval_or_class_new.test_list_references_from_associations_by_the_models_name,..._in_namespaced_models,..._in_concerns(two includers, a concern included through another, an unused one, ERB) and..._named_for_their_model(_klasswith and without::Taco, inherited_resources with and withoutclass_name:, a model whose name ends inController).definitions_in_object,resolve_through_object,test_list_references_to_constants_defined_through_objectand..._through_a_namespaces_object(a namespace's ownObjectin another file, and an empty one), andtest_check_contents_of_a_file_read_twice_with_experimental_parser.test_list_references_to_constants_of_objects_ancestors, and unit tests forobjects_in_namespacesandmodels_including.defined_namecall site, thewhileinnest_constant_name, and each branch of the association lookup and theclass_evalnaming, reverted alone, fails at least one of these tests.simple_app, packwerk 3.3.1 and this build give the same references for 66 files ofclass ::Foo,::Foo = 1,class_evalandClass.newcases, except one older difference listed below. The build frommaindiffered on 57 lines.compute_typeand_klassas above, includingKitchen::Taco::Tacowith no::Taco; a concern inPlainand inBilling::Invoice, and one included through another concern, giveTaco,Billing::TacoandBilling::Taco. inherited_resources 2.1.0, run under Ruby 3.4.11, looks upbelongs_toas described.class Object::Barinmodule CoreExtBar,class Object; class Qux; end; Y = 1Quxand::Y,class Object::Chargein amodule Paymentswith its ownObjectPayments::Object::Charge, and findsBasicObject::FooandKernel::FOOfrom any module, after the top level's own.Not covered
includenot written asinclude/prependin a class or module body (Model.include(M),send(:include, M),Foo.class_eval { include M },ActiveSupport.on_load), and an association in aFoo.class_evalblock: pks doesn't see the model, and the module's name, or none, stands in.Object::Barwhere the namespace's ownObjecthas noBar: Ruby raises a NameError, and pks goes on to::Bar, as it does for any name written with a namespace.class A::Binsidemodule M, whereAis top-level, is::A::Bin Ruby; pks names it::M::A::B. Telling them apart needs the namespaces Zeitwerk makes from directories, which pks doesn't read in this mode. TheObjectcase above is the one it can tell.--experimental-parser, packwerk counts the scopeKkofKk::Ll = 1as a reference, and readsself::Fooas::Foo; pks doesn't, as before this PR.