Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ jobs:
- name: Run Ruby tests
run: bundle exec rake test

- name: Run Rubydex linter
if: matrix.os == 'ubuntu-latest' && matrix.ruby == '4.0'
run: bundle exec rdx lint .

- name: Save Rust compile cache
id: rust-compile-cache-save
uses: actions/cache/save@v6
Expand Down
17 changes: 14 additions & 3 deletions ext/rubydex/config.c
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "config.h"
#include "diagnostic.h"
#include "rustbindings.h"
#include "utils.h"

Expand Down Expand Up @@ -29,9 +30,19 @@ static VALUE config_linter_build(VALUE opaque_rule_array) {
for (size_t i = 0; i < rule_array->len; i++) {
CLinterRule rule = rule_array->items[i];
VALUE rule_name = rb_str_freeze(rb_utf8_str_new(rule.name, (long)rule.name_length));
VALUE argv[] = {rule_name, rule.enabled ? Qtrue : Qfalse};

rb_hash_aset(rules, rule_name, rb_class_new_instance(2, argv, cRuleConfig));
VALUE exclude_patterns = rb_ary_new_capa((long)rule.exclude_patterns_length);
for (size_t j = 0; j < rule.exclude_patterns_length; j++) {
CConfigString pattern = rule.exclude_patterns[j];
rb_ary_push(exclude_patterns, rb_str_freeze(rb_utf8_str_new(pattern.data, (long)pattern.length)));
}
rb_obj_freeze(exclude_patterns);

VALUE severity = rule.severity == NULL
? Qnil
: rdxi_build_diagnostic_severity_value(mRubydex, *rule.severity);
VALUE argv[] = {rule_name, rule.enabled ? Qtrue : Qfalse, exclude_patterns, severity};

rb_hash_aset(rules, rule_name, rb_class_new_instance(4, argv, cRuleConfig));
}

return rb_class_new_instance(1, &rules, cLinterConfig);
Expand Down
210 changes: 210 additions & 0 deletions lib/ruby_lsp/rubydex/addon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# frozen_string_literal: true

require "rubydex/linter"

# 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
# the stable version until the v0.27 series is stable, so here we are hand-rolling our own `depend_on_ruby_lsp!` method
# to avoid notifying them.
lsp_version = Gem::Version.new(RubyLsp::VERSION)
return unless [">= 0.27.0.beta4", "< 0.28"].all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(lsp_version) }

module Rubydex
module Linter
module RubyLsp
class Addon < ::RubyLsp::Addon
CONFIGURATION_FILE = "rubydex.toml" #: String

# @override
#: () -> String
def name
"Rubydex Linter"
end

# @override
#: () -> String
def version
::Rubydex::VERSION
end

# @override
#: (::RubyLsp::GlobalState, Thread::Queue) -> void
def activate(global_state, outgoing_queue)
@outgoing_queue = outgoing_queue #: Thread::Queue?
@linter = Linter.new(global_state) #: Linter?

global_state.register_formatter(
"rubydex",
@linter, #: as !nil
)

register_additional_file_watchers(global_state, outgoing_queue)
end

# @override
#: () -> void
def deactivate; end

#: (Array[{ uri: String, type: Integer }]) -> void
def workspace_did_change_watched_files(changes)
return unless @linter && @outgoing_queue

@linter.reload_configuration if changes.any? { |change| configuration_change?(change) }
@linter.lint!

@linter.diagnostics_to_clear.each do |uri|
@outgoing_queue << ::RubyLsp::Notification.publish_diagnostics(uri, [])
end

@linter.current_diagnostics.each do |uri, diagnostics|
@outgoing_queue << ::RubyLsp::Notification.publish_diagnostics(uri, diagnostics)
end
end

private

#: (::RubyLsp::GlobalState, Thread::Queue) -> void
def register_additional_file_watchers(global_state, outgoing_queue)
return unless global_state.client_capabilities.supports_watching_files

outgoing_queue << ::RubyLsp::Request.new(
id: "rubydex-linter-file-watcher",
method: "client/registerCapability",
params: ::RubyLsp::Interface::RegistrationParams.new(
registrations: [
::RubyLsp::Interface::Registration.new(
id: "workspace/didChangeWatchedFilesRubydexLinter",
method: "workspace/didChangeWatchedFiles",
register_options: ::RubyLsp::Interface::DidChangeWatchedFilesRegistrationOptions.new(
watchers: [
::RubyLsp::Interface::FileSystemWatcher.new(
glob_pattern: ::RubyLsp::Interface::RelativePattern.new(
base_uri: global_state.workspace_uri.to_s,
pattern: CONFIGURATION_FILE,
),
kind: ::RubyLsp::Constant::WatchKind::CREATE | ::RubyLsp::Constant::WatchKind::CHANGE,
),
],
),
),
],
),
)
end

# The Ruby LSP forwards every watched file change to every add-on, including the ones registered by other add-ons,
# so we have to check that the change is actually about our configuration file.
#: ({ uri: String, type: Integer }) -> bool
def configuration_change?(change)
path = URI(change[:uri]).full_path
return false unless path

File.basename(path) == CONFIGURATION_FILE
end
end

class Linter
include ::RubyLsp::Requests::Support::Formatter

DIAGNOSTIC_SEVERITIES = {
::Rubydex::Severity::Error => ::RubyLsp::Constant::DiagnosticSeverity::ERROR,
::Rubydex::Severity::Warning => ::RubyLsp::Constant::DiagnosticSeverity::WARNING,
::Rubydex::Severity::Information => ::RubyLsp::Constant::DiagnosticSeverity::INFORMATION,
::Rubydex::Severity::Hint => ::RubyLsp::Constant::DiagnosticSeverity::HINT,
}.freeze #: Hash[singleton(::Rubydex::Severity::Base), Integer]

#: Hash[String, Array[::RubyLsp::Interface::Diagnostic]]
attr_reader :current_diagnostics

#: Array[String]
attr_reader :diagnostics_to_clear

#: (::RubyLsp::GlobalState) -> void
def initialize(global_state)
@graph = global_state.graph #: ::Rubydex::Graph
@workspace_path = global_state.workspace_path #: String
@runner = build_runner #: ::Rubydex::Linter::Runner

@current_diagnostics = {} #: Hash[String, Array[::RubyLsp::Interface::Diagnostic]]
@diagnostics_to_clear = [] #: Array[String]
end

# @override
#: (URI::Generic, ::RubyLsp::Document[untyped]) -> Array[::RubyLsp::Interface::Diagnostic]?
def run_diagnostic(uri, _document)
@current_diagnostics[uri.to_s]
end

# @override
#: (URI::Generic, ::RubyLsp::RubyDocument) -> String?
def run_formatting(uri, document); end

# @override
#: (URI::Generic, String, Integer) -> String?
def run_range_formatting(uri, source, base_indentation); end

#: () -> void
def lint!
@diagnostics_to_clear = @current_diagnostics.keys
@current_diagnostics.clear

@runner.run.diagnostics.each do |diagnostic|
uri = diagnostic.location.uri
(@current_diagnostics[uri] ||= []) << to_lsp_diagnostic(diagnostic)
end

@diagnostics_to_clear -= @current_diagnostics.keys
end

#: () -> void
def reload_configuration
@runner = build_runner
end

private

#: () -> ::Rubydex::Linter::Runner
def build_runner
config = ::Rubydex::Config.load(@workspace_path)
rules = ::Rubydex::Linter::RuleLoader.load(@workspace_path)
::Rubydex::Linter::Runner.new(@graph, rules:, config: config.linter)
end

#: (::Rubydex::Diagnostic) -> ::RubyLsp::Interface::Diagnostic
def to_lsp_diagnostic(diagnostic)
location = diagnostic.location

::RubyLsp::Interface::Diagnostic.new(
message: diagnostic.message,
source: "Rubydex",
code: diagnostic.rule,
severity: DIAGNOSTIC_SEVERITIES.fetch(diagnostic.severity),
range: lsp_range(location),
related_information: diagnostic.related_information.map do |information|
::RubyLsp::Interface::DiagnosticRelatedInformation.new(
location: ::RubyLsp::Interface::Location.new(
uri: information.location.uri,
range: lsp_range(information.location),
),
message: information.message,
)
end,
)
end

#: (::Rubydex::Location) -> ::RubyLsp::Interface::Range
def lsp_range(location)
::RubyLsp::Interface::Range.new(
start: ::RubyLsp::Interface::Position.new(
line: location.start_line,
character: location.start_column,
),
end: ::RubyLsp::Interface::Position.new(
line: location.end_line,
character: location.end_column,
),
)
end
end
end
end
end
68 changes: 68 additions & 0 deletions lib/rubydex/cli/command/explain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require "rubydex/cli/command"

module Rubydex
module CLI
# `rdx explain <RULE> [PATH]` — prints documentation for every discovered rule with that name.
class Command
class Explain < Command
command "explain"
arguments "<RULE> [PATH]"
summary "Print complete documentation for a linter rule"

#: -> void
def run
parse_options!

rule_name = argv.shift
abort_with_usage("`explain` requires a rule name argument") unless rule_name

workspace_path = File.expand_path(argv.shift || Dir.pwd)
abort_with_usage("unexpected argument: #{argv.first}") unless argv.empty?
abort_with_usage("workspace is not a directory: #{workspace_path}") unless File.directory?(workspace_path)

require "rubydex/linter"

rules = load_linter_rules(workspace_path).select { |rule_class| rule_class.rule_name == rule_name }
abort("Rule does not exist: #{rule_name}") if rules.empty?

graph = Rubydex::Graph.configure_for_workspace(workspace_path)
rule_files = rules.map do |rule_class|
Object.const_source_location(rule_class.name).fetch(0)
end
graph.index_all([File.expand_path("../../linter/rule.rb", __dir__), *rule_files])
graph.resolve

puts(rules.sort_by(&:name).map { |rule_class| documentation_for(rule_class, graph) }.join("\n"))
end

private

#: (String workspace_path) -> Array[singleton(Rubydex::Linter::Rule)]
def load_linter_rules(workspace_path)
Rubydex::Linter::RuleLoader.load(workspace_path)
rescue Rubydex::Linter::RuleLoadError => error
abort(error.message)
end

#: (singleton(Rubydex::Linter::Rule) rule_class, Graph graph) -> String
def documentation_for(rule_class, graph)
rule_name = rule_class.name #: as !nil
declaration = graph[rule_name] #: as !nil
documentation = declaration.definitions.flat_map do |definition|
definition.comments.map { |comment| comment.string.gsub(/^#\s*/, "") }
end.join("\n")

return "#{rule_name}: no documentation available." if documentation.empty?

<<~DOCUMENTATION
#{rule_name}

#{documentation}
DOCUMENTATION
end
end
end
end
end
Loading