reference_extractor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bac9513331865230939596e19a86fb7e446498d4c4f9194dcb68a97b5377a668
4
+ data.tar.gz: e5db87762acef10e792fb4f56b6cd333ea8f08a43b2f40f9d7c9f5206eb7daa0
5
+ SHA512:
6
+ metadata.gz: 5439eaacdaf0df2b261122de2d5853542b4a51841945197aca3ee04b33dc259348946401598125faea3c7f941a826f06d1ef5dd265ee3a56dece72e944d0a6c8
7
+ data.tar.gz: 5970480b43f52c8abfe2c4d0a54dfffca9b024113d19a2642b6f93b10e4b5cfbf540dec4b0887ec0380d500846032358f889f96c1cfd70c0399172c57a562ce3
data/.standard.yml ADDED
@@ -0,0 +1,5 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.4
4
+ ignore:
5
+ - 'test/fixtures/formats/**/*'
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.4.7
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2025-10-30
6
+
7
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Philip Theus (prev. Mueller)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Philip Theus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # 🚧 [WIP] ReferenceExtractor [WIP] 🚧
2
+
3
+ ## Introduction
4
+
5
+ ReferenceExtractor parses Ruby files and returns the constants they reference, using your Zeitwerk autoloaders to resolve what lives where. It gives you a graph you can use for architecture rules like layering or dependency checks.
6
+
7
+ ReferenceExtractor is in _prototype_ stage. It works in general but is not battle tested.
8
+
9
+ It is based on [packwerk](https://github.com/Shopify/packwerk).
10
+
11
+ ## Usage
12
+
13
+ ```ruby
14
+ extractor = ReferenceExtractor::Extractor.new(
15
+ autoloaders: Rails.autoloaders,
16
+ root_path: Rails.root
17
+ )
18
+
19
+ # From a string snippet
20
+ extractor.references_from_string("Order.find(1)")
21
+ # => [#<ReferenceExtractor::Reference constant=#<ReferenceExtractor::ConstantContext name=\"::Order\" ...>>]
22
+
23
+ # From a file relative to root_path
24
+ extractor.references_from_file("app/models/user.rb")
25
+ # => [#<ReferenceExtractor::Reference ...>, ...]
26
+ ```
27
+
28
+ ## Development
29
+
30
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
31
+
32
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
33
+
34
+ ## Publishing to RubyGems
35
+
36
+ 1. Update the version in `lib/reference_extractor/version.rb`. Push / merge to main.
37
+ 2. Build the gem:
38
+
39
+ ```bash
40
+ gem build reference_extractor.gemspec
41
+ ```
42
+
43
+ This should produce `reference_extractor-<version>.gem`.
44
+ 3. Sign in to RubyGems (only needed once):
45
+
46
+ ```bash
47
+ gem signin
48
+ ```
49
+
50
+ 4. Push the built gem:
51
+
52
+ ```bash
53
+ gem push reference_extractor-<version>.gem
54
+ ```
55
+
56
+ 5. Tag the release:
57
+
58
+ ```bash
59
+ git tag v<version> && git push origin v<version>
60
+ ```
61
+
62
+ ## Contributing
63
+
64
+ Bug reports and pull requests are welcome on GitHub at https://github.com/exterm/reference_extractor. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/exterm/reference_extractor/blob/main/CODE_OF_CONDUCT.md).
65
+
66
+ ## License
67
+
68
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
69
+
70
+ ## Code of Conduct
71
+
72
+ Everyone interacting in the ReferenceExtractor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/exterm/reference_extractor/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "reference_extractor"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ ConstantContext = Struct.new(:name, :location)
5
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ # Public API for extracting constant references from Ruby code (that is autoloaded via Zeitwerk).
5
+ #
6
+ # @example
7
+ # extractor = ReferenceExtractor::Extractor.new(autoloaders: Rails.autoloaders, root_path: Rails.root)
8
+ # references = extractor.references_from_string("Order.find(1)")
9
+ # references = extractor.references_from_file("app/models/user.rb")
10
+ class Extractor
11
+ attr_reader :root_path
12
+
13
+ # @param autoloaders [Enumerable] Collection of Zeitwerk loaders, e.g. from `Rails.autoloaders`
14
+ # @param root_path [String, Pathname] The root path of the application, e.g. from `Rails.root`
15
+ def initialize(autoloaders:, root_path:)
16
+ @autoloaders = autoloaders
17
+ @root_path = Pathname.new(root_path)
18
+ @context_provider = Internal::ConstantDiscovery.new(root_path:, loaders: @autoloaders)
19
+ end
20
+
21
+ # Extract constant references from a Ruby code string.
22
+ #
23
+ # @param snippet [String] The Ruby code to analyze
24
+ # @return [Array<Reference>] Array of references to autoloaded constants in project files
25
+ def references_from_string(snippet)
26
+ ast = parse_ruby_string(snippet)
27
+ return [] unless ast
28
+
29
+ extract_references(ast, relative_path: "<snippet>")
30
+ end
31
+
32
+ # Extract constant references from a Ruby file.
33
+ #
34
+ # @param file_path [String, Pathname] Path to the Ruby file (relative to root_path or absolute)
35
+ # @return [Array<Reference>] Array of references to autoloaded constants in project files
36
+ def references_from_file(file_path)
37
+ absolute_path = Pathname.new(file_path).expand_path(root_path)
38
+ return [] unless File.exist?(absolute_path)
39
+
40
+ ast = parse_file(absolute_path)
41
+ return [] unless ast
42
+
43
+ relative_path = Pathname.new(absolute_path).relative_path_from(root_path).to_s
44
+ extract_references(ast, relative_path:)
45
+ end
46
+
47
+ private
48
+
49
+ def parse_ruby_string(snippet)
50
+ parser = Internal::Parsers::Ruby.new
51
+ parser.call(io: StringIO.new(snippet))
52
+ end
53
+
54
+ def parse_file(file_path)
55
+ parser = Internal::Parsers::Factory.instance.for_path(file_path.to_s)
56
+ raise ArgumentError, "Unsupported file type: #{file_path}" unless parser
57
+
58
+ File.open(file_path, "r") do |io|
59
+ parser.call(io:, file_path: file_path.to_s)
60
+ end
61
+ end
62
+
63
+ def extract_references(root_node, relative_path:)
64
+ ast_reference_extractor.extract_references(root_node, relative_path:)
65
+ end
66
+
67
+ def ast_reference_extractor
68
+ @ast_reference_extractor ||= Internal::AstReferenceExtractor.new(
69
+ # TO DO: Add association inspector
70
+ constant_name_inspectors: [Internal::ConstNodeInspector.new],
71
+ context_provider: @context_provider,
72
+ root_path:
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ module Internal
5
+ # Extracts a possible constant reference from a given AST node.
6
+ class AstReferenceExtractor
7
+ class << self
8
+ def get_fully_qualified_references_from(unresolved_references, context_provider)
9
+ fully_qualified_references = []
10
+
11
+ unresolved_references.each do |unresolved_reference|
12
+ constant =
13
+ context_provider.context_for(
14
+ unresolved_reference.constant_name,
15
+ current_namespace_path: unresolved_reference.namespace_path
16
+ )
17
+
18
+ next if constant.nil?
19
+
20
+ # Ignore references that resolve to the same file they originate from.
21
+ next if constant.location.to_s == unresolved_reference.relative_path.to_s
22
+
23
+ fully_qualified_references << Reference.new(
24
+ relative_path: unresolved_reference.relative_path,
25
+ constant: constant,
26
+ source_location: unresolved_reference.source_location
27
+ )
28
+ end
29
+
30
+ fully_qualified_references
31
+ end
32
+ end
33
+
34
+ def initialize(
35
+ constant_name_inspectors:,
36
+ context_provider:,
37
+ root_path:
38
+ )
39
+ @constant_name_inspectors = constant_name_inspectors
40
+ @context_provider = context_provider
41
+ @root_path = root_path
42
+ end
43
+
44
+ # Extract and resolve all references from the AST in one step
45
+ def extract_references(root_node, relative_path:)
46
+ local_constant_definitions = ParsedConstantDefinitions.new(root_node: root_node)
47
+ unresolved_references = collect_references(
48
+ root_node,
49
+ ancestors: [],
50
+ relative_path: relative_path,
51
+ local_constant_definitions: local_constant_definitions
52
+ )
53
+ self.class.get_fully_qualified_references_from(unresolved_references, @context_provider)
54
+ end
55
+
56
+ def reference_from_node(node, ancestors:, relative_path:, local_constant_definitions:)
57
+ constant_name = nil
58
+
59
+ @constant_name_inspectors.each do |inspector|
60
+ constant_name = inspector.constant_name_from_node(node, ancestors:)
61
+
62
+ break if constant_name
63
+ end
64
+
65
+ if constant_name
66
+ reference_from_constant(
67
+ constant_name,
68
+ node:,
69
+ ancestors:,
70
+ relative_path:,
71
+ local_constant_definitions:
72
+ )
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def reference_from_constant(constant_name, node:, ancestors:, relative_path:, local_constant_definitions:)
79
+ namespace_path = NodeHelpers.enclosing_namespace_path(node, ancestors: ancestors)
80
+
81
+ return if local_reference?(constant_name, NodeHelpers.name_location(node), namespace_path, local_constant_definitions)
82
+
83
+ UnresolvedReference.new(
84
+ constant_name:,
85
+ namespace_path:,
86
+ relative_path: relative_path,
87
+ source_location: NodeHelpers.location(node)
88
+ )
89
+ end
90
+
91
+ def local_reference?(constant_name, name_location, namespace_path, local_constant_definitions)
92
+ local_constant_definitions.local_reference?(
93
+ constant_name,
94
+ location: name_location,
95
+ namespace_path: namespace_path
96
+ )
97
+ end
98
+
99
+ def collect_references(node, ancestors:, relative_path:, local_constant_definitions:)
100
+ reference = reference_from_node(
101
+ node,
102
+ ancestors:,
103
+ relative_path:,
104
+ local_constant_definitions:
105
+ )
106
+
107
+ child_references = NodeHelpers.each_child(node).flat_map do |child|
108
+ collect_references(
109
+ child,
110
+ ancestors: [node] + ancestors,
111
+ relative_path:,
112
+ local_constant_definitions:
113
+ )
114
+ end
115
+
116
+ ([reference] + child_references).compact
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ module Internal
5
+ # Extracts a constant name from an AST node of type :const
6
+ class ConstNodeInspector
7
+ def constant_name_from_node(node, ancestors:)
8
+ return nil unless NodeHelpers.constant?(node)
9
+
10
+ parent = ancestors.first
11
+
12
+ # Only process the root `const` node for namespaced constant references. For example, in the
13
+ # reference `Spam::Eggs::Thing`, we only process the const node associated with `Spam`.
14
+ return nil unless root_constant?(parent)
15
+
16
+ if parent && constant_in_module_or_class_definition?(node, parent: parent)
17
+ fully_qualify_constant(ancestors)
18
+ else
19
+ begin
20
+ NodeHelpers.constant_name(node)
21
+ rescue NodeHelpers::TypeError
22
+ nil
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def root_constant?(parent)
30
+ !(parent && NodeHelpers.constant?(parent))
31
+ end
32
+
33
+ def constant_in_module_or_class_definition?(node, parent:)
34
+ parent_name = NodeHelpers.module_name_from_definition(parent)
35
+ parent_name && parent_name == NodeHelpers.constant_name(node)
36
+ end
37
+
38
+ def fully_qualify_constant(ancestors)
39
+ # We're defining a class with this name, in which case the constant is implicitly fully qualified by its
40
+ # enclosing namespace
41
+ "::" + NodeHelpers.parent_module_name(ancestors: ancestors)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ module Internal
5
+ # Get information about unresolved constants without loading the application code.
6
+ # Information gathered: Fully qualified name and path to file containing the definition.
7
+ #
8
+ # The implementation makes a few assumptions about the code base:
9
+ # - `Something::SomeOtherThing` is defined in a path of either `something/some_other_thing.rb` or `something.rb`,
10
+ # relative to the load path. Rails' `zeitwerk` autoloader makes the same assumption.
11
+ # - It is OK to not always infer the exact file defining the constant. For example, when a constant is inherited, we
12
+ # have no way of inferring the file it is defined in. You could argue though that inheritance means that another
13
+ # constant with the same name exists in the inheriting class, and this view is sufficient for all our use cases.
14
+ class ConstantDiscovery
15
+ class Error < StandardError; end
16
+
17
+ def initialize(root_path:, loaders:)
18
+ @root_path = root_path
19
+ @loaders = loaders
20
+ end
21
+
22
+ # Analyze a constant via its name.
23
+ # If the constant is unresolved, we need the current namespace path to correctly infer its full name
24
+ #
25
+ # @param const_name [String] The unresolved constant's name.
26
+ # @param current_namespace_path [Array<String>] (optional) The namespace of the context in which the constant is
27
+ # used, e.g. ["Apps", "Models"] for `Apps::Models`. Defaults to [] which means top level.
28
+ # @return [ConstantContext]
29
+ def context_for(const_name, current_namespace_path: [])
30
+ current_namespace_path = [] if const_name.start_with?("::")
31
+ const_name, location = resolve_constant(const_name.delete_prefix("::"), current_namespace_path)
32
+
33
+ return unless location
34
+
35
+ relative_location = Pathname.new(location).relative_path_from(@root_path)
36
+ ConstantContext.new(const_name, relative_location)
37
+ end
38
+
39
+ # Analyze the constants and raise errors if any potential issues are encountered that would prevent
40
+ # resolving the context for constants, such as ambiguous constant locations.
41
+ #
42
+ # @return [ConstantDiscovery]
43
+ def validate_constants
44
+ const_locations
45
+ true
46
+ end
47
+
48
+ private
49
+
50
+ def const_locations
51
+ return @const_locations unless @const_locations.nil?
52
+
53
+ all_cpaths = @loaders.inject({}) do |cpaths, loader|
54
+ paths = loader.all_expected_cpaths.filter do |cpath, _const|
55
+ cpath.ends_with?(".rb")
56
+ end
57
+ cpaths.merge(paths)
58
+ end
59
+ paths_by_const = all_cpaths.invert
60
+ validate_constant_paths(paths_by_const, all_cpaths: all_cpaths)
61
+ @const_locations = paths_by_const
62
+ end
63
+
64
+ def resolve_constant(const_name, current_namespace_path, original_name: const_name)
65
+ namespace, location = resolve_traversing_namespace_path(const_name, current_namespace_path)
66
+ if location
67
+ ["::" + namespace.push(original_name).join("::"), location]
68
+ elsif !const_name.include?("::")
69
+ # constant could not be resolved to a file in the given load paths
70
+ [nil, nil]
71
+ else
72
+ parent_constant = const_name.split("::")[0..-2].join("::")
73
+ resolve_constant(parent_constant, current_namespace_path, original_name:)
74
+ end
75
+ end
76
+
77
+ def resolve_traversing_namespace_path(const_name, current_namespace_path)
78
+ fully_qualified_name_guess = (current_namespace_path + [const_name]).join("::")
79
+
80
+ location = const_locations[fully_qualified_name_guess]
81
+ if location || fully_qualified_name_guess == const_name
82
+ [current_namespace_path, location]
83
+ else
84
+ resolve_traversing_namespace_path(const_name, current_namespace_path[0..-2])
85
+ end
86
+ end
87
+
88
+ def validate_constant_paths(paths_by_constant, all_cpaths:)
89
+ raise(Error, "Could not find any ruby files.") if all_cpaths.empty?
90
+
91
+ is_ambiguous = all_cpaths.size != paths_by_constant.size
92
+ raise(Error, ambiguous_constants_hint(paths_by_constant, all_cpaths: all_cpaths)) if is_ambiguous
93
+ end
94
+
95
+ def ambiguous_constants_hint(paths_by_constant, all_cpaths:)
96
+ ambiguous_constants = all_cpaths.except(*paths_by_constant.invert.keys).values
97
+ <<~MSG
98
+ Ambiguous constant definition:
99
+ #{ambiguous_constants.map { |const| " - #{const}" }.join("\n")}
100
+ MSG
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReferenceExtractor
4
+ module Internal
5
+ module Node
6
+ Location = Struct.new(:line, :column)
7
+ end
8
+ end
9
+ end