ruby-lsp 0.9.2 → 0.9.4

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: edcd6c49e7546a82b2e64a023c04be808dac40695a2e5667f915f17b4f9fc08a
4
- data.tar.gz: d8751bfd61a98e1a42d596b03d0dca55bd3441d3ec206b7ab36c6bf38f3afdcd
3
+ metadata.gz: 5a300c17567fae60c2ed50c941d8ada19bf6b6fd951b621029880c03afd6d6f5
4
+ data.tar.gz: 297d0d2064fbe7e97783e26fbfbdbb2191b6a32a43636e743864ad08665f49dd
5
5
  SHA512:
6
- metadata.gz: d71efe1d5848a42076452ddc0b714926bd2fed03730ffc5d509df1dbed67524484af607cb473fb03c9bee2867eb64654afa64ca8c349a39cde09e321cb6550b8
7
- data.tar.gz: a87eedc485b3280982c25d45c455d1a5420582ad30739cdb4268068b766b707104f37d9eba82875494f5e5eb5bb0bb6592ad2e42bfcb65cf20f65a8636d57210
6
+ metadata.gz: ee884d5c7f60e1d3fac9069a170967a761407a6470b7fe7442686bdbdfc1a4cbd626b7bcd4d368072bce0bab349df935bc0e7e9bdcad7b2fa238143afbc78075
7
+ data.tar.gz: 317ebe547e3a9730c0161a2ad8b0dd130e819bf92a97c59525e5bc40e1e84bc95e0e610f87f144aebef957a622e141254938954bafd354e7f3d1249e022d2edb
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.2
1
+ 0.9.4
data/exe/ruby-lsp CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "optparse"
5
5
 
6
+ original_args = ARGV.dup
6
7
  options = {}
7
8
  parser = OptionParser.new do |opts|
8
9
  opts.banner = "Usage: ruby-lsp [options]"
@@ -33,7 +34,7 @@ parser = OptionParser.new do |opts|
33
34
  end
34
35
 
35
36
  begin
36
- parser.parse
37
+ parser.parse!
37
38
  rescue OptionParser::InvalidOption => e
38
39
  warn(e)
39
40
  warn("")
@@ -56,7 +57,7 @@ if ENV["BUNDLE_GEMFILE"].nil?
56
57
 
57
58
  env = { "BUNDLE_GEMFILE" => bundle_gemfile }
58
59
  env["BUNDLE_PATH"] = bundle_path if bundle_path
59
- exit exec(env, "bundle exec ruby-lsp #{ARGV.join(" ")}")
60
+ exit exec(env, "bundle exec ruby-lsp #{original_args.join(" ")}")
60
61
  end
61
62
 
62
63
  require "sorbet-runtime"
@@ -24,11 +24,11 @@ module RubyIndexer
24
24
 
25
25
  @excluded_gems = T.let(excluded_gem_names, T::Array[String])
26
26
  @included_gems = T.let([], T::Array[String])
27
- @excluded_patterns = T.let(["**/*_test.rb"], T::Array[String])
27
+ @excluded_patterns = T.let([File.join("**", "*_test.rb")], T::Array[String])
28
28
  path = Bundler.settings["path"]
29
- @excluded_patterns << "#{File.expand_path(path, Dir.pwd)}/**/*.rb" if path
29
+ @excluded_patterns << File.join(File.expand_path(path, Dir.pwd), "**", "*.rb") if path
30
30
 
31
- @included_patterns = T.let(["#{Dir.pwd}/**/*.rb"], T::Array[String])
31
+ @included_patterns = T.let([File.join(Dir.pwd, "**", "*.rb")], T::Array[String])
32
32
  @excluded_magic_comments = T.let(
33
33
  [
34
34
  "frozen_string_literal:",
@@ -61,8 +61,8 @@ module RubyIndexer
61
61
  raise e, "Syntax error while loading .index.yml configuration: #{e.message}"
62
62
  end
63
63
 
64
- sig { returns(T::Array[String]) }
65
- def files_to_index
64
+ sig { returns(T::Array[IndexablePath]) }
65
+ def indexables
66
66
  excluded_gems = @excluded_gems - @included_gems
67
67
  locked_gems = Bundler.locked_gems&.specs
68
68
 
@@ -70,19 +70,22 @@ module RubyIndexer
70
70
  # having duplicates if BUNDLE_PATH is set to a folder inside the project structure
71
71
 
72
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)
73
+ indexables = @included_patterns.flat_map do |pattern|
74
+ Dir.glob(pattern, File::FNM_PATHNAME | File::FNM_EXTGLOB).map! do |path|
75
+ load_path_entry = $LOAD_PATH.find { |load_path| path.start_with?(load_path) }
76
+ IndexablePath.new(load_path_entry, path)
77
+ end
75
78
  end
76
79
 
77
80
  # Remove user specified patterns
78
- files_to_index.reject! do |path|
81
+ indexables.reject! do |indexable|
79
82
  @excluded_patterns.any? do |pattern|
80
- File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
83
+ File.fnmatch?(pattern, indexable.full_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
81
84
  end
82
85
  end
83
86
 
84
87
  # Add default gems to the list of files to be indexed
85
- Dir.glob("#{RbConfig::CONFIG["rubylibdir"]}/*").each do |default_path|
88
+ Dir.glob(File.join(RbConfig::CONFIG["rubylibdir"], "*")).each do |default_path|
86
89
  # The default_path might be a Ruby file or a folder with the gem's name. For example:
87
90
  # bundler/
88
91
  # bundler.rb
@@ -103,10 +106,14 @@ module RubyIndexer
103
106
 
104
107
  if pathname.directory?
105
108
  # 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))
109
+ indexables.concat(
110
+ Dir.glob(File.join(default_path, "**", "*.rb"), File::FNM_PATHNAME | File::FNM_EXTGLOB).map! do |path|
111
+ IndexablePath.new(RbConfig::CONFIG["rubylibdir"], path)
112
+ end,
113
+ )
107
114
  else
108
115
  # If the default_path is a Ruby file, we index it
109
- files_to_index << default_path
116
+ indexables << IndexablePath.new(RbConfig::CONFIG["rubylibdir"], default_path)
110
117
  end
111
118
  end
112
119
 
@@ -121,15 +128,20 @@ module RubyIndexer
121
128
  # duplicates or accidentally ignoring exclude patterns
122
129
  next if spec.full_gem_path == Dir.pwd
123
130
 
124
- files_to_index.concat(Dir.glob("#{spec.full_gem_path}/{#{spec.require_paths.join(",")}}/**/*.rb"))
131
+ indexables.concat(
132
+ spec.require_paths.flat_map do |require_path|
133
+ load_path_entry = File.join(spec.full_gem_path, require_path)
134
+ Dir.glob(File.join(load_path_entry, "**", "*.rb")).map! { |path| IndexablePath.new(load_path_entry, path) }
135
+ end,
136
+ )
125
137
  rescue Gem::MissingSpecError
126
138
  # If a gem is scoped only to some specific platform, then its dependencies may not be installed either, but they
127
139
  # are still listed in locked_gems. We can't index them because they are not installed for the platform, so we
128
140
  # just ignore if they're missing
129
141
  end
130
142
 
131
- files_to_index.uniq!
132
- files_to_index
143
+ indexables.uniq!
144
+ indexables
133
145
  end
134
146
 
135
147
  sig { returns(Regexp) }
@@ -17,35 +17,55 @@ module RubyIndexer
17
17
  # }
18
18
  @entries = T.let({}, T::Hash[String, T::Array[Entry]])
19
19
 
20
+ # Holds all entries in the index using a prefix tree for searching based on prefixes to provide autocompletion
21
+ @entries_tree = T.let(PrefixTree[T::Array[Entry]].new, PrefixTree[T::Array[Entry]])
22
+
20
23
  # Holds references to where entries where discovered so that we can easily delete them
21
24
  # {
22
25
  # "/my/project/foo.rb" => [#<Entry::Class>, #<Entry::Class>],
23
26
  # "/my/project/bar.rb" => [#<Entry::Class>],
24
27
  # }
25
28
  @files_to_entries = T.let({}, T::Hash[String, T::Array[Entry]])
29
+
30
+ # Holds all require paths for every indexed item so that we can provide autocomplete for requires
31
+ @require_paths_tree = T.let(PrefixTree[String].new, PrefixTree[String])
26
32
  end
27
33
 
28
- sig { params(path: String).void }
29
- def delete(path)
34
+ sig { params(indexable: IndexablePath).void }
35
+ def delete(indexable)
30
36
  # For each constant discovered in `path`, delete the associated entry from the index. If there are no entries
31
37
  # left, delete the constant from the index.
32
- @files_to_entries[path]&.each do |entry|
33
- entries = @entries[entry.name]
38
+ @files_to_entries[indexable.full_path]&.each do |entry|
39
+ name = entry.name
40
+ entries = @entries[name]
34
41
  next unless entries
35
42
 
36
43
  # Delete the specific entry from the list for this name
37
44
  entries.delete(entry)
38
- # If all entries were deleted, then remove the name from the hash
39
- @entries.delete(entry.name) if entries.empty?
45
+
46
+ # If all entries were deleted, then remove the name from the hash and from the prefix tree. Otherwise, update
47
+ # the prefix tree with the current entries
48
+ if entries.empty?
49
+ @entries.delete(name)
50
+ @entries_tree.delete(name)
51
+ else
52
+ @entries_tree.insert(name, entries)
53
+ end
40
54
  end
41
55
 
42
- @files_to_entries.delete(path)
56
+ @files_to_entries.delete(indexable.full_path)
57
+
58
+ require_path = indexable.require_path
59
+ @require_paths_tree.delete(require_path) if require_path
43
60
  end
44
61
 
45
62
  sig { params(entry: Entry).void }
46
63
  def <<(entry)
47
- (@entries[entry.name] ||= []) << entry
64
+ name = entry.name
65
+
66
+ (@entries[name] ||= []) << entry
48
67
  (@files_to_entries[entry.file_path] ||= []) << entry
68
+ @entries_tree.insert(name, T.must(@entries[name]))
49
69
  end
50
70
 
51
71
  sig { params(fully_qualified_name: String).returns(T.nilable(T::Array[Entry])) }
@@ -53,6 +73,36 @@ module RubyIndexer
53
73
  @entries[fully_qualified_name.delete_prefix("::")]
54
74
  end
55
75
 
76
+ sig { params(query: String).returns(T::Array[String]) }
77
+ def search_require_paths(query)
78
+ @require_paths_tree.search(query)
79
+ end
80
+
81
+ # Searches entries in the index based on an exact prefix, intended for providing autocomplete. All possible matches
82
+ # to the prefix are returned. The return is an array of arrays, where each entry is the array of entries for a given
83
+ # name match. For example:
84
+ # ## Example
85
+ # ```ruby
86
+ # # If the index has two entries for `Foo::Bar` and one for `Foo::Baz`, then:
87
+ # index.prefix_search("Foo::B")
88
+ # # Will return:
89
+ # [
90
+ # [#<Entry::Class name="Foo::Bar">, #<Entry::Class name="Foo::Bar">],
91
+ # [#<Entry::Class name="Foo::Baz">],
92
+ # ]
93
+ # ```
94
+ sig { params(query: String, nesting: T::Array[String]).returns(T::Array[T::Array[Entry]]) }
95
+ def prefix_search(query, nesting)
96
+ results = (nesting.length + 1).downto(0).flat_map do |i|
97
+ prefix = T.must(nesting[0...i]).join("::")
98
+ namespaced_query = prefix.empty? ? query : "#{prefix}::#{query}"
99
+ @entries_tree.search(namespaced_query)
100
+ end
101
+
102
+ results.uniq!
103
+ results
104
+ end
105
+
56
106
  # Fuzzy searches index entries based on Jaro-Winkler similarity. If no query is provided, all entries are returned
57
107
  sig { params(query: T.nilable(String)).returns(T::Array[Entry]) }
58
108
  def fuzzy_search(query)
@@ -85,16 +135,19 @@ module RubyIndexer
85
135
  nil
86
136
  end
87
137
 
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) }
138
+ sig { params(indexable_paths: T::Array[IndexablePath]).void }
139
+ def index_all(indexable_paths: RubyIndexer.configuration.indexables)
140
+ indexable_paths.each { |path| index_single(path) }
91
141
  end
92
142
 
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)
143
+ sig { params(indexable_path: IndexablePath, source: T.nilable(String)).void }
144
+ def index_single(indexable_path, source = nil)
145
+ content = source || File.read(indexable_path.full_path)
146
+ visitor = IndexVisitor.new(self, YARP.parse(content), indexable_path.full_path)
97
147
  visitor.run
148
+
149
+ require_path = indexable_path.require_path
150
+ @require_paths_tree.insert(require_path, require_path) if require_path
98
151
  rescue Errno::EISDIR
99
152
  # If `path` is a directory, just ignore it and continue indexing
100
153
  end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyIndexer
5
+ class IndexablePath
6
+ extend T::Sig
7
+
8
+ sig { returns(T.nilable(String)) }
9
+ attr_reader :require_path
10
+
11
+ sig { returns(String) }
12
+ attr_reader :full_path
13
+
14
+ # An IndexablePath is instantiated with a load_path_entry and a full_path. The load_path_entry is where the file can
15
+ # be found in the $LOAD_PATH, which we use to determine the require_path. The load_path_entry may be `nil` if the
16
+ # indexer is configured to go through files that do not belong in the $LOAD_PATH. For example,
17
+ # `sorbet/tapioca/require.rb` ends up being a part of the paths to be indexed because it's a Ruby file inside the
18
+ # project, but the `sorbet` folder is not a part of the $LOAD_PATH. That means that both its load_path_entry and
19
+ # require_path will be `nil`, since it cannot be required by the project
20
+ sig { params(load_path_entry: T.nilable(String), full_path: String).void }
21
+ def initialize(load_path_entry, full_path)
22
+ @full_path = full_path
23
+ @require_path = T.let(
24
+ load_path_entry ? Pathname.new(full_path).relative_path_from(load_path_entry).to_s.delete_suffix(".rb") : nil,
25
+ T.nilable(String),
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,153 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module RubyIndexer
5
+ # A PrefixTree is a data structure that allows searching for partial strings fast. The tree is similar to a nested
6
+ # hash structure, where the keys are the characters of the inserted strings.
7
+ #
8
+ # ## Example
9
+ # ```ruby
10
+ # tree = PrefixTree[String].new
11
+ # # Insert entries using the same key and value
12
+ # tree.insert("bar", "bar")
13
+ # tree.insert("baz", "baz")
14
+ # # Internally, the structure is analogous to this, but using nodes:
15
+ # # {
16
+ # # "b" => {
17
+ # # "a" => {
18
+ # # "r" => "bar",
19
+ # # "z" => "baz"
20
+ # # }
21
+ # # }
22
+ # # }
23
+ # # When we search it, it finds all possible values based on partial (or complete matches):
24
+ # tree.search("") # => ["bar", "baz"]
25
+ # tree.search("b") # => ["bar", "baz"]
26
+ # tree.search("ba") # => ["bar", "baz"]
27
+ # tree.search("bar") # => ["bar"]
28
+ # ```
29
+ #
30
+ # A PrefixTree is useful for autocomplete, since we always want to find all alternatives while the developer hasn't
31
+ # finished typing yet. This PrefixTree implementation allows for string keys and any arbitrary value using the generic
32
+ # `Value` type.
33
+ #
34
+ # See https://en.wikipedia.org/wiki/Trie for more information
35
+ class PrefixTree
36
+ extend T::Sig
37
+ extend T::Generic
38
+
39
+ Value = type_member
40
+
41
+ sig { void }
42
+ def initialize
43
+ @root = T.let(Node.new("", ""), Node[Value])
44
+ end
45
+
46
+ # Search the PrefixTree based on a given `prefix`. If `foo` is an entry in the tree, then searching for `fo` will
47
+ # return it as a result. The result is always an array of the type of value attribute to the generic `Value` type.
48
+ # Notice that if the `Value` is an array, this method will return an array of arrays, where each entry is the array
49
+ # of values for a given match
50
+ sig { params(prefix: String).returns(T::Array[Value]) }
51
+ def search(prefix)
52
+ node = find_node(prefix)
53
+ return [] unless node
54
+
55
+ node.collect
56
+ end
57
+
58
+ # Inserts a `value` using the given `key`
59
+ sig { params(key: String, value: Value).void }
60
+ def insert(key, value)
61
+ node = @root
62
+
63
+ key.each_char do |char|
64
+ node = node.children[char] ||= Node.new(char, value, node)
65
+ end
66
+
67
+ # This line is to allow a value to be overridden. When we are indexing files, we want to be able to update entries
68
+ # for a given fully qualified name if we find more occurrences of it. Without being able to override, that would
69
+ # not be possible
70
+ node.value = value
71
+ node.leaf = true
72
+ end
73
+
74
+ # Deletes the entry identified by `key` from the tree. Notice that a partial match will still delete all entries
75
+ # that match it. For example, if the tree contains `foo` and we ask to delete `fo`, then `foo` will be deleted
76
+ sig { params(key: String).void }
77
+ def delete(key)
78
+ node = find_node(key)
79
+ return unless node
80
+
81
+ # Remove the node from the tree and then go up the parents to remove any of them with empty children
82
+ parent = T.let(T.must(node.parent), T.nilable(Node[Value]))
83
+
84
+ while parent
85
+ parent.children.delete(node.key)
86
+ return if parent.children.any? || parent.leaf
87
+
88
+ node = parent
89
+ parent = parent.parent
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # Find a node that matches the given `key`
96
+ sig { params(key: String).returns(T.nilable(Node[Value])) }
97
+ def find_node(key)
98
+ node = @root
99
+
100
+ key.each_char do |char|
101
+ snode = node.children[char]
102
+ return nil unless snode
103
+
104
+ node = snode
105
+ end
106
+
107
+ node
108
+ end
109
+
110
+ class Node
111
+ extend T::Sig
112
+ extend T::Generic
113
+
114
+ Value = type_member
115
+
116
+ sig { returns(T::Hash[String, Node[Value]]) }
117
+ attr_reader :children
118
+
119
+ sig { returns(String) }
120
+ attr_reader :key
121
+
122
+ sig { returns(Value) }
123
+ attr_accessor :value
124
+
125
+ sig { returns(T::Boolean) }
126
+ attr_accessor :leaf
127
+
128
+ sig { returns(T.nilable(Node[Value])) }
129
+ attr_reader :parent
130
+
131
+ sig { params(key: String, value: Value, parent: T.nilable(Node[Value])).void }
132
+ def initialize(key, value, parent = nil)
133
+ @key = key
134
+ @value = value
135
+ @parent = parent
136
+ @children = T.let({}, T::Hash[String, Node[Value]])
137
+ @leaf = T.let(false, T::Boolean)
138
+ end
139
+
140
+ sig { returns(T::Array[Value]) }
141
+ def collect
142
+ result = T.let([], T::Array[Value])
143
+ result << value if leaf
144
+
145
+ children.each_value do |node|
146
+ result.concat(node.collect)
147
+ end
148
+
149
+ result
150
+ end
151
+ end
152
+ end
153
+ end
@@ -4,9 +4,11 @@
4
4
  require "yaml"
5
5
  require "did_you_mean"
6
6
 
7
+ require "ruby_indexer/lib/ruby_indexer/indexable_path"
7
8
  require "ruby_indexer/lib/ruby_indexer/visitor"
8
9
  require "ruby_indexer/lib/ruby_indexer/index"
9
10
  require "ruby_indexer/lib/ruby_indexer/configuration"
11
+ require "ruby_indexer/lib/ruby_indexer/prefix_tree"
10
12
 
11
13
  module RubyIndexer
12
14
  class << self
@@ -139,7 +139,7 @@ module RubyIndexer
139
139
 
140
140
  assert_entry("Foo", Index::Entry::Class, "/fake/path/foo.rb:0-0:1-2")
141
141
 
142
- @index.delete("/fake/path/foo.rb")
142
+ @index.delete(IndexablePath.new(nil, "/fake/path/foo.rb"))
143
143
  refute_entry("Foo")
144
144
  assert_empty(@index.instance_variable_get(:@files_to_entries))
145
145
  end
@@ -11,55 +11,57 @@ module RubyIndexer
11
11
 
12
12
  def test_load_configuration_executes_configure_block
13
13
  @config.load_config
14
- files_to_index = @config.files_to_index
14
+ indexables = @config.indexables
15
15
 
16
- assert(files_to_index.none? { |path| path.include?("test/fixtures") })
17
- assert(files_to_index.none? { |path| path.include?("minitest-reporters") })
18
- assert(files_to_index.none? { |path| path == __FILE__ })
16
+ assert(indexables.none? { |indexable| indexable.full_path.include?("test/fixtures") })
17
+ assert(indexables.none? { |indexable| indexable.full_path.include?("minitest-reporters") })
18
+ assert(indexables.none? { |indexable| indexable.full_path == __FILE__ })
19
19
  end
20
20
 
21
- def test_files_to_index_only_includes_gem_require_paths
21
+ def test_indexables_only_includes_gem_require_paths
22
22
  @config.load_config
23
- files_to_index = @config.files_to_index
23
+ indexables = @config.indexables
24
24
 
25
25
  Bundler.locked_gems.specs.each do |lazy_spec|
26
26
  next if lazy_spec.name == "ruby-lsp"
27
27
 
28
28
  spec = Gem::Specification.find_by_name(lazy_spec.name)
29
- assert(files_to_index.none? { |path| path.start_with?("#{spec.full_gem_path}/test/") })
29
+ assert(indexables.none? { |indexable| indexable.full_path.start_with?("#{spec.full_gem_path}/test/") })
30
30
  rescue Gem::MissingSpecError
31
31
  # Transitive dependencies might be missing when running tests on Windows
32
32
  end
33
33
  end
34
34
 
35
- def test_files_to_index_does_not_include_default_gem_path_when_in_bundle
35
+ def test_indexables_does_not_include_default_gem_path_when_in_bundle
36
36
  @config.load_config
37
- files_to_index = @config.files_to_index
37
+ indexables = @config.indexables
38
38
 
39
- assert(files_to_index.none? { |path| path.start_with?("#{RbConfig::CONFIG["rubylibdir"]}/psych") })
39
+ assert(
40
+ indexables.none? { |indexable| indexable.full_path.start_with?("#{RbConfig::CONFIG["rubylibdir"]}/psych") },
41
+ )
40
42
  end
41
43
 
42
- def test_files_to_index_includes_default_gems
44
+ def test_indexables_includes_default_gems
43
45
  @config.load_config
44
- files_to_index = @config.files_to_index
46
+ indexables = @config.indexables.map(&:full_path)
45
47
 
46
- assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")
47
- assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/ipaddr.rb")
48
- assert_includes(files_to_index, "#{RbConfig::CONFIG["rubylibdir"]}/abbrev.rb")
48
+ assert_includes(indexables, "#{RbConfig::CONFIG["rubylibdir"]}/pathname.rb")
49
+ assert_includes(indexables, "#{RbConfig::CONFIG["rubylibdir"]}/ipaddr.rb")
50
+ assert_includes(indexables, "#{RbConfig::CONFIG["rubylibdir"]}/abbrev.rb")
49
51
  end
50
52
 
51
- def test_files_to_index_includes_project_files
53
+ def test_indexables_includes_project_files
52
54
  @config.load_config
53
- files_to_index = @config.files_to_index
55
+ indexables = @config.indexables.map(&:full_path)
54
56
 
55
57
  Dir.glob("#{Dir.pwd}/lib/**/*.rb").each do |path|
56
58
  next if path.end_with?("_test.rb")
57
59
 
58
- assert_includes(files_to_index, path)
60
+ assert_includes(indexables, path)
59
61
  end
60
62
  end
61
63
 
62
- def test_files_to_index_avoids_duplicates_if_bundle_path_is_inside_project
64
+ def test_indexables_avoids_duplicates_if_bundle_path_is_inside_project
63
65
  Bundler.settings.set_global("path", "vendor/bundle")
64
66
  config = Configuration.new
65
67
  config.load_config
@@ -69,18 +71,22 @@ module RubyIndexer
69
71
  Bundler.settings.set_global("path", nil)
70
72
  end
71
73
 
72
- def test_files_to_index_does_not_include_gems_own_installed_files
74
+ def test_indexables_does_not_include_gems_own_installed_files
73
75
  @config.load_config
74
- files_to_index = @config.files_to_index
76
+ indexables = @config.indexables
75
77
 
76
- assert(files_to_index.none? { |path| path.start_with?(Bundler.bundle_path.join("gems", "ruby-lsp").to_s) })
78
+ assert(
79
+ indexables.none? do |indexable|
80
+ indexable.full_path.start_with?(Bundler.bundle_path.join("gems", "ruby-lsp").to_s)
81
+ end,
82
+ )
77
83
  end
78
84
 
79
85
  def test_paths_are_unique
80
86
  @config.load_config
81
- files_to_index = @config.files_to_index
87
+ indexables = @config.indexables
82
88
 
83
- assert_equal(files_to_index.uniq.length, files_to_index.length)
89
+ assert_equal(indexables.uniq.length, indexables.length)
84
90
  end
85
91
 
86
92
  def test_configuration_raises_for_unknown_keys
@@ -6,11 +6,11 @@ require_relative "test_case"
6
6
  module RubyIndexer
7
7
  class IndexTest < TestCase
8
8
  def test_deleting_one_entry_for_a_class
9
- @index.index_single("/fake/path/foo.rb", <<~RUBY)
9
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY)
10
10
  class Foo
11
11
  end
12
12
  RUBY
13
- @index.index_single("/fake/path/other_foo.rb", <<~RUBY)
13
+ @index.index_single(IndexablePath.new(nil, "/fake/path/other_foo.rb"), <<~RUBY)
14
14
  class Foo
15
15
  end
16
16
  RUBY
@@ -18,13 +18,13 @@ module RubyIndexer
18
18
  entries = @index["Foo"]
19
19
  assert_equal(2, entries.length)
20
20
 
21
- @index.delete("/fake/path/other_foo.rb")
21
+ @index.delete(IndexablePath.new(nil, "/fake/path/other_foo.rb"))
22
22
  entries = @index["Foo"]
23
23
  assert_equal(1, entries.length)
24
24
  end
25
25
 
26
26
  def test_deleting_all_entries_for_a_class
27
- @index.index_single("/fake/path/foo.rb", <<~RUBY)
27
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY)
28
28
  class Foo
29
29
  end
30
30
  RUBY
@@ -32,13 +32,13 @@ module RubyIndexer
32
32
  entries = @index["Foo"]
33
33
  assert_equal(1, entries.length)
34
34
 
35
- @index.delete("/fake/path/foo.rb")
35
+ @index.delete(IndexablePath.new(nil, "/fake/path/foo.rb"))
36
36
  entries = @index["Foo"]
37
37
  assert_nil(entries)
38
38
  end
39
39
 
40
40
  def test_index_resolve
41
- @index.index_single("/fake/path/foo.rb", <<~RUBY)
41
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY)
42
42
  class Bar; end
43
43
 
44
44
  module Foo
@@ -72,7 +72,7 @@ module RubyIndexer
72
72
  end
73
73
 
74
74
  def test_accessing_with_colon_colon_prefix
75
- @index.index_single("/fake/path/foo.rb", <<~RUBY)
75
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY)
76
76
  class Bar; end
77
77
 
78
78
  module Foo
@@ -92,7 +92,7 @@ module RubyIndexer
92
92
  end
93
93
 
94
94
  def test_fuzzy_search
95
- @index.index_single("/fake/path/foo.rb", <<~RUBY)
95
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY)
96
96
  class Bar; end
97
97
 
98
98
  module Foo
@@ -121,9 +121,42 @@ module RubyIndexer
121
121
 
122
122
  def test_index_single_ignores_directories
123
123
  FileUtils.mkdir("lib/this_is_a_dir.rb")
124
- @index.index_single("lib/this_is_a_dir.rb")
124
+ @index.index_single(IndexablePath.new(nil, "lib/this_is_a_dir.rb"))
125
125
  ensure
126
126
  FileUtils.rm_r("lib/this_is_a_dir.rb")
127
127
  end
128
+
129
+ def test_searching_for_require_paths
130
+ @index.index_single(IndexablePath.new("/fake", "/fake/path/foo.rb"), <<~RUBY)
131
+ class Foo
132
+ end
133
+ RUBY
134
+ @index.index_single(IndexablePath.new("/fake", "/fake/path/other_foo.rb"), <<~RUBY)
135
+ class Foo
136
+ end
137
+ RUBY
138
+
139
+ assert_equal(["path/foo", "path/other_foo"], @index.search_require_paths("path"))
140
+ end
141
+
142
+ def test_searching_for_entries_based_on_prefix
143
+ @index.index_single(IndexablePath.new("/fake", "/fake/path/foo.rb"), <<~RUBY)
144
+ class Foo::Bar
145
+ end
146
+ RUBY
147
+ @index.index_single(IndexablePath.new("/fake", "/fake/path/other_foo.rb"), <<~RUBY)
148
+ class Foo::Bar
149
+ end
150
+
151
+ class Foo::Baz
152
+ end
153
+ RUBY
154
+
155
+ results = @index.prefix_search("Foo", []).map { |entries| entries.map(&:name) }
156
+ assert_equal([["Foo::Bar", "Foo::Bar"], ["Foo::Baz"]], results)
157
+
158
+ results = @index.prefix_search("Ba", ["Foo"]).map { |entries| entries.map(&:name) }
159
+ assert_equal([["Foo::Bar", "Foo::Bar"], ["Foo::Baz"]], results)
160
+ end
128
161
  end
129
162
  end
@@ -0,0 +1,150 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "test_helper"
5
+
6
+ module RubyIndexer
7
+ class PrefixTreeTest < Minitest::Test
8
+ def test_empty
9
+ tree = PrefixTree.new
10
+
11
+ assert_empty(tree.search(""))
12
+ assert_empty(tree.search("foo"))
13
+ end
14
+
15
+ def test_single_item
16
+ tree = PrefixTree.new
17
+ tree.insert("foo", "foo")
18
+
19
+ assert_equal(["foo"], tree.search(""))
20
+ assert_equal(["foo"], tree.search("foo"))
21
+ assert_empty(tree.search("bar"))
22
+ end
23
+
24
+ def test_multiple_items
25
+ tree = PrefixTree[String].new
26
+ ["foo", "bar", "baz"].each { |item| tree.insert(item, item) }
27
+
28
+ assert_equal(["foo", "bar", "baz"], tree.search(""))
29
+ assert_equal(["bar", "baz"], tree.search("b"))
30
+ assert_equal(["foo"], tree.search("fo"))
31
+ assert_equal(["bar", "baz"], tree.search("ba"))
32
+ assert_equal(["baz"], tree.search("baz"))
33
+ assert_empty(tree.search("qux"))
34
+ end
35
+
36
+ def test_multiple_prefixes
37
+ tree = PrefixTree[String].new
38
+ ["fo", "foo"].each { |item| tree.insert(item, item) }
39
+
40
+ assert_equal(["fo", "foo"], tree.search(""))
41
+ assert_equal(["fo", "foo"], tree.search("f"))
42
+ assert_equal(["fo", "foo"], tree.search("fo"))
43
+ assert_equal(["foo"], tree.search("foo"))
44
+ assert_empty(tree.search("fooo"))
45
+ end
46
+
47
+ def test_multiple_prefixes_with_shuffled_order
48
+ tree = PrefixTree[String].new
49
+ [
50
+ "foo/bar/base",
51
+ "foo/bar/on",
52
+ "foo/bar/support/selection",
53
+ "foo/bar/support/runner",
54
+ "foo/internal",
55
+ "foo/bar/document",
56
+ "foo/bar/code",
57
+ "foo/bar/support/rails",
58
+ "foo/bar/diagnostics",
59
+ "foo/bar/document2",
60
+ "foo/bar/support/runner2",
61
+ "foo/bar/support/diagnostic",
62
+ "foo/document",
63
+ "foo/bar/formatting",
64
+ "foo/bar/support/highlight",
65
+ "foo/bar/semantic",
66
+ "foo/bar/support/prefix",
67
+ "foo/bar/folding",
68
+ "foo/bar/selection",
69
+ "foo/bar/support/syntax",
70
+ "foo/bar/document3",
71
+ "foo/bar/hover",
72
+ "foo/bar/support/semantic",
73
+ "foo/bar/support/source",
74
+ "foo/bar/inlay",
75
+ "foo/requests",
76
+ "foo/bar/support/formatting",
77
+ "foo/bar/path",
78
+ "foo/executor",
79
+ ].each { |item| tree.insert(item, item) }
80
+
81
+ assert_equal(
82
+ [
83
+ "foo/bar/support/selection",
84
+ "foo/bar/support/semantic",
85
+ "foo/bar/support/syntax",
86
+ "foo/bar/support/source",
87
+ "foo/bar/support/runner",
88
+ "foo/bar/support/runner2",
89
+ "foo/bar/support/rails",
90
+ "foo/bar/support/diagnostic",
91
+ "foo/bar/support/highlight",
92
+ "foo/bar/support/prefix",
93
+ "foo/bar/support/formatting",
94
+ ],
95
+ tree.search("foo/bar/support"),
96
+ )
97
+ end
98
+
99
+ def test_deletion
100
+ tree = PrefixTree[String].new
101
+ ["foo/bar", "foo/baz"].each { |item| tree.insert(item, item) }
102
+ assert_equal(["foo/bar", "foo/baz"], tree.search("foo"))
103
+
104
+ tree.delete("foo/bar")
105
+ assert_empty(tree.search("foo/bar"))
106
+ assert_equal(["foo/baz"], tree.search("foo"))
107
+ end
108
+
109
+ def test_delete_does_not_impact_other_keys_with_the_same_value
110
+ tree = PrefixTree[String].new
111
+ tree.insert("key1", "value")
112
+ tree.insert("key2", "value")
113
+ assert_equal(["value", "value"], tree.search("key"))
114
+
115
+ tree.delete("key2")
116
+ assert_empty(tree.search("key2"))
117
+ assert_equal(["value"], tree.search("key1"))
118
+ end
119
+
120
+ def test_deleted_node_is_removed_from_the_tree
121
+ tree = PrefixTree[String].new
122
+ tree.insert("foo/bar", "foo/bar")
123
+ assert_equal(["foo/bar"], tree.search("foo"))
124
+
125
+ tree.delete("foo/bar")
126
+ root = tree.instance_variable_get(:@root)
127
+ assert_empty(root.children)
128
+ end
129
+
130
+ def test_deleting_non_terminal_nodes
131
+ tree = PrefixTree[String].new
132
+ tree.insert("abc", "value1")
133
+ tree.insert("abcdef", "value2")
134
+
135
+ tree.delete("abcdef")
136
+ assert_empty(tree.search("abcdef"))
137
+ assert_equal(["value1"], tree.search("abc"))
138
+ end
139
+
140
+ def test_overriding_values
141
+ tree = PrefixTree[Integer].new
142
+
143
+ tree.insert("foo/bar", 123)
144
+ assert_equal([123], tree.search("foo/bar"))
145
+
146
+ tree.insert("foo/bar", 456)
147
+ assert_equal([456], tree.search("foo/bar"))
148
+ end
149
+ end
150
+ end
@@ -12,7 +12,7 @@ module RubyIndexer
12
12
  private
13
13
 
14
14
  def index(source)
15
- @index.index_single("/fake/path/foo.rb", source)
15
+ @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), source)
16
16
  end
17
17
 
18
18
  def assert_entry(expected_name, type, expected_location)
@@ -104,6 +104,7 @@ module RubyLsp
104
104
  emitter.visit(document.tree) if document.parsed?
105
105
 
106
106
  code_lens.merge_external_listeners_responses!
107
+ document_symbol.merge_external_listeners_responses!
107
108
 
108
109
  # Store all responses retrieve in this round of visits in the cache and then return the response for the request
109
110
  # we actually received
@@ -177,6 +178,8 @@ module RubyLsp
177
178
  workspace_symbol(request.dig(:params, :query))
178
179
  when "rubyLsp/textDocument/showSyntaxTree"
179
180
  show_syntax_tree(uri, request.dig(:params, :range))
181
+ else
182
+ VOID
180
183
  end
181
184
  end
182
185
 
@@ -188,14 +191,17 @@ module RubyLsp
188
191
  file_path = uri.to_standardized_path
189
192
  next if file_path.nil? || File.directory?(file_path)
190
193
 
194
+ load_path_entry = $LOAD_PATH.find { |load_path| file_path.start_with?(load_path) }
195
+ indexable = RubyIndexer::IndexablePath.new(load_path_entry, file_path)
196
+
191
197
  case change[:type]
192
198
  when Constant::FileChangeType::CREATED
193
- @index.index_single(file_path)
199
+ @index.index_single(indexable)
194
200
  when Constant::FileChangeType::CHANGED
195
- @index.delete(file_path)
196
- @index.index_single(file_path)
201
+ @index.delete(indexable)
202
+ @index.index_single(indexable)
197
203
  when Constant::FileChangeType::DELETED
198
- @index.delete(file_path)
204
+ @index.delete(indexable)
199
205
  end
200
206
  end
201
207
 
@@ -297,7 +303,9 @@ module RubyLsp
297
303
  hover.response
298
304
  end
299
305
 
300
- sig { params(uri: URI::Generic, content_changes: T::Array[Document::EditShape], version: Integer).returns(Object) }
306
+ sig do
307
+ params(uri: URI::Generic, content_changes: T::Array[Document::EditShape], version: Integer).returns(Object)
308
+ end
301
309
  def text_document_did_change(uri, content_changes, version)
302
310
  @store.push_edits(uri: uri, edits: content_changes, version: version)
303
311
  VOID
@@ -501,7 +509,7 @@ module RubyLsp
501
509
  return unless target
502
510
 
503
511
  emitter = EventEmitter.new
504
- listener = Requests::PathCompletion.new(emitter, @message_queue)
512
+ listener = Requests::PathCompletion.new(@index, emitter, @message_queue)
505
513
  emitter.emit_for_target(target)
506
514
  listener.response
507
515
  end
@@ -124,5 +124,14 @@ module RubyLsp
124
124
  ).returns(T.nilable(Listener[T.nilable(Interface::Hover)]))
125
125
  end
126
126
  def create_hover_listener(emitter, message_queue); end
127
+
128
+ # Creates a new DocumentSymbol listener. This method is invoked on every DocumentSymbol request
129
+ sig do
130
+ overridable.params(
131
+ emitter: EventEmitter,
132
+ message_queue: Thread::Queue,
133
+ ).returns(T.nilable(Listener[T::Array[Interface::DocumentSymbol]]))
134
+ end
135
+ def create_document_symbol_listener(emitter, message_queue); end
127
136
  end
128
137
  end
@@ -60,6 +60,10 @@ module RubyLsp
60
60
  T::Array[T.any(SymbolHierarchyRoot, Interface::DocumentSymbol)],
61
61
  )
62
62
 
63
+ @external_listeners.concat(
64
+ Extension.extensions.filter_map { |ext| ext.create_document_symbol_listener(emitter, message_queue) },
65
+ )
66
+
63
67
  emitter.register(
64
68
  self,
65
69
  :on_class,
@@ -75,6 +79,13 @@ module RubyLsp
75
79
  )
76
80
  end
77
81
 
82
+ # Merges responses from other listeners
83
+ sig { override.params(other: Listener[ResponseType]).returns(T.self_type) }
84
+ def merge_response!(other)
85
+ @response.concat(other.response)
86
+ self
87
+ end
88
+
78
89
  sig { params(node: SyntaxTree::ClassDeclaration).void }
79
90
  def on_class(node)
80
91
  @stack << create_document_symbol(
@@ -22,33 +22,24 @@ module RubyLsp
22
22
  sig { override.returns(ResponseType) }
23
23
  attr_reader :response
24
24
 
25
- sig { params(emitter: EventEmitter, message_queue: Thread::Queue).void }
26
- def initialize(emitter, message_queue)
27
- super
25
+ sig { params(index: RubyIndexer::Index, emitter: EventEmitter, message_queue: Thread::Queue).void }
26
+ def initialize(index, emitter, message_queue)
27
+ super(emitter, message_queue)
28
28
  @response = T.let([], ResponseType)
29
- @tree = T.let(Support::PrefixTree.new(collect_load_path_files), Support::PrefixTree)
29
+ @index = index
30
30
 
31
31
  emitter.register(self, :on_tstring_content)
32
32
  end
33
33
 
34
34
  sig { params(node: SyntaxTree::TStringContent).void }
35
35
  def on_tstring_content(node)
36
- @tree.search(node.value).sort.each do |path|
36
+ @index.search_require_paths(node.value).sort!.each do |path|
37
37
  @response << build_completion(path, node)
38
38
  end
39
39
  end
40
40
 
41
41
  private
42
42
 
43
- sig { returns(T::Array[String]) }
44
- def collect_load_path_files
45
- $LOAD_PATH.flat_map do |p|
46
- Dir.glob("**/*.rb", base: p)
47
- end.map! do |result|
48
- result.delete_suffix!(".rb")
49
- end
50
- end
51
-
52
43
  sig { params(label: String, node: SyntaxTree::TStringContent).returns(Interface::CompletionItem) }
53
44
  def build_completion(label, node)
54
45
  Interface::CompletionItem.new(
@@ -53,7 +53,6 @@ module RubyLsp
53
53
  autoload :Sorbet, "ruby_lsp/requests/support/sorbet"
54
54
  autoload :HighlightTarget, "ruby_lsp/requests/support/highlight_target"
55
55
  autoload :RailsDocumentClient, "ruby_lsp/requests/support/rails_document_client"
56
- autoload :PrefixTree, "ruby_lsp/requests/support/prefix_tree"
57
56
  autoload :Common, "ruby_lsp/requests/support/common"
58
57
  autoload :FormatterRunner, "ruby_lsp/requests/support/formatter_runner"
59
58
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-08-29 00:00:00.000000000 Z
11
+ date: 2023-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: language_server-protocol
@@ -67,7 +67,7 @@ dependencies:
67
67
  version: '0.9'
68
68
  - - "<"
69
69
  - !ruby/object:Gem::Version
70
- version: '0.10'
70
+ version: '0.11'
71
71
  type: :runtime
72
72
  prerelease: false
73
73
  version_requirements: !ruby/object:Gem::Requirement
@@ -77,7 +77,7 @@ dependencies:
77
77
  version: '0.9'
78
78
  - - "<"
79
79
  - !ruby/object:Gem::Version
80
- version: '0.10'
80
+ version: '0.11'
81
81
  description: An opinionated language server for Ruby
82
82
  email:
83
83
  - ruby@shopify.com
@@ -97,12 +97,15 @@ files:
97
97
  - lib/ruby-lsp.rb
98
98
  - lib/ruby_indexer/lib/ruby_indexer/configuration.rb
99
99
  - lib/ruby_indexer/lib/ruby_indexer/index.rb
100
+ - lib/ruby_indexer/lib/ruby_indexer/indexable_path.rb
101
+ - lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb
100
102
  - lib/ruby_indexer/lib/ruby_indexer/visitor.rb
101
103
  - lib/ruby_indexer/ruby_indexer.rb
102
104
  - lib/ruby_indexer/test/classes_and_modules_test.rb
103
105
  - lib/ruby_indexer/test/configuration_test.rb
104
106
  - lib/ruby_indexer/test/constant_test.rb
105
107
  - lib/ruby_indexer/test/index_test.rb
108
+ - lib/ruby_indexer/test/prefix_tree_test.rb
106
109
  - lib/ruby_indexer/test/test_case.rb
107
110
  - lib/ruby_lsp/check_docs.rb
108
111
  - lib/ruby_lsp/document.rb
@@ -135,7 +138,6 @@ files:
135
138
  - lib/ruby_lsp/requests/support/dependency_detector.rb
136
139
  - lib/ruby_lsp/requests/support/formatter_runner.rb
137
140
  - lib/ruby_lsp/requests/support/highlight_target.rb
138
- - lib/ruby_lsp/requests/support/prefix_tree.rb
139
141
  - lib/ruby_lsp/requests/support/rubocop_diagnostic.rb
140
142
  - lib/ruby_lsp/requests/support/rubocop_diagnostics_runner.rb
141
143
  - lib/ruby_lsp/requests/support/rubocop_formatting_runner.rb
@@ -170,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
172
  - !ruby/object:Gem::Version
171
173
  version: '0'
172
174
  requirements: []
173
- rubygems_version: 3.4.18
175
+ rubygems_version: 3.4.19
174
176
  signing_key:
175
177
  specification_version: 4
176
178
  summary: An opinionated language server for Ruby
@@ -1,80 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- module RubyLsp
5
- module Requests
6
- module Support
7
- class PrefixTree
8
- extend T::Sig
9
-
10
- sig { params(items: T::Array[String]).void }
11
- def initialize(items)
12
- @root = T.let(Node.new(""), Node)
13
-
14
- items.each do |item|
15
- insert(item)
16
- end
17
- end
18
-
19
- sig { params(prefix: String).returns(T::Array[String]) }
20
- def search(prefix)
21
- node = T.let(@root, Node)
22
-
23
- prefix.each_char do |char|
24
- snode = node.children[char]
25
- return [] unless snode
26
-
27
- node = snode
28
- end
29
-
30
- node.collect
31
- end
32
-
33
- private
34
-
35
- sig { params(item: String).void }
36
- def insert(item)
37
- node = T.let(@root, Node)
38
-
39
- item.each_char do |char|
40
- node = node.children[char] ||= Node.new(node.value + char)
41
- end
42
-
43
- node.leaf = true
44
- end
45
-
46
- class Node
47
- extend T::Sig
48
-
49
- sig { returns(T::Hash[String, Node]) }
50
- attr_reader :children
51
-
52
- sig { returns(String) }
53
- attr_reader :value
54
-
55
- sig { returns(T::Boolean) }
56
- attr_accessor :leaf
57
-
58
- sig { params(value: String).void }
59
- def initialize(value)
60
- @children = T.let({}, T::Hash[String, Node])
61
- @value = T.let(value, String)
62
- @leaf = T.let(false, T::Boolean)
63
- end
64
-
65
- sig { returns(T::Array[String]) }
66
- def collect
67
- result = T.let([], T::Array[String])
68
- result << value if leaf
69
-
70
- children.each_value do |node|
71
- result.concat(node.collect)
72
- end
73
-
74
- result
75
- end
76
- end
77
- end
78
- end
79
- end
80
- end