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,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/linter"
|
|
4
|
+
|
|
5
|
+
# This add-on is only supported by the beta version of the LSP. We don't want to keep showing window dialogs to users of
|
|
6
|
+
# the stable version until the v0.27 series is stable, so here we are hand-rolling our own `depend_on_ruby_lsp!` method
|
|
7
|
+
# to avoid notifying them.
|
|
8
|
+
lsp_version = Gem::Version.new(RubyLsp::VERSION)
|
|
9
|
+
return unless [">= 0.27.0.beta4", "< 0.28"].all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(lsp_version) }
|
|
10
|
+
|
|
11
|
+
module Rubydex
|
|
12
|
+
module Linter
|
|
13
|
+
module RubyLsp
|
|
14
|
+
class Addon < ::RubyLsp::Addon
|
|
15
|
+
CONFIGURATION_FILE = "rubydex.toml" #: String
|
|
16
|
+
|
|
17
|
+
# @override
|
|
18
|
+
#: () -> String
|
|
19
|
+
def name
|
|
20
|
+
"Rubydex Linter"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @override
|
|
24
|
+
#: () -> String
|
|
25
|
+
def version
|
|
26
|
+
::Rubydex::VERSION
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @override
|
|
30
|
+
#: (::RubyLsp::GlobalState, Thread::Queue) -> void
|
|
31
|
+
def activate(global_state, outgoing_queue)
|
|
32
|
+
@outgoing_queue = outgoing_queue #: Thread::Queue?
|
|
33
|
+
@linter = Linter.new(global_state) #: Linter?
|
|
34
|
+
|
|
35
|
+
global_state.register_formatter(
|
|
36
|
+
"rubydex",
|
|
37
|
+
@linter, #: as !nil
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
register_additional_file_watchers(global_state, outgoing_queue)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @override
|
|
44
|
+
#: () -> void
|
|
45
|
+
def deactivate; end
|
|
46
|
+
|
|
47
|
+
#: (Array[{ uri: String, type: Integer }]) -> void
|
|
48
|
+
def workspace_did_change_watched_files(changes)
|
|
49
|
+
return unless @linter && @outgoing_queue
|
|
50
|
+
|
|
51
|
+
@linter.reload_configuration if changes.any? { |change| configuration_change?(change) }
|
|
52
|
+
@linter.lint!
|
|
53
|
+
|
|
54
|
+
@linter.diagnostics_to_clear.each do |uri|
|
|
55
|
+
@outgoing_queue << ::RubyLsp::Notification.publish_diagnostics(uri, [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@linter.current_diagnostics.each do |uri, diagnostics|
|
|
59
|
+
@outgoing_queue << ::RubyLsp::Notification.publish_diagnostics(uri, diagnostics)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
#: (::RubyLsp::GlobalState, Thread::Queue) -> void
|
|
66
|
+
def register_additional_file_watchers(global_state, outgoing_queue)
|
|
67
|
+
return unless global_state.client_capabilities.supports_watching_files
|
|
68
|
+
|
|
69
|
+
outgoing_queue << ::RubyLsp::Request.new(
|
|
70
|
+
id: "rubydex-linter-file-watcher",
|
|
71
|
+
method: "client/registerCapability",
|
|
72
|
+
params: ::RubyLsp::Interface::RegistrationParams.new(
|
|
73
|
+
registrations: [
|
|
74
|
+
::RubyLsp::Interface::Registration.new(
|
|
75
|
+
id: "workspace/didChangeWatchedFilesRubydexLinter",
|
|
76
|
+
method: "workspace/didChangeWatchedFiles",
|
|
77
|
+
register_options: ::RubyLsp::Interface::DidChangeWatchedFilesRegistrationOptions.new(
|
|
78
|
+
watchers: [
|
|
79
|
+
::RubyLsp::Interface::FileSystemWatcher.new(
|
|
80
|
+
glob_pattern: ::RubyLsp::Interface::RelativePattern.new(
|
|
81
|
+
base_uri: global_state.workspace_uri.to_s,
|
|
82
|
+
pattern: CONFIGURATION_FILE,
|
|
83
|
+
),
|
|
84
|
+
kind: ::RubyLsp::Constant::WatchKind::CREATE | ::RubyLsp::Constant::WatchKind::CHANGE,
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The Ruby LSP forwards every watched file change to every add-on, including the ones registered by other add-ons,
|
|
95
|
+
# so we have to check that the change is actually about our configuration file.
|
|
96
|
+
#: ({ uri: String, type: Integer }) -> bool
|
|
97
|
+
def configuration_change?(change)
|
|
98
|
+
path = URI(change[:uri]).full_path
|
|
99
|
+
return false unless path
|
|
100
|
+
|
|
101
|
+
File.basename(path) == CONFIGURATION_FILE
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class Linter
|
|
106
|
+
include ::RubyLsp::Requests::Support::Formatter
|
|
107
|
+
|
|
108
|
+
DIAGNOSTIC_SEVERITIES = {
|
|
109
|
+
::Rubydex::Severity::Error => ::RubyLsp::Constant::DiagnosticSeverity::ERROR,
|
|
110
|
+
::Rubydex::Severity::Warning => ::RubyLsp::Constant::DiagnosticSeverity::WARNING,
|
|
111
|
+
::Rubydex::Severity::Information => ::RubyLsp::Constant::DiagnosticSeverity::INFORMATION,
|
|
112
|
+
::Rubydex::Severity::Hint => ::RubyLsp::Constant::DiagnosticSeverity::HINT,
|
|
113
|
+
}.freeze #: Hash[singleton(::Rubydex::Severity::Base), Integer]
|
|
114
|
+
|
|
115
|
+
#: Hash[String, Array[::RubyLsp::Interface::Diagnostic]]
|
|
116
|
+
attr_reader :current_diagnostics
|
|
117
|
+
|
|
118
|
+
#: Array[String]
|
|
119
|
+
attr_reader :diagnostics_to_clear
|
|
120
|
+
|
|
121
|
+
#: (::RubyLsp::GlobalState) -> void
|
|
122
|
+
def initialize(global_state)
|
|
123
|
+
@graph = global_state.graph #: ::Rubydex::Graph
|
|
124
|
+
@workspace_path = global_state.workspace_path #: String
|
|
125
|
+
::Rubydex::Linter::RuleLoader.load(@workspace_path)
|
|
126
|
+
@custom_rules = ::Rubydex::Linter::CustomRule.subclasses #: Array[singleton(::Rubydex::Linter::CustomRule)]
|
|
127
|
+
|
|
128
|
+
@config = ::Rubydex::Config.load(@workspace_path) #: ::Rubydex::Config
|
|
129
|
+
@runner = ::Rubydex::Linter::Runner.new(@graph, custom_rules: @custom_rules, config: @config.linter) #: ::Rubydex::Linter::Runner
|
|
130
|
+
|
|
131
|
+
@current_diagnostics = {} #: Hash[String, Array[::RubyLsp::Interface::Diagnostic]]
|
|
132
|
+
@diagnostics_to_clear = [] #: Array[String]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @override
|
|
136
|
+
#: (URI::Generic, ::RubyLsp::Document[untyped]) -> Array[::RubyLsp::Interface::Diagnostic]?
|
|
137
|
+
def run_diagnostic(uri, _document)
|
|
138
|
+
@current_diagnostics[uri.to_s]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @override
|
|
142
|
+
#: (URI::Generic, ::RubyLsp::RubyDocument) -> String?
|
|
143
|
+
def run_formatting(uri, document); end
|
|
144
|
+
|
|
145
|
+
# @override
|
|
146
|
+
#: (URI::Generic, String, Integer) -> String?
|
|
147
|
+
def run_range_formatting(uri, source, base_indentation); end
|
|
148
|
+
|
|
149
|
+
#: () -> void
|
|
150
|
+
def lint!
|
|
151
|
+
@diagnostics_to_clear = @current_diagnostics.keys
|
|
152
|
+
@current_diagnostics.clear
|
|
153
|
+
|
|
154
|
+
linter_config = @config.linter
|
|
155
|
+
|
|
156
|
+
@runner.run.each do |diagnostic|
|
|
157
|
+
uri = diagnostic.location.uri
|
|
158
|
+
(@current_diagnostics[uri] ||= []) << to_lsp_diagnostic(diagnostic, linter_config)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@diagnostics_to_clear -= @current_diagnostics.keys
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
#: () -> void
|
|
165
|
+
def reload_configuration
|
|
166
|
+
@config = ::Rubydex::Config.load(@workspace_path) #: ::Rubydex::Config
|
|
167
|
+
@runner = ::Rubydex::Linter::Runner.new(@graph, custom_rules: @custom_rules, config: @config.linter) #: ::Rubydex::Linter::Runner
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
#: (::Rubydex::Diagnostic, ::Rubydex::LinterConfig) -> ::RubyLsp::Interface::Diagnostic
|
|
173
|
+
def to_lsp_diagnostic(diagnostic, linter_config)
|
|
174
|
+
location = diagnostic.location
|
|
175
|
+
rule = diagnostic.rule
|
|
176
|
+
|
|
177
|
+
::RubyLsp::Interface::Diagnostic.new(
|
|
178
|
+
message: diagnostic.message,
|
|
179
|
+
source: "Rubydex",
|
|
180
|
+
code: rule.rule_name,
|
|
181
|
+
severity: DIAGNOSTIC_SEVERITIES.fetch(rule.severity(linter_config)),
|
|
182
|
+
range: lsp_range(location),
|
|
183
|
+
related_information: diagnostic.related_information.map do |information|
|
|
184
|
+
::RubyLsp::Interface::DiagnosticRelatedInformation.new(
|
|
185
|
+
location: ::RubyLsp::Interface::Location.new(
|
|
186
|
+
uri: information.location.uri,
|
|
187
|
+
range: lsp_range(information.location),
|
|
188
|
+
),
|
|
189
|
+
message: information.message,
|
|
190
|
+
)
|
|
191
|
+
end,
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
#: (::Rubydex::Location) -> ::RubyLsp::Interface::Range
|
|
196
|
+
def lsp_range(location)
|
|
197
|
+
::RubyLsp::Interface::Range.new(
|
|
198
|
+
start: ::RubyLsp::Interface::Position.new(
|
|
199
|
+
line: location.start_line,
|
|
200
|
+
character: location.start_column,
|
|
201
|
+
),
|
|
202
|
+
end: ::RubyLsp::Interface::Position.new(
|
|
203
|
+
line: location.end_line,
|
|
204
|
+
character: location.end_column,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/cli/command"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
# `rdx console` — opens an IRB session with a populated graph for the current workspace.
|
|
8
|
+
class Command
|
|
9
|
+
class Console < Command
|
|
10
|
+
command "console"
|
|
11
|
+
summary "Open an interactive session with a populated graph for the current workspace"
|
|
12
|
+
|
|
13
|
+
#: -> void
|
|
14
|
+
def run
|
|
15
|
+
parse_options!
|
|
16
|
+
|
|
17
|
+
start_irb(build_graph($stderr))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Starts IRB with `graph` in scope.
|
|
23
|
+
#
|
|
24
|
+
#: (Rubydex::Graph graph) -> void
|
|
25
|
+
def start_irb(graph)
|
|
26
|
+
require_irb
|
|
27
|
+
|
|
28
|
+
IRB.setup(nil)
|
|
29
|
+
IRB.conf[:IRB_NAME] = "rubydex"
|
|
30
|
+
|
|
31
|
+
# The session uses IRB's own workspace rather than a binding captured here: `IRB::WorkSpace`
|
|
32
|
+
# derives its binding from a copy of `IRB::TOPLEVEL_BINDING`, so `self` is `main` and
|
|
33
|
+
# `class Foo; end` at the prompt defines `::Foo`, exactly as in a stock `irb`. A binding
|
|
34
|
+
# captured inside this class would make `self` the command object instead.
|
|
35
|
+
workspace = IRB::WorkSpace.new
|
|
36
|
+
workspace.local_variable_set(:graph, graph)
|
|
37
|
+
IRB::Irb.new(workspace).run(IRB.conf)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `irb` is a development dependency rather than a runtime one, so a missing gem is reported
|
|
41
|
+
# instead of raised. Only a missing `irb` is reported that way: IRB loads `reline`, which
|
|
42
|
+
# needs `fiddle` on Windows, and that failure has to surface as itself rather than as a
|
|
43
|
+
# missing `irb`.
|
|
44
|
+
#: -> void
|
|
45
|
+
def require_irb
|
|
46
|
+
require "irb"
|
|
47
|
+
rescue LoadError => e
|
|
48
|
+
raise unless e.path == "irb"
|
|
49
|
+
|
|
50
|
+
abort("Interactive mode requires `irb` to be in the bundle")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex"
|
|
4
|
+
require "rubydex/linter/rule_loader"
|
|
5
|
+
|
|
6
|
+
module Rubydex
|
|
7
|
+
module CLI
|
|
8
|
+
class Command
|
|
9
|
+
class Lint
|
|
10
|
+
# `rdx lint explain <RULE>` — prints documentation for rules with a matching name.
|
|
11
|
+
class Explain < Command
|
|
12
|
+
BASE_RULE_NAME = "Rubydex::Linter::CustomRule" #: String
|
|
13
|
+
BASE_RULE_PATH = File.expand_path("../../../linter/custom_rule.rb", __dir__) #: String
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
#: -> String
|
|
17
|
+
def usage_form
|
|
18
|
+
"lint explain <RULE>"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: -> void
|
|
23
|
+
def run
|
|
24
|
+
parse_options!
|
|
25
|
+
|
|
26
|
+
rule_name = argv.shift
|
|
27
|
+
abort_with_usage("`lint explain` requires a rule name argument") unless rule_name
|
|
28
|
+
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
|
|
29
|
+
|
|
30
|
+
workspace_path = current_workspace_path
|
|
31
|
+
|
|
32
|
+
graph = Rubydex::Graph.configure_for_workspace(workspace_path)
|
|
33
|
+
graph.index_all([BASE_RULE_PATH, *Rubydex::Linter::RuleLoader.paths(workspace_path)])
|
|
34
|
+
graph.resolve
|
|
35
|
+
|
|
36
|
+
base_rule = graph[BASE_RULE_NAME]
|
|
37
|
+
abort("Base rule class #{BASE_RULE_NAME} is not found. This is likely an issue in Rubydex itself") unless base_rule.is_a?(Rubydex::Class)
|
|
38
|
+
|
|
39
|
+
rule_declarations = base_rule.descendants.grep(Rubydex::Class) #: as Array[Rubydex::Class]
|
|
40
|
+
matched_rule_names = rule_declarations.filter_map do |declaration|
|
|
41
|
+
declaration_name = declaration.name
|
|
42
|
+
next unless declaration_name
|
|
43
|
+
next if declaration_name == BASE_RULE_NAME
|
|
44
|
+
next unless declaration_name.end_with?(rule_name)
|
|
45
|
+
|
|
46
|
+
declaration
|
|
47
|
+
end
|
|
48
|
+
abort("Rule does not exist: #{rule_name}") if matched_rule_names.empty?
|
|
49
|
+
|
|
50
|
+
puts(matched_rule_names.sort_by(&:name).map { |rule| documentation_for(rule) }.join("\n"))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
#: (Rubydex::Class rule) -> String
|
|
56
|
+
def documentation_for(rule)
|
|
57
|
+
rule_name = rule.name #: as !nil
|
|
58
|
+
documentation = rule.definitions.flat_map do |definition|
|
|
59
|
+
definition.comments.map { |comment| comment.string.gsub(/^#\s*/, "") }
|
|
60
|
+
end.join("\n")
|
|
61
|
+
|
|
62
|
+
return "#{rule_name}: no documentation available." if documentation.empty?
|
|
63
|
+
|
|
64
|
+
<<~DOCUMENTATION
|
|
65
|
+
#{rule_name}
|
|
66
|
+
|
|
67
|
+
#{documentation}
|
|
68
|
+
DOCUMENTATION
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/cli/command"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
# `rdx lint` — discovers project and dependency rules and runs them against the current workspace.
|
|
8
|
+
class Command
|
|
9
|
+
class Lint < Command
|
|
10
|
+
command "lint"
|
|
11
|
+
summary "Run semantic lint rules in the current workspace"
|
|
12
|
+
|
|
13
|
+
#: -> void
|
|
14
|
+
def run
|
|
15
|
+
if argv.first == "explain"
|
|
16
|
+
argv.shift
|
|
17
|
+
require "rubydex/cli/command/lint/explain"
|
|
18
|
+
Explain.new(argv).run
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
parse_options! do |parser|
|
|
23
|
+
parser.separator("")
|
|
24
|
+
parser.separator("Commands:")
|
|
25
|
+
parser.separator(" explain <RULE> Print documentation for matching linter rules")
|
|
26
|
+
parser.separator("")
|
|
27
|
+
parser.separator("Options:")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
|
|
31
|
+
workspace_path = current_workspace_path
|
|
32
|
+
|
|
33
|
+
# Keep top-level help lightweight: command discovery loads this file before the native
|
|
34
|
+
# extension, while linter support is only needed when this command runs.
|
|
35
|
+
require "rubydex/linter"
|
|
36
|
+
|
|
37
|
+
custom_rules = load_linter_rules(workspace_path)
|
|
38
|
+
warn_unknown_rules(custom_rules)
|
|
39
|
+
|
|
40
|
+
graph = build_graph($stderr, workspace_path:, config:)
|
|
41
|
+
runner = Rubydex::Linter::Runner.new(graph, custom_rules:, config: linter_config)
|
|
42
|
+
$stderr.puts("Linting...")
|
|
43
|
+
diagnostics = runner.run
|
|
44
|
+
by_severity = diagnostics.group_by { |diagnostic| diagnostic.rule.severity(linter_config) }
|
|
45
|
+
|
|
46
|
+
if diagnostics.empty?
|
|
47
|
+
print_summary(graph.documents.count, by_severity)
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
print_offenses(by_severity, graph)
|
|
52
|
+
exit(1) if by_severity[Rubydex::Severity::Error]&.any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
#: -> Rubydex::Config
|
|
58
|
+
def config
|
|
59
|
+
@config ||= Rubydex::Config.load(current_workspace_path) #: Rubydex::Config?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: -> Rubydex::LinterConfig
|
|
63
|
+
def linter_config
|
|
64
|
+
@linter_config ||= config.linter #: Rubydex::LinterConfig?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (Array[singleton(Rubydex::Linter::CustomRule)] custom_rule_classes) -> void
|
|
68
|
+
def warn_unknown_rules(custom_rule_classes)
|
|
69
|
+
known_rule_names = (Rubydex::Rules::ALL + custom_rule_classes).map(&:rule_name).uniq.sort
|
|
70
|
+
unknown_rule_names = linter_config.rules.keys.reject { |name| known_rule_names.include?(name) }.sort
|
|
71
|
+
return if unknown_rule_names.empty?
|
|
72
|
+
|
|
73
|
+
formatted_names = unknown_rule_names.map { |name| "`#{name}`" }.join(", ")
|
|
74
|
+
warn(
|
|
75
|
+
"warning: linter config references rules that were not loaded: #{formatted_names}. " \
|
|
76
|
+
"Known rules: #{known_rule_names.join(", ")}",
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (String workspace_path) -> Array[singleton(Rubydex::Linter::CustomRule)]
|
|
81
|
+
def load_linter_rules(workspace_path)
|
|
82
|
+
Rubydex::Linter::RuleLoader.load(workspace_path)
|
|
83
|
+
Rubydex::Linter::CustomRule.subclasses
|
|
84
|
+
rescue Rubydex::Linter::RuleLoadError => error
|
|
85
|
+
abort(error.message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (Hash[singleton(Severity::Base), Array[Diagnostic]], Graph) -> void
|
|
89
|
+
def print_offenses(grouped_diagnostics, graph)
|
|
90
|
+
puts("Offenses:")
|
|
91
|
+
puts
|
|
92
|
+
|
|
93
|
+
Rubydex::Severity::ALL.each do |severity|
|
|
94
|
+
diagnostics = grouped_diagnostics[severity]
|
|
95
|
+
next unless diagnostics
|
|
96
|
+
|
|
97
|
+
diagnostics.sort_by! do |diagnostic|
|
|
98
|
+
location = diagnostic.location
|
|
99
|
+
|
|
100
|
+
[
|
|
101
|
+
location.uri,
|
|
102
|
+
location.start_line,
|
|
103
|
+
location.start_column,
|
|
104
|
+
location.end_line,
|
|
105
|
+
location.end_column,
|
|
106
|
+
diagnostic.rule.rule_name,
|
|
107
|
+
diagnostic.message,
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
diagnostics.each do |diagnostic|
|
|
112
|
+
puts(format_linter_diagnostic(severity, diagnostic, workspace_path: graph.workspace_path))
|
|
113
|
+
print_source_excerpt(diagnostic.location)
|
|
114
|
+
puts
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
print_summary(graph.documents.count, grouped_diagnostics)
|
|
119
|
+
puts("For more information about a rule, run `rdx lint explain RuleName`.")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (Location location, workspace_path: String) -> String
|
|
123
|
+
def format_linter_location(location, workspace_path:)
|
|
124
|
+
display_location = location.to_display
|
|
125
|
+
path = Rubydex::Linter::Helpers::PathHelpers.display_path(display_location, workspace: workspace_path)
|
|
126
|
+
|
|
127
|
+
"#{path}:#{display_location.start_line}:#{display_location.start_column}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#: (singleton(Severity::Base), Diagnostic, workspace_path: String) -> String
|
|
131
|
+
def format_linter_diagnostic(severity, diagnostic, workspace_path:)
|
|
132
|
+
content = +"#{format_linter_location(diagnostic.location, workspace_path:)}: " \
|
|
133
|
+
"#{severity.value}: #{diagnostic.rule.rule_name}: #{diagnostic.message}"
|
|
134
|
+
|
|
135
|
+
diagnostic.related_information.each do |information|
|
|
136
|
+
content << "\n #{format_linter_location(information.location, workspace_path:)}: #{information.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
content
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
#: (Location location) -> void
|
|
143
|
+
def print_source_excerpt(location)
|
|
144
|
+
line = source_line_for(location)
|
|
145
|
+
return unless line
|
|
146
|
+
|
|
147
|
+
end_column = location.end_line == location.start_line ? location.end_column : line.length
|
|
148
|
+
carets = "^" * [end_column - location.start_column, 1].max
|
|
149
|
+
|
|
150
|
+
puts
|
|
151
|
+
puts(line)
|
|
152
|
+
puts("#{" " * location.start_column}#{carets}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: (Location location) -> String?
|
|
156
|
+
def source_line_for(location)
|
|
157
|
+
source_lines_for(location.to_file_path)[location.start_line]
|
|
158
|
+
rescue Errno::ENOENT, Errno::EACCES, Rubydex::Location::NotFileUriError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
#: (String path) -> Array[String]
|
|
163
|
+
def source_lines_for(path)
|
|
164
|
+
@source_lines_cache ||= {} #: Hash[String, Array[String]]?
|
|
165
|
+
@source_lines_cache[path] ||= File.readlines(path, chomp: true)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
#: (Integer, Hash[singleton(Severity::Base), Array[Diagnostic]]) -> void
|
|
169
|
+
def print_summary(file_count, grouped_diagnostics)
|
|
170
|
+
if grouped_diagnostics.empty?
|
|
171
|
+
puts("#{file_count} #{pluralize("file", file_count)} inspected, no offenses detected")
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
offense_count = grouped_diagnostics.each_value.sum(&:length)
|
|
176
|
+
|
|
177
|
+
error_count = grouped_diagnostics[Rubydex::Severity::Error]&.length || 0
|
|
178
|
+
warning_count = grouped_diagnostics[Rubydex::Severity::Warning]&.length || 0
|
|
179
|
+
information_count = grouped_diagnostics[Rubydex::Severity::Information]&.length || 0
|
|
180
|
+
hint_count = grouped_diagnostics[Rubydex::Severity::Hint]&.length || 0
|
|
181
|
+
|
|
182
|
+
severity_summary = [
|
|
183
|
+
"#{error_count} #{pluralize("error", error_count)}",
|
|
184
|
+
"#{warning_count} #{pluralize("warning", warning_count)}",
|
|
185
|
+
"#{information_count} info",
|
|
186
|
+
"#{hint_count} #{pluralize("hint", hint_count)}",
|
|
187
|
+
].join(", ")
|
|
188
|
+
|
|
189
|
+
puts(
|
|
190
|
+
"#{file_count} #{pluralize("file", file_count)} inspected, " \
|
|
191
|
+
"#{offense_count} #{pluralize("offense", offense_count)} detected: #{severity_summary}",
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
#: (String word, Integer count) -> String
|
|
196
|
+
def pluralize(word, count)
|
|
197
|
+
count == 1 ? word : "#{word}s"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/cli/command"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
# `rdx mcp [PATH]` — runs the MCP server for AI assistants. The workspace defaults to the
|
|
8
|
+
# current directory.
|
|
9
|
+
class Command
|
|
10
|
+
class Mcp < Command
|
|
11
|
+
command "mcp"
|
|
12
|
+
arguments "[PATH]"
|
|
13
|
+
summary "Run the MCP server for AI assistants (workspace defaults to the current dir)"
|
|
14
|
+
|
|
15
|
+
#: -> void
|
|
16
|
+
def run
|
|
17
|
+
parse_options!
|
|
18
|
+
|
|
19
|
+
path = argv.shift || Dir.pwd
|
|
20
|
+
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
|
|
21
|
+
|
|
22
|
+
# The MCP server manages its own graph lifecycle for the given workspace, so it does not go
|
|
23
|
+
# through `build_graph`.
|
|
24
|
+
require "rubydex/mcp_server"
|
|
25
|
+
Rubydex::MCPServer.run(path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubydex/cli/command"
|
|
4
|
+
|
|
5
|
+
module Rubydex
|
|
6
|
+
module CLI
|
|
7
|
+
# `rdx query <CYPHER>` — runs a Cypher query against the workspace graph and prints the result.
|
|
8
|
+
# `--schema` describes the queryable schema instead, which needs no graph.
|
|
9
|
+
class Command
|
|
10
|
+
class Query < Command
|
|
11
|
+
command "query"
|
|
12
|
+
arguments "<CYPHER>"
|
|
13
|
+
summary <<~TEXT
|
|
14
|
+
Run a Cypher query against the workspace graph and print the result.
|
|
15
|
+
Use `query --schema` to describe the queryable schema (labels,
|
|
16
|
+
relationships, properties) without indexing the workspace.
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
#: -> void
|
|
20
|
+
def run
|
|
21
|
+
schema = false
|
|
22
|
+
format = "table"
|
|
23
|
+
|
|
24
|
+
parse_options!(options: true) do |parser|
|
|
25
|
+
parser.on("--schema", "Describe the queryable schema instead of running a query") { schema = true }
|
|
26
|
+
parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") do |value|
|
|
27
|
+
format = value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
query = argv.shift
|
|
32
|
+
|
|
33
|
+
if schema
|
|
34
|
+
warn("warning: ignoring query argument because `--schema` was given") if query
|
|
35
|
+
# The schema is static, so describe it without building a graph.
|
|
36
|
+
print(Rubydex::Query.schema(format))
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if query.nil? || query.empty?
|
|
41
|
+
abort_with_usage("`query` requires a Cypher query argument (or pass `--schema`)")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parse the query up front so a malformed query fails fast, before the expensive indexing.
|
|
45
|
+
parsed = parse_query(query)
|
|
46
|
+
|
|
47
|
+
# Progress goes to stderr so stdout carries only the result (e.g. for piping a query's JSON).
|
|
48
|
+
graph = build_graph($stderr)
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
result = parsed.run(graph)
|
|
52
|
+
rescue Rubydex::QueryExecutionError => e
|
|
53
|
+
abort(e.message)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
print(result.render(format))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
#: (String query) -> Rubydex::Query
|
|
62
|
+
def parse_query(query)
|
|
63
|
+
Rubydex::Query.parse(query)
|
|
64
|
+
rescue Rubydex::QuerySyntaxError => e
|
|
65
|
+
abort(e.message)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|