ruby-lsp 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed62114ae68bf84cf95f96a2517bc2e729ebaf572500b3871f253bc4b584d7ba
4
- data.tar.gz: d34dd2719e3b8088f59f4c38988b3fa7058c866a0d34b8b0b0ea391e18ec64d2
3
+ metadata.gz: 3dd7fa2d26430cf1974e1d94bbfa860166dd964ffe9268d9b307b323baa86954
4
+ data.tar.gz: 6183306fd3d34396de880184fe9a09cdafccd6cb071b7927e458f658d9397221
5
5
  SHA512:
6
- metadata.gz: d9d7bdf1a6ce4d3a7711a0032236d25d3b79682239f801354c15c01661a8e3b1110be90dd503e1d75dcd442520893d6cc852ef5a1eaeeb5a0834305a1c3350c7
7
- data.tar.gz: f55f82e138cb65fb7450afe86092d294bca06bcd6043439723567ed6538a1b373eed10ba5f3702e9d17f16fc4faa6b5447c048fb5683388e52bea1588d23dc63
6
+ metadata.gz: ed5e0524b0001497cd857dad621a6b072434375ef2be8583e12ddfcd2b7d104b279ab7ea026db09e563ddd908d7d575da682bf6a30244cd6aca68da6b4ee2ad6
7
+ data.tar.gz: 8de1bdb11dfb12c34129e3fddeae89c6b3700a74cb6483424523df9584a28a8b52499fade45d7c7c047ee517ed53a5a4f54b9fcc04a386a17b092999a264563a
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.0
1
+ 0.9.0
data/exe/ruby-lsp CHANGED
@@ -1,6 +1,46 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "optparse"
5
+
6
+ options = {}
7
+ parser = OptionParser.new do |opts|
8
+ opts.banner = "Usage: ruby-lsp [options]"
9
+
10
+ opts.on("--version", "Print ruby-lsp version") do
11
+ require "ruby-lsp"
12
+ puts RubyLsp::VERSION
13
+ exit(0)
14
+ end
15
+
16
+ opts.on("--debug", "Launch the Ruby LSP with a debugger attached") do
17
+ options[:debug] = true
18
+ end
19
+
20
+ opts.on(
21
+ "--branch [BRANCH]",
22
+ "Launch the Ruby LSP using the specified branch rather than the release version",
23
+ ) do |branch|
24
+ options[:branch] = branch
25
+ end
26
+
27
+ opts.on("-h", "--help", "Print this help") do
28
+ puts opts.help
29
+ puts
30
+ puts "See https://shopify.github.io/ruby-lsp/ for more information"
31
+ exit(0)
32
+ end
33
+ end
34
+
35
+ begin
36
+ parser.parse
37
+ rescue OptionParser::InvalidOption => e
38
+ warn(e)
39
+ warn("")
40
+ warn(parser.help)
41
+ exit(1)
42
+ end
43
+
4
44
  # When we're running without bundler, then we need to make sure the custom bundle is fully configured and re-execute
5
45
  # using `BUNDLE_GEMFILE=.ruby-lsp/Gemfile bundle exec ruby-lsp` so that we have access to the gems that are a part of
6
46
  # the application's bundle
@@ -8,7 +48,7 @@ if ENV["BUNDLE_GEMFILE"].nil?
8
48
  require_relative "../lib/ruby_lsp/setup_bundler"
9
49
 
10
50
  begin
11
- bundle_gemfile, bundle_path = RubyLsp::SetupBundler.new(Dir.pwd).setup!
51
+ bundle_gemfile, bundle_path = RubyLsp::SetupBundler.new(Dir.pwd, branch: options[:branch]).setup!
12
52
  rescue RubyLsp::SetupBundler::BundleNotLocked
13
53
  warn("Project contains a Gemfile, but no Gemfile.lock. Run `bundle install` to lock gems and restart the server")
14
54
  exit(78)
@@ -38,38 +78,6 @@ end
38
78
 
39
79
  require_relative "../lib/ruby_lsp/internal"
40
80
 
41
- require "optparse"
42
-
43
- options = {}
44
- parser = OptionParser.new do |opts|
45
- opts.banner = "Usage: ruby-lsp [options]"
46
-
47
- opts.on("--version", "Print ruby-lsp version") do
48
- puts RubyLsp::VERSION
49
- exit(0)
50
- end
51
-
52
- opts.on("--debug", "Launch the Ruby LSP with a debugger attached") do
53
- options[:debug] = true
54
- end
55
-
56
- opts.on("-h", "--help", "Print this help") do
57
- puts opts.help
58
- puts
59
- puts "See https://shopify.github.io/ruby-lsp/ for more information"
60
- exit(0)
61
- end
62
- end
63
-
64
- begin
65
- parser.parse!
66
- rescue OptionParser::InvalidOption => e
67
- warn(e)
68
- warn("")
69
- warn(parser.help)
70
- exit(1)
71
- end
72
-
73
81
  if options[:debug]
74
82
  if ["x64-mingw-ucrt", "x64-mingw32"].include?(RUBY_PLATFORM)
75
83
  puts "Debugging is not supported on Windows"
data/lib/core_ext/uri.rb CHANGED
@@ -6,16 +6,16 @@ module URI
6
6
  class << self
7
7
  extend T::Sig
8
8
 
9
- sig { params(path: String, scheme: String).returns(URI::Generic) }
10
- def from_path(path:, scheme: "file")
9
+ sig { params(path: String, fragment: T.nilable(String), scheme: String).returns(URI::Generic) }
10
+ def from_path(path:, fragment: nil, scheme: "file")
11
11
  # On Windows, if the path begins with the disk name, we need to add a leading slash to make it a valid URI
12
- escaped_path = if /^[A-Z]:/.match?(path)
12
+ escaped_path = if /^[A-Z]:/i.match?(path)
13
13
  DEFAULT_PARSER.escape("/#{path}")
14
14
  else
15
15
  DEFAULT_PARSER.escape(path)
16
16
  end
17
17
 
18
- build(scheme: scheme, path: escaped_path)
18
+ build(scheme: scheme, path: escaped_path, fragment: fragment)
19
19
  end
20
20
  end
21
21
 
@@ -26,20 +26,15 @@ module URI
26
26
  parsed_path = path
27
27
  return unless parsed_path
28
28
 
29
+ unescaped_path = DEFAULT_PARSER.unescape(parsed_path)
30
+
29
31
  # On Windows, when we're getting the file system path back from the URI, we need to remove the leading forward
30
32
  # slash
31
- actual_path = if %r{^/[A-Z]:}.match?(parsed_path)
32
- parsed_path.delete_prefix("/")
33
+ if %r{^/[A-Z]:}i.match?(unescaped_path)
34
+ unescaped_path.delete_prefix("/")
33
35
  else
34
- parsed_path
36
+ unescaped_path
35
37
  end
36
-
37
- CGI.unescape(actual_path)
38
- end
39
-
40
- sig { returns(String) }
41
- def storage_key
42
- T.must(to_standardized_path || opaque)
43
38
  end
44
39
  end
45
40
  end
@@ -0,0 +1,166 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyIndexer
5
+ class Configuration
6
+ extend T::Sig
7
+
8
+ CONFIGURATION_SCHEMA = T.let(
9
+ {
10
+ "excluded_gems" => Array,
11
+ "included_gems" => Array,
12
+ "excluded_patterns" => Array,
13
+ "included_patterns" => Array,
14
+ "excluded_magic_comments" => Array,
15
+ }.freeze,
16
+ T::Hash[String, T.untyped],
17
+ )
18
+
19
+ sig { void }
20
+ def initialize
21
+ excluded_gem_names = Bundler.definition.dependencies.filter_map do |dependency|
22
+ dependency.name if dependency.groups == [:development]
23
+ end
24
+
25
+ @excluded_gems = T.let(excluded_gem_names, T::Array[String])
26
+ @included_gems = T.let([], T::Array[String])
27
+ @excluded_patterns = T.let(["**/*_test.rb"], T::Array[String])
28
+ path = Bundler.settings["path"]
29
+ @excluded_patterns << "#{File.expand_path(path, Dir.pwd)}/**/*.rb" if path
30
+
31
+ @included_patterns = T.let(["#{Dir.pwd}/**/*.rb"], T::Array[String])
32
+ @excluded_magic_comments = T.let(
33
+ [
34
+ "frozen_string_literal:",
35
+ "typed:",
36
+ "compiled:",
37
+ "encoding:",
38
+ "shareable_constant_value:",
39
+ "warn_indent:",
40
+ "rubocop:",
41
+ "nodoc:",
42
+ "doc:",
43
+ "coding:",
44
+ "warn_past_scope:",
45
+ ],
46
+ T::Array[String],
47
+ )
48
+ end
49
+
50
+ sig { void }
51
+ def load_config
52
+ return unless File.exist?(".index.yml")
53
+
54
+ config = YAML.parse_file(".index.yml")
55
+ return unless config
56
+
57
+ config_hash = config.to_ruby
58
+ validate_config!(config_hash)
59
+ apply_config(config_hash)
60
+ rescue Psych::SyntaxError => e
61
+ raise e, "Syntax error while loading .index.yml configuration: #{e.message}"
62
+ end
63
+
64
+ sig { returns(T::Array[String]) }
65
+ def files_to_index
66
+ excluded_gems = @excluded_gems - @included_gems
67
+ locked_gems = Bundler.locked_gems&.specs
68
+
69
+ # NOTE: indexing the patterns (both included and excluded) needs to happen before indexing gems, otherwise we risk
70
+ # having duplicates if BUNDLE_PATH is set to a folder inside the project structure
71
+
72
+ # Add user specified patterns
73
+ files_to_index = @included_patterns.flat_map do |pattern|
74
+ Dir.glob(pattern, File::FNM_PATHNAME | File::FNM_EXTGLOB)
75
+ end
76
+
77
+ # Remove user specified patterns
78
+ files_to_index.reject! do |path|
79
+ @excluded_patterns.any? do |pattern|
80
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
81
+ end
82
+ end
83
+
84
+ # Add default gems to the list of files to be indexed
85
+ Dir.glob("#{RbConfig::CONFIG["rubylibdir"]}/*").each do |default_path|
86
+ # The default_path might be a Ruby file or a folder with the gem's name. For example:
87
+ # bundler/
88
+ # bundler.rb
89
+ # psych/
90
+ # psych.rb
91
+ pathname = Pathname.new(default_path)
92
+ short_name = pathname.basename.to_s.delete_suffix(".rb")
93
+
94
+ # If the gem name is excluded, then we skip it
95
+ next if excluded_gems.include?(short_name)
96
+
97
+ # If the default gem is also a part of the bundle, we skip indexing the default one and index only the one in
98
+ # the bundle, which won't be in `default_path`, but will be in `Bundler.bundle_path` instead
99
+ next if locked_gems&.any? do |locked_spec|
100
+ locked_spec.name == short_name &&
101
+ !Gem::Specification.find_by_name(short_name).full_gem_path.start_with?(RbConfig::CONFIG["rubylibprefix"])
102
+ end
103
+
104
+ if pathname.directory?
105
+ # If the default_path is a directory, we index all the Ruby files in it
106
+ files_to_index.concat(Dir.glob("#{default_path}/**/*.rb", File::FNM_PATHNAME | File::FNM_EXTGLOB))
107
+ else
108
+ # If the default_path is a Ruby file, we index it
109
+ files_to_index << default_path
110
+ end
111
+ end
112
+
113
+ # Add the locked gems to the list of files to be indexed
114
+ locked_gems&.each do |lazy_spec|
115
+ next if excluded_gems.include?(lazy_spec.name)
116
+
117
+ spec = Gem::Specification.find_by_name(lazy_spec.name)
118
+
119
+ # When working on a gem, it will be included in the locked_gems list. Since these are the project's own files,
120
+ # we have already included and handled exclude patterns for it and should not re-include or it'll lead to
121
+ # duplicates or accidentally ignoring exclude patterns
122
+ next if spec.full_gem_path == Dir.pwd
123
+
124
+ files_to_index.concat(Dir.glob("#{spec.full_gem_path}/{#{spec.require_paths.join(",")}}/**/*.rb"))
125
+ rescue Gem::MissingSpecError
126
+ # If a gem is scoped only to some specific platform, then its dependencies may not be installed either, but they
127
+ # are still listed in locked_gems. We can't index them because they are not installed for the platform, so we
128
+ # just ignore if they're missing
129
+ end
130
+
131
+ files_to_index.uniq!
132
+ files_to_index
133
+ end
134
+
135
+ sig { returns(Regexp) }
136
+ def magic_comment_regex
137
+ /^\s*#\s*#{@excluded_magic_comments.join("|")}/
138
+ end
139
+
140
+ private
141
+
142
+ sig { params(config: T::Hash[String, T.untyped]).void }
143
+ def validate_config!(config)
144
+ errors = config.filter_map do |key, value|
145
+ type = CONFIGURATION_SCHEMA[key]
146
+
147
+ if type.nil?
148
+ "Unknown configuration option: #{key}"
149
+ elsif !value.is_a?(type)
150
+ "Expected #{key} to be a #{type}, but got #{value.class}"
151
+ end
152
+ end
153
+
154
+ raise ArgumentError, errors.join("\n") if errors.any?
155
+ end
156
+
157
+ sig { params(config: T::Hash[String, T.untyped]).void }
158
+ def apply_config(config)
159
+ @excluded_gems.concat(config["excluded_gems"]) if config["excluded_gems"]
160
+ @included_gems.concat(config["included_gems"]) if config["included_gems"]
161
+ @excluded_patterns.concat(config["excluded_patterns"]) if config["excluded_patterns"]
162
+ @included_patterns.concat(config["included_patterns"]) if config["included_patterns"]
163
+ @excluded_magic_comments.concat(config["excluded_magic_comments"]) if config["excluded_magic_comments"]
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,147 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyIndexer
5
+ class Index
6
+ extend T::Sig
7
+
8
+ # The minimum Jaro-Winkler similarity score for an entry to be considered a match for a given fuzzy search query
9
+ ENTRY_SIMILARITY_THRESHOLD = 0.7
10
+
11
+ sig { void }
12
+ def initialize
13
+ # Holds all entries in the index using the following format:
14
+ # {
15
+ # "Foo" => [#<Entry::Class>, #<Entry::Class>],
16
+ # "Foo::Bar" => [#<Entry::Class>],
17
+ # }
18
+ @entries = T.let({}, T::Hash[String, T::Array[Entry]])
19
+
20
+ # Holds references to where entries where discovered so that we can easily delete them
21
+ # {
22
+ # "/my/project/foo.rb" => [#<Entry::Class>, #<Entry::Class>],
23
+ # "/my/project/bar.rb" => [#<Entry::Class>],
24
+ # }
25
+ @files_to_entries = T.let({}, T::Hash[String, T::Array[Entry]])
26
+ end
27
+
28
+ sig { params(path: String).void }
29
+ def delete(path)
30
+ # For each constant discovered in `path`, delete the associated entry from the index. If there are no entries
31
+ # left, delete the constant from the index.
32
+ @files_to_entries[path]&.each do |entry|
33
+ entries = @entries[entry.name]
34
+ next unless entries
35
+
36
+ # Delete the specific entry from the list for this name
37
+ entries.delete(entry)
38
+ # If all entries were deleted, then remove the name from the hash
39
+ @entries.delete(entry.name) if entries.empty?
40
+ end
41
+
42
+ @files_to_entries.delete(path)
43
+ end
44
+
45
+ sig { params(entry: Entry).void }
46
+ def <<(entry)
47
+ (@entries[entry.name] ||= []) << entry
48
+ (@files_to_entries[entry.file_path] ||= []) << entry
49
+ end
50
+
51
+ sig { params(fully_qualified_name: String).returns(T.nilable(T::Array[Entry])) }
52
+ def [](fully_qualified_name)
53
+ @entries[fully_qualified_name.delete_prefix("::")]
54
+ end
55
+
56
+ # Fuzzy searches index entries based on Jaro-Winkler similarity. If no query is provided, all entries are returned
57
+ sig { params(query: T.nilable(String)).returns(T::Array[Entry]) }
58
+ def fuzzy_search(query)
59
+ return @entries.flat_map { |_name, entries| entries } unless query
60
+
61
+ normalized_query = query.gsub("::", "").downcase
62
+
63
+ results = @entries.filter_map do |name, entries|
64
+ similarity = DidYouMean::JaroWinkler.distance(name.gsub("::", "").downcase, normalized_query)
65
+ [entries, -similarity] if similarity > ENTRY_SIMILARITY_THRESHOLD
66
+ end
67
+ results.sort_by!(&:last)
68
+ results.flat_map(&:first)
69
+ end
70
+
71
+ # Try to find the entry based on the nesting from the most specific to the least specific. For example, if we have
72
+ # the nesting as ["Foo", "Bar"] and the name as "Baz", we will try to find it in this order:
73
+ # 1. Foo::Bar::Baz
74
+ # 2. Foo::Baz
75
+ # 3. Baz
76
+ sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(T::Array[Entry])) }
77
+ def resolve(name, nesting)
78
+ (nesting.length + 1).downto(0).each do |i|
79
+ prefix = T.must(nesting[0...i]).join("::")
80
+ full_name = prefix.empty? ? name : "#{prefix}::#{name}"
81
+ entries = @entries[full_name]
82
+ return entries if entries
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ sig { params(paths: T::Array[String]).void }
89
+ def index_all(paths: RubyIndexer.configuration.files_to_index)
90
+ paths.each { |path| index_single(path) }
91
+ end
92
+
93
+ sig { params(path: String, source: T.nilable(String)).void }
94
+ def index_single(path, source = nil)
95
+ content = source || File.read(path)
96
+ visitor = IndexVisitor.new(self, YARP.parse(content), path)
97
+ visitor.run
98
+ rescue Errno::EISDIR
99
+ # If `path` is a directory, just ignore it and continue indexing
100
+ end
101
+
102
+ class Entry
103
+ extend T::Sig
104
+
105
+ sig { returns(String) }
106
+ attr_reader :name
107
+
108
+ sig { returns(String) }
109
+ attr_reader :file_path
110
+
111
+ sig { returns(YARP::Location) }
112
+ attr_reader :location
113
+
114
+ sig { returns(T::Array[String]) }
115
+ attr_reader :comments
116
+
117
+ sig { params(name: String, file_path: String, location: YARP::Location, comments: T::Array[String]).void }
118
+ def initialize(name, file_path, location, comments)
119
+ @name = name
120
+ @file_path = file_path
121
+ @location = location
122
+ @comments = comments
123
+ end
124
+
125
+ sig { returns(String) }
126
+ def file_name
127
+ File.basename(@file_path)
128
+ end
129
+
130
+ class Namespace < Entry
131
+ sig { returns(String) }
132
+ def short_name
133
+ T.must(@name.split("::").last)
134
+ end
135
+ end
136
+
137
+ class Module < Namespace
138
+ end
139
+
140
+ class Class < Namespace
141
+ end
142
+
143
+ class Constant < Entry
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,123 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyIndexer
5
+ class IndexVisitor < YARP::Visitor
6
+ extend T::Sig
7
+
8
+ sig { params(index: Index, parse_result: YARP::ParseResult, file_path: String).void }
9
+ def initialize(index, parse_result, file_path)
10
+ @index = index
11
+ @parse_result = parse_result
12
+ @file_path = file_path
13
+ @stack = T.let([], T::Array[String])
14
+ @comments_by_line = T.let(
15
+ parse_result.comments.to_h do |c|
16
+ [c.location.start_line, c]
17
+ end,
18
+ T::Hash[Integer, YARP::Comment],
19
+ )
20
+
21
+ super()
22
+ end
23
+
24
+ sig { void }
25
+ def run
26
+ visit(@parse_result.value)
27
+ end
28
+
29
+ sig { params(node: T.nilable(YARP::Node)).void }
30
+ def visit(node)
31
+ case node
32
+ when YARP::ProgramNode, YARP::StatementsNode
33
+ visit_child_nodes(node)
34
+ when YARP::ClassNode
35
+ add_index_entry(node, Index::Entry::Class)
36
+ when YARP::ModuleNode
37
+ add_index_entry(node, Index::Entry::Module)
38
+ when YARP::ConstantWriteNode, YARP::ConstantOrWriteNode
39
+ add_constant(node)
40
+ when YARP::ConstantPathWriteNode, YARP::ConstantPathOrWriteNode
41
+ add_constant_with_path(node)
42
+ end
43
+ end
44
+
45
+ # Override to avoid using `map` instead of `each`
46
+ sig { params(nodes: T::Array[T.nilable(YARP::Node)]).void }
47
+ def visit_all(nodes)
48
+ nodes.each { |node| visit(node) }
49
+ end
50
+
51
+ private
52
+
53
+ sig do
54
+ params(
55
+ node: T.any(YARP::ConstantWriteNode, YARP::ConstantOrWriteNode),
56
+ ).void
57
+ end
58
+ def add_constant(node)
59
+ comments = collect_comments(node)
60
+ @index << Index::Entry::Constant.new(fully_qualify_name(node.name), @file_path, node.location, comments)
61
+ end
62
+
63
+ sig do
64
+ params(
65
+ node: T.any(YARP::ConstantPathWriteNode, YARP::ConstantPathOrWriteNode),
66
+ ).void
67
+ end
68
+ def add_constant_with_path(node)
69
+ # ignore variable constants like `var::FOO` or `self.class::FOO`
70
+ return unless node.target.parent.nil? || node.target.parent.is_a?(YARP::ConstantReadNode)
71
+
72
+ name = node.target.location.slice
73
+ comments = collect_comments(node)
74
+ @index << Index::Entry::Constant.new(fully_qualify_name(name), @file_path, node.location, comments)
75
+ end
76
+
77
+ sig { params(node: T.any(YARP::ClassNode, YARP::ModuleNode), klass: T.class_of(Index::Entry)).void }
78
+ def add_index_entry(node, klass)
79
+ name = node.constant_path.location.slice
80
+
81
+ unless /^[A-Z:]/.match?(name)
82
+ return visit_child_nodes(node)
83
+ end
84
+
85
+ comments = collect_comments(node)
86
+ @index << klass.new(fully_qualify_name(name), @file_path, node.location, comments)
87
+ @stack << name
88
+ visit_child_nodes(node)
89
+ @stack.pop
90
+ end
91
+
92
+ sig { params(node: YARP::Node).returns(T::Array[String]) }
93
+ def collect_comments(node)
94
+ comments = []
95
+
96
+ start_line = node.location.start_line - 1
97
+ start_line -= 1 unless @comments_by_line.key?(start_line)
98
+
99
+ start_line.downto(1) do |line|
100
+ comment = @comments_by_line[line]
101
+ break unless comment
102
+
103
+ comment_content = comment.location.slice.chomp
104
+ next if comment_content.match?(RubyIndexer.configuration.magic_comment_regex)
105
+
106
+ comment_content.delete_prefix!("#")
107
+ comment_content.delete_prefix!(" ")
108
+ comments.unshift(comment_content)
109
+ end
110
+
111
+ comments
112
+ end
113
+
114
+ sig { params(name: String).returns(String) }
115
+ def fully_qualify_name(name)
116
+ if @stack.empty? || name.start_with?("::")
117
+ name
118
+ else
119
+ "#{@stack.join("::")}::#{name}"
120
+ end.delete_prefix("::")
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,20 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "did_you_mean"
6
+
7
+ require "ruby_indexer/lib/ruby_indexer/visitor"
8
+ require "ruby_indexer/lib/ruby_indexer/index"
9
+ require "ruby_indexer/lib/ruby_indexer/configuration"
10
+
11
+ module RubyIndexer
12
+ class << self
13
+ extend T::Sig
14
+
15
+ sig { returns(Configuration) }
16
+ def configuration
17
+ @configuration ||= T.let(Configuration.new, T.nilable(Configuration))
18
+ end
19
+ end
20
+ end