.rubocop.yml 🔗
@@ -21,6 +21,7 @@ Metrics/BlockLength:
- json
Exclude:
- test/*
+ - lib/tasks/*
Metrics/AbcSize:
Exclude:
Phillip Davis created
- uses solargraph sourcemap, which requires a patch for Ruby 3.4
that is not available (patched solargraph version is incompatible
with pinned rubocop)
- conditionally checks for two different kinds of ivar nodes
using the same check that solargraph uses internally to decide between
current and legacy parsers
.rubocop.yml | 1
Gemfile | 1
Rakefile | 2
lib/solargraph_ruby34_fix.rb | 34 +++++++
lib/tasks/verify_mixin_ivars.rake | 142 +++++++++++++++++++++++++++++++++
5 files changed, 180 insertions(+)
@@ -21,6 +21,7 @@ Metrics/BlockLength:
- json
Exclude:
- test/*
+ - lib/tasks/*
Metrics/AbcSize:
Exclude:
@@ -35,6 +35,7 @@ group(:development) do
gem "pry-reload"
gem "pry-rescue"
gem "pry-stack_explorer"
+ gem "solargraph"
end
group(:test) do
@@ -20,6 +20,8 @@ rescue LoadError
nil
end
+Dir.glob("lib/tasks/**/*.rake").each { |r| load r }
+
task :entr do
sh "sh", "-c", "git ls-files | entr -s 'rake test && rubocop'"
end
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Monkey-patch to fix Solargraph 0.48.0 bug with Ruby 3.4+
+# See: https://github.com/castwide/solargraph/issues/733
+# Fixed in Solargraph PR #735 (merged Jan 7, 2025) but not in 0.48.0
+# https://github.com/castwide/solargraph/pull/735/files
+#
+# Bug: node_range method crashes when passed nil node
+# This happens, for example,
+# with rescue clauses that don't assign exception to a variable
+# Example: "rescue NameError" instead of "rescue NameError => e"
+
+if defined?(RubyVM::AbstractSyntaxTree)
+ require "solargraph"
+
+ module Solargraph
+ module Parser
+ module Rubyvm
+ module ClassMethods
+ # Fix from PR #735: Add nil check to node_range
+ def node_range(node)
+ if node.nil?
+ nil
+ else
+ st = Position.new(node.first_lineno - 1, node.first_column)
+ en = Position.new(node.last_lineno - 1, node.last_column)
+ Range.new(st, en)
+ end
+ end
+ end
+ end
+ end
+ end
+end
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+desc "Verify mixin ivar requirements using Solargraph"
+task :verify_mixin_ivars do
+ require "set"
+ require "solargraph"
+ require_relative "../solargraph_ruby34_fix"
+
+ if defined?(RubyVM::AbstractSyntaxTree)
+ def find_ivars(node, ivars=Set.new)
+ return ivars unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
+
+ ivars.add(node.children[0].to_s) if [:IASGN, :IVAR].include?(node.type)
+ node.children.filter { |child|
+ child.is_a?(RubyVM::AbstractSyntaxTree::Node)
+ }.each do |child|
+ find_ivars(child, ivars)
+ end
+
+ ivars
+ end
+ else
+ require "parser/current"
+
+ def find_ivars(node, ivars=Set.new)
+ return ivars unless node.is_a?(Parser::AST::Node)
+
+ ivars.add(node.children[0].to_s) if [:ivasgn, :ivar].include?(node.type)
+
+ node.children.each do |child|
+ find_ivars(child, ivars)
+ end
+
+ ivars
+ end
+ end
+
+ lib_files = Dir.glob("lib/**/*.rb")
+ sources = lib_files.map { |file|
+ Solargraph::Source.load_string(File.read(file), file)
+ }
+ # @type [Array<Solargraph::SourceMap>]
+ source_maps = sources.filter_map { |source|
+ Solargraph::SourceMap.map(source)
+ }
+ violations = []
+
+ modules_with_ivars = {}
+
+ all_pins = source_maps.flat_map(&:pins)
+ # @type [Array<Solargraph::Pin::Namespace>]
+ module_pins = all_pins.select { |p|
+ p.is_a?(Solargraph::Pin::Namespace) && p.type == :module
+ }
+
+ module_pins.each do |mod|
+ source_map = source_maps.find { |sm| sm.filename == mod.location.filename }
+ next unless source_map
+
+ ivars = Set.new
+ source_map.pins.each do |pin|
+ next unless pin.is_a?(Solargraph::Pin::Method)
+ next unless pin.closure && pin.closure.path == mod.path
+
+ find_ivars(pin.node, ivars) if pin.node
+ end
+
+ modules_with_ivars[mod] = ivars.to_a unless ivars.empty?
+ end
+
+ # @type [Array<Solargraph::Pin::Namespace>]
+ class_pins = all_pins.select { |p|
+ p.is_a?(Solargraph::Pin::Namespace) && p.type == :class
+ }
+
+ class_pins.each do |klass|
+ source_map = source_maps.find { |sm|
+ sm.filename == klass.location.filename
+ }
+ next unless source_map
+
+ # @type [Array<Solargraph::Pin::Reference::Include>]
+ included_modules = source_map.pins.select { |p|
+ p.is_a?(Solargraph::Pin::Reference::Include) && p.closure == klass
+ }
+
+ next if included_modules.empty?
+
+ init_method = source_map.pins.find { |p|
+ p.is_a?(Solargraph::Pin::Method) &&
+ p.name == "initialize" &&
+ p.closure && p.closure.path == klass.path
+ }
+
+ init_ivars = Set.new
+ if init_method
+ source_map.pins.each do |pin|
+ if pin.is_a?(Solargraph::Pin::InstanceVariable) &&
+ pin.closure == init_method
+ init_ivars.add(pin.name)
+ end
+ end
+ end
+
+ included_modules.each do |include_pin|
+ mod = module_pins.find { |m| m.path == include_pin.name }
+ next unless mod
+
+ required_ivars = modules_with_ivars[mod]
+ next unless required_ivars
+
+ missing = Set.new(required_ivars) - init_ivars
+
+ missing.each do |ivar|
+ violations << {
+ file: klass.location.filename,
+ line: klass.location.range.start.line + 1,
+ class: klass.path,
+ module: mod.path,
+ ivar: ivar
+ }
+ end
+ end
+ end
+
+ if violations.empty?
+ puts "✓ All classes properly initialize ivars required by their mixins"
+ else
+ puts "✗ Found #{violations.size} missing ivar initialization(s):\n\n"
+
+ violations.each do |v|
+ puts "#{v[:file]}:#{v[:line]}"
+ puts " Class '#{v[:class]}' includes module '#{v[:module]}'"
+ puts " which references '#{v[:ivar]}',"
+ puts " but '#{v[:class]}#initialize' doesn't set it"
+ puts " Add '#{v[:ivar]} = ...' to the initialize method"
+ puts
+ end
+
+ exit 1
+ end
+end