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,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