hind 0.1.14 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edc5339a6fe71b30f48781500d996901bef7732b5d6ed6a54ce561d057edce19
4
- data.tar.gz: 8dd64ea610eaf53314e918d91846d42839b522550e2b29a2a626228f7c459fbd
3
+ metadata.gz: ba0a23c04400d06ebf294a87558c5893fb195b0e477acf49772598827f1afdba
4
+ data.tar.gz: a213c39aa228dd18612d45b85a1087aaec375ba3ba2477dc5dd7ff14dd0a094a
5
5
  SHA512:
6
- metadata.gz: d85651252a56a48c32c3285205cea32d0aa99e8098b396168cb1bf3357b61a9deff6ef90ceecee3b891761205bcb1d420235d4859b780629555080c040d9b25e
7
- data.tar.gz: e69fa976556441508e1f46f7bb2636989a3cb810922636b1e460e3fa68130c7dc60538e55609349365506bf3fe21807a379c4afd5f54f4272582f02071164eef
6
+ metadata.gz: d806daccb7faa4378c8bd644ff094b6fb8dda612ecaae3536cdf5f2e6e6551ebbdd0d5de35612daa4a7e9727c2e51fee44c6a0fe029882397f19dbbda9a15cae
7
+ data.tar.gz: a0586d0ec3f980ae369138fd712fe670162be4ac6444f495fb8c414253fcfd251df4c4ccec80988305178ff20ddbb93034bbe1b812058b752b8a9fe14e31d66e
data/README.md CHANGED
@@ -18,6 +18,10 @@ gem 'hind'
18
18
 
19
19
  ## Usage
20
20
 
21
+ ### GitLab CI Integration
22
+
23
+ See [GitLab CI Integration Guide](docs/gitlab_ci.md) for detailed instructions.
24
+
21
25
  ### Generating LSIF Data
22
26
 
23
27
  To generate LSIF data for your Ruby project:
@@ -39,6 +43,16 @@ hind lsif -e "test/**/*" -e "spec/**/*"
39
43
  hind lsif -v
40
44
  ```
41
45
 
46
+ ### Generating SCIP Data
47
+
48
+ To generate SCIP data for your Ruby project:
49
+
50
+ ```bash
51
+ hind scip
52
+ ```
53
+
54
+ This will produce an `index.scip` file which is a JSON serialization of the SCIP index.
55
+
42
56
  Options:
43
57
  - `-d, --directory DIR` - Root directory to process (default: current directory)
44
58
  - `-o, --output FILE` - Output file path (default: dump.lsif)
@@ -100,6 +114,25 @@ bundle exec rspec
100
114
  bundle exec bin/hind
101
115
  ```
102
116
 
117
+ ## Roadmap / TODO
118
+
119
+ The following features are planned for future releases to improve indexing fidelity and performance:
120
+
121
+ - **Ruby Semantic Fidelity**
122
+ - [ ] Ancestor Chain Resolution (`include`, `extend`, `prepend`)
123
+ - [ ] Local Variables & Method Parameters
124
+ - [ ] Dynamic Method Support (`attr_accessor`, `delegate`, etc.)
125
+ - [ ] Block & Lambda Scopes
126
+ - **External Dependencies**
127
+ - [ ] External Gem Resolution (linking to symbols in dependencies)
128
+ - [ ] Core & Standard Library Linking
129
+ - **Advanced SCIP/LSIF Features**
130
+ - [ ] SCIP Relationships (implementation/inheritance edges)
131
+ - [ ] LSIF Monikers for cross-repo resolution
132
+ - **Performance**
133
+ - [ ] Parallel Processing for large codebases
134
+ - [ ] Incremental Indexing
135
+
103
136
  ## Contributing
104
137
 
105
138
  Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/hind.
data/lib/hind/cli.rb CHANGED
@@ -5,33 +5,98 @@ require 'json'
5
5
  require 'pathname'
6
6
  require 'fileutils'
7
7
 
8
+ require_relative 'lsif/global_state'
9
+ require_relative 'lsif/generator'
10
+ require_relative 'scip/generator'
11
+
8
12
  module Hind
9
13
  class CLI < Thor
10
14
  class_option :verbose, type: :boolean, aliases: '-v', desc: 'Enable verbose output'
11
15
 
12
- desc 'lsif', 'Generate LSIF index for Ruby classes, modules, and constants'
13
- method_option :directory, type: :string, aliases: '-d', default: '.', desc: 'Root directory to process'
16
+ desc 'lsif [DIRECTORY]', 'Generate LSIF index for Ruby classes, modules, and constants'
17
+ method_option :directory, type: :string, aliases: '-d', desc: 'Root directory to process (deprecated, use positional argument)'
14
18
  method_option :output, type: :string, aliases: '-o', default: 'dump.lsif', desc: 'Output file path'
15
19
  method_option :glob, type: :string, aliases: '-g', default: '**/*.rb', desc: 'File pattern to match'
16
20
  method_option :force, type: :boolean, aliases: '-f', desc: 'Overwrite output file if it exists'
17
21
  method_option :exclude, type: :array, aliases: '-e', desc: 'Patterns to exclude'
18
- def lsif
19
- validate_directory(options[:directory])
22
+ def lsif(dir = options[:directory] || '.')
23
+ validate_directory(dir)
20
24
  validate_output_file(options[:output], options[:force])
21
25
 
22
- files = find_files(options[:directory], options[:glob], options[:exclude])
23
- abort "No files found matching pattern '#{options[:glob]}'" if files.empty?
26
+ files = find_files(dir, options[:glob], options[:exclude])
27
+ abort "No files found matching pattern '#{options[:glob]}' in #{dir}" if files.empty?
24
28
 
25
- say "Found #{files.length} files to process", :green if options[:verbose]
29
+ say "Found #{files.length} files to process in #{dir}", :green if options[:verbose]
26
30
 
27
31
  begin
28
- generate_lsif(files, options)
32
+ generate_lsif(files, dir, options)
33
+
34
+ # Add debug info from the global state
35
+ if options[:verbose]
36
+ debug_info = LSIF::GlobalState.instance.debug_info
37
+ say "\nGlobal State Summary:", :cyan
38
+ say " Classes: #{debug_info[:classes_count]} (#{debug_info[:open_classes_count]} open classes)", :cyan
39
+ say " Modules: #{debug_info[:modules_count]} (#{debug_info[:open_modules_count]} open modules)", :cyan
40
+ say " Constants: #{debug_info[:constants_count]}", :cyan
41
+ say " References: #{debug_info[:references_count]}", :cyan
42
+ say " Result Sets: #{debug_info[:result_sets_count]}", :cyan
43
+
44
+ # Report on the most frequently reopened classes/modules
45
+ if debug_info[:open_classes_count] > 0
46
+ most_opened_classes = LSIF::GlobalState.instance.classes
47
+ .map { |name, data| [name, data[:definitions].size] }
48
+ .select { |_, count| count > 1 }
49
+ .sort_by { |_, count| -count }
50
+ .take(5)
51
+
52
+ say "\nMost frequently reopened classes:", :cyan
53
+ most_opened_classes.each do |name, count|
54
+ say " #{name}: #{count} definitions", :cyan
55
+ end
56
+ end
57
+
58
+ if debug_info[:open_modules_count] > 0
59
+ most_opened_modules = LSIF::GlobalState.instance.modules
60
+ .map { |name, data| [name, data[:definitions].size] }
61
+ .select { |_, count| count > 1 }
62
+ .sort_by { |_, count| -count }
63
+ .take(5)
64
+
65
+ say "\nMost frequently reopened modules:", :cyan
66
+ most_opened_modules.each do |name, count|
67
+ say " #{name}: #{count} definitions", :cyan
68
+ end
69
+ end
70
+ end
71
+
29
72
  say "\nLSIF data has been written to: #{options[:output]}", :green if options[:verbose]
30
73
  rescue => e
31
74
  handle_error(e, options[:verbose])
32
75
  end
33
76
  end
34
77
 
78
+ desc 'scip [DIRECTORY]', 'Generate SCIP index'
79
+ method_option :directory, type: :string, aliases: '-d', desc: 'Root directory to process (deprecated, use positional argument)'
80
+ method_option :output, type: :string, aliases: '-o', default: 'index.scip', desc: 'Output file path'
81
+ method_option :glob, type: :string, aliases: '-g', default: '**/*.rb', desc: 'File pattern to match'
82
+ method_option :force, type: :boolean, aliases: '-f', desc: 'Overwrite output file if it exists'
83
+ method_option :exclude, type: :array, aliases: '-e', desc: 'Patterns to exclude'
84
+ def scip(dir = options[:directory] || '.')
85
+ validate_directory(dir)
86
+ validate_output_file(options[:output], options[:force])
87
+
88
+ files = find_files(dir, options[:glob], options[:exclude])
89
+ abort "No files found matching pattern '#{options[:glob]}' in #{dir}" if files.empty?
90
+
91
+ say "Found #{files.length} files to process in #{dir}", :green if options[:verbose]
92
+
93
+ generator = Hind::SCIP::Generator.new(File.expand_path(dir))
94
+ index = generator.execute(files)
95
+
96
+ File.write(options[:output], index.to_proto, mode: 'wb')
97
+ say "SCIP index written to #{options[:output]}", :green
98
+ end
99
+
35
100
  desc 'version', 'Show version'
36
101
  def version
37
102
  say "Hind version #{Hind::VERSION}"
@@ -39,76 +104,26 @@ module Hind
39
104
 
40
105
  private
41
106
 
42
- def generate_lsif(files, options)
107
+ def generate_lsif(files, directory, options)
43
108
  # Initialize generator with absolute project root
44
109
  generator = Hind::LSIF::Generator.new(
45
110
  {
46
111
  vertex_id: 1,
47
- initial: true,
48
- projectRoot: File.expand_path(options[:directory])
112
+ projectRoot: File.expand_path(directory)
49
113
  }
50
114
  )
51
115
 
52
116
  File.open(options[:output], 'w') do |output_file|
53
- say 'First pass: Collecting declarations...', :cyan if options[:verbose]
117
+ say 'Processing files...', :cyan if options[:verbose]
54
118
 
55
- # Write initial LSIF data (metadata and project vertices)
56
- initial_data = generator.get_initial_data
57
- if initial_data&.any?
58
- say 'Writing initial LSIF data...', :cyan if options[:verbose]
59
- output_file.puts(initial_data.map(&:to_json).join("\n"))
60
- end
119
+ lsif_data = generator.execute(files, options)
61
120
 
62
- # First pass: Process all files to collect declarations
63
- declaration_data = {}
64
- files.each do |file|
65
- absolute_path = File.expand_path(file)
66
- relative_path = Pathname.new(absolute_path)
67
- .relative_path_from(Pathname.new(generator.metadata[:projectRoot]))
68
- .to_s
69
-
70
- begin
71
- content = File.read(absolute_path)
72
- file_declaration_data = generator.collect_file_declarations(content, relative_path)
73
- declaration_data.merge!(file_declaration_data)
74
- rescue => e
75
- warn "Warning: Failed to read file '#{file}': #{e.message}"
76
- next
77
- end
121
+ # Get counts from global state
122
+ if options[:verbose]
123
+ say "Found #{LSIF::GlobalState.instance.classes.size} classes, #{LSIF::GlobalState.instance.modules.size} modules, and #{LSIF::GlobalState.instance.constants.size} constants", :cyan
78
124
  end
79
125
 
80
- say "Found #{declaration_data[:declarations]&.size} declarations (classes, modules, constants)", :cyan if options[:verbose]
81
-
82
- # Write declaration LSIF data next
83
- if declaration_data[:lsif_data].any?
84
- output_file.puts(declaration_data[:lsif_data].map(&:to_json).join("\n"))
85
- end
86
-
87
- say 'Processing files for references...', :cyan if options[:verbose]
88
-
89
- # Second pass: Process each file for references
90
- files.each do |file|
91
- absolute_path = File.expand_path(file)
92
- relative_path = Pathname.new(absolute_path)
93
- .relative_path_from(Pathname.new(generator.metadata[:projectRoot]))
94
- .to_s
95
-
96
- if options[:verbose]
97
- say "Processing file: #{relative_path}", :cyan
98
- end
99
-
100
- begin
101
- content = File.read(absolute_path)
102
- reference_lsif_data = generator.process_file(
103
- content: content,
104
- uri: relative_path
105
- )
106
- output_file.puts(reference_lsif_data.map(&:to_json).join("\n"))
107
- rescue => e
108
- warn "Warning: Failed to read file '#{file}': #{e.message}"
109
- next
110
- end
111
- end
126
+ output_file.puts(lsif_data.map(&:to_json).join("\n"))
112
127
  end
113
128
  end
114
129
 
@@ -127,7 +142,12 @@ module Hind
127
142
 
128
143
  def find_files(directory, glob, exclude_patterns)
129
144
  pattern = File.join(directory, glob)
130
- files = Dir.glob(pattern)
145
+ absolute_directory = File.expand_path(directory)
146
+
147
+ files = Dir.glob(pattern).map do |file|
148
+ # Return relative path to the directory for indexing
149
+ Pathname.new(File.expand_path(file)).relative_path_from(Pathname.new(absolute_directory)).to_s
150
+ end
131
151
 
132
152
  exclude_patterns&.each do |exclude|
133
153
  files.reject! { |f| File.fnmatch?(exclude, f) }
@@ -7,13 +7,14 @@ require 'pathname'
7
7
 
8
8
  require_relative 'visitors/declaration_visitor'
9
9
  require_relative 'visitors/reference_visitor'
10
+ require_relative 'global_state'
10
11
 
11
12
  module Hind
12
13
  module LSIF
13
14
  class Generator
14
15
  LSIF_VERSION = '0.4.3'
15
16
 
16
- attr_reader :metadata, :global_state, :document_id, :current_uri
17
+ attr_reader :metadata, :document_id, :current_uri
17
18
 
18
19
  def initialize(metadata = {})
19
20
  @vertex_id = metadata[:vertex_id] || 1
@@ -22,73 +23,91 @@ module Hind
22
23
  projectRoot: File.expand_path(metadata[:projectRoot] || Dir.pwd)
23
24
  }.merge(metadata)
24
25
 
25
- @global_state = GlobalState.new
26
+ # Reset the global state when initializing a new generator
27
+ GlobalState.instance.reset
26
28
  @document_ids = {}
27
29
  @current_document_id = nil
28
30
  @lsif_data = []
29
31
  @current_uri = nil
30
32
  @last_vertex_id = @vertex_id
31
-
32
- initialize_project if metadata[:initial]
33
+ @last_reference_index = 0
34
+ @initial_data = []
35
+ @initial_data_emitted = false
33
36
  end
34
37
 
35
- def collect_file_declarations(content, path)
36
- @current_uri = path
37
- @document_id = nil
38
- @current_document_id = nil
38
+ def execute(files, options)
39
+ # We need to ensure metadata/project vertices are the very first ones.
40
+ # If we collect declarations first, we might emit vertices.
41
+ # So we should initialize project first.
42
+
43
+ unless @initial_data_emitted
44
+ initialize_project
45
+ @initial_data_emitted = true
46
+ # Ensure these are at the start of @lsif_data
47
+ # specific logic: if we just added them, they are at the end (idx 0 if empty)
48
+ # but if we called collect_declarations separately?
49
+ # Actually, execute is the main entry point. @lsif_data is empty approx.
50
+ end
39
51
 
40
- begin
41
- ast = Parser.new(content).parse
42
- setup_document
43
- visitor = DeclarationVisitor.new(self, path)
44
- visitor.visit(ast)
45
- finalize_document_state
46
- rescue => e
47
- warn "Warning: Failed to collect declarations from '#{path}': #{e.message}"
52
+ # First pass: Declarations
53
+ files.each do |file|
54
+ @document_id = nil # Reset for each file
55
+ absolute_path = File.join(@metadata[:projectRoot], file)
56
+ next unless File.exist?(absolute_path)
57
+
58
+ source = File.read(absolute_path)
59
+ collect_file_declarations(source, file)
48
60
  end
49
61
 
50
- # Store the last used vertex ID and reference index
51
- @last_vertex_id = @vertex_id
52
- @last_reference_index = @lsif_data.length
62
+ # Second pass: References
63
+ files.each do |file|
64
+ @document_id = nil # Reset for each file
65
+ absolute_path = File.join(@metadata[:projectRoot], file)
66
+ next unless File.exist?(absolute_path)
53
67
 
54
- {
55
- declarations: @global_state.declarations,
56
- lsif_data: @lsif_data
57
- }
58
- end
68
+ source = File.read(absolute_path)
69
+ process_file(content: source, uri: file)
70
+ end
59
71
 
60
- def process_file(params)
61
- @current_uri = params[:uri]
62
- content = params[:content]
72
+ finalize_document_state
63
73
 
64
- # Restore vertex ID from last declaration pass
65
- @vertex_id = @last_vertex_id
74
+ @lsif_data
75
+ end
66
76
 
67
- @document_id = nil
68
- @current_document_id = nil
77
+ def collect_file_declarations(content, uri)
78
+ @current_uri = uri
79
+ result = Prism.parse(content)
69
80
 
70
- setup_document
71
- ast = Parser.new(content).parse
81
+ declaration_visitor = DeclarationVisitor.new(self, uri)
82
+ result.value.accept(declaration_visitor)
72
83
 
73
- visitor = ReferenceVisitor.new(self, @current_uri)
74
- visitor.visit(ast)
84
+ {lsif_data: @lsif_data - @initial_data}
85
+ ensure
86
+ @current_uri = nil
87
+ end
75
88
 
76
- finalize_document_state
89
+ def process_file(content:, uri:)
90
+ @current_uri = uri
91
+ setup_document if @document_id.nil? || @document_ids[uri].nil?
92
+ @document_id = @document_ids[uri]
93
+ @current_document_id = @document_id
77
94
 
78
- # Update last vertex ID
79
- @last_vertex_id = @vertex_id
95
+ result = Prism.parse(content)
96
+
97
+ reference_visitor = ReferenceVisitor.new(self, uri)
98
+ result.value.accept(reference_visitor)
80
99
 
81
- # Return only the new LSIF data since last call
82
- result = @lsif_data[@last_reference_index..]
83
- @last_reference_index = @lsif_data.length
84
- result
100
+ finalize_document_state
101
+ @lsif_data
102
+ ensure
103
+ @current_uri = nil
85
104
  end
86
105
 
87
106
  def get_initial_data
88
107
  @initial_data
89
108
  end
90
109
 
91
- def register_declaration(declaration)
110
+ def register_class_declaration(declaration)
92
111
  return unless @current_uri && declaration[:node]
93
112
 
94
113
  qualified_name = declaration[:name]
@@ -96,15 +115,8 @@ module Hind
96
115
  setup_document if @document_id.nil?
97
116
  current_doc_id = @document_id
98
117
 
99
- range_id = if declaration[:type] == :constant
100
- create_range(declaration[:node].name_loc)
101
- elsif declaration[:type] == :module
102
- create_range(declaration[:node].constant_path.location)
103
- elsif declaration[:type] == :class
104
- create_range(declaration[:node].constant_path.location)
105
- else
106
- create_range(declaration[:node].location)
107
- end
118
+ range_location = declaration[:range_location] || declaration[:node].constant_path.location
119
+ range_id = create_range(range_location)
108
120
  return unless range_id
109
121
 
110
122
  result_set_id = emit_vertex('resultSet')
@@ -115,7 +127,7 @@ module Hind
115
127
 
116
128
  emit_edge('item', def_result_id, [range_id], 'definitions', current_doc_id)
117
129
 
118
- hover_content = generate_hover_content(declaration)
130
+ hover_content = generate_class_hover_content(declaration)
119
131
  hover_id = emit_vertex('hoverResult', {
120
132
  contents: [{
121
133
  language: 'ruby',
@@ -124,21 +136,96 @@ module Hind
124
136
  })
125
137
  emit_edge('textDocument/hover', result_set_id, hover_id)
126
138
 
127
- @global_state.add_declaration(qualified_name, {
128
- type: declaration[:type],
129
- scope: declaration[:scope],
130
- file: @current_uri,
131
- range_id: range_id,
132
- result_set_id: result_set_id,
133
- document_id: current_doc_id
134
- }.merge(declaration))
139
+ declaration[:range_id] = range_id
140
+ declaration[:result_set_id] = result_set_id
141
+ declaration[:document_id] = current_doc_id
142
+ declaration[:file] = @current_uri
143
+
144
+ GlobalState.instance.add_class(qualified_name, declaration)
145
+
146
+ result_set_id
147
+ end
148
+
149
+ def register_module_declaration(declaration)
150
+ return unless @current_uri && declaration[:node]
151
+
152
+ qualified_name = declaration[:name]
153
+
154
+ setup_document if @document_id.nil?
155
+ current_doc_id = @document_id
156
+
157
+ range_location = declaration[:range_location] || declaration[:node].constant_path.location
158
+ range_id = create_range(range_location)
159
+ return unless range_id
160
+
161
+ result_set_id = emit_vertex('resultSet')
162
+ emit_edge('next', range_id, result_set_id)
163
+
164
+ def_result_id = emit_vertex('definitionResult')
165
+ emit_edge('textDocument/definition', result_set_id, def_result_id)
166
+
167
+ emit_edge('item', def_result_id, [range_id], 'definitions', current_doc_id)
168
+
169
+ hover_content = generate_module_hover_content(declaration)
170
+ hover_id = emit_vertex('hoverResult', {
171
+ contents: [{
172
+ language: 'ruby',
173
+ value: hover_content
174
+ }]
175
+ })
176
+ emit_edge('textDocument/hover', result_set_id, hover_id)
177
+
178
+ declaration[:range_id] = range_id
179
+ declaration[:result_set_id] = result_set_id
180
+ declaration[:document_id] = current_doc_id
181
+ declaration[:file] = @current_uri
182
+
183
+ GlobalState.instance.add_module(qualified_name, declaration)
184
+
185
+ result_set_id
186
+ end
187
+
188
+ def register_constant_declaration(declaration)
189
+ return unless @current_uri && declaration[:node]
190
+
191
+ qualified_name = declaration[:name]
192
+
193
+ setup_document if @document_id.nil?
194
+ current_doc_id = @document_id
195
+
196
+ range_id = create_range(declaration[:node].name_loc)
197
+ return unless range_id
198
+
199
+ result_set_id = emit_vertex('resultSet')
200
+ emit_edge('next', range_id, result_set_id)
201
+
202
+ def_result_id = emit_vertex('definitionResult')
203
+ emit_edge('textDocument/definition', result_set_id, def_result_id)
204
+
205
+ emit_edge('item', def_result_id, [range_id], 'definitions', current_doc_id)
206
+
207
+ hover_content = generate_constant_hover_content(declaration)
208
+ hover_id = emit_vertex('hoverResult', {
209
+ contents: [{
210
+ language: 'ruby',
211
+ value: hover_content
212
+ }]
213
+ })
214
+ emit_edge('textDocument/hover', result_set_id, hover_id)
215
+
216
+ declaration[:range_id] = range_id
217
+ declaration[:result_set_id] = result_set_id
218
+ declaration[:document_id] = current_doc_id
219
+ declaration[:file] = @current_uri
220
+
221
+ GlobalState.instance.add_constant(qualified_name, declaration)
135
222
 
136
223
  result_set_id
137
224
  end
138
225
 
139
226
  def register_reference(reference)
140
227
  return unless @current_uri && reference[:node]
141
- return unless @global_state.has_declaration?(reference[:name])
228
+ return unless GlobalState.instance.has_declaration?(reference[:name])
142
229
 
143
230
  setup_document if @document_id.nil?
144
231
  current_doc_id = @document_id
@@ -146,10 +233,11 @@ module Hind
146
233
  range_id = create_range(reference[:node].location)
147
234
  return unless range_id
148
235
 
149
- declaration = @global_state.declarations[reference[:name]]
150
- return unless declaration[:result_set_id]
236
+ # Get the primary declaration for this reference
237
+ declaration = GlobalState.instance.get_declaration(reference[:name])
238
+ return unless declaration && declaration[:result_set_id]
151
239
 
152
- @global_state.add_reference(reference[:name], @current_uri, range_id, current_doc_id)
240
+ GlobalState.instance.add_reference(reference[:name], @current_uri, range_id, current_doc_id)
153
241
  emit_edge('next', range_id, declaration[:result_set_id])
154
242
 
155
243
  reference_result = emit_vertex('referenceResult')
@@ -170,11 +258,18 @@ module Hind
170
258
  }
171
259
  })
172
260
 
173
- @global_state.project_id = emit_vertex('project', {kind: 'ruby'})
261
+ project_id = emit_vertex('project', {kind: 'ruby'})
262
+ GlobalState.instance.project_id = project_id
263
+
264
+ # Store initial data separately
265
+ @initial_data = @lsif_data.dup
174
266
  end
175
267
 
176
268
  def setup_document
177
- return if @document_id
269
+ if @document_ids[@current_uri]
270
+ @document_id = @document_ids[@current_uri]
271
+ return
272
+ end
178
273
  return unless @current_uri
179
274
 
180
275
  file_path = File.join(@metadata[:projectRoot], @current_uri)
@@ -187,13 +282,13 @@ module Hind
187
282
  @document_ids[@current_uri] = @document_id
188
283
  @current_document_id = @document_id
189
284
 
190
- emit_edge('contains', @global_state.project_id, [@document_id]) if @global_state.project_id
285
+ emit_edge('contains', GlobalState.instance.project_id, [@document_id]) if GlobalState.instance.project_id
191
286
  end
192
287
 
193
288
  def finalize_document_state
194
289
  return unless @current_uri && @document_id
195
290
 
196
- ranges = @global_state.get_ranges_for_file(@current_uri)
291
+ ranges = GlobalState.instance.get_ranges_for_file(@current_uri)
197
292
  if ranges&.any?
198
293
  emit_edge('contains', @document_id, ranges, nil, @document_id)
199
294
  end
@@ -202,6 +297,9 @@ module Hind
202
297
  def create_range(location)
203
298
  return nil unless @current_uri && location
204
299
 
300
+ cached_id = GlobalState.instance.find_range_id(@current_uri, location)
301
+ return cached_id if cached_id
302
+
205
303
  range_id = emit_vertex('range', {
206
304
  start: {
207
305
  line: location.start_line - 1, # Convert from 1-based to 0-based numbering
@@ -213,7 +311,7 @@ module Hind
213
311
  }
214
312
  })
215
313
 
216
- @global_state.add_range(@current_uri, range_id)
314
+ GlobalState.instance.add_range(@current_uri, range_id, location)
217
315
  range_id
218
316
  end
219
317
 
@@ -263,20 +361,19 @@ module Hind
263
361
  @vertex_id - 1
264
362
  end
265
363
 
266
- def generate_hover_content(declaration)
267
- case declaration[:type]
268
- when :class
269
- hover = ["class #{declaration[:name]}"]
270
- hover << " < #{declaration[:superclass]}" if declaration[:superclass]
271
- hover.join
272
- when :module
273
- "module #{declaration[:name]}"
274
- when :constant
275
- value_info = declaration[:node].value.respond_to?(:content) ? " = #{declaration[:node].value.content}" : ''
276
- "#{declaration[:name]}#{value_info}"
277
- else
278
- declaration[:name].to_s
279
- end
364
+ def generate_class_hover_content(declaration)
365
+ hover = ["class #{declaration[:name]}"]
366
+ hover << " < #{declaration[:superclass]}" if declaration[:superclass]
367
+ hover.join
368
+ end
369
+
370
+ def generate_module_hover_content(declaration)
371
+ "module #{declaration[:name]}"
372
+ end
373
+
374
+ def generate_constant_hover_content(declaration)
375
+ value_info = declaration[:node].value.respond_to?(:content) ? " = #{declaration[:node].value.content}" : ''
376
+ "#{declaration[:name]}#{value_info}"
280
377
  end
281
378
 
282
379
  def format_hover_data(data)