diff --git a/.rubocop.yml b/.rubocop.yml index f78db8ea03c76de8ec7684a2a4a8caac22de8198..8bbe094fe18f1c7302493f4965da11d109264037 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -21,6 +21,7 @@ Metrics/BlockLength: - json Exclude: - test/* + - lib/tasks/* Metrics/AbcSize: Exclude: diff --git a/Gemfile b/Gemfile index 22ec7ce5214f68fe612fe5604607562b18412af3..6156b973715267d75852f9e857ba5d5d4e2ca9ce 100644 --- a/Gemfile +++ b/Gemfile @@ -35,6 +35,7 @@ group(:development) do gem "pry-reload" gem "pry-rescue" gem "pry-stack_explorer" + gem "solargraph" end group(:test) do diff --git a/Rakefile b/Rakefile index 13c8fbaaf7c72c41f711746e0275074190fe3651..3d5cbae16c7636490dd64005aaf1e63fd5e603b5 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/lib/solargraph_ruby34_fix.rb b/lib/solargraph_ruby34_fix.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7c693537caac9bb6148164e37a7c61aa408fa16 --- /dev/null +++ b/lib/solargraph_ruby34_fix.rb @@ -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 diff --git a/lib/tasks/verify_mixin_ivars.rake b/lib/tasks/verify_mixin_ivars.rake new file mode 100644 index 0000000000000000000000000000000000000000..9321e0d27c8512470edc6e38a8996386b58c56d2 --- /dev/null +++ b/lib/tasks/verify_mixin_ivars.rake @@ -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] + source_maps = sources.filter_map { |source| + Solargraph::SourceMap.map(source) + } + violations = [] + + modules_with_ivars = {} + + all_pins = source_maps.flat_map(&:pins) + # @type [Array] + 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] + 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] + 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