docscribe 1.1.0 → 1.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +662 -187
  3. data/exe/docscribe +2 -126
  4. data/lib/docscribe/cli/config_builder.rb +62 -0
  5. data/lib/docscribe/cli/init.rb +58 -0
  6. data/lib/docscribe/cli/options.rb +204 -0
  7. data/lib/docscribe/cli/run.rb +415 -0
  8. data/lib/docscribe/cli.rb +31 -0
  9. data/lib/docscribe/config/defaults.rb +71 -0
  10. data/lib/docscribe/config/emit.rb +126 -0
  11. data/lib/docscribe/config/filtering.rb +160 -0
  12. data/lib/docscribe/config/loader.rb +59 -0
  13. data/lib/docscribe/config/rbs.rb +51 -0
  14. data/lib/docscribe/config/sorbet.rb +87 -0
  15. data/lib/docscribe/config/sorting.rb +23 -0
  16. data/lib/docscribe/config/template.rb +176 -0
  17. data/lib/docscribe/config/utils.rb +102 -0
  18. data/lib/docscribe/config.rb +20 -230
  19. data/lib/docscribe/infer/ast_walk.rb +28 -0
  20. data/lib/docscribe/infer/constants.rb +11 -0
  21. data/lib/docscribe/infer/literals.rb +55 -0
  22. data/lib/docscribe/infer/names.rb +43 -0
  23. data/lib/docscribe/infer/params.rb +62 -0
  24. data/lib/docscribe/infer/raises.rb +68 -0
  25. data/lib/docscribe/infer/returns.rb +171 -0
  26. data/lib/docscribe/infer.rb +104 -258
  27. data/lib/docscribe/inline_rewriter/collector.rb +845 -0
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +383 -0
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +605 -0
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +228 -0
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +244 -0
  32. data/lib/docscribe/inline_rewriter.rb +599 -428
  33. data/lib/docscribe/parsing.rb +55 -44
  34. data/lib/docscribe/types/provider_chain.rb +37 -0
  35. data/lib/docscribe/types/rbs/provider.rb +213 -0
  36. data/lib/docscribe/types/rbs/type_formatter.rb +132 -0
  37. data/lib/docscribe/types/signature.rb +65 -0
  38. data/lib/docscribe/types/sorbet/base_provider.rb +217 -0
  39. data/lib/docscribe/types/sorbet/rbi_provider.rb +35 -0
  40. data/lib/docscribe/types/sorbet/source_provider.rb +25 -0
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +37 -3
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Decide whether a file path should be processed based on `filter.files`.
6
+ #
7
+ # File paths are matched relative to the current working directory when possible.
8
+ # Exclude rules win. If no include rules are configured, files are included by default.
9
+ #
10
+ # @param [String] path file path to test
11
+ # @raise [StandardError]
12
+ # @return [Boolean]
13
+ def process_file?(path)
14
+ files = raw.dig('filter', 'files') || {}
15
+ include_patterns = normalize_file_patterns(files['include'])
16
+ exclude_patterns = normalize_file_patterns(files['exclude'])
17
+
18
+ rel = begin
19
+ Pathname.new(path).relative_path_from(Pathname.pwd).to_s
20
+ rescue StandardError
21
+ path
22
+ end
23
+
24
+ return false if file_matches_any?(exclude_patterns, rel)
25
+ return true if include_patterns.empty?
26
+
27
+ file_matches_any?(include_patterns, rel)
28
+ end
29
+
30
+ # Decide whether a method should be processed based on configured method filters.
31
+ #
32
+ # Method IDs are normalized as:
33
+ # - instance method => `MyModule::MyClass#foo`
34
+ # - class method => `MyModule::MyClass.foo`
35
+ #
36
+ # Exclude rules win. If no include rules are configured, methods are included by default
37
+ # subject to scope and visibility allow-lists.
38
+ #
39
+ # @param [String] container enclosing class/module name
40
+ # @param [Symbol] scope :instance or :class
41
+ # @param [Symbol] visibility :public, :protected, or :private
42
+ # @param [String, Symbol] name method name
43
+ # @return [Boolean]
44
+ def process_method?(container:, scope:, visibility:, name:)
45
+ return false unless filter_scopes.include?(scope.to_s)
46
+ return false unless filter_visibilities.include?(visibility.to_s)
47
+
48
+ method_id = "#{container}#{scope == :instance ? '#' : '.'}#{name}"
49
+
50
+ return false if matches_any?(filter_exclude_patterns, method_id)
51
+
52
+ inc = filter_include_patterns
53
+ return true if inc.empty?
54
+
55
+ matches_any?(inc, method_id)
56
+ end
57
+
58
+ private
59
+
60
+ # Normalize file filter patterns:
61
+ # - compact nils
62
+ # - stringify
63
+ # - remove empties
64
+ # - expand shorthand directory forms
65
+ #
66
+ # @private
67
+ # @param [Array<String>, nil] list raw pattern list
68
+ # @return [Array<String>]
69
+ def normalize_file_patterns(list)
70
+ Array(list).compact.map(&:to_s).reject(&:empty?).flat_map { |pat| expand_directory_shorthand(pat) }.uniq
71
+ end
72
+
73
+ # Expand a directory-like pattern into a recursive glob when appropriate.
74
+ #
75
+ # Examples:
76
+ # - `"spec/"` => `"spec/**/*"`
77
+ # - `"spec"` => `"spec/**/*"` if `spec` exists as a directory
78
+ #
79
+ # @private
80
+ # @param [String] pattern
81
+ # @return [Array<String>]
82
+ def expand_directory_shorthand(pattern)
83
+ pat = pattern.dup
84
+
85
+ if pat.end_with?('/')
86
+ ["#{pat}**/*"]
87
+ elsif !pat.match?(/[*?\[]|{/) && File.directory?(pat)
88
+ ["#{pat}/**/*"]
89
+ else
90
+ [pat]
91
+ end
92
+ end
93
+
94
+ # Check whether a file path matches any configured file pattern.
95
+ #
96
+ # @private
97
+ # @param [Array<String>] patterns
98
+ # @param [String] path
99
+ # @return [Boolean]
100
+ def file_matches_any?(patterns, path)
101
+ patterns.any? { |pat| file_match_pattern?(pat, path) }
102
+ end
103
+
104
+ # Match a file path against a single configured file filter.
105
+ #
106
+ # Supports:
107
+ # - `/regex/`
108
+ # - globs
109
+ # - recursive glob shorthand normalization
110
+ #
111
+ # @private
112
+ # @param [String] pattern
113
+ # @param [String] path
114
+ # @return [Boolean]
115
+ def file_match_pattern?(pattern, path)
116
+ if pattern.start_with?('/') && pattern.end_with?('/') && pattern.length >= 2
117
+ return Regexp.new(pattern[1..-2]).match?(path)
118
+ end
119
+
120
+ patterns_to_try = [pattern]
121
+ patterns_to_try << pattern.gsub('/**/', '/') if pattern.include?('/**/')
122
+
123
+ patterns_to_try.any? do |pat|
124
+ File.fnmatch?(pat, path, File::FNM_EXTGLOB | File::FNM_PATHNAME)
125
+ end
126
+ end
127
+
128
+ # Allowed method scopes from config/defaults.
129
+ #
130
+ # @private
131
+ # @return [Array<String>]
132
+ def filter_scopes
133
+ Array(raw.dig('filter', 'scopes') || DEFAULT.dig('filter', 'scopes')).map(&:to_s)
134
+ end
135
+
136
+ # Allowed method visibilities from config/defaults.
137
+ #
138
+ # @private
139
+ # @return [Array<String>]
140
+ def filter_visibilities
141
+ Array(raw.dig('filter', 'visibilities') || DEFAULT.dig('filter', 'visibilities')).map(&:to_s)
142
+ end
143
+
144
+ # Exclude method filter patterns.
145
+ #
146
+ # @private
147
+ # @return [Array<String>]
148
+ def filter_exclude_patterns
149
+ Array(raw.dig('filter', 'exclude') || DEFAULT.dig('filter', 'exclude')).map(&:to_s).reject(&:empty?)
150
+ end
151
+
152
+ # Include method filter patterns.
153
+ #
154
+ # @private
155
+ # @return [Array<String>]
156
+ def filter_include_patterns
157
+ Array(raw.dig('filter', 'include') || DEFAULT.dig('filter', 'include')).map(&:to_s).reject(&:empty?)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Load Docscribe configuration from YAML.
6
+ #
7
+ # Resolution order:
8
+ # - explicit `path`, if it exists
9
+ # - `docscribe.yml` in the current directory, if present
10
+ # - otherwise defaults only
11
+ #
12
+ # @param [String, nil] path optional config path
13
+ # @return [Docscribe::Config]
14
+ def self.load(path = nil)
15
+ raw = {}
16
+ if path && File.file?(path)
17
+ raw = safe_load_file_compat(path)
18
+ elsif File.file?('docscribe.yml')
19
+ raw = safe_load_file_compat('docscribe.yml')
20
+ end
21
+ new(raw)
22
+ end
23
+
24
+ # Safely load YAML from a file across Ruby/Psych versions.
25
+ #
26
+ # Uses `YAML.safe_load_file` when available, otherwise falls back to reading the file
27
+ # and calling {safe_load_compat}.
28
+ #
29
+ # @param [String] path file path
30
+ # @return [Hash]
31
+ def self.safe_load_file_compat(path)
32
+ if YAML.respond_to?(:safe_load_file)
33
+ YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: true) || {}
34
+ else
35
+ yaml = File.open(path, 'r:bom|utf-8', &:read)
36
+ safe_load_compat(yaml, filename: path) || {}
37
+ end
38
+ end
39
+
40
+ # Safely load YAML from a string across Psych API versions.
41
+ #
42
+ # @param [String] yaml YAML document
43
+ # @param [String, nil] filename optional filename for diagnostics
44
+ # @raise [ArgumentError]
45
+ # @return [Hash]
46
+ def self.safe_load_compat(yaml, filename: nil)
47
+ Psych.safe_load(
48
+ yaml,
49
+ permitted_classes: [],
50
+ permitted_symbols: [],
51
+ aliases: true,
52
+ filename: filename
53
+ )
54
+ rescue ArgumentError
55
+ # Older Psych signature uses positional args
56
+ Psych.safe_load(yaml, [], [], true, filename)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Return a memoized RBS provider if RBS integration is enabled and available.
6
+ #
7
+ # If RBS cannot be loaded, this returns nil and Docscribe falls back to
8
+ # inference.
9
+ #
10
+ # @raise [LoadError]
11
+ # @return [Docscribe::Types::RBS::Provider, nil]
12
+ def rbs_provider
13
+ return nil unless rbs_enabled?
14
+
15
+ @rbs_provider ||= begin
16
+ require 'docscribe/types/rbs/provider'
17
+ Docscribe::Types::RBS::Provider.new(
18
+ sig_dirs: rbs_sig_dirs,
19
+ collapse_generics: rbs_collapse_generics?
20
+ )
21
+ rescue LoadError
22
+ nil
23
+ end
24
+ end
25
+
26
+ # Whether RBS integration is enabled.
27
+ #
28
+ # @return [Boolean]
29
+ def rbs_enabled?
30
+ fetch_bool(%w[rbs enabled], false)
31
+ end
32
+
33
+ # Signature directories used by the RBS provider.
34
+ #
35
+ # @return [Array<String>]
36
+ def rbs_sig_dirs
37
+ Array(raw.dig('rbs', 'sig_dirs') || DEFAULT.dig('rbs', 'sig_dirs')).map(&:to_s)
38
+ end
39
+
40
+ # Whether generic RBS types should be collapsed to simpler container names.
41
+ #
42
+ # Examples:
43
+ # - `Hash<Symbol, String>` => `Hash`
44
+ # - `Array<Integer>` => `Array`
45
+ #
46
+ # @return [Boolean]
47
+ def rbs_collapse_generics?
48
+ fetch_bool(%w[rbs collapse_generics], false)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Build the effective external signature provider chain for a given source.
6
+ #
7
+ # Provider precedence is:
8
+ # 1. inline Sorbet signatures from the current source
9
+ # 2. Sorbet RBI files
10
+ # 3. RBS files
11
+ #
12
+ # Returns nil when no external type provider is enabled or available.
13
+ #
14
+ # @param [String] source Ruby source being rewritten
15
+ # @param [String] file source name for diagnostics
16
+ # @raise [LoadError]
17
+ # @return [Docscribe::Types::ProviderChain, nil]
18
+ def signature_provider_for(source:, file:)
19
+ providers = []
20
+
21
+ if sorbet_enabled?
22
+ begin
23
+ require 'docscribe/types/sorbet/source_provider'
24
+ providers << Docscribe::Types::Sorbet::SourceProvider.new(
25
+ source: source,
26
+ file: file,
27
+ collapse_generics: sorbet_collapse_generics?
28
+ )
29
+ rescue LoadError
30
+ # Sorbet support is optional; fall back quietly.
31
+ end
32
+
33
+ providers << sorbet_rbi_provider
34
+ end
35
+
36
+ providers << rbs_provider if rbs_enabled?
37
+
38
+ providers = providers.compact
39
+ return nil if providers.empty?
40
+
41
+ require 'docscribe/types/provider_chain'
42
+ Docscribe::Types::ProviderChain.new(*providers)
43
+ end
44
+
45
+ # Return a memoized Sorbet RBI provider if Sorbet integration is enabled.
46
+ #
47
+ # @raise [LoadError]
48
+ # @return [Docscribe::Types::Sorbet::RBIProvider, nil]
49
+ def sorbet_rbi_provider
50
+ return nil unless sorbet_enabled?
51
+
52
+ @sorbet_rbi_provider ||= begin
53
+ require 'docscribe/types/sorbet/rbi_provider'
54
+ Docscribe::Types::Sorbet::RBIProvider.new(
55
+ rbi_dirs: sorbet_rbi_dirs,
56
+ collapse_generics: sorbet_collapse_generics?
57
+ )
58
+ rescue LoadError
59
+ nil
60
+ end
61
+ end
62
+
63
+ # Whether Sorbet support is enabled in config.
64
+ #
65
+ # @return [Boolean]
66
+ def sorbet_enabled?
67
+ fetch_bool(%w[sorbet enabled], false)
68
+ end
69
+
70
+ # RBI directories searched by the Sorbet provider.
71
+ #
72
+ # @return [Array<String>]
73
+ def sorbet_rbi_dirs
74
+ Array(raw.dig('sorbet', 'rbi_dirs') || DEFAULT.dig('sorbet', 'rbi_dirs')).map(&:to_s)
75
+ end
76
+
77
+ # Whether generic Sorbet/RBI container types should be simplified.
78
+ #
79
+ # Falls back to the RBS `collapse_generics` setting when Sorbet-specific
80
+ # config is not present.
81
+ #
82
+ # @return [Boolean]
83
+ def sorbet_collapse_generics?
84
+ fetch_bool(%w[sorbet collapse_generics], rbs_collapse_generics?)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Whether sortable tag normalization is enabled for doc-like blocks.
6
+ #
7
+ # @return [Boolean]
8
+ def sort_tags?
9
+ raw.dig('doc', 'sort_tags') != false
10
+ end
11
+
12
+ # Configured sortable tag order.
13
+ #
14
+ # Tags are normalized without a leading `@`.
15
+ #
16
+ # @return [Array<String>]
17
+ def tag_order
18
+ Array(raw.dig('doc', 'tag_order') || DEFAULT.dig('doc', 'tag_order')).map do |t|
19
+ t.to_s.sub(/\A@/, '')
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ # Return the default YAML template used by `docscribe init`.
6
+ #
7
+ # The template documents the most common CLI workflows and all supported
8
+ # configuration sections with comments.
9
+ # @see Docscribe::Config::DEFAULT
10
+ #
11
+ # @return [String]
12
+ def self.default_yaml
13
+ <<~YAML
14
+ ---
15
+ # Docscribe configuration file
16
+ #
17
+ # Inspect what safe doc updates would be applied:
18
+ # bundle exec docscribe lib
19
+ #
20
+ # Apply safe doc updates:
21
+ # bundle exec docscribe -a lib
22
+ #
23
+ # Apply aggressive doc updates (rebuild existing doc blocks):
24
+ # bundle exec docscribe -A lib
25
+ #
26
+
27
+ emit:
28
+ # Emit the header line:
29
+ #
30
+ # +MyClass#my_method+ -> ReturnType
31
+ header: true
32
+
33
+ # Emit @param tags.
34
+ param_tags: true
35
+
36
+ # Emit @return tag (can be overridden per scope/visibility under methods:).
37
+ return_tag: true
38
+
39
+ # Emit @private / @protected tags based on Ruby visibility context.
40
+ visibility_tags: true
41
+
42
+ # Emit @raise tags inferred from rescue clauses / raise/fail calls.
43
+ raise_tags: true
44
+
45
+ # Emit conditional rescue return tags:
46
+ #
47
+ # @return [String] if FooError, BarError
48
+ rescue_conditional_returns: true
49
+
50
+ # Generate @!attribute docs for attr_reader/attr_writer/attr_accessor.
51
+ attributes: false
52
+
53
+ doc:
54
+ # Default text inserted into each generated doc block.
55
+ default_message: "Method documentation."
56
+
57
+ # Default text appended to generated @param tags.
58
+ param_documentation: "Param documentation."
59
+
60
+ # Style for generated @param tags:
61
+ # - type_name => @param [Type] name
62
+ # - name_type => @param name [Type]
63
+ param_tag_style: "type_name"
64
+
65
+ # Sort generated / merged tags in safe mode when possible.
66
+ sort_tags: true
67
+
68
+ # Tag order used when sorting contiguous tag runs.
69
+ tag_order: ["todo", "note", "api", "private", "protected", "param", "option", "yieldparam", "raise", "return"]
70
+
71
+ methods:
72
+ # Per-scope / per-visibility overrides.
73
+ #
74
+ # Example:
75
+ # methods:
76
+ # instance:
77
+ # public:
78
+ # default_message: "Public API."
79
+ # return_tag: true
80
+ instance:
81
+ public: {}
82
+ protected: {}
83
+ private: {}
84
+
85
+ class:
86
+ public: {}
87
+ protected: {}
88
+ private: {}
89
+
90
+ inference:
91
+ # Type used when inference is uncertain.
92
+ fallback_type: "Object"
93
+
94
+ # Whether nil unions become optional types (for example String | nil => String?).
95
+ nil_as_optional: true
96
+
97
+ # Special-case: treat keyword arg named options/options: as a Hash.
98
+ treat_options_keyword_as_hash: true
99
+
100
+ filter:
101
+ # Filter which methods Docscribe touches.
102
+ #
103
+ # Method id format:
104
+ # instance: "MyModule::MyClass#instance_method"
105
+ # class: "MyModule::MyClass.class_method"
106
+ #
107
+ # Patterns:
108
+ # - glob: "*#initialize", "MyApp::*#*"
109
+ # - regex: "/^MyApp::.*#(foo|bar)$/"
110
+ #
111
+ # Semantics:
112
+ # - scopes / visibilities act as allow-lists
113
+ # - exclude wins
114
+ # - if include is empty => include everything (subject to allow-lists)
115
+ visibilities: ["public", "protected", "private"]
116
+ scopes: ["instance", "class"]
117
+ include: []
118
+ exclude: []
119
+
120
+ files:
121
+ # Filter which files Docscribe processes (paths are matched relative
122
+ # to the project root).
123
+ #
124
+ # Tips:
125
+ # - Use directory shorthand to exclude a whole directory:
126
+ # exclude: ["spec"]
127
+ # - Or use globs:
128
+ # exclude: ["spec/**/*.rb", "vendor/**/*.rb"]
129
+ include: []
130
+ exclude: ["spec"]
131
+
132
+ rbs:
133
+ # Optional: use RBS signatures to improve @param / @return types.
134
+ #
135
+ # CLI equivalent:
136
+ # bundle exec docscribe -a --rbs --sig-dir sig lib
137
+ #
138
+ # Under Bundler, you may need `gem "rbs"` in your Gemfile (or a
139
+ # Gemfile that includes it), otherwise `require "rbs"` may fail and
140
+ # Docscribe will fall back to inference.
141
+ enabled: false
142
+
143
+ # Signature directories (repeatable via --sig-dir).
144
+ sig_dirs: ["sig"]
145
+
146
+ # If true, simplify generic types:
147
+ # - Hash<Symbol, String> => Hash
148
+ # - Array<Integer> => Array
149
+ collapse_generics: false
150
+
151
+ sorbet:
152
+ # Optional: use Sorbet signatures from inline `sig` declarations and
153
+ # RBI files to improve @param / @return types.
154
+ #
155
+ # CLI equivalent:
156
+ # bundle exec docscribe -a --sorbet --rbi-dir sorbet/rbi lib
157
+ #
158
+ # Sorbet resolution order is:
159
+ # 1. inline `sig` in the current source file
160
+ # 2. RBI files
161
+ # 3. RBS
162
+ # 4. AST inference
163
+ enabled: false
164
+
165
+ # RBI directories scanned recursively for `.rbi` files
166
+ # (repeatable via --rbi-dir).
167
+ rbi_dirs: ["sorbet/rbi", "rbi"]
168
+
169
+ # If true, simplify generic types:
170
+ # - Hash<Symbol, String> => Hash
171
+ # - Array<Integer> => Array
172
+ collapse_generics: false
173
+ YAML
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ class Config
5
+ private
6
+
7
+ # Fetch a boolean method-level override for a given scope/visibility pair.
8
+ #
9
+ # @private
10
+ # @param [Symbol] scope :instance or :class
11
+ # @param [Symbol] vis :public, :protected, or :private
12
+ # @param [String] key override key
13
+ # @param [Boolean] default fallback value
14
+ # @return [Boolean]
15
+ def method_override_bool(scope, vis, key, default:)
16
+ node = raw.dig('methods', scope_to_key(scope), vis.to_s, key)
17
+ node.nil? ? default : !!node
18
+ end
19
+
20
+ # Fetch a string method-level override for a given scope/visibility pair.
21
+ #
22
+ # @private
23
+ # @param [Symbol] scope :instance or :class
24
+ # @param [Symbol] vis :public, :protected, or :private
25
+ # @param [String] key override key
26
+ # @param [String] default fallback value
27
+ # @return [String]
28
+ def method_override_str(scope, vis, key, default:)
29
+ node = raw.dig('methods', scope_to_key(scope), vis.to_s, key)
30
+ node.nil? ? default : node.to_s
31
+ end
32
+
33
+ # Fetch a boolean config value by nested path with a default fallback.
34
+ #
35
+ # @private
36
+ # @param [Array<String>] path nested config keys
37
+ # @param [Boolean] default fallback value
38
+ # @return [Boolean]
39
+ def fetch_bool(path, default)
40
+ node = raw
41
+ path.each { |k| node = node[k] if node }
42
+ node.nil? ? default : !!node
43
+ end
44
+
45
+ # Convert an internal scope symbol into the config key used under `methods`.
46
+ #
47
+ # @private
48
+ # @param [Symbol] scope
49
+ # @return [String]
50
+ def scope_to_key(scope)
51
+ scope == :class ? 'class' : 'instance'
52
+ end
53
+
54
+ # Check whether any pattern matches the given text.
55
+ #
56
+ # @private
57
+ # @param [Array<String>] patterns
58
+ # @param [String] text
59
+ # @return [Boolean]
60
+ def matches_any?(patterns, text)
61
+ patterns.any? { |pat| match_pattern?(pat, text) }
62
+ end
63
+
64
+ # Match a method filter pattern against a method ID.
65
+ #
66
+ # Supports:
67
+ # - `/regex/`
68
+ # - shell-style glob patterns
69
+ #
70
+ # @private
71
+ # @param [String] pattern
72
+ # @param [String] text
73
+ # @return [Boolean]
74
+ def match_pattern?(pattern, text)
75
+ if pattern.start_with?('/') && pattern.end_with?('/') && pattern.length >= 2
76
+ Regexp.new(pattern[1..-2]).match?(text)
77
+ else
78
+ File.fnmatch?(pattern, text, File::FNM_EXTGLOB)
79
+ end
80
+ end
81
+
82
+ # Deep-merge two hashes, preferring values from the second hash.
83
+ #
84
+ # Nested hashes are merged recursively; non-hash values are replaced.
85
+ #
86
+ # @private
87
+ # @param [Hash] hash1 base hash
88
+ # @param [Hash, nil] hash2 override hash
89
+ # @return [Hash]
90
+ def deep_merge(hash1, hash2)
91
+ return hash1 unless hash2
92
+
93
+ hash1.merge(hash2) do |_, v1, v2|
94
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
95
+ deep_merge(v1, v2)
96
+ else
97
+ v2
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end