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.
- checksums.yaml +4 -4
- data/README.md +110 -6
- data/THIRD_PARTY_LICENSES.html +271 -2
- data/exe/rdx +2 -73
- data/ext/rubydex/config.c +140 -0
- data/ext/rubydex/config.h +16 -0
- data/ext/rubydex/declaration.c +1 -1
- data/ext/rubydex/definition.c +32 -4
- data/ext/rubydex/diagnostic.c +75 -1
- data/ext/rubydex/diagnostic.h +2 -0
- data/ext/rubydex/graph.c +27 -48
- data/ext/rubydex/graph.h +6 -0
- data/ext/rubydex/query.c +487 -0
- data/ext/rubydex/query.h +8 -0
- data/ext/rubydex/reference.c +60 -0
- data/ext/rubydex/rubydex.c +4 -0
- data/ext/rubydex/utils.c +23 -4
- data/ext/rubydex/utils.h +5 -0
- data/lib/ruby_lsp/rubydex/addon.rb +211 -0
- data/lib/rubydex/cli/command/console.rb +55 -0
- data/lib/rubydex/cli/command/lint/explain.rb +74 -0
- data/lib/rubydex/cli/command/lint.rb +202 -0
- data/lib/rubydex/cli/command/mcp.rb +30 -0
- data/lib/rubydex/cli/command/query.rb +70 -0
- data/lib/rubydex/cli/command/skill.rb +69 -0
- data/lib/rubydex/cli/command.rb +168 -0
- data/lib/rubydex/cli.rb +93 -0
- data/lib/rubydex/config.rb +59 -0
- data/lib/rubydex/diagnostic.rb +12 -3
- data/lib/rubydex/errors.rb +42 -1
- data/lib/rubydex/graph.rb +10 -3
- data/lib/rubydex/linter/custom_rule.rb +97 -0
- data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
- data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
- data/lib/rubydex/linter/rule_loader.rb +36 -0
- data/lib/rubydex/linter/rule_test_case.rb +343 -0
- data/lib/rubydex/linter/runner.rb +56 -0
- data/lib/rubydex/linter.rb +19 -0
- data/lib/rubydex/location.rb +3 -0
- data/lib/rubydex/mcp_server.rb +1 -2
- data/lib/rubydex/related_information.rb +17 -0
- data/lib/rubydex/rule.rb +33 -0
- data/lib/rubydex/severity.rb +70 -0
- data/lib/rubydex/skill.rb +88 -0
- data/lib/rubydex/skill_registry.rb +62 -0
- data/lib/rubydex/version.rb +1 -1
- data/lib/rubydex.rb +6 -0
- data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
- data/rbi/rubydex.rbi +578 -15
- data/rust/Cargo.lock +7 -0
- data/rust/rubydex/Cargo.toml +1 -0
- data/rust/rubydex/benches/graph_memory.rs +20 -4
- data/rust/rubydex/src/compile_assertions.rs +15 -0
- data/rust/rubydex/src/config.rs +538 -157
- data/rust/rubydex/src/diagnostic.rs +66 -40
- data/rust/rubydex/src/errors.rs +0 -1
- data/rust/rubydex/src/indexing/local_graph.rs +6 -5
- data/rust/rubydex/src/indexing/rbs_indexer.rs +284 -8
- data/rust/rubydex/src/indexing/ruby_indexer.rs +59 -70
- data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +195 -86
- data/rust/rubydex/src/lib.rs +1 -0
- data/rust/rubydex/src/listing.rs +26 -1
- data/rust/rubydex/src/main.rs +9 -128
- data/rust/rubydex/src/model/declaration.rs +301 -229
- data/rust/rubydex/src/model/definitions.rs +27 -26
- data/rust/rubydex/src/model/document.rs +43 -7
- data/rust/rubydex/src/model/graph.rs +67 -68
- data/rust/rubydex/src/model/id.rs +55 -0
- data/rust/rubydex/src/model/ids.rs +21 -9
- data/rust/rubydex/src/model/name.rs +88 -19
- data/rust/rubydex/src/model/references.rs +16 -13
- data/rust/rubydex/src/operation/ruby_builder.rs +78 -104
- data/rust/rubydex/src/path_helpers.rs +77 -0
- data/rust/rubydex/src/query/cypher/schema.rs +853 -0
- data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
- data/rust/rubydex/src/query/cypher/tests.rs +253 -0
- data/rust/rubydex/src/query/cypher.rs +54 -0
- data/rust/rubydex/src/query.rs +125 -43
- data/rust/rubydex/src/resolution.rs +368 -395
- data/rust/rubydex/src/resolution_tests.rs +504 -78
- data/rust/rubydex/src/test_utils/context.rs +2 -1
- data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
- data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
- data/rust/rubydex/tests/cli.rs +4 -4
- data/rust/rubydex-sys/src/config_api.rs +205 -0
- data/rust/rubydex-sys/src/cypher_api.rs +791 -0
- data/rust/rubydex-sys/src/declaration_api.rs +6 -3
- data/rust/rubydex-sys/src/definition_api.rs +27 -7
- data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
- data/rust/rubydex-sys/src/graph_api.rs +31 -68
- data/rust/rubydex-sys/src/lib.rs +2 -0
- data/rust/rubydex-sys/src/name_api.rs +2 -6
- data/rust/rubydex-sys/src/reference_api.rs +58 -12
- data/rust/rubydex-sys/src/utils.rs +37 -0
- data/skills/send-private-method/SKILL.md +133 -0
- 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
|
data/lib/rubydex/location.rb
CHANGED
|
@@ -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
|
data/lib/rubydex/mcp_server.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/rubydex/rule.rb
ADDED
|
@@ -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
|