Skip to content
Merged
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
21 changes: 15 additions & 6 deletions lib/rubydex/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,30 @@ def parse_options!(options: false)
end

# Builds the workspace graph, sending progress messages to `progress_io`.
#: (IO progress_io) -> Rubydex::Graph
def build_graph(progress_io)
graph = Rubydex::Graph.configure_for_workspace(Dir.pwd)
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
#: (IO progress_io, ?workspace_path: String, ?config: Rubydex::Config, ?fail_on_index_errors: bool) -> Rubydex::Graph
def build_graph(
progress_io,
workspace_path: Dir.pwd,
config: Rubydex::Config.load(workspace_path),
fail_on_index_errors: false
)
graph = Rubydex::Graph.new
graph.load_config(config)
errors = with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
abort(errors.join("\n")) if fail_on_index_errors && !errors.empty?

with_timer(progress_io, "Resolving graph...") { graph.resolve }
graph
end

#: (IO io, String message) { -> void } -> void
#: [T] (IO io, String message) { -> T } -> T
def with_timer(io, message)
io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
yield
result = yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
io.puts(" finished in #{duration.round(2)}ms")
result
end
end
end
Expand Down
99 changes: 99 additions & 0 deletions lib/rubydex/cli/command/lint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require "rubydex/cli/command"

module Rubydex
module CLI
# `rdx lint [PATH]` — discovers project and dependency rules and runs them against a workspace.
class Command
class Lint < Command
RULE_GLOB = "rubydex_linter/rules/**/*.rb" #: String

command "lint"
arguments "[PATH]"
summary "Run semantic lint rules against a workspace"

#: -> void
def run
parse_options!

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)

# Keep top-level help lightweight: command discovery loads this file before the native
# extension, while linter support is only needed when this command runs.
require "rubydex/linter"

rules = load_linter_rules(workspace_path)
abort("No Rubydex::Linter::Rule subclasses were loaded") if rules.empty?
config = Rubydex::Config.load(workspace_path)
warn_unknown_rules(config.linter, rules)

graph = build_graph($stderr, workspace_path:, config:, fail_on_index_errors: true)
result = Rubydex::Linter::Runner.new(graph, rules:, config: config.linter).run
result.diagnostics.each { |diagnostic| puts(format_linter_diagnostic(diagnostic)) }
exit(1) unless result.success?
end

private

#: (Rubydex::LinterConfig config, Array[singleton(Rubydex::Linter::Rule)] known_rule_classes) -> void
def warn_unknown_rules(config, known_rule_classes)
known_rule_names = known_rule_classes.map(&:rule_name).uniq.sort
unknown_rule_names = config.rules.keys.reject { |name| known_rule_names.include?(name) }.sort
return if unknown_rule_names.empty?

formatted_names = unknown_rule_names.map { |name| "`#{name}`" }.join(", ")
warn(
"warning: linter config references rules that were not loaded: #{formatted_names}. " \
"Known rules: #{known_rule_names.join(", ")}",
)
end

#: (String workspace_path) -> Array[singleton(Linter::Rule)]
def load_linter_rules(workspace_path)
existing_rules = Rubydex::Linter::Rule.subclasses
rule_files = Dir.glob(RULE_GLOB, base: workspace_path).map do |rule_file|
File.expand_path(rule_file, workspace_path)
end
if ENV["BUNDLE_GEMFILE"]
rule_files.concat(Gem.find_latest_files(RULE_GLOB))
end

rule_files.each do |rule_file|
require rule_file
rescue LoadError, SyntaxError => error
abort("Unable to load linter rules from #{rule_file}: #{error.message}")
end

Rubydex::Linter::Rule.subclasses - existing_rules

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if someone will one day try to create a custom rule by subclassing another rule? 🤔

@st0012 st0012 Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linter in Core currently does this as well as we haven't seen a need to create abstract rule classes. I think we can design this later and finish the porting first. Will create an issue for this.

Issue: #984

end

#: (Location location) -> String
def format_linter_location(location)
display_location = location.to_display
path = begin
display_location.to_file_path
rescue Rubydex::Location::NotFileUriError
display_location.uri
end

"#{path}:#{display_location.start_line}:#{display_location.start_column}"
end

#: (Diagnostic diagnostic) -> String
def format_linter_diagnostic(diagnostic)
content = +"#{format_linter_location(diagnostic.location)}: " \
"#{diagnostic.severity.value}: #{diagnostic.rule}: #{diagnostic.message}"

diagnostic.related_information.each do |information|
content << "\n #{format_linter_location(information.location)}: #{information.message}"
end

content
end
end
end
end
end
6 changes: 6 additions & 0 deletions lib/rubydex/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ def initialize(rules)
@rules = rules.freeze
freeze
end

#: (singleton(Linter::Rule) rule_class) -> bool
def rule_enabled?(rule_class)
rule = @rules[rule_class.rule_name]
!rule || rule.enabled?
end
end

# The settings of a single linter rule, read from a `[linter.rules.RuleName]` table.
Expand Down
12 changes: 12 additions & 0 deletions lib/rubydex/linter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

require "rubydex"
require "rubydex/linter/result"
require "rubydex/linter/rule"
require "rubydex/linter/runner"

module Rubydex
# Framework for running semantic lint rules against a resolved Rubydex graph.
module Linter
end
end
20 changes: 20 additions & 0 deletions lib/rubydex/linter/result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module Rubydex
module Linter
class Result
#: Array[Diagnostic]
attr_reader :diagnostics

#: (Array[Diagnostic]) -> void
def initialize(diagnostics)
@diagnostics = diagnostics
end

#: () -> bool
def success?
@diagnostics.none? { |diagnostic| diagnostic.severity == Severity::Error }
end
end
end
end
61 changes: 61 additions & 0 deletions lib/rubydex/linter/rule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module Rubydex
module Linter
# Base class for semantic lint rules.
# @abstract
class Rule
class << self
#: () -> String
def rule_name
name.split("::").last
end
end

#: Graph
attr_reader :graph

#: LinterConfig
attr_reader :config

#: Array[Diagnostic]
attr_reader :diagnostics

#: (Graph, config: LinterConfig) -> void
def initialize(graph, config:)
@graph = graph
@config = config
@diagnostics = [] #: Array[Diagnostic]
end

# @abstract
#: () -> singleton(Severity::Base)
def severity
raise NotImplementedError, "Subclasses must implement the severity method"
end

# @abstract
#: () -> void
def lint
raise NotImplementedError, "Subclasses must implement the lint method"
end

protected

#: (
#| String,
#| Location,
#| ?related_information: Array[RelatedInformation],
#| ) -> void
def add_diagnostic(message, location, related_information: [])
@diagnostics << Diagnostic.new(
rule: self.class.rule_name,
message: message,
location: location,
severity: severity,
related_information: related_information,
)
end
end
end
end
65 changes: 65 additions & 0 deletions lib/rubydex/linter/runner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

require "pathname"

module Rubydex
module Linter
class Runner
#: Graph
attr_reader :graph

#: Array[singleton(Rule)]
attr_reader :rules

#: (Graph, rules: Array[singleton(Rule)], config: LinterConfig) -> void
def initialize(graph, rules:, config:)
raise ArgumentError, "At least one linter rule is required" if rules.empty?

@graph = graph
@config = config
@rules = rules.select { |rule| config.rule_enabled?(rule) }.sort_by { |rule| rule.name.to_s }
end

#: () -> Result
def run
rule_diagnostics = @rules.flat_map do |rule_class|
rule = rule_class.new(@graph, config: @config)
rule.lint
rule.diagnostics
end

diagnostics = (@graph.diagnostics + rule_diagnostics).select do |diagnostic|
diagnostic_in_workspace?(diagnostic)
end.sort_by do |diagnostic|
location = diagnostic.location
[
location.uri,
location.start_line,
location.start_column,
location.end_line,
location.end_column,
diagnostic.rule,
diagnostic.message,
]
end

Result.new(diagnostics)
end

private

#: (Diagnostic) -> bool
def diagnostic_in_workspace?(diagnostic)
path = URI::RFC2396_PARSER.unescape(diagnostic.location.to_file_path)
workspace_path = Pathname.new(File.expand_path(@graph.workspace_path))
relative_path = Pathname.new(File.expand_path(path)).relative_path_from(workspace_path)

relative_path.each_filename.first != ".."
rescue Location::NotFileUriError
true
rescue ArgumentError
false
end
end
end
end
72 changes: 72 additions & 0 deletions rbi/rubydex.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,75 @@ class Rubydex::Diagnostic
def related_information; end
end

module Rubydex::Linter; end

class Rubydex::Linter::Rule
abstract!

sig { returns(String) }
def self.rule_name; end

sig { params(graph: Rubydex::Graph, config: Rubydex::LinterConfig).void }
def initialize(graph, config:); end

sig { returns(Rubydex::Graph) }
def graph; end

sig { returns(Rubydex::LinterConfig) }
def config; end

sig { returns(T::Array[Rubydex::Diagnostic]) }
def diagnostics; end

sig { abstract.returns(T.class_of(Rubydex::Severity::Base)) }
def severity; end

sig { abstract.void }
def lint; end

protected

sig do
params(
message: String,
location: Rubydex::Location,
related_information: T::Array[Rubydex::RelatedInformation],
).void
end
def add_diagnostic(message, location, related_information: []); end
end

class Rubydex::Linter::Runner
sig do
params(
graph: Rubydex::Graph,
rules: T::Array[T.class_of(Rubydex::Linter::Rule)],
config: Rubydex::LinterConfig,
).void
end
def initialize(graph, rules:, config:); end

sig { returns(Rubydex::Graph) }
def graph; end

sig { returns(T::Array[T.class_of(Rubydex::Linter::Rule)]) }
def rules; end

sig { returns(Rubydex::Linter::Result) }
def run; end
end

class Rubydex::Linter::Result
sig { params(diagnostics: T::Array[Rubydex::Diagnostic]).void }
def initialize(diagnostics); end

sig { returns(T::Array[Rubydex::Diagnostic]) }
def diagnostics; end

sig { returns(T::Boolean) }
def success?; end
end

class Rubydex::Keyword
sig { params(name: String, documentation: String).void }
def initialize(name, documentation); end
Expand Down Expand Up @@ -424,6 +493,9 @@ class Rubydex::LinterConfig

sig { params(rules: T::Hash[String, Rubydex::RuleConfig]).void }
def initialize(rules); end

sig { params(rule_class: T.class_of(Rubydex::Linter::Rule)).returns(T::Boolean) }
def rule_enabled?(rule_class); end
end

# The settings of a single linter rule, read from a `[linter.rules.RuleName]` table.
Expand Down
Loading
Loading