diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..3afa11b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,54 @@ +inherit_from: .rubocop_todo.yml + +AllCops: + NewCops: enable + SuggestExtensions: true + EnabledByDefault: true + TargetRubyVersion: 3.2.2 + +Style/Copyright: + Enabled: false + +Style/MethodCallWithArgsParentheses: + Enabled: false + +Layout/RedundantLineBreak: + Enabled: false + +Style/MissingElse: + Enabled: false + +Lint/ConstantResolution: + Enabled: false + +Layout/LineLength: + Max: 129 + +Metrics/MethodLength: + Max: 29 + +Metrics/AbcSize: + Max: 37 + +Metrics/BlockLength: + Max: 12 + Exclude: + - 'spec/**/*_spec.rb' + +Metrics/CyclomaticComplexity: + Max: 9 + +Metrics/PerceivedComplexity: + Max: 10 + +Style/MethodCalledOnDoEndBlock: + Exclude: + - 'spec/**/*_spec.rb' + +Style/ClassEqualityComparison: + Exclude: + - 'lib/rspec/memory/trace.rb' + +Style/StringHashKeys: + Exclude: + - 'spec/rspec/memory_spec.rb' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..24c768f --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,52 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2023-09-17 04:59:27 UTC using RuboCop version 1.56.3. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 7 +# Configuration parameters: Include, IgnoredGems, OnlyFor. +# Include: **/*.gemfile, **/Gemfile, **/gems.rb +Bundler/GemComment: + Exclude: + - 'gems.rb' + +# Offense count: 1 +# Configuration parameters: EnforcedStyle, Include. +# SupportedStyles: Gemfile, gems.rb +# Include: **/Gemfile, **/gems.rb, **/Gemfile.lock, **/gems.locked +Bundler/GemFilename: + Exclude: + - 'gems.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods. +# AllowedMethods: enums +Lint/ConstantDefinitionInBlock: + Exclude: + - 'lib/rspec/memory/trace.rb' + +# Offense count: 2 +Lint/StructNewOverride: + Exclude: + - 'lib/rspec/memory/trace.rb' + +# Offense count: 3 +# Configuration parameters: AllowedConstants. +Style/Documentation: + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/rspec/memory/matchers/limit_allocations.rb' + - 'lib/rspec/memory/trace.rb' + +# Offense count: 13 +# Configuration parameters: RequireForNonPublicMethods. +Style/DocumentationMethod: + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/rspec/memory/matchers/limit_allocations.rb' + - 'lib/rspec/memory/trace.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..9e79f6c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.2.2 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..75471ab --- /dev/null +++ b/Guardfile @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSPEC_CONFIG = { + cmd: 'bundle exec rspec', + all_on_start: true, + all_after_pass: true, + halt_on_fail: true, + results_file: '/tmp/.guard_rspec_results-rspec-memory' +}.freeze + +guard :rspec, RSPEC_CONFIG do + require 'guard/rspec/dsl' + dsl = Guard::RSpec::Dsl.new(self) + + # Feel free to open issues for suggestions and improvements + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } + watch(rspec.spec_files) + + # Ruby files + ruby = dsl.ruby + dsl.watch_spec_files_for(ruby.lib_files) + + %w[matchers/limit_allocations.rb trace.rb].each do |file_path| + watch("lib/rspec/memory/#{file_path}") { 'spec/rspec/memory_spec.rb' } + end +end + +RUBOCOP_CONFIG = { + cli: '--display-cop-names --parallel', + all_on_start: false, + halt_on_fail: true +}.freeze + +guard :rubocop, RUBOCOP_CONFIG do + watch(/.+\.rb$/) + watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) } + watch('Guardfile') + watch('rspec-memory.gemspec') +end diff --git a/gems.rb b/gems.rb index 4e0b492..79c73ec 100644 --- a/gems.rb +++ b/gems.rb @@ -5,15 +5,23 @@ source 'https://rubygems.org' +ruby '3.2.2' + # Specify your gem's dependencies in rspec-memory.gemspec gemspec group :maintenance, optional: true do - gem "bake-gem" - gem "bake-modernize" + gem 'bake-gem', '~> 0.4.0' + gem 'bake-modernize', '~> 0.17.8' +end + +group :development, :test do + gem 'covered', '~> 0.25.0', require: false + gem 'guard-rspec', '~> 4.7' + gem 'guard-rubocop', '~> 1.5' end group :test do - gem "bake-test" - gem "bake-test-external" + gem 'bake-test', '~> 0.2.0' + gem 'bake-test-external', '~> 0.3.3' end diff --git a/lib/rspec/memory.rb b/lib/rspec/memory.rb index 18f928a..c28bdf9 100644 --- a/lib/rspec/memory.rb +++ b/lib/rspec/memory.rb @@ -6,5 +6,5 @@ require_relative 'memory/matchers/limit_allocations' RSpec.shared_context RSpec::Memory do - include RSpec::Memory::Matchers + include RSpec::Memory::Matchers end diff --git a/lib/rspec/memory/matchers/limit_allocations.rb b/lib/rspec/memory/matchers/limit_allocations.rb index 6eb4a34..baf7d4c 100644 --- a/lib/rspec/memory/matchers/limit_allocations.rb +++ b/lib/rspec/memory/matchers/limit_allocations.rb @@ -8,96 +8,98 @@ require 'rspec/expectations' module RSpec - module Memory - module Matchers - class LimitAllocations - include RSpec::Matchers::Composable - - def initialize(allocations = {}, count: nil, size: nil) - @count = count - @size = size - - @allocations = {} - @errors = [] - - allocations.each do |klass, count| - self.of(klass, count: count) - end - end - - def supports_block_expectations? - true - end - - def of(klass, **limits) - @allocations[klass] = limits - - return self - end - - private def check(value, limit) - case limit - when Range - unless limit.include? value - yield "expected within #{limit}" - end - when Integer - unless value == limit - yield "expected exactly #{limit}" - end - end - end - - def matches?(given_proc) - return true unless trace = Trace.capture(@allocations.keys, &given_proc) - - if @count or @size - # If the spec specifies a total limit, we have a limit which we can enforce which takes all allocations into account: - total = trace.total - - check(total.count, @count) do |expected| - @errors << "allocated #{total.count} instances, #{total.size} bytes, #{expected} instances" - end if @count - - check(total.size, @size) do |expected| - @errors << "allocated #{total.count} instances, #{total.size} bytes, #{expected} bytes" - end if @size - else - # Otherwise unspecified allocations are considered an error: - trace.ignored.each do |klass, allocation| - @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, but it was not specified" - end - end - - trace.allocated.each do |klass, allocation| - next unless acceptable = @allocations[klass] - - check(allocation.count, acceptable[:count]) do |expected| - @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, #{expected} instances" - end - - check(allocation.size, acceptable[:size]) do |expected| - @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, #{expected} bytes" - end - end - - return @errors.empty? - end - - def failure_message - "exceeded allocation limit: #{@errors.join(', ')}" - end - end - - if respond_to?(:ruby2_keywords, true) - def limit_allocations(count: nil, size: nil, **allocations) - LimitAllocations.new(allocations, count: count, size: size) - end - else - def limit_allocations(*arguments) - LimitAllocations.new(*arguments) - end - end - end - end + module Memory + module Matchers + class LimitAllocations + include RSpec::Matchers::Composable + + def initialize(allocations = {}, count: nil, size: nil) + @count = count + @size = size + + @allocations = {} + @errors = [] + + allocations.each do |klass, cnt| + of(klass, count: cnt) + end + end + + def supports_block_expectations? + true + end + + def of(klass, **limits) + @allocations[klass] = limits + + self + end + + def matches?(given_proc) + return true unless (trace = Trace.capture(@allocations.keys, &given_proc)) + + if @count || @size + # If the spec specifies a total limit, we have a limit which we can enforce which takes all allocations into account: + total = trace.total + + if @count + check(total.count, @count) do |expected| + @errors << "allocated #{total.count} instances, #{total.size} bytes, #{expected} instances" + end + end + + if @size + check(total.size, @size) do |expected| + @errors << "allocated #{total.count} instances, #{total.size} bytes, #{expected} bytes" + end + end + else + # Otherwise unspecified allocations are considered an error: + trace.ignored.each do |klass, allocation| + @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, but it was not specified" + end + end + + trace.allocated.each do |klass, allocation| + next unless (acceptable = @allocations[klass]) + + check(allocation.count, acceptable[:count]) do |expected| + @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, #{expected} instances" + end + + check(allocation.size, acceptable[:size]) do |expected| + @errors << "allocated #{allocation.count} #{klass} instances, #{allocation.size} bytes, #{expected} bytes" + end + end + + @errors.empty? + end + + private + + def check(value, limit) + case limit + when Range + yield "expected within #{limit}" unless limit.include? value + when Integer + yield "expected exactly #{limit}" unless value == limit + end + end + + def failure_message + "exceeded allocation limit: #{@errors.join(', ')}" + end + end + + if respond_to?(:ruby2_keywords, true) + def limit_allocations(count: nil, size: nil, **allocations) + LimitAllocations.new(allocations, count:, size:) + end + else + def limit_allocations(*) + LimitAllocations.new(*) + end + end + end + end end diff --git a/lib/rspec/memory/trace.rb b/lib/rspec/memory/trace.rb index 5ec494f..9e3b6f3 100644 --- a/lib/rspec/memory/trace.rb +++ b/lib/rspec/memory/trace.rb @@ -7,118 +7,117 @@ require 'objspace' module RSpec - module Memory - Allocation = Struct.new(:count, :size) do - SLOT_SIZE = ObjectSpace.memsize_of(Object.new) - - def << object - self.count += 1 - - # We don't want to force specs to take the slot size into account. - self.size += ObjectSpace.memsize_of(object) - SLOT_SIZE - end - - def self.default_hash - Hash.new{|h,k| h[k] = Allocation.new(0, 0)} - end - end - - class Trace - def self.supported? - # There are issues on truffleruby-1.0.0rc9 - return false if RUBY_ENGINE == "truffleruby" - - ObjectSpace.respond_to?(:trace_object_allocations) - end - - if supported? - def self.capture(*args, &block) - self.new(*args).tap do |trace| - trace.capture(&block) - end - end - else - def self.capture(*args, &block) - yield - - return nil - end - end - - def initialize(klasses) - @klasses = klasses - - @allocated = Allocation.default_hash - @retained = Allocation.default_hash - - @ignored = Allocation.default_hash - - @total = Allocation.new(0, 0) - end - - attr :allocated - attr :retained - - attr :ignored - - attr :total - - def current_objects(generation) - allocations = [] - - ObjectSpace.each_object do |object| - if ObjectSpace.allocation_generation(object) == generation - allocations << object - end - end - - return allocations - end - - def find_base(object) - @klasses.find{|klass| object.is_a? klass} - end - - def capture(&block) - GC.start - - begin - GC.disable - - generation = GC.count - ObjectSpace.trace_object_allocations(&block) - - allocated = current_objects(generation) - ensure - GC.enable - end - - GC.start - retained = current_objects(generation) - - # All allocated objects, including those freed in the last GC: - allocated.each do |object| - if klass = find_base(object) - @allocated[klass] << object - else - # If the user specified classes, but we can't pin this allocation to a specific class, we issue a warning. - if @klasses.any? - warn "Ignoring allocation of #{object.class} at #{ObjectSpace.allocation_sourcefile(object)}:#{ObjectSpace.allocation_sourceline(object)}" - end - - @ignored[object.class] << object - end - - @total << object - end - - # Retained objects are still alive after a final GC: - retained.each do |object| - if klass = find_base(object) - @retained[klass] << object - end - end - end - end - end + module Memory + Allocation = + Struct.new(:count, :size) do + SLOT_SIZE = ObjectSpace.memsize_of(Object.new) + + def <<(object) + self.count += 1 + + # We don't want to force specs to take the slot size into account. + self.size += ObjectSpace.memsize_of(object) - SLOT_SIZE + end + + def self.default_hash + Hash.new { |h, k| h[k] = Allocation.new(0, 0) } + end + end + + class Trace + def self.supported? + # There are issues on truffleruby-1.0.0rc9 + return false if RUBY_ENGINE == 'truffleruby' + + ObjectSpace.respond_to?(:trace_object_allocations) + end + + if supported? + def self.capture(*, &) + new(*).tap do |trace| + trace.capture(&) + end + end + else + def self.capture(*_args, &) + yield + + nil + end + end + + def initialize(klasses) + @klasses = klasses + + @allocated = Allocation.default_hash + @retained = Allocation.default_hash + + @ignored = Allocation.default_hash + + @total = Allocation.new(0, 0) + end + + attr_reader :allocated, :retained, :ignored, :total + + def current_objects(generation) + allocations = [] + + ObjectSpace.each_object do |object| + allocations << object if ObjectSpace.allocation_generation(object) == generation + end + + allocations + end + + def find_base(object) + @klasses.find { |klass| (klass.is_a?(String) && object.class.name == klass) || object.is_a?(klass) } + end + + def capture(&) + GC.start + + begin + GC.disable + + generation = GC.count + ObjectSpace.trace_object_allocations(&) + + allocated = current_objects(generation) + ensure + GC.enable + end + + GC.start + retained = current_objects(generation) + + # All allocated objects, including those freed in the last GC: + allocated.each do |object| + if (klass = find_base(object)) + @allocated[klass] << object + else + # If the user specified classes, but we can't pin this allocation to a specific class, we issue a warning. + if @klasses.any? + file = ObjectSpace.allocation_sourcefile(object) + line = ObjectSpace.allocation_sourceline(object) + + warn "Ignoring allocation of #{object.class} at #{file}:#{line}" + end + + @ignored[object.class] << object + end + + @total << object + end + + # Retained objects are still alive after a final GC: + retained.each do |object| + if (klass = find_base(object)) + @retained[klass] << object + end + end + end + end + + private_constant :Allocation + end end diff --git a/lib/rspec/memory/version.rb b/lib/rspec/memory/version.rb index 0e68176..c6d8df9 100644 --- a/lib/rspec/memory/version.rb +++ b/lib/rspec/memory/version.rb @@ -4,7 +4,9 @@ # Copyright, 2019-2023, by Samuel Williams. module RSpec - module Memory - VERSION = "1.0.4" - end + module Memory + VERSION = '1.0.4' + + public_constant :VERSION + end end diff --git a/readme.md b/readme.md index 5ef222d..deee777 100644 --- a/readme.md +++ b/readme.md @@ -54,6 +54,28 @@ RSpec.describe "memory allocations" do end ``` +### private constants + +As private constants cannot be referenced we can pride them as strings: + +``` ruby + it "allows using private constants as strings" do + expect do + Timeout.timeout(1) do + String.new + end + end.to limit_allocations( + Thread::Mutex => { count: 1, size: 32 }, + String => { count: 1, size: 0 }, + Timeout::Error => { count: 1, size: 48 }, + Array => { count: 2, size: 0 }, + Proc => { count: 3, size: 120 }, + Hash => { count: 1, size: 128 }, + Thread => { count: 1, size: 360 }, + 'Timeout::Request' => { count: 1, size: 40 } + ) +``` + ## Contributing We welcome contributions to this project. diff --git a/rspec-memory.gemspec b/rspec-memory.gemspec index bff7dd5..7bb30d9 100644 --- a/rspec-memory.gemspec +++ b/rspec-memory.gemspec @@ -1,24 +1,25 @@ # frozen_string_literal: true -require_relative "lib/rspec/memory/version" +require_relative 'lib/rspec/memory/version' Gem::Specification.new do |spec| - spec.name = "rspec-memory" - spec.version = RSpec::Memory::VERSION - - spec.summary = "RSpec helpers for checking memory allocations." - spec.authors = ["Samuel Williams", "Olle Jonsson", "Cyril Roelandt", "Daniel Leidert", "Felix Yan"] - spec.license = "MIT" - - spec.cert_chain = ['release.cert'] - spec.signing_key = File.expand_path('~/.gem/release.pem') - - spec.homepage = "https://github.com/socketry/rspec-memory" - - spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) - - spec.add_dependency "rspec", "~> 3.0" - - spec.add_development_dependency "bundler" - spec.add_development_dependency "covered" + spec.name = 'rspec-memory' + spec.version = RSpec::Memory::VERSION + + spec.summary = 'RSpec helpers for checking memory allocations.' + spec.authors = ['Samuel Williams', 'Olle Jonsson', 'Cyril Roelandt', 'Daniel Leidert', 'Felix Yan'] + spec.license = 'MIT' + + spec.cert_chain = ['release.cert'] + spec.signing_key = File.expand_path('~/.gem/release.pem') + + spec.homepage = 'https://github.com/socketry/rspec-memory' + + spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) + + spec.required_ruby_version = '>= 3.2.2' + + spec.add_dependency 'rspec', '~> 3.0' + + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/spec/rspec/memory_spec.rb b/spec/rspec/memory_spec.rb index e323e4c..c62cbe6 100644 --- a/spec/rspec/memory_spec.rb +++ b/spec/rspec/memory_spec.rb @@ -4,77 +4,103 @@ # Copyright, 2019-2023, by Samuel Williams. require 'rspec/memory' +require 'timeout' RSpec.describe RSpec::Memory do - include_context RSpec::Memory - - it "should execute code in block" do - string = nil - - expect do - string = String.new - end.to limit_allocations(String => 1) - - expect(string).to_not be_nil - end - - context "on supported platform", if: RSpec::Memory::Trace.supported? do - it "should not exceed specified count limit" do - expect do - 2.times{String.new} - end.to limit_allocations(String => 2) - - expect do - 2.times{String.new} - end.to limit_allocations.of(String, count: 2) - end - - it "should fail if there are untracked allocations" do - expect do - expect do - Array.new - end.to limit_allocations - end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /it was not specified/) - end - - it "should exceed specified count limit" do - expect do - expect do - 6.times{String.new} - end.to limit_allocations(String => 4) - end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected exactly 4 instances/) - end if RSpec::Memory::Trace.supported? - - it "should be within specified count range" do - expect do - 2.times{String.new} - end.to limit_allocations(String => 1..3) - - expect do - 2.times{String.new} - end.to limit_allocations.of(String, count: 1..3) - end - - it "should exceed specified count range" do - expect do - expect do - 6.times{String.new} - end.to limit_allocations(String => 1..3) - end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected within 1..3 instances/) - end - - it "should not exceed specified size limit" do - expect do - "a" * 100_000 - end.to limit_allocations.of(String, size: 100_001) - end - - it "should exceed specified size limit" do - expect do - expect do - "a" * 120_000 - end.to limit_allocations(size: 100_000) - end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected exactly 100000 bytes/) - end - end + include_context RSpec::Memory + + it 'should execute code in block' do + string = nil + + expect do + string = String.new + end.to limit_allocations(String => 1) + + expect(string).to_not be_nil + end + + context 'on supported platform', if: RSpec::Memory::Trace.supported? do + it 'should not exceed specified count limit' do + expect do + 2.times { String.new } + end.to limit_allocations(String => 2) + + expect do + 2.times { String.new } + end.to limit_allocations.of(String, count: 2) + end + + it 'should fail if there are untracked allocations' do + expect do + expect do + [] + end.to limit_allocations + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /it was not specified/) + end + + if RSpec::Memory::Trace.supported? + it 'should exceed specified count limit' do + expect do + expect do + 6.times { String.new } + end.to limit_allocations(String => 4) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected exactly 4 instances/) + end + end + + it 'should be within specified count range' do + expect do + 2.times { String.new } + end.to limit_allocations(String => 1..3) + + expect do + 2.times { String.new } + end.to limit_allocations.of(String, count: 1..3) + end + + it 'should exceed specified count range' do + expect do + expect do + 6.times { String.new } + end.to limit_allocations(String => 1..3) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected within 1\.\.3 instances/) + end + + it 'should not exceed specified size limit' do + expect do + 'a' * 100_000 + end.to limit_allocations.of(String, size: 100_001) + end + + it 'should exceed specified size limit' do + expect do + expect do + 'a' * 120_000 + end.to limit_allocations(size: 100_000) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected exactly 100000 bytes/) + end + + it 'allows constants as strings' do + expect do + 'a' * 100_000 + end.to limit_allocations.of('String', size: 100_001) + end + + it 'allows using private constants as strings' do + expect do + Timeout.timeout(1) do + String.new + end + end.to limit_allocations( + Thread::Mutex => { count: 1, size: 32 }, + String => { count: 1, size: 0 }, + Timeout::Error => { count: 1, size: 48 }, + Array => { count: 2, size: 0 }, + Proc => { count: 3, size: 120 }, + Hash => { count: 1, size: 128 }, + Thread => { count: 1, size: 360 }, + 'Timeout::Request' => { count: 1, size: 40 } + ) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 44fcedd..a1360d1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,13 +6,16 @@ require 'covered/rspec' RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! - config.expect_with :rspec do |c| - c.syntax = :expect - end + config.expect_with :rspec do |c| + c.syntax = :expect + end + + config.filter_run focus: true + config.run_all_when_everything_filtered = true end