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
1 change: 1 addition & 0 deletions lib/rubyzen.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

# Load RSpec matchers manually since they don't follow class/module naming conventions
require_relative 'rubyzen/matchers/be_empty_matcher'
require_relative 'rubyzen/matchers/be_empty_with_exceptions_matcher'
require_relative 'rubyzen/matchers/be_true_matcher'
require_relative 'rubyzen/matchers/be_false_matcher'

Expand Down
5 changes: 5 additions & 0 deletions lib/rubyzen/collections/modules_collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ module Collections
class ModulesCollection < BaseCollection
include Rubyzen::Providers::CollectionFilterProvider

def all_methods

@bkoell Bastian (bkoell) Apr 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: check if classes collection is doing this and has same name

all_methods = flat_map(&:all_methods)
MethodsCollection.new(all_methods)
end

def classes
all_classes = flat_map(&:classes)
ClassesCollection.new(all_classes)
Expand Down
58 changes: 55 additions & 3 deletions lib/rubyzen/declarations/class_declaration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def name_without_modules
def superclass_name
super_node = node.children[1]
return nil unless super_node&.type == :const

super_node.const_name
end

Expand All @@ -50,16 +51,16 @@ def superclass_prefix?(prefix)

def instance_methods
Collections::MethodsCollection.new(
node.each_node(:def).map do |def_node|
instance_method_nodes.map do |def_node|
MethodDeclaration.new(def_node, self)
end
)
end

def class_methods
Collections::MethodsCollection.new(
node.each_node(:defs).map do |defs_node|
MethodDeclaration.new(defs_node, self)
class_method_nodes.map do |method_node|
MethodDeclaration.new(method_node, self)
end
)
end
Expand All @@ -71,6 +72,57 @@ def called_method_names
def top_level_module
file_declaration.top_level_module_name
end

private

def class_body_node
node.children[2]
end

def class_body_children
body = class_body_node
return [] unless body

body.type == :begin ? body.child_nodes : [body]
end

def instance_method_nodes
class_body_children.select { |child| child.type == :def }
end

def class_defs_nodes
class_body_children.select do |child|
child.type == :defs && child.children[0]&.type == :self
end
end

def class_method_nodes
class_defs_nodes + class_sclass_def_nodes
end

def class_sclass_def_nodes
class_body_children
.select { |child| singleton_class_node?(child) }
.flat_map do |child|
body_children(child.children[1]).select do |body_child|
method_node?(body_child)
end
end
end

def singleton_class_node?(child)
child.type == :sclass && child.children[0]&.type == :self
end

def body_children(body)
return [] unless body

body.type == :begin ? body.child_nodes : [body]
end

def method_node?(child)
%i[def defs].include?(child.type)
end
end
end
end
43 changes: 42 additions & 1 deletion lib/rubyzen/declarations/module_declaration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,48 @@ def modules
end

def classes
node.each_node(:class).map { |class_node| ClassDeclaration.new(class_node, file_declaration) }
node.each_node(:class).map do |class_node|
ClassDeclaration.new(class_node, file_declaration)
end
end

def all_methods
Collections::MethodsCollection.new(
direct_method_nodes.map { |method_node| MethodDeclaration.new(method_node, self) }
)
end

private

def module_body_node
node.children[1]
end

def module_body_children
body = module_body_node
return [] unless body

body.type == :begin ? body.child_nodes : [body]
end

def direct_method_nodes
direct_nodes = module_body_children.select do |child|
%i[def defs].include?(child.type)
end
singleton_nodes = module_body_children.flat_map do |child|
next [] unless child.type == :sclass
next [] unless child.children[0]&.type == :self

sclass_body = child.children[1]
next [] unless sclass_body

sclass_children = sclass_body.type == :begin ? sclass_body.child_nodes : [sclass_body]
sclass_children.select do |sclass_child|
%i[def defs].include?(sclass_child.type)
end
end

direct_nodes + singleton_nodes
end
end
end
Expand Down
32 changes: 32 additions & 0 deletions lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
RSpec::Matchers.define :be_empty_with_exceptions do |custom_message=nil, allowlist: nil, baseline: nil|

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shouldn't we also do the same for be_true and be_false?

We could also update the existing matchers to accept an optional allowlist and baseline, instead of having different matchers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, how would this work with different types of baseline?

For example, sometimes we have a baseline of classes, or baseline of files, or baseline of method names, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It works with classes / and filenames because it check against each of the fields... Sorry this should of still been a draft

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What about be_true and be_false?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we truly have no consumer for the be_true and be_false` matchers with baselines, then I am ok pushing this to a future PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why don't we update the existing matchers to add an optional param of baseline? Wouldn't this be more consistent with the existing API?

include Rubyzen::Matchers::MatcherHelpers

match do |subject_collection|
@custom_message = custom_message
@classified_items = classify_items(subject_collection, allowlist: allowlist, baseline: baseline)
@offenders = @classified_items[:violations]
stale_exception_groups = []
stale_exception_groups << 'baseline entries' if @classified_items[:stale_baseline].any?
stale_exception_groups << 'allowlist entries' if @classified_items[:stale_allowlist].any?

@failure_reason = if @classified_items[:violations].any? && stale_exception_groups.any?
"Expected to be empty, but found live violations and stale #{stale_exception_groups.join(' and ')}."
elsif @classified_items[:violations].any?
'Expected to be empty, but found live violations.'
elsif stale_exception_groups.any?
"Expected to be empty, but found stale #{stale_exception_groups.join(' and ')}."
end

@classified_items[:violations].empty? &&
@classified_items[:stale_baseline].empty? &&
@classified_items[:stale_allowlist].empty?
end

failure_message do |_|
message_for_failure(@failure_reason || 'Expected to be empty, but had unmatched elements.')
end

failure_message_when_negated do |_|
message_for_failure('Expected not to be empty, but had no matched elements.')
end
end
126 changes: 114 additions & 12 deletions lib/rubyzen/matchers/matcher_helpers.rb
Original file line number Diff line number Diff line change
@@ -1,32 +1,134 @@
module Rubyzen
module Matchers
module MatcherHelpers
def normalize_exception_entries(entries)
Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
end

def item_details(item)
{
name: item.respond_to?(:name) ? item.name : nil,
class_name: item.respond_to?(:class_name) ? item.class_name : nil,
file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
line: item.respond_to?(:line) ? item.line : nil
}
end

def item_identifiers(item)
details = item_details(item)
identifiers = [details[:name], details[:class_name], details[:file_path]]

if details[:line]
identifiers << "#{details[:file_path]}:#{details[:line]}"
end

identifiers.compact.uniq
end

def exception_entry_matches_item?(entry, item)
normalized_entry = entry.to_s.strip
return false if normalized_entry.empty?

details = item_details(item)
return true if item_identifiers(item).include?(normalized_entry)

file_path = details[:file_path]
file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
end

def classify_items(subject_collection, allowlist: nil, baseline: nil)
items = Array(subject_collection).compact
normalized_allowlist = normalize_exception_entries(allowlist)
normalized_baseline = normalize_exception_entries(baseline)
matched_baseline_entries = []
matched_allowlist_entries = []

grouped_items = items.group_by do |item|
matching_baseline_entry = normalized_baseline.find do |entry|
exception_entry_matches_item?(entry, item)
end

if matching_baseline_entry
matched_baseline_entries << matching_baseline_entry
:baseline
else
matching_allowlist_entry = normalized_allowlist.find do |entry|
exception_entry_matches_item?(entry, item)
end

if matching_allowlist_entry
matched_allowlist_entries << matching_allowlist_entry
:allowlist
else
:violations
end
end
end

classifications = {
baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
}

classifications.merge(
stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
)
end

def element_name(item)
name = item.respond_to?(:name) ? item.name : nil
class_name = item.respond_to?(:class_name) ? item.class_name : nil
file_path = item.respond_to?(:file_path) ? item.file_path : 'Unknown file'
location = "#{file_path}:#{item.line}"
details = item_details(item)
location = [details[:file_path], details[:line]].compact.join(':')

case
when name && class_name
" - element: #{name}\n - class: #{class_name}\n - file: #{location}"
when name
" - element: #{name}\n - file: #{location}"
when class_name
" - class: #{class_name}\n - file: #{location}"
when details[:name] && details[:class_name]
" - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
when details[:name]
" - element: #{details[:name]}\n - file: #{location}"
when details[:class_name]
" - class: #{details[:class_name]}\n - file: #{location}"
else
" - unknown element in #{location}"
end
end

def formatted_matcher_groups
return unless defined?(@classified_items) && @classified_items

sections = []

if @classified_items[:violations].any?
sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
end

if @classified_items[:stale_baseline].any?
stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
end

if @classified_items[:stale_allowlist].any?
stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
end

sections.join("\n")
end

def self.included(base)
base.define_method(:message_for_failure) do |base_message|
return @failure_message if @failure_message

details = formatted_matcher_groups

if @custom_message
if @offenders.any?
"#{@custom_message}\nViolations: #{@offenders.join(', ')}"
if details && !details.empty?
"#{@custom_message}\n#{details}"
else
@custom_message
end
elsif details && !details.empty?
"#{base_message}\n#{details}"
else
base_message
end
Expand Down
20 changes: 18 additions & 2 deletions lib/rubyzen/providers/class_name_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,25 @@ def class_name

def class_name_recursive(declaration)
return if declaration.nil?
return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration)
return nil if declaration.is_a?(Rubyzen::Declarations::FileDeclaration)
return class_name_recursive(declaration.parent) if declaration.respond_to?(:parent)
return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration)

if declaration.is_a?(Rubyzen::Declarations::ModuleDeclaration)
return module_class_name(declaration)
end

class_name_recursive(parent_declaration(declaration))
end

def module_class_name(declaration)
parent_class_name = class_name_recursive(parent_declaration(declaration))
parent_class_name || declaration.name
end

def parent_declaration(declaration)
return unless declaration.respond_to?(:parent)

declaration.parent
end
end
end
Expand Down
Loading
Loading