ivar 0.2.0 → 0.4.6

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.augment-guidelines +5 -3
  3. data/.devcontainer/devcontainer.json +28 -20
  4. data/.devcontainer/post-create.sh +18 -0
  5. data/.editorconfig +35 -0
  6. data/.rubocop.yml +6 -0
  7. data/.standard.yml +1 -1
  8. data/.vscode/extensions.json +3 -1
  9. data/.vscode/launch.json +25 -0
  10. data/.vscode/settings.json +38 -2
  11. data/CHANGELOG.md +99 -1
  12. data/README.md +272 -207
  13. data/Rakefile +1 -1
  14. data/VERSION.md +46 -0
  15. data/examples/check_all_block_example.rb +84 -0
  16. data/examples/check_all_example.rb +42 -0
  17. data/examples/inheritance_with_kwarg_init.rb +156 -0
  18. data/examples/inheritance_with_positional_init.rb +142 -0
  19. data/examples/mixed_positional_and_kwarg_init.rb +125 -0
  20. data/examples/require_check_all_example.rb +23 -0
  21. data/examples/sandwich_inheritance.rb +1 -1
  22. data/examples/sandwich_with_accessors.rb +78 -0
  23. data/examples/sandwich_with_block_values.rb +54 -0
  24. data/examples/sandwich_with_checked.rb +0 -1
  25. data/examples/sandwich_with_checked_once.rb +0 -1
  26. data/examples/sandwich_with_initial_values.rb +52 -0
  27. data/examples/sandwich_with_ivar_block.rb +6 -9
  28. data/examples/sandwich_with_ivar_macro.rb +4 -4
  29. data/examples/sandwich_with_kwarg_init.rb +78 -0
  30. data/examples/sandwich_with_positional_init.rb +50 -0
  31. data/examples/sandwich_with_shared_values.rb +54 -0
  32. data/hooks/README.md +42 -0
  33. data/hooks/install.sh +12 -0
  34. data/hooks/pre-commit +54 -0
  35. data/lib/ivar/check_all.rb +7 -0
  36. data/lib/ivar/check_all_manager.rb +72 -0
  37. data/lib/ivar/check_policy.rb +29 -0
  38. data/lib/ivar/checked/class_methods.rb +19 -0
  39. data/lib/ivar/checked/instance_methods.rb +35 -0
  40. data/lib/ivar/checked.rb +17 -24
  41. data/lib/ivar/declaration.rb +30 -0
  42. data/lib/ivar/explicit_declaration.rb +56 -0
  43. data/lib/ivar/explicit_keyword_declaration.rb +24 -0
  44. data/lib/ivar/explicit_positional_declaration.rb +19 -0
  45. data/lib/ivar/macros.rb +48 -111
  46. data/lib/ivar/manifest.rb +124 -0
  47. data/lib/ivar/policies.rb +13 -1
  48. data/lib/ivar/project_root.rb +59 -0
  49. data/lib/ivar/targeted_prism_analysis.rb +144 -0
  50. data/lib/ivar/validation.rb +6 -29
  51. data/lib/ivar/version.rb +1 -1
  52. data/lib/ivar.rb +141 -9
  53. data/script/console +11 -0
  54. data/script/de-lint +2 -0
  55. data/script/de-lint-unsafe +2 -0
  56. data/script/lint +2 -0
  57. data/script/release +213 -0
  58. data/script/setup +8 -0
  59. data/script/test +2 -0
  60. metadata +46 -8
  61. data/examples/sandwich_with_kwarg.rb +0 -45
  62. data/ivar.gemspec +0 -49
  63. data/lib/ivar/auto_check.rb +0 -77
  64. data/lib/ivar/prism_analysis.rb +0 -102
data/lib/ivar/checked.rb CHANGED
@@ -1,40 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "validation"
4
+ require_relative "macros"
5
+ require_relative "check_policy"
6
+ require_relative "checked/class_methods"
7
+ require_relative "checked/instance_methods"
4
8
 
5
9
  module Ivar
6
- # Provides automatic validation for instance variables
7
- # When included, automatically checks instance variables after initialization
10
+ # Provides automatic validation for instance variables.
11
+ # When included in a class, this module:
12
+ # 1. Automatically calls check_ivars after initialization
13
+ # 2. Extends the class with CheckPolicy for policy configuration
14
+ # 3. Extends the class with Macros for ivar declarations
15
+ # 4. Sets a default check policy of :warn
16
+ # 5. Handles proper inheritance of these behaviors in subclasses
8
17
  module Checked
9
18
  # When this module is included in a class, it extends the class
10
19
  # with ClassMethods and includes the Validation module
20
+ # @param base [Class] The class that is including this module
11
21
  def self.included(base)
12
22
  base.include(Validation)
13
23
  base.extend(ClassMethods)
24
+ base.extend(CheckPolicy)
25
+ base.extend(Macros)
14
26
  base.prepend(InstanceMethods)
15
- end
16
-
17
- # Class methods added to the including class
18
- module ClassMethods
19
- # Hook method called when the module is included
20
- def inherited(subclass)
21
- super
22
- # Ensure subclasses also get the initialize wrapper
23
- subclass.prepend(InstanceMethods)
24
- end
25
- end
26
27
 
27
- # Instance methods that will be prepended to the including class
28
- module InstanceMethods
29
- # Wrap the initialize method to automatically call check_ivars
30
- def initialize(*args, **kwargs, &block)
31
- # Call the original initialize method
32
- super
33
- # Automatically check instance variables
34
- # We need to collect all instance variables from the current object
35
- # and pass them to check_ivars_once to ensure they're all recognized
36
- check_ivars_once
37
- end
28
+ # Set default policy for Checked to :warn
29
+ # This can be overridden by calling ivar_check_policy in the class
30
+ base.ivar_check_policy(Ivar.check_policy)
38
31
  end
39
32
  end
40
33
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ivar
4
+ # Base class for all declarations
5
+ class Declaration
6
+ # @return [Symbol] The name of the instance variable
7
+ attr_reader :name, :manifest
8
+
9
+ # Initialize a new declaration
10
+ # @param name [Symbol, String] The name of the instance variable
11
+ def initialize(name, manifest)
12
+ @name = name.to_sym
13
+ @manifest = manifest
14
+ end
15
+
16
+ # Called when the declaration is added to a class
17
+ # @param klass [Class, Module] The class or module the declaration is added to
18
+ def on_declare(klass)
19
+ # Base implementation does nothing
20
+ end
21
+
22
+ # Called before object initialization
23
+ # @param instance [Object] The object being initialized
24
+ # @param args [Array] Positional arguments
25
+ # @param kwargs [Hash] Keyword arguments
26
+ def before_init(instance, args, kwargs)
27
+ # Base implementation does nothing
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "declaration"
4
+ require_relative "macros"
5
+
6
+ module Ivar
7
+ # Represents an explicit declaration from the ivar macro
8
+ class ExplicitDeclaration < Declaration
9
+ # Initialize a new explicit declaration
10
+ # @param name [Symbol, String] The name of the instance variable
11
+ # @param options [Hash] Options for the declaration
12
+ def initialize(name, manifest, options = {})
13
+ super(name, manifest)
14
+ @init_method = options[:init]
15
+ @initial_value = options[:value]
16
+ @reader = options[:reader] || false
17
+ @writer = options[:writer] || false
18
+ @accessor = options[:accessor] || false
19
+ @init_block = options[:block]
20
+ end
21
+
22
+ # Called when the declaration is added to a class
23
+ # @param klass [Class, Module] The class or module the declaration is added to
24
+ def on_declare(klass)
25
+ add_accessor_methods(klass)
26
+ end
27
+
28
+ # Check if this declaration uses keyword argument initialization
29
+ # @return [Boolean] Whether this declaration uses keyword argument initialization
30
+ def kwarg_init? = false
31
+
32
+ # Called before object initialization
33
+ # @param instance [Object] The object being initialized
34
+ # @param args [Array] Positional arguments
35
+ # @param kwargs [Hash] Keyword arguments
36
+ def before_init(instance, args, kwargs)
37
+ if @init_block
38
+ instance.instance_variable_set(@name, @init_block.call(@name))
39
+ end
40
+ if @initial_value != Ivar::Macros::UNSET
41
+ instance.instance_variable_set(@name, @initial_value)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Add accessor methods to the class
48
+ # @param klass [Class, Module] The class to add methods to
49
+ def add_accessor_methods(klass)
50
+ var_name = @name.to_s.delete_prefix("@")
51
+
52
+ klass.__send__(:attr_reader, var_name) if @reader || @accessor
53
+ klass.__send__(:attr_writer, var_name) if @writer || @accessor
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "explicit_declaration"
4
+
5
+ module Ivar
6
+ # Represents an explicit declaration that initializes from keyword arguments
7
+ class ExplicitKeywordDeclaration < ExplicitDeclaration
8
+ # Check if this declaration uses keyword argument initialization
9
+ # @return [Boolean] Whether this declaration uses keyword argument initialization
10
+ def kwarg_init? = true
11
+
12
+ # Called before object initialization
13
+ # @param instance [Object] The object being initialized
14
+ # @param args [Array] Positional arguments
15
+ # @param kwargs [Hash] Keyword arguments
16
+ def before_init(instance, args, kwargs)
17
+ super
18
+ kwarg_name = @name.to_s.delete_prefix("@").to_sym
19
+ if kwargs.key?(kwarg_name)
20
+ instance.instance_variable_set(@name, kwargs.delete(kwarg_name))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "explicit_declaration"
4
+
5
+ module Ivar
6
+ # Represents an explicit declaration that initializes from positional arguments
7
+ class ExplicitPositionalDeclaration < ExplicitDeclaration
8
+ # Called before object initialization
9
+ # @param instance [Object] The object being initialized
10
+ # @param args [Array] Positional arguments
11
+ # @param kwargs [Hash] Keyword arguments
12
+ def before_init(instance, args, kwargs)
13
+ super
14
+ if args.length > 0
15
+ instance.instance_variable_set(@name, args.shift)
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/ivar/macros.rb CHANGED
@@ -3,121 +3,58 @@
3
3
  module Ivar
4
4
  # Provides macros for working with instance variables
5
5
  module Macros
6
+ # Special flag object to detect when a parameter is not provided
7
+ UNSET = Object.new.freeze
8
+
6
9
  # When this module is extended, it adds class methods to the extending class
7
10
  def self.extended(base)
8
- # Store pre-declared instance variables for this class
9
- base.instance_variable_set(:@__ivar_pre_declared_ivars, [])
10
- # Store initialization block
11
- base.instance_variable_set(:@__ivar_init_block, nil)
12
- # Store keyword argument mappings
13
- base.instance_variable_set(:@__ivar_kwarg_mappings, [])
14
- end
15
-
16
- # Declares instance variables that should be pre-initialized to nil
17
- # before the initializer is called
18
- # @param ivars [Array<Symbol>] Instance variables to pre-initialize
19
- # @param kwarg [Array<Symbol>] Instance variables to initialize from keyword arguments
20
- # @yield Optional block to execute in the context of the instance before initialization
21
- def ivar(*ivars, kwarg: [], &block)
22
- # Store the pre-declared instance variables
23
- pre_declared = instance_variable_get(:@__ivar_pre_declared_ivars) || []
24
- instance_variable_set(:@__ivar_pre_declared_ivars, pre_declared + ivars)
25
-
26
- # Store the keyword argument mappings
27
- kwarg_mappings = instance_variable_get(:@__ivar_kwarg_mappings) || []
28
- instance_variable_set(:@__ivar_kwarg_mappings, kwarg_mappings + Array(kwarg))
29
-
30
- # Store the initialization block if provided
31
- instance_variable_set(:@__ivar_init_block, block) if block
32
- end
33
-
34
- # Hook method called when the module is included
35
- def inherited(subclass)
36
- super
37
- # Copy pre-declared instance variables to subclass
38
- parent_ivars = instance_variable_get(:@__ivar_pre_declared_ivars) || []
39
- subclass.instance_variable_set(:@__ivar_pre_declared_ivars, parent_ivars.dup)
40
-
41
- # Copy keyword argument mappings to subclass
42
- parent_kwarg_mappings = instance_variable_get(:@__ivar_kwarg_mappings) || []
43
- subclass.instance_variable_set(:@__ivar_kwarg_mappings, parent_kwarg_mappings.dup)
44
-
45
- # Copy initialization block to subclass
46
- parent_block = instance_variable_get(:@__ivar_init_block)
47
- subclass.instance_variable_set(:@__ivar_init_block, parent_block) if parent_block
48
- end
49
-
50
- # Get the pre-declared instance variables for this class
51
- # @return [Array<Symbol>] Pre-declared instance variables
52
- def ivar_pre_declared
53
- instance_variable_get(:@__ivar_pre_declared_ivars) || []
54
- end
55
-
56
- # Get the keyword argument mappings for this class
57
- # @return [Array<Symbol>] Keyword argument mappings
58
- def ivar_kwarg_mappings
59
- instance_variable_get(:@__ivar_kwarg_mappings) || []
60
- end
61
-
62
- # Get the initialization block for this class
63
- # @return [Proc, nil] The initialization block or nil if none was provided
64
- def ivar_init_block
65
- instance_variable_get(:@__ivar_init_block)
66
- end
67
- end
68
-
69
- # Module to pre-initialize instance variables
70
- module PreInitializeIvars
71
- # Initialize pre-declared instance variables to nil
72
- def initialize_pre_declared_ivars
73
- klass = self.class
74
- while klass.respond_to?(:ivar_pre_declared)
75
- klass.ivar_pre_declared.each do |ivar|
76
- instance_variable_set(ivar, nil) unless instance_variable_defined?(ivar)
11
+ # Get or create a manifest for this class
12
+ Ivar.get_or_create_manifest(base)
13
+ end
14
+
15
+ # Declares instance variables that should be considered valid
16
+ # without being explicitly initialized
17
+ # @param ivars [Array<Symbol>] Instance variables to declare
18
+ # @param value [Object] Optional value to initialize all declared variables with
19
+ # Example: ivar :@foo, :@bar, value: 123
20
+ # @param init [Symbol] Initialization method for the variable
21
+ # :kwarg or :keyword - initializes from a keyword argument with the same name
22
+ # Example: ivar :@foo, init: :kwarg
23
+ # @param reader [Boolean] If true, creates attr_reader for all declared variables
24
+ # Example: ivar :@foo, :@bar, reader: true
25
+ # @param writer [Boolean] If true, creates attr_writer for all declared variables
26
+ # Example: ivar :@foo, :@bar, writer: true
27
+ # @param accessor [Boolean] If true, creates attr_accessor for all declared variables
28
+ # Example: ivar :@foo, :@bar, accessor: true
29
+ # @param ivars_with_values [Hash] Individual initial values for instance variables
30
+ # Example: ivar "@foo": 123, "@bar": 456
31
+ # @yield [varname] Block to generate initial values based on variable name
32
+ # Example: ivar(:@foo, :@bar) { |varname| "#{varname} default" }
33
+ def ivar(*ivars, value: UNSET, init: nil, reader: false, writer: false, accessor: false, **ivars_with_values, &block)
34
+ manifest = Ivar.get_or_create_manifest(self)
35
+
36
+ ivar_hash = ivars.map { |ivar| [ivar, value] }.to_h.merge(ivars_with_values)
37
+
38
+ ivar_hash.each do |ivar_name, ivar_value|
39
+ raise ArgumentError, "ivars must be symbols (#{ivar_name.inspect})" unless ivar_name.is_a?(Symbol)
40
+ raise ArgumentError, "ivar names must start with @ (#{ivar_name.inspect})" unless /\A@/.match?(ivar_name)
41
+
42
+ options = {init:, value: ivar_value, reader:, writer:, accessor:, block:}
43
+
44
+ declaration = case init
45
+ when :kwarg, :keyword
46
+ Ivar::ExplicitKeywordDeclaration.new(ivar_name, manifest, options)
47
+ when :arg, :positional
48
+ # TODO: probably fail if a duplicate positional comes in
49
+ # There aren't any obvious semantics for it.
50
+ Ivar::ExplicitPositionalDeclaration.new(ivar_name, manifest, options)
51
+ when nil
52
+ Ivar::ExplicitDeclaration.new(ivar_name, manifest, options)
53
+ else
54
+ raise ArgumentError, "Invalid init method: #{init.inspect}"
77
55
  end
78
- klass = klass.superclass
79
- end
80
- end
81
-
82
- # Execute the initialization block in the context of the instance
83
- def execute_ivar_init_block
84
- klass = self.class
85
- while klass.respond_to?(:ivar_init_block)
86
- block = klass.ivar_init_block
87
- instance_eval(&block) if block
88
- klass = klass.superclass
89
- end
90
- end
91
-
92
- # Get all keyword argument mappings from the class hierarchy
93
- # @return [Array<Symbol>] All keyword argument mappings
94
- def all_kwarg_mappings
95
- mappings = []
96
- klass = self.class
97
- while klass.respond_to?(:ivar_kwarg_mappings)
98
- mappings.concat(klass.ivar_kwarg_mappings)
99
- klass = klass.superclass
56
+ manifest.add_explicit_declaration(declaration)
100
57
  end
101
- mappings
102
- end
103
-
104
- # Initialize instance variables from keyword arguments
105
- # @param kwargs [Hash] Keyword arguments
106
- # @return [Hash] Remaining keyword arguments
107
- def initialize_from_kwargs(kwargs)
108
- remaining_kwargs = kwargs.dup
109
-
110
- all_kwarg_mappings.each do |ivar|
111
- # Convert @ivar_name to ivar_name for keyword lookup
112
- key = ivar.to_s.delete_prefix("@").to_sym
113
-
114
- if remaining_kwargs.key?(key)
115
- instance_variable_set(ivar, remaining_kwargs[key])
116
- remaining_kwargs.delete(key)
117
- end
118
- end
119
-
120
- remaining_kwargs
121
58
  end
122
59
  end
123
60
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "declaration"
4
+ require_relative "explicit_declaration"
5
+ require_relative "explicit_keyword_declaration"
6
+ require_relative "explicit_positional_declaration"
7
+
8
+ module Ivar
9
+ # Represents a manifest of instance variable declarations for a class/module
10
+ class Manifest
11
+ # @return [Class, Module] The class or module this manifest is associated with
12
+ attr_reader :owner
13
+
14
+ # Initialize a new manifest
15
+ # @param owner [Class, Module] The class or module this manifest is associated with
16
+ def initialize(owner)
17
+ @owner = owner
18
+ @declarations_by_name = {}
19
+ end
20
+
21
+ # @return [Hash<Symbol, Declaration>] The declarations hash keyed by variable name
22
+ attr_reader :declarations_by_name
23
+
24
+ # @return [Array<Declaration>] The declarations in this manifest
25
+ def declarations
26
+ @declarations_by_name.values
27
+ end
28
+
29
+ # Add an explicit declaration to the manifest
30
+ # @param declaration [ExplicitDeclaration] The declaration to add
31
+ # @return [ExplicitDeclaration] The added declaration
32
+ def add_explicit_declaration(declaration)
33
+ name = declaration.name
34
+ @declarations_by_name[name] = declaration
35
+ declaration.on_declare(@owner)
36
+ declaration
37
+ end
38
+
39
+ # Get all ancestor manifests in reverse order (from highest to lowest in the hierarchy)
40
+ # Only includes ancestors that have existing manifests
41
+ # @return [Array<Manifest>] Array of ancestor manifests
42
+ def ancestor_manifests
43
+ return [] unless @owner.respond_to?(:ancestors)
44
+
45
+ @owner
46
+ .ancestors.reject { |ancestor| ancestor == @owner }
47
+ .filter_map { |ancestor| Ivar.get_manifest(ancestor, create: false) }
48
+ .reverse
49
+ end
50
+
51
+ def explicitly_declared_ivars
52
+ all_declarations.grep(ExplicitDeclaration).map(&:name)
53
+ end
54
+
55
+ # Get all declarations, including those from ancestor manifests
56
+ # @return [Array<Declaration>] All declarations
57
+ def all_declarations
58
+ ancestor_manifests
59
+ .flat_map(&:declarations)
60
+ .+(declarations)
61
+ # use hash stores to preserve order and deduplicate by name
62
+ .each_with_object({}) { |decl, acc| acc[decl.name] = decl }
63
+ .values
64
+ end
65
+
66
+ # Check if a variable is declared in this manifest or ancestor manifests
67
+ # @param name [Symbol, String] The variable name
68
+ # @return [Boolean] Whether the variable is declared
69
+ def declared?(name)
70
+ name = name.to_sym
71
+
72
+ # Check in this manifest first
73
+ return true if @declarations_by_name.key?(name)
74
+
75
+ # Then check in ancestor manifests
76
+ ancestor_manifests.any? do |ancestor_manifest|
77
+ ancestor_manifest.declarations_by_name.key?(name)
78
+ end
79
+ end
80
+
81
+ # Get a declaration by name
82
+ # @param name [Symbol, String] The variable name
83
+ # @return [Declaration, nil] The declaration, or nil if not found
84
+ def get_declaration(name)
85
+ name = name.to_sym
86
+
87
+ # Check in this manifest first
88
+ return @declarations_by_name[name] if @declarations_by_name.key?(name)
89
+
90
+ # Then check in ancestor manifests, starting from the closest ancestor
91
+ ancestor_manifests.each do |ancestor_manifest|
92
+ if ancestor_manifest.declarations_by_name.key?(name)
93
+ return ancestor_manifest.declarations_by_name[name]
94
+ end
95
+ end
96
+
97
+ nil
98
+ end
99
+
100
+ # Get all explicit declarations
101
+ # @return [Array<ExplicitDeclaration>] All explicit declarations
102
+ def explicit_declarations
103
+ declarations.select { |decl| decl.is_a?(ExplicitDeclaration) }
104
+ end
105
+
106
+ # Process before_init callbacks for all declarations
107
+ # @param instance [Object] The object being initialized
108
+ # @param args [Array] Positional arguments
109
+ # @param kwargs [Hash] Keyword arguments
110
+ # @return [Array, Hash] The modified args and kwargs
111
+ def process_before_init(instance, args, kwargs)
112
+ # Get all declarations from parent to child, with child declarations taking precedence
113
+ declarations_to_process = all_declarations
114
+
115
+ # Process all initializations in a single pass
116
+ # The before_init method will handle keyword arguments with proper precedence
117
+ declarations_to_process.each do |declaration|
118
+ declaration.before_init(instance, args, kwargs)
119
+ end
120
+
121
+ [args, kwargs]
122
+ end
123
+ end
124
+ end
data/lib/ivar/policies.rb CHANGED
@@ -115,12 +115,24 @@ module Ivar
115
115
  end
116
116
  end
117
117
 
118
+ # Policy that does nothing (no-op) for unknown instance variables
119
+ class NonePolicy < Policy
120
+ # Handle unknown instance variables by doing nothing
121
+ # @param unknown_refs [Array<Hash>] References to unknown instance variables
122
+ # @param klass [Class] The class being checked
123
+ # @param allowed_ivars [Array<Symbol>] List of allowed instance variables
124
+ def handle_unknown_ivars(_unknown_refs, _klass, _allowed_ivars)
125
+ # No-op - do nothing
126
+ end
127
+ end
128
+
118
129
  # Map of policy symbols to policy classes
119
130
  POLICY_CLASSES = {
120
131
  warn: WarnPolicy,
121
132
  warn_once: WarnOncePolicy,
122
133
  raise: RaisePolicy,
123
- log: LogPolicy
134
+ log: LogPolicy,
135
+ none: NonePolicy
124
136
  }.freeze
125
137
 
126
138
  # Get a policy instance from a symbol or policy object
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ivar
6
+ # Handles project root detection and caching
7
+ class ProjectRoot
8
+ # Project root indicator files, in order of precedence
9
+ INDICATORS = %w[Gemfile .git .ruby-version Rakefile].freeze
10
+
11
+ def initialize
12
+ @cache = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Determines the project root directory based on the caller's location
17
+ # @param caller_location [String, nil] Optional file path to start from (defaults to caller's location)
18
+ # @return [String] The absolute path to the project root directory
19
+ def find(caller_location = nil)
20
+ file_path = caller_location || caller_locations(2, 1).first&.path
21
+ return Dir.pwd unless file_path
22
+
23
+ @mutex.synchronize do
24
+ return @cache[file_path] if @cache.key?(file_path)
25
+ end
26
+
27
+ dir = File.dirname(File.expand_path(file_path))
28
+ root = find_project_root(dir)
29
+
30
+ @mutex.synchronize do
31
+ @cache[file_path] = root
32
+ end
33
+
34
+ root
35
+ end
36
+
37
+ # Clear the cache (mainly for testing)
38
+ def clear_cache
39
+ @mutex.synchronize { @cache.clear }
40
+ end
41
+
42
+ private
43
+
44
+ # Find the project root by walking up the directory tree
45
+ # @param start_dir [String] Directory to start the search from
46
+ # @return [String] The project root directory
47
+ def find_project_root(start_dir)
48
+ path = Pathname.new(start_dir)
49
+
50
+ path.ascend do |dir|
51
+ INDICATORS.each do |indicator|
52
+ return dir.to_s if dir.join(indicator).exist?
53
+ end
54
+ end
55
+
56
+ start_dir
57
+ end
58
+ end
59
+ end