rubydex 0.2.9 → 0.4.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +110 -6
  3. data/THIRD_PARTY_LICENSES.html +271 -2
  4. data/exe/rdx +2 -73
  5. data/ext/rubydex/config.c +140 -0
  6. data/ext/rubydex/config.h +16 -0
  7. data/ext/rubydex/declaration.c +1 -1
  8. data/ext/rubydex/definition.c +32 -4
  9. data/ext/rubydex/diagnostic.c +75 -1
  10. data/ext/rubydex/diagnostic.h +2 -0
  11. data/ext/rubydex/graph.c +27 -48
  12. data/ext/rubydex/graph.h +6 -0
  13. data/ext/rubydex/query.c +487 -0
  14. data/ext/rubydex/query.h +8 -0
  15. data/ext/rubydex/reference.c +60 -0
  16. data/ext/rubydex/rubydex.c +4 -0
  17. data/ext/rubydex/utils.c +23 -4
  18. data/ext/rubydex/utils.h +5 -0
  19. data/lib/ruby_lsp/rubydex/addon.rb +211 -0
  20. data/lib/rubydex/cli/command/console.rb +55 -0
  21. data/lib/rubydex/cli/command/lint/explain.rb +74 -0
  22. data/lib/rubydex/cli/command/lint.rb +202 -0
  23. data/lib/rubydex/cli/command/mcp.rb +30 -0
  24. data/lib/rubydex/cli/command/query.rb +70 -0
  25. data/lib/rubydex/cli/command/skill.rb +69 -0
  26. data/lib/rubydex/cli/command.rb +168 -0
  27. data/lib/rubydex/cli.rb +93 -0
  28. data/lib/rubydex/config.rb +59 -0
  29. data/lib/rubydex/diagnostic.rb +12 -3
  30. data/lib/rubydex/errors.rb +42 -1
  31. data/lib/rubydex/graph.rb +10 -3
  32. data/lib/rubydex/linter/custom_rule.rb +97 -0
  33. data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
  34. data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
  35. data/lib/rubydex/linter/rule_loader.rb +36 -0
  36. data/lib/rubydex/linter/rule_test_case.rb +343 -0
  37. data/lib/rubydex/linter/runner.rb +56 -0
  38. data/lib/rubydex/linter.rb +19 -0
  39. data/lib/rubydex/location.rb +3 -0
  40. data/lib/rubydex/mcp_server.rb +1 -2
  41. data/lib/rubydex/related_information.rb +17 -0
  42. data/lib/rubydex/rule.rb +33 -0
  43. data/lib/rubydex/severity.rb +70 -0
  44. data/lib/rubydex/skill.rb +88 -0
  45. data/lib/rubydex/skill_registry.rb +62 -0
  46. data/lib/rubydex/version.rb +1 -1
  47. data/lib/rubydex.rb +6 -0
  48. data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
  49. data/rbi/rubydex.rbi +578 -15
  50. data/rust/Cargo.lock +7 -0
  51. data/rust/rubydex/Cargo.toml +1 -0
  52. data/rust/rubydex/benches/graph_memory.rs +20 -4
  53. data/rust/rubydex/src/compile_assertions.rs +15 -0
  54. data/rust/rubydex/src/config.rs +538 -157
  55. data/rust/rubydex/src/diagnostic.rs +66 -40
  56. data/rust/rubydex/src/errors.rs +0 -1
  57. data/rust/rubydex/src/indexing/local_graph.rs +6 -5
  58. data/rust/rubydex/src/indexing/rbs_indexer.rs +284 -8
  59. data/rust/rubydex/src/indexing/ruby_indexer.rs +59 -70
  60. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +195 -86
  61. data/rust/rubydex/src/lib.rs +1 -0
  62. data/rust/rubydex/src/listing.rs +26 -1
  63. data/rust/rubydex/src/main.rs +9 -128
  64. data/rust/rubydex/src/model/declaration.rs +301 -229
  65. data/rust/rubydex/src/model/definitions.rs +27 -26
  66. data/rust/rubydex/src/model/document.rs +43 -7
  67. data/rust/rubydex/src/model/graph.rs +67 -68
  68. data/rust/rubydex/src/model/id.rs +55 -0
  69. data/rust/rubydex/src/model/ids.rs +21 -9
  70. data/rust/rubydex/src/model/name.rs +88 -19
  71. data/rust/rubydex/src/model/references.rs +16 -13
  72. data/rust/rubydex/src/operation/ruby_builder.rs +78 -104
  73. data/rust/rubydex/src/path_helpers.rs +77 -0
  74. data/rust/rubydex/src/query/cypher/schema.rs +853 -0
  75. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  76. data/rust/rubydex/src/query/cypher/tests.rs +253 -0
  77. data/rust/rubydex/src/query/cypher.rs +54 -0
  78. data/rust/rubydex/src/query.rs +125 -43
  79. data/rust/rubydex/src/resolution.rs +368 -395
  80. data/rust/rubydex/src/resolution_tests.rs +504 -78
  81. data/rust/rubydex/src/test_utils/context.rs +2 -1
  82. data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
  83. data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
  84. data/rust/rubydex/tests/cli.rs +4 -4
  85. data/rust/rubydex-sys/src/config_api.rs +205 -0
  86. data/rust/rubydex-sys/src/cypher_api.rs +791 -0
  87. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  88. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  89. data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
  90. data/rust/rubydex-sys/src/graph_api.rs +31 -68
  91. data/rust/rubydex-sys/src/lib.rs +2 -0
  92. data/rust/rubydex-sys/src/name_api.rs +2 -6
  93. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  94. data/rust/rubydex-sys/src/utils.rs +37 -0
  95. data/skills/send-private-method/SKILL.md +133 -0
  96. metadata +37 -2
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module Linter
5
+ module Helpers
6
+ # File and URI access for linter rules.
7
+ module SourceAccessHelpers
8
+ #: (String) -> Location
9
+ def file_location(uri)
10
+ Location.new(uri: uri, start_line: 0, end_line: 0, start_column: 0, end_column: 0)
11
+ end
12
+
13
+ #: (String) -> String
14
+ def path_for_uri(uri)
15
+ file_location(uri).to_file_path
16
+ rescue Location::NotFileUriError
17
+ uri
18
+ end
19
+
20
+ private
21
+
22
+ #: (Location) -> String?
23
+ def source_for_location(location)
24
+ File.read(location.to_file_path)
25
+ rescue Errno::ENOENT
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module Linter
5
+ # Loads project and bundled-gem rules using the Rubydex linter path convention.
6
+ class RuleLoader
7
+ BUILT_IN_RULE_GLOB = File.expand_path("../../rubydex_linter/rules/**/*.rb", __dir__) #: String
8
+ RULE_GLOB = "rubydex_linter/rules/**/*.rb" #: String
9
+
10
+ class << self
11
+ #: (String workspace_path) -> Array[String]
12
+ def paths(workspace_path)
13
+ rule_files = Dir.glob(BUILT_IN_RULE_GLOB)
14
+ rule_files.concat(Dir.glob(RULE_GLOB, base: workspace_path).map do |rule_file|
15
+ File.expand_path(rule_file, workspace_path)
16
+ end)
17
+ rule_files.concat(Gem.find_latest_files(RULE_GLOB)) if ENV["BUNDLE_GEMFILE"]
18
+
19
+ # Bundler can return Rubydex's built-in rules through the gem search as well.
20
+ rule_files.uniq
21
+ end
22
+
23
+ #: (String workspace_path) -> void
24
+ def load(workspace_path)
25
+ paths(workspace_path).each do |rule_file|
26
+ require rule_file
27
+ rescue LoadError, SyntaxError => error
28
+ raise RuleLoadError, "Unable to load linter rules from #{rule_file}: #{error.message}", cause: error
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class RuleLoadError < StandardError; end
35
+ end
36
+ end
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "minitest/test"
5
+ require "pathname"
6
+ require "tmpdir"
7
+ require "uri"
8
+ require "rubydex/linter"
9
+
10
+ module Rubydex
11
+ module Linter
12
+ # Base test case for Rubydex linter rules.
13
+ #
14
+ # Add expected diagnostics below the source:
15
+ #
16
+ # assert_diagnostics(<<~RUBY)
17
+ # FOO = 123
18
+ # ^^^ Failure: FOO
19
+ # RUBY
20
+ #
21
+ # The carets mark the diagnostic range. Use `^{}` for a zero-width range.
22
+ # Pass a hash to test multiple files. Repeat an annotation line for each message line.
23
+ # By default, `FooTest` tests the `Foo` rule.
24
+ class RuleTestCase < Minitest::Test
25
+ DEFAULT_FILE = "test.rb" #: String
26
+
27
+ ANNOTATION_PATTERN = /\A(?<indent>\s*)(?<carets>(?<!\\)\^+|\^\{\})(?:\s(?<message>.*))?\z/ #: Regexp
28
+
29
+ #: type annotation = [Integer, Integer, Integer, String]
30
+
31
+ #: String
32
+ attr_reader :workspace_path
33
+
34
+ #: (String) -> void
35
+ def initialize(name)
36
+ super
37
+ @workspace_path = File.realpath(Dir.mktmpdir("rubydex-rule-test-")) #: String
38
+ end
39
+
40
+ #: -> void
41
+ def teardown
42
+ super
43
+ ensure
44
+ FileUtils.remove_entry(workspace_path)
45
+ end
46
+
47
+ #: -> singleton(CustomRule)
48
+ def rule_class
49
+ @rule_class ||= begin
50
+ name = self.class.to_s.delete_suffix("Test")
51
+ if name == self.class.to_s
52
+ raise "Could not infer rule class from #{self.class}; override `#rule_class`"
53
+ end
54
+
55
+ Object.const_get(name) #: as singleton(CustomRule)
56
+ end #: singleton(CustomRule)?
57
+ end
58
+
59
+ # Registers sources that are loaded into the graph for every assertion.
60
+ # Shared sources must not contain caret annotations. Test-specific
61
+ # sources win on filename collision.
62
+ #: (Hash[String, String]) -> void
63
+ def add_shared_source(sources)
64
+ shared_sources.merge!(sources)
65
+ end
66
+
67
+ # Returns absolute file paths whose diagnostics should be ignored.
68
+ #: -> Array[String]
69
+ def ignored_diagnostic_files
70
+ @ignored_diagnostic_files ||= [] #: Array[String]?
71
+ end
72
+
73
+ #: -> LinterConfig
74
+ def rule_config
75
+ @rule_config ||= LinterConfig.new({}) #: LinterConfig?
76
+ end
77
+
78
+ # Runs the rule and compares every diagnostic location with the inline
79
+ # annotations in the corresponding source.
80
+ #: (*(String | Hash[String | Symbol, String])) ?{ (Graph) -> CustomRule } -> Array[Diagnostic]
81
+ def assert_diagnostics(*args, &rule_builder)
82
+ sources = validated_shared_source.merge(normalize_sources(args))
83
+ clean_per_file, expected_per_file = write_sources(sources)
84
+
85
+ diagnostics = run_rule(&rule_builder)
86
+ actual_per_file = annotations_for_diagnostics(diagnostics, clean_per_file)
87
+
88
+ surprise_files = actual_per_file.keys - clean_per_file.keys - ignored_diagnostic_files
89
+ refute_predicate(
90
+ surprise_files,
91
+ :any?,
92
+ "Diagnostics in unexpected files: #{surprise_files.inspect}",
93
+ )
94
+
95
+ clean_per_file.each do |filename, clean|
96
+ expected = render_annotated(clean, expected_per_file[filename] || [])
97
+ actual = render_annotated(clean, actual_per_file[filename] || [])
98
+ assert_equal(expected, actual, "Mismatch in #{filename}")
99
+ end
100
+
101
+ diagnostics
102
+ end
103
+
104
+ # Runs the rule and asserts no diagnostics are reported in the provided
105
+ # sources. Sources must not contain caret annotations.
106
+ #: (*(String | Hash[String | Symbol, String])) ?{ (Graph) -> CustomRule } -> Array[Diagnostic]
107
+ def assert_no_diagnostics(*args, &rule_builder)
108
+ sources = validated_shared_source.merge(normalize_sources(args))
109
+ write_sources(sources)
110
+
111
+ diagnostics = run_rule(&rule_builder)
112
+ ignored = ignored_diagnostic_files
113
+ reported = diagnostics.reject { |diagnostic| ignored.include?(diagnostic.location.to_file_path) }
114
+ assert_empty(reported, "Expected no diagnostics, got #{reported.map(&:message).inspect}")
115
+ diagnostics
116
+ end
117
+
118
+ #: (
119
+ #| String,
120
+ #| *(String | Hash[String | Symbol, String]),
121
+ #| ?after_excluding: Array[String],
122
+ #| ) ?{ (Graph) -> CustomRule } -> void
123
+ def assert_handles_missing_required_dependency(dependency, *args, after_excluding: [], &rule_builder)
124
+ sources = validated_shared_source.dup #: Hash[String, String]
125
+ after_excluding.each { |filename| sources.delete(filename) }
126
+ sources.merge!(normalize_sources(args))
127
+ write_sources(sources)
128
+
129
+ error = assert_raises(MissingGraphDependencyError) do
130
+ run_rule(&rule_builder)
131
+ end
132
+
133
+ expected_message = MissingGraphDependencyError.new(rule_class.rule_name, dependency).message
134
+ assert_equal(expected_message, error.message)
135
+ end
136
+
137
+ private
138
+
139
+ #: (Hash[String, String]) -> [Hash[String, String], Hash[String, Array[annotation]]]
140
+ def write_sources(sources)
141
+ clean_per_file = {} #: Hash[String, String]
142
+ expected_per_file = {} #: Hash[String, Array[annotation]]
143
+
144
+ virtual_sources.clear
145
+
146
+ sources.each do |filename, annotated|
147
+ clean, annotations = parse_annotations(annotated)
148
+ if Pathname(filename).absolute?
149
+ absolute_path = filename
150
+ virtual_sources[absolute_path] = clean
151
+ else
152
+ absolute_path = File.join(workspace_path, filename)
153
+ FileUtils.mkdir_p(File.dirname(absolute_path))
154
+ File.write(absolute_path, clean)
155
+ absolute_path = File.realpath(absolute_path)
156
+ end
157
+ clean_per_file[absolute_path] = clean
158
+ expected_per_file[absolute_path] = annotations
159
+ end
160
+
161
+ [clean_per_file, expected_per_file]
162
+ end
163
+
164
+ #: -> Hash[String, String]
165
+ def shared_sources
166
+ @shared_sources ||= {} #: Hash[String, String]?
167
+ end
168
+
169
+ #: -> Hash[String, String]
170
+ def virtual_sources
171
+ @virtual_sources ||= {} #: Hash[String, String]?
172
+ end
173
+
174
+ #: -> Hash[String, String]
175
+ def validated_shared_source
176
+ shared_sources.each do |filename, source|
177
+ _, annotations = parse_annotations(source)
178
+ raise "Shared source #{filename} must not contain caret annotations" unless annotations.empty?
179
+ end
180
+ shared_sources
181
+ end
182
+
183
+ #: (Array[String | Hash[String | Symbol, String]]) -> Hash[String, String]
184
+ def normalize_sources(args)
185
+ case args
186
+ in []
187
+ {}
188
+ in [String => source]
189
+ { DEFAULT_FILE => source }
190
+ in [Hash => hash]
191
+ hash.transform_keys(&:to_s)
192
+ else
193
+ raise ArgumentError, "expected a String or a { filename => source } Hash, got: #{args.inspect}"
194
+ end
195
+ end
196
+
197
+ #: (String) -> [String, Array[annotation]]
198
+ def parse_annotations(annotated_source)
199
+ clean = [] #: Array[String]
200
+ annotations = [] #: Array[annotation]
201
+
202
+ annotated_source.each_line do |line|
203
+ if (match = ANNOTATION_PATTERN.match(line.chomp))
204
+ indent = match[:indent]&.length #: as !nil
205
+ carets = match[:carets] #: as !nil
206
+ start_column = indent
207
+ end_column = if carets == "^{}"
208
+ indent
209
+ else
210
+ indent + carets.length
211
+ end
212
+
213
+ target_line = clean.empty? ? 0 : clean.size - 1
214
+ message = match[:message].to_s
215
+ last = annotations.last
216
+ if last && last[0] == target_line && last[1] == start_column && last[2] == end_column
217
+ last[3] = "#{last[3]}\n#{message}"
218
+ else
219
+ annotations << [target_line, start_column, end_column, message]
220
+ end
221
+ else
222
+ clean << line
223
+ end
224
+ end
225
+
226
+ [clean.join, annotations]
227
+ end
228
+
229
+ #: ?{ (Graph) -> CustomRule } -> Array[Diagnostic]
230
+ def run_rule(&rule_builder)
231
+ graph = Graph.configure_for_workspace(workspace_path)
232
+ file_paths = workspace_file_paths
233
+ graph.index_all(file_paths.reject { |path| File.extname(path) == ".rake" })
234
+ index_rake_files(graph, file_paths)
235
+
236
+ virtual_sources.each do |path, source|
237
+ graph.index_source(uri_for_path(path), source, source_id_for(path))
238
+ end
239
+ graph.resolve
240
+
241
+ rule = rule_builder ? rule_builder.call(graph) : rule_class.new(graph, config: rule_config)
242
+ rule.lint
243
+ rule.diagnostics
244
+ end
245
+
246
+ #: (Graph, Array[String]) -> void
247
+ def index_rake_files(graph, file_paths)
248
+ file_paths.each do |path|
249
+ next unless File.extname(path) == ".rake"
250
+
251
+ graph.index_source(uri_for_path(path), File.read(path), "ruby")
252
+ end
253
+ end
254
+
255
+ #: -> Array[String]
256
+ def workspace_file_paths
257
+ Dir.glob("**/*", base: workspace_path).sort.filter_map do |relative_path|
258
+ absolute_path = File.join(workspace_path, relative_path)
259
+ next unless File.file?(absolute_path)
260
+
261
+ case File.extname(relative_path)
262
+ when ".rb", ".rbi", ".rake", ".rbs"
263
+ File.realpath(absolute_path)
264
+ else
265
+ raise "Unsupported file type: #{relative_path}"
266
+ end
267
+ end
268
+ end
269
+
270
+ #: (String) -> String
271
+ def source_id_for(path)
272
+ case File.extname(path)
273
+ when ".rb", ".rbi", ".rake", ".ru"
274
+ "ruby"
275
+ when ".rbs"
276
+ "rbs"
277
+ else
278
+ raise "Unsupported file type: #{path}"
279
+ end
280
+ end
281
+
282
+ #: (Array[Diagnostic], Hash[String, String]) -> Hash[String, Array[annotation]]
283
+ def annotations_for_diagnostics(diagnostics, sources)
284
+ result = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[annotation]]
285
+
286
+ diagnostics.each do |diagnostic|
287
+ located_messages = [[diagnostic.location, diagnostic.message]] #: Array[[Location, String]]
288
+ diagnostic.related_information.each do |related|
289
+ located_messages << [related.location, related.message]
290
+ end
291
+
292
+ located_messages.each do |location, message|
293
+ path = location.to_file_path
294
+ line_index = location.start_line
295
+ start_column = location.start_column
296
+ end_column =
297
+ if location.end_line == location.start_line
298
+ location.end_column
299
+ else
300
+ clean = sources[path]
301
+ line_text = clean ? (clean.each_line.to_a[line_index] || "").chomp : ""
302
+ line_text.length
303
+ end
304
+
305
+ annotations = result[path] #: as !nil
306
+ annotations << [line_index, start_column, end_column, message.chomp]
307
+ end
308
+ end
309
+
310
+ result
311
+ end
312
+
313
+ #: (String, Array[annotation]) -> String
314
+ def render_annotated(clean_source, annotations)
315
+ lines = clean_source.each_line.to_a
316
+ sorted = annotations.sort_by do |line_index, start_column, end_column, message|
317
+ [line_index, start_column, end_column, message]
318
+ end
319
+ output = lines.dup
320
+
321
+ sorted.reverse_each do |line_index, start_column, end_column, message|
322
+ caret_count = end_column - start_column
323
+ indentation = " " * start_column
324
+ carets = caret_count.zero? ? "^{}" : "^" * caret_count
325
+ markers = if message.empty?
326
+ ["#{indentation}#{carets}\n"]
327
+ else
328
+ message.split("\n", -1).map { |line| "#{indentation}#{carets} #{line}\n" }
329
+ end
330
+ output[line_index + 1, 0] = markers
331
+ end
332
+
333
+ output.join
334
+ end
335
+
336
+ #: (String) -> String
337
+ def uri_for_path(path)
338
+ uri_path = Gem.win_platform? ? "/#{path}" : path
339
+ URI::File.build(path: uri_path).to_s
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Rubydex
6
+ module Linter
7
+ class Runner
8
+ #: Graph
9
+ attr_reader :graph
10
+
11
+ #: Array[singleton(CustomRule)]
12
+ attr_reader :custom_rules
13
+
14
+ #: (Graph, custom_rules: Array[singleton(CustomRule)], config: LinterConfig) -> void
15
+ def initialize(graph, custom_rules:, config:)
16
+ @graph = graph
17
+ @config = config
18
+ @custom_rules = custom_rules.select { |rule| config.rule_enabled?(rule) }.sort_by(&:rule_name)
19
+ @dependency_patterns = Gem.path.map { |path| "#{path}/**/*" } #: Array[String]
20
+ end
21
+
22
+ #: () -> Array[Diagnostic]
23
+ def run
24
+ diagnostics = @graph.diagnostics
25
+
26
+ @custom_rules.each do |rule_class|
27
+ rule = rule_class.new(@graph, config: @config)
28
+ rule.lint
29
+ diagnostics.concat(rule.diagnostics)
30
+ end
31
+
32
+ # Make sure we have the forward/back slash at the end to avoid accidentally matching sibling directories that
33
+ # share a prefix.
34
+ workspace_path = File.join(@graph.workspace_path, "")
35
+
36
+ # Filter out diagnostics that are excluded, inside dependencies or otherwise not a part of the workspace.
37
+ diagnostics.select! do |diagnostic|
38
+ rule = diagnostic.rule
39
+ next false unless @config.rule_enabled?(rule)
40
+
41
+ excluded_patterns = @config.excludes_for(rule).map { |pattern| "#{workspace_path}#{pattern}" }
42
+ excluded_patterns.concat(@dependency_patterns)
43
+
44
+ path = diagnostic.location.to_file_path
45
+
46
+ path.start_with?(workspace_path) &&
47
+ excluded_patterns.none? { |p| File.fnmatch?(p, path, Helpers::PathHelpers::EXCLUDE_FNMATCH_FLAGS) }
48
+ rescue Location::NotFileUriError
49
+ true
50
+ end
51
+
52
+ diagnostics
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+ require "rubydex/linter/helpers/path_helpers"
5
+ require "rubydex/linter/helpers/source_access_helpers"
6
+ require "rubydex/linter/custom_rule"
7
+
8
+ Dir.glob(File.expand_path("../rubydex_linter/rules/**/*.rb", __dir__)).each do |rule_file|
9
+ require rule_file
10
+ end
11
+
12
+ require "rubydex/linter/rule_loader"
13
+ require "rubydex/linter/runner"
14
+
15
+ module Rubydex
16
+ # Framework for running semantic lint rules against a resolved Rubydex graph.
17
+ module Linter
18
+ end
19
+ end
@@ -42,6 +42,9 @@ module Rubydex
42
42
  raise NotFileUriError, "URI is not a file:// URI: #{@uri}" unless uri.scheme == "file"
43
43
 
44
44
  path = uri.path
45
+ raise NotFileUriError, "URI has no file path: #{@uri}" if path.nil? || path.empty?
46
+
47
+ path = URI.decode_uri_component(path)
45
48
  # TODO: This has to go away once we have a proper URI abstraction
46
49
  path.delete_prefix!("/") if Gem.win_platform?
47
50
  path
@@ -28,8 +28,7 @@ module Rubydex
28
28
  def initialize(root_path:, transport: nil)
29
29
  @root_path = root_path
30
30
  @transport = transport
31
- @graph = Graph.new(workspace_path: @root_path)
32
- @graph.load_config
31
+ @graph = Graph.configure_for_workspace(@root_path)
33
32
  @index_finished = false
34
33
  @incoming_queue = Thread::Queue.new
35
34
  @outgoing_queue = Thread::Queue.new
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ class RelatedInformation
5
+ #: String
6
+ attr_reader :message
7
+
8
+ #: Location
9
+ attr_reader :location
10
+
11
+ #: (String, Location) -> void
12
+ def initialize(message, location)
13
+ @message = message
14
+ @location = location
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ # The identity of a rule. We have different mechanisms for collecting diagnostics, but they all share the same
5
+ # fundamental identity: a unique name and a default severity that may be overridden by the user's configuration.
6
+ #
7
+ # By keeping this identity concept unified, we are able to treat rules consistently regardless of whether they were
8
+ # collected by the graph during the analysis or by the linter afterwards.
9
+ #
10
+ # @abstract
11
+ class Rule
12
+ class << self
13
+ #: () -> String
14
+ def rule_name
15
+ name #: as !nil
16
+ .split("::").last #: as !nil
17
+ end
18
+
19
+ # @abstract
20
+ #: () -> singleton(Severity::Base)
21
+ def default_severity
22
+ raise NotImplementedError, "Subclasses must implement the default_severity method"
23
+ end
24
+
25
+ # Returns the resolved severity of the rule based on the given configuration.
26
+ #
27
+ #: (LinterConfig) -> singleton(Severity::Base)
28
+ def severity(config)
29
+ config.severity_for(self) || default_severity
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module Severity
5
+ # @abstract
6
+ class Base
7
+ class << self
8
+ # @abstract
9
+ #: () -> Symbol
10
+ def value
11
+ raise NotImplementedError, "Subclasses must implement the value method"
12
+ end
13
+ end
14
+ end
15
+
16
+ class Error < Base
17
+ class << self
18
+ # @override
19
+ #: () -> Symbol
20
+ def value
21
+ :error
22
+ end
23
+ end
24
+ end
25
+
26
+ class Warning < Base
27
+ class << self
28
+ # @override
29
+ #: () -> Symbol
30
+ def value
31
+ :warning
32
+ end
33
+ end
34
+ end
35
+
36
+ class Information < Base
37
+ class << self
38
+ # @override
39
+ #: () -> Symbol
40
+ def value
41
+ :information
42
+ end
43
+ end
44
+ end
45
+
46
+ class Hint < Base
47
+ class << self
48
+ # @override
49
+ #: () -> Symbol
50
+ def value
51
+ :hint
52
+ end
53
+ end
54
+ end
55
+
56
+ # All severities order based on their importance.
57
+ ALL = [Error, Warning, Information, Hint].freeze #: Array[singleton(Base)]
58
+ VALUE_MAP = ALL.to_h { |severity| [severity.value, severity] }.freeze #: Hash[Symbol, singleton(Base)]
59
+
60
+ class << self
61
+ #: (Symbol) -> singleton(Base)
62
+ def from_value(value)
63
+ severity = VALUE_MAP[value]
64
+ return severity if severity
65
+
66
+ raise ArgumentError, "Invalid severity #{value.inspect}. Expected one of: #{VALUE_MAP.keys.join(", ")}"
67
+ end
68
+ end
69
+ end
70
+ end