diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..1e5595e --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,22 @@ +--- +name: Coverage + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install Ruby (3.2) + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + - name: Build and test with RSpec + run: bundle exec rspec + - name: Report coverage to Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: coverage/lcov.info diff --git a/.github/workflows/danger-comment.yml b/.github/workflows/danger-comment.yml new file mode 100644 index 0000000..e6aa88c --- /dev/null +++ b/.github/workflows/danger-comment.yml @@ -0,0 +1,11 @@ +name: Danger Comment + +on: + workflow_run: + workflows: [Danger] + types: [completed] + +jobs: + comment: + uses: numbata/danger-pr-comment/.github/workflows/danger-comment.yml@main + secrets: inherit diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000..76c745c --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,13 @@ +name: Danger + +on: + pull_request: + types: [opened, reopened, edited, synchronize] + +jobs: + danger: + uses: numbata/danger-pr-comment/.github/workflows/danger-run.yml@main + secrets: inherit + with: + ruby-version: '3.1' + bundler-cache: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..bc70a44 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,18 @@ +name: Lint + +on: [push, pull_request] + +jobs: + rubocop: + name: RuboCop + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + bundler-cache: true + - run: bundle exec rubocop + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a9aa482 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +--- +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + entry: + - { ruby: '2.7', allowed-failure: false } + - { ruby: '3.0', allowed-failure: false } + - { ruby: '3.1', allowed-failure: false } + - { ruby: '3.2', allowed-failure: false } + - { ruby: '4.0', allowed-failure: false } + - { ruby: 'ruby-head', allowed-failure: true } + - { ruby: 'truffleruby-head', allowed-failure: true } + - { ruby: 'jruby-head', allowed-failure: true } + name: test (${{ matrix.entry.ruby }}) + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.entry.ruby }} + - run: bundle install --jobs=3 --retry=3 + - run: bundle exec rake spec + continue-on-error: ${{ matrix.entry.allowed-failure }} + - name: Specs for when the i18n gem is not available + run: | + cd spec_i18n + bundle install --jobs=3 --retry=3 + pwd + bundle exec rake spec + continue-on-error: ${{ matrix.entry.allowed-failure }} diff --git a/.gitignore b/.gitignore index a3e145b..70c558c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Gemfile.lock pkg +coverage diff --git a/.rubocop.yml b/.rubocop.yml index 1330a68..ec2ada8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,28 +1,60 @@ AllCops: + TargetRubyVersion: 2.7 + NewCops: enable Exclude: - vendor/**/* - - bin/**/* -LineLength: +Metrics/BlockLength: + Exclude: + - 'spec/**/*_spec.rb' + +Metrics/ModuleLength: Enabled: false -MethodLength: +RSpec/SpecFilePathFormat: + Enabled: false + +Layout/LineLength: + Enabled: false + +Style/HashEachMethods: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Naming/FileName: Enabled: false -ClassLength: +Style/Documentation: Enabled: false -Documentation: - # don't require classes to be documented +Naming/MethodParameterName: Enabled: false -Encoding: - # no need to always specify encoding +RSpec/NestedGroups: Enabled: false -FileName: - # allow ruby-enum.rb +RSpec/ExampleLength: Enabled: false -RaiseArgs: +RSpec/DescribedClass: Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +Lint/ConstantDefinitionInBlock: + Enabled: false + +RSpec/LeakyConstantDeclaration: + Enabled: false + +inherit_from: .rubocop_todo.yml + +plugins: + - rubocop-rake + - rubocop-rspec diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..00ce58d --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,7 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2025-12-30 16:44:41 UTC using RuboCop version 1.82.1. +# 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. diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 71a9236..0000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: ruby - -cache: bundler - -rvm: - - ruby-head - - 2.1.1 - - 2.1.0 - - 2.0.0 - - 1.9.3 - - jruby-19mode - - jruby-head - - rbx-2 - -matrix: - allow_failures: - - rvm: ruby-head - - rvm: jruby-head diff --git a/CHANGELOG.md b/CHANGELOG.md index 861fcc0..eb5202b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,80 @@ -### Next +### 1.2.2 (Next) +* [#65](https://github.com/dblock/ruby-enum/pull/65): Fixed coverage reporting to Coveralls not running on pull requests by switching from `coveralls_reborn`/`COVERALLS_REPO_TOKEN` to `coverallsapp/github-action`/`GITHUB_TOKEN` - [@dblock](https://github.com/dblock). * Your contribution here. -### 0.4.0 (6/29/2014) +### 1.2.1 (2026/08/15) + +* [#62](https://github.com/dblock/ruby-enum/issues/62): Fixed `values`, `value?` and `key` to not return a superclass' overridden value when a subclass redefines a key, matching the behavior of `to_h` and other methods - [@dblock](https://github.com/dblock). +* [#64](https://github.com/dblock/ruby-enum/pull/64): Updated copyright year to 2026 - [@dblock](https://github.com/dblock). + +### 1.2.0 (2026/08/14) + +* [#49](https://github.com/dblock/ruby-enum/issues/49): Fixed `NoMethodError` on `keys`, `values`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` when a subclass defines no enums of its own - [@dblock](https://github.com/dblock). +* [#49](https://github.com/dblock/ruby-enum/issues/49): `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` now include enums defined in a superclass, matching the existing behavior of `values` - [@dblock](https://github.com/dblock). +* [#61](https://github.com/dblock/ruby-enum/pull/61): Document performance overhead in README, add `rake benchmark:basic` - [@dblock](https://github.com/dblock). + +### 1.1.0 (2026/06/20) + +* [#56](https://github.com/dblock/ruby-enum/pull/56): Fix `DuplicateKeyError` when the enum class is reloaded - [@flvrone](https://github.com/flvrone). +* [#54](https://github.com/dblock/ruby-enum/pull/54): Add support for Ruby 4.0 - [@dblock](https://github.com/dblock). +* [#53](https://github.com/dblock/ruby-enum/pull/53): Replace code climate with coveralls - [@dblock](https://github.com/dblock). + +### 1.0.0 (2023/01/10) + +* [#41](https://github.com/dblock/ruby-enum/pull/41): Make i18n dependency optional - [@peterfication](https://github.com/peterfication). +* [#43](https://github.com/dblock/ruby-enum/pull/43): Add exhaustive case matcher - [@peterfication](https://github.com/peterfication). +* [#40](https://github.com/dblock/ruby-enum/pull/39): Enable new Rubocop cops and address/allowlist lints - [@petergoldstein](https://github.com/petergoldstein). +* [#39](https://github.com/dblock/ruby-enum/pull/39): Require Ruby >= 2.7 - [@petergoldstein](https://github.com/petergoldstein). +* [#38](https://github.com/dblock/ruby-enum/pull/38): Ensure Ruby >= 2.3 - [@ojab](https://github.com/ojab). + +### 0.9.0 (2021/01/21) + +* [#34](https://github.com/dblock/ruby-enum/pull/34): Added support for Ruby 3.0 - [@dblock](https://github.com/dblock). +* [#29](https://github.com/dblock/ruby-enum/pull/29): Added superclass values when enumerating with `Ruby::Enum#values` - [@gi](https://github.com/gi). +* [#30](https://github.com/dblock/ruby-enum/pull/30): Default value to key - [@gi](https://github.com/gi). +* [#34](https://github.com/dblock/ruby-enum/pull/34): Replaced Travis-CI with Github Actions, added Danger PR linter - [@dblock](https://github.com/dblock). +* [#37](https://github.com/dblock/ruby-enum/pull/37): Added code coverage - [@dblock](https://github.com/dblock). + +### 0.8.0 (2020/03/27) + +* [#22](https://github.com/dblock/ruby-enum/pull/22): Added `Ruby::Enum#each_key` and `Ruby::Enum#each_value` - [@dblock](https://github.com/dblock). +* [#22](https://github.com/dblock/ruby-enum/pull/22): Dropped support for Ruby 2.2 - [@dblock](https://github.com/dblock). +* [#22](https://github.com/dblock/ruby-enum/pull/22): Upgraded RuboCop to 0.80.1 - [@dblock](https://github.com/dblock). + +### 0.7.2 (2017/03/15) + +* [#18](https://github.com/dblock/ruby-enum/pull/18): Added support for non constant definition - [@laertispappas](https://github.com/laertispappas). + +### 0.7.1 (2017/02/23) + +* [#16](https://github.com/dblock/ruby-enum/pull/16): Replaced `const_missing` with `const_set` - [@laertispappas](https://github.com/laertispappas). + +### 0.7.0 (2017/02/21) + +* [#3](https://github.com/dblock/ruby-enum/pull/13): Added support for subclassing an Enum - [@laertispappas](https://github.com/laertispappas). + +### 0.6.0 (2016/05/12) + +* [#12](https://github.com/dblock/ruby-enum/pull/12): A `Ruby::Enum::Errors::DuplicateKeyError` or a `Ruby::Enum::Errors::DuplciateKeyValyeError` will now be raised when duplicate keys / values are defined - [@laertispappas](https://github.com/laertispappas). + +### 0.5.0 (2015/11/20) + +* [#8](https://github.com/dblock/ruby-enum/pull/8): Added `Ruby::Enum#key`, `Ruby::Enum#value`, `Ruby::Enum#key?`, and `Ruby::Enum#value?` - [@dmolesUC3](https://github.com/dmolesUC3). + +### 0.4.0 (2014/06/29) * [#5](https://github.com/dblock/ruby-enum/pull/5): Mixed in `Enumerable` - [@kgann](https://github.com/kgann). -### 0.3.0 (5/19/2014) +### 0.3.0 (2014/05/19) * [#4](https://github.com/dblock/ruby-enum/pull/4): Added `Ruby::Enum#map` - [@ArnaudRinquin](https://github.com/ArnaudRinquin). -### 0.2.1 (5/15/2013) +### 0.2.1 (2013/05/15) * Added `Ruby::Enum#values`, `Ruby::Enum#keys` and `Ruby::Enum#to_h` - [@dblock](https://github.com/dblock). * A `Ruby::Enum::Errors::UninitializedConstantError` will now be raised when referencing an undefined enum - [@dblock](https://github.com/dblock). -### 0.1.0 (5/14/2013) +### 0.1.0 (2013/05/14) * Initial public release, live-coded during [May 2013 NYC.rb](http://code.dblock.org/your-first-ruby-gem) - [@dblock](https://github.com/dblock). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..976b05b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to Ruby-Enum + +This project is work of [many contributors](https://github.com/dblock/ruby-enum/graphs/contributors). + +You're encouraged to submit [pull requests](https://github.com/dblock/ruby-enum/pulls), [propose features and discuss issues](https://github.com/dblock/ruby-enum/issues). + +In the examples below, substitute your Github username for `contributor` in URLs. + +### Fork the Project + +Fork the [project on Github](https://github.com/dblock/ruby-enum) and check out your copy. + +``` +git clone https://github.com/contributor/ruby-enum.git +cd ruby-enum +git remote add upstream https://github.com/dblock/ruby-enum.git +``` + +### Bundle Install and Test + +Ensure that you can build the project and run tests. + +``` +bundle install +bundle exec rake +``` + +## Contribute Code + +### Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. + +``` +git checkout master +git pull upstream master +git checkout -b my-feature-branch +``` + +### Write Tests + +Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add tests to [spec](spec). + +We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. + +### Write Code + +Implement your feature or bug fix. + +Ruby style is enforced with [Rubocop](https://github.com/bbatsov/rubocop). Run `bundle exec rubocop` and fix any style issues highlighted, auto-correct issues when possible with `bundle exec rubocop -a`. To silence generally ingored issues, including line lengths or code complexity metrics, run `bundle exec rubocop --auto-gen-config`. + +Make sure that `bundle exec rake` completes without errors. + +### Write Documentation + +Document any external behavior in the [README](README.md). + +### Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md) under *Next Release*. Don't remove *Your contribution here*. + +Make it look like every other line, including a link to the issue being fixed, your name and link to your Github account. + +### Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed and why. + +``` +git add ... +git commit +``` + +### Push + +``` +git push origin my-feature-branch +``` + +### Make a Pull Request + +Go to https://github.com/contributor/ruby-enum and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. + +### Update CHANGELOG Again + +Update the [CHANGELOG](CHANGELOG.md) with the pull request number. A typical entry looks as follows. + +``` +* [#123](https://github.com/dblock/ruby-enum/pull/123): Reticulated splines - [@contributor](https://github.com/contributor). +``` + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +### Rebase + +If you've been working on a change for a while, rebase with upstream/master. + +``` +git fetch upstream +git rebase upstream/master +git push origin my-feature-branch -f +``` + +### Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. + +### Be Patient + +It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! + +## Thank You + +Please do know that we really appreciate and value your time and work. We love you, really. diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000..b4bc366 --- /dev/null +++ b/Dangerfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +danger.import_dangerfile(gem: 'danger-pr-comment') + +changelog.check! +toc.check! diff --git a/Gemfile b/Gemfile index f3118e5..82808f4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,23 @@ -source "http://rubygems.org" +# frozen_string_literal: true + +source 'http://rubygems.org' gemspec -gem "rspec" -gem "rake" -gem "rubocop", "0.21.0" +gem 'rake' + +group :development, :test do + gem 'danger' + gem 'danger-changelog' + gem 'danger-pr-comment' + gem 'danger-toc' + gem 'rspec' + gem 'rubocop', '1.82.1' + gem 'rubocop-rake' + gem 'rubocop-rspec' +end + +group :test do + gem 'simplecov' + gem 'simplecov-lcov', require: false +end diff --git a/LICENSE.md b/LICENSE.md index a5f8cc0..549897c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2013-2014 Daniel Doubrovkine. +Copyright (c) 2013-2026 Daniel Doubrovkine. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 1adb218..882d603 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,367 @@ Ruby::Enum ========== -[![Build Status](https://travis-ci.org/dblock/ruby-enum.png)](https://travis-ci.org/dblock/ruby-enum) +[![Gem Version](http://img.shields.io/gem/v/ruby-enum.svg)](http://badge.fury.io/rb/ruby-enum) +[![Test](https://github.com/dblock/ruby-enum/actions/workflows/test.yml/badge.svg)](https://github.com/dblock/ruby-enum/actions/workflows/test.yml) +[![Coverage Status](https://coveralls.io/repos/github/dblock/ruby-enum/badge.svg?branch=master)](https://coveralls.io/github/dblock/ruby-enum?branch=master) -Enum-like behavior for Ruby, heavily inspired by [this](http://www.rubyfleebie.com/enumerations-and-ruby) and improved upon [another blog post](http://code.dblock.org/how-to-define-enums-in-ruby). +Enum-like behavior for Ruby, heavily inspired by [this](http://www.rubyfleebie.com/enumerations-and-ruby), and improved upon [another blog post](http://code.dblock.org/how-to-define-enums-in-ruby). + +## Table of Contents + +- [Usage](#usage) + - [Constants](#constants) + - [Class Methods](#class-methods) + - [Default Value](#default-value) + - [Enumerating](#enumerating) + - [Iterating](#iterating) + - [Mapping](#mapping) + - [Reducing](#reducing) + - [Sorting](#sorting) + - [Hashing](#hashing) + - [Retrieving keys and values](#retrieving-keys-and-values) + - [Mapping keys to values](#mapping-keys-to-values) + - [Mapping values to keys](#mapping-values-to-keys) + - [Duplicate enumerator keys or duplicate values](#duplicate-enumerator-keys-or-duplicate-values) + - [Inheritance](#inheritance) + - [Exhaustive case matcher](#exhaustive-case-matcher) + - [I18n support](#i18n-support) +- [Benchmarks](#benchmarks) + - [Performance](#performance) +- [Contributing](#contributing) +- [Copyright and License](#copyright-and-license) +- [Related Projects](#related-projects) ## Usage +Enums can be defined and accessed either as constants, or class methods, which is a matter of preference. + +### Constants + +Define enums, and reference them as constants. + +``` ruby +class OrderState + include Ruby::Enum + + define :CREATED, 'created' + define :PAID, 'paid' +end +``` + +``` ruby +OrderState::CREATED # 'created' +OrderState::PAID # 'paid' +OrderState::UNKNOWN # raises Ruby::Enum::Errors::UninitializedConstantError +OrderState.keys # [ :CREATED, :PAID ] +OrderState.values # [ 'created', 'paid' ] +OrderState.to_h # { :CREATED => 'created', :PAID => 'paid' } +``` + +### Class Methods + +Define enums, and reference them as class methods. + ``` ruby -class Colors +class OrderState include Ruby::Enum - define :RED, "red" - define :GREEN, "green" + define :created, 'created' + define :paid, 'paid' end ``` -### Referencing +```ruby +OrderState.created # 'created' +OrderState.paid # 'paid' +OrderState.undefined # NoMethodError is raised +OrderState.keys # [ :created, :paid ] +OrderState.values # ['created', 'paid'] +OrderState.to_h # { :created => 'created', :paid => 'paid' } +``` + +### Default Value + +The value is optional. If unspecified, the value will default to the key. + +``` ruby +class OrderState + include Ruby::Enum + + define :UNSPECIFIED + define :unspecified +end +``` ``` ruby -Colors::RED # "red" -Colors::GREEN # "green" -Colors::UNDEFINED # raises Ruby::Enum::Errors::UninitializedConstantError -Colors.keys # [ :RED, :GREEN ] -Colors.values # [ "red", "green" ] -Colors.to_h # { :RED => "red", :GREEN => "green" } +OrderState::UNSPECIFIED # :UNSPECIFIED +OrderState.unspecified # :unspecified ``` -### All `Enumerable` methods are supported. +### Enumerating + +Enums support all `Enumerable` methods. #### Iterating ``` ruby -Colors.each do |key, enum| - # key and enum.key is :RED, :GREEN - # enum.value is "red", "green" +OrderState.each do |key, enum| + # key and enum.key are :CREATED, :PAID + # enum.value is 'created', 'paid' +end +``` + +``` ruby +OrderState.each_key do |key| + # :CREATED, :PAID +end +``` + +``` ruby +OrderState.each_value do |value| + # 'created', 'paid' end ``` #### Mapping ``` ruby -Colors.map do |key, enum| - # key and enum.key is :RED, :GREEN - # enum.value is "red", "green" +OrderState.map do |key, enum| + # key and enum.key are :CREATED, :PAID + # enum.value is 'created', 'paid' [enum.value, key] end -# => [ ['red', :RED], ['green', :GREEN] ] +# => [ ['created', :CREATED], ['paid', :PAID] ] ``` #### Reducing ``` ruby -Colors.reduce([]) do |arr, (key, enum)| - # key and enum.key is :RED, :GREEN - # enum.value is "red", "green" +OrderState.reduce([]) do |arr, (key, enum)| + # key and enum.key are :CREATED, :PAID + # enum.value is 'created', 'paid' arr << [enum.value, key] end -# => [ ['red', :RED], ['green', :GREEN] ] +# => [ ['created', :CREATED], ['paid', :PAID] ] ``` #### Sorting + ``` ruby -Colors.sort_by do |key, enum| - # key and enum.key is :RED, :GREEN - # enum.value is "red", "green" - enum.value +OrderState.sort_by do |key, enum| + # key and enum.key are :CREATED, :PAID + # enum.value is 'created', 'paid' + enum.value.length end -# => [ [:GREEN, #], [:RED, #] ] +# => [[:PAID, #], [:CREATED, #]] ``` -## Contributing +### Hashing + +Several hash-like methods are supported. + +#### Retrieving keys and values + +``` ruby +OrderState.keys +# => [:CREATED, :PAID] + +OrderState.values +# => ['created', 'paid'] +``` + +#### Mapping keys to values + +``` ruby +OrderState.key?(:CREATED) +# => true + +OrderState.value(:CREATED) +# => 'created' + +OrderState.key?(:FAILED) +# => false + +OrderState.value(:FAILED) +# => nil +``` + +#### Mapping values to keys + +``` ruby +OrderState.value?('paid') +# => true + +OrderState.key('paid') +# => :PAID + +OrderState.value?('failed') +# => false + +OrderState.key('failed') +# => nil +``` -You're encouraged to contribute to this gem. +### Duplicate enumerator keys or duplicate values -* Fork this project. -* Make changes, write tests. -* Updated [CHANGELOG](CHANGELOG.md). -* Make a pull request, bonus points for topic branches. +Defining duplicate enums raises `Ruby::Enum::Errors::DuplicateKeyError`. + +```ruby +class OrderState + include Ruby::Enum + + define :CREATED, 'created' + define :CREATED, 'recreated' # raises DuplicateKeyError +end +``` + +Defining a duplicate value raises `Ruby::Enum::Errors::DuplicateValueError`. + +```ruby +class OrderState + include Ruby::Enum + + define :CREATED, 'created' + define :RECREATED, 'created' # raises DuplicateValueError +end +``` + +The `DuplicateValueError` exception is raised to be consistent with the unique key constraint. Since keys are unique, there needs to be a way to map values to keys using `OrderState.value('created')`. + +### Inheritance + +When inheriting from a `Ruby::Enum` class, all defined enums in the parent class will be accessible in subclasses as well. Subclasses can also provide extra enums, as usual. + +``` ruby +class OrderState + include Ruby::Enum + + define :CREATED, 'CREATED' + define :PAID, 'PAID' +end + +class ShippedOrderState < OrderState + define :PREPARED, 'PREPARED' + define :SHIPPED, 'SHIPPED' +end +``` + +``` ruby +ShippedOrderState::CREATED # 'CREATED' +ShippedOrderState::PAID # 'PAID' +ShippedOrderState::PREPARED # 'PREPARED' +ShippedOrderState::SHIPPED # 'SHIPPED' +``` + +The `values` class method will enumerate the values from all base classes. + +``` ruby +OrderState.values # ['CREATED', 'PAID'] +ShippedOrderState.values # ['CREATED', 'PAID', 'PREPARED', SHIPPED'] +``` + +All other enumerating and hashing methods (`keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each`) also consider enums defined anywhere in the class hierarchy. + +``` ruby +ShippedOrderState.keys # [:CREATED, :PAID, :PREPARED, :SHIPPED] +ShippedOrderState.key?(:CREATED) # true +ShippedOrderState.value(:CREATED) # 'CREATED' +``` + +A subclass may redefine a key or value already used by a parent class without raising `DuplicateKeyError` or `DuplicateValueError`; its own definition takes precedence. + +``` ruby +class ShippedOrderState < OrderState + define :CREATED, 'RECREATED' # does not raise, overrides the parent class' definition +end + +ShippedOrderState::CREATED # 'RECREATED' +ShippedOrderState.value(:CREATED) # 'RECREATED' +OrderState.value(:CREATED) # 'CREATED', unaffected +``` + +### Exhaustive case matcher + +If you want to make sure that you cover all cases in a case stament, you can use the exhaustive case matcher: `Ruby::Enum::Case`. It will raise an error if a case/enum value is not handled, or if a value is specified that's not part of the enum. This is inspired by the [Rust Pattern Syntax](https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html). If multiple cases match, all matches are being executed. The return value is the value from the matched case, or an array of return values if multiple cases matched. + +> NOTE: This will add checks at runtime which might lead to worse performance. See [benchmarks](#benchmarks). + +> NOTE: `:else` is a reserved keyword if you want to use `Ruby::Enum::Case`. + +```ruby +class Color < OrderState + include Ruby::Enum + include Ruby::Enum::Case + + define :RED, :red + define :GREEN, :green + define :BLUE, :blue + define :YELLOW, :yellow +end +``` + +```ruby +color = Color::RED +Color.Case(color, { + [Color::GREEN, Color::BLUE] => -> { "order is green or blue" }, + Color::YELLOW => -> { "order is yellow" }, + Color::RED => -> { "order is red" }, +}) +``` + +It also supports default/else: + +```ruby +color = Color::RED +Color.Case(color, { + [Color::GREEN, Color::BLUE] => -> { "order is green or blue" }, + else: -> { "order is yellow or red" }, +}) +``` + +### I18n support + +This gem has an optional dependency to `i18n`. If it's available, the error messages will have a nice description and can be translated. If it's not available, the errors will only contain the message keys. + +```ruby +# Add this to your Gemfile if you want to have a nice error description instead of just a message key. +gem "i18n" +``` + +## Benchmarks + +Benchmark scripts are defined in the [`benchmarks`](benchmarks) folder and can be run with Rake: + +```console +rake benchmark:basic +rake benchmark:case +rake benchmark:inheritance +``` + +### Performance + +Constant access (e.g. `Colors::RED`) has no measurable overhead versus a plain Ruby constant, since it's just a constant lookup either way. Basic operations backed by a hash lookup - `value`, `key`, `key?`, `value?`, `keys`, `values` - carry some overhead (roughly 3-5x) compared to using a plain `Hash` directly, due to the extra method dispatch and object wrapping `Ruby::Enum` does internally. Run `rake benchmark:basic` to measure this on your own machine. + +This overhead is constant regardless of how deep a subclass hierarchy is - `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` merge and memoize enums inherited from superclasses, so a subclass' lookups are effectively as fast as the base class' (see `rake benchmark:inheritance`). + +The one notable exception is `Ruby::Enum::Case`, whose exhaustive `case`-like matcher is significantly slower (on the order of 50-100x, see `rake benchmark:case`) than a native Ruby `case`/`when` statement, since it builds and evaluates lambdas on every call rather than being optimized by the Ruby VM. Prefer a native `case` statement in hot code paths and reserve `Ruby::Enum::Case` for cases where its exhaustiveness check is worth the overhead. + +For most applications this overhead is negligible in absolute terms (low single-digit microseconds per call), but it's worth being aware of in very hot code paths. + +## Contributing + +You're encouraged to contribute to ruby-enum. See [CONTRIBUTING](CONTRIBUTING.md) for details. ## Copyright and License -Copyright (c) 2013-2014, Daniel Doubrovkine and [Contributors](CHANGELOG.md). +Copyright (c) 2013-2026, Daniel Doubrovkine and [Contributors](CHANGELOG.md). This project is licensed under the [MIT License](LICENSE.md). + +## Related Projects + +* [typesafe_enum](https://github.com/dmolesUC3/typesafe_enum): Typesafe enums, inspired by Java. +* [renum](https://github.com/duelinmarkers/renum): A readable, but terse enum. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..9e37e9b --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,69 @@ +# Releasing Ruby-Enum + +There are no hard rules about when to release ruby-enum. Release bug fixes frequently, features not so frequently and breaking API changes rarely. + +### Release + +Run tests, check that all tests succeed locally. + +``` +bundle install +rake +``` + +Check that the last build succeeded in [GitHub Actions](https://github.com/dblock/ruby-enum/actions) for all supported platforms. + +Add a date to this release in [CHANGELOG.md](CHANGELOG.md). + +``` +### 0.2.2 (2015/7/10) +``` + +Remove the line with "Your contribution here.", since there will be no more contributions to this release. + +Commit your changes. + +``` +git add README.md CHANGELOG.md lib/ruby-enum/version.rb +git commit -m "Preparing for release, 0.2.2." +``` + +Release. If you have MFA enabled on RubyGems (you should), `rake release` will build the gem, tag it, and push commits/tags to GitHub, but fail at the RubyGems push. Push the gem manually with your OTP. + +``` +$ rake release + +ruby-enum 1.1.0 built to pkg/ruby-enum-1.1.0.gem. +Tagged v1.1.0. +Pushed git commits and tags. +... +You have enabled multi-factor authentication. Please enter OTP code. +``` + +Then push to RubyGems manually: + +``` +$ gem push pkg/ruby-enum-1.1.0.gem --otp +Pushing gem to https://rubygems.org... +Successfully registered gem: ruby-enum (1.1.0) +``` + +### Prepare for the Next Version + +Add the next release to [CHANGELOG.md](CHANGELOG.md). + +``` +### 0.2.3 (Next) + +* Your contribution here. +``` + +Increment the third version number in [lib/ruby-enum/version.rb](lib/ruby-enum/version.rb). + +Commit your changes. + +``` +git add CHANGELOG.md lib/ruby-enum/version.rb +git commit -m "Preparing for next development iteration, 0.2.3." +git push origin master +``` diff --git a/Rakefile b/Rakefile index 5d76844..6e78748 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rubygems' require 'bundler/gem_tasks' @@ -11,6 +13,23 @@ RSpec::Core::RakeTask.new(:spec) do |spec| end require 'rubocop/rake_task' -Rubocop::RakeTask.new(:rubocop) +RuboCop::RakeTask.new(:rubocop) + +task default: %i[rubocop spec] + +namespace :benchmark do + desc 'Run benchmark for basic Ruby::Enum operations vs. plain Ruby equivalents' + task :basic do + require_relative 'benchmarks/basic' + end -task default: [:rubocop, :spec] + desc 'Run benchmark for the Ruby::Enum::Case' + task :case do + require_relative 'benchmarks/case' + end + + desc 'Run benchmark for enum lookups on classes with inheritance' + task :inheritance do + require_relative 'benchmarks/inheritance' + end +end diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..9fa3247 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,83 @@ +# Upgrading Ruby::Enum + +## Upgrading to >= 1.2.0 + +### Inheritance & `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` + +This only applies to classes that inherit from another which is a `Ruby::Enum`. + +Prior to version `1.2.0`, only `values` enumerated enums defined in the entire class hierarchy; `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` only considered enums defined directly on the class, silently ignoring anything defined in a superclass. + +As of version `1.2.0`, these methods behave consistently with `values` and also enumerate/consider enums defined anywhere in the class hierarchy, ancestors first. A subclass may still redefine a key or value already used by a superclass; its own definition takes precedence. + +``` ruby +class PrimaryColors + include Ruby::Enum + + define :RED, 'RED' + define :GREEN, 'GREEN' +end + +class RainbowColors < PrimaryColors + define :ORANGE, 'ORANGE' +end +``` + +`gem 'ruby-enum', '< 1.2.0'` + +``` ruby +RainbowColors.keys # => [:ORANGE] +RainbowColors.key?(:RED) # => false +``` + +`gem 'ruby-enum', '>= 1.2.0'` + +``` ruby +RainbowColors.keys # => [:RED, :GREEN, :ORANGE] +RainbowColors.key?(:RED) # => true +``` + +See [#49](https://github.com/dblock/ruby-enum/issues/49) for more information. + +## Upgrading to >= 0.9.0 + +### Inheritance & `Ruby::Enum.values` + +This only applies to classes that inherit from another which is a `Ruby::Enum`. + +Prior to version `0.9.0`, the `values` class method would enumerate only the +values defined in the class. + +As of version `0.9.0`, the `values` class method enumerates values defined in +the entire class heirarchy, ancestors first. + +``` ruby +class PrimaryColors + include Ruby::Enum + + define :RED, 'RED' + define :GREEN, 'GREEN' + define :BLUE, 'BLUE' +end + +class RainbowColors < PrimaryColors + define :ORANGE, 'ORANGE' + define :YELLOW, 'YELLOW' + define :INIDGO, 'INIDGO' + define :VIOLET, 'VIOLET' +end +``` + +`gem 'ruby-enum', '< 0.9.0'` + +``` ruby +RainbowColors.values # ['ORANGE', 'YELLOW', 'INIDGO', 'VIOLET'] +``` + +`gem 'ruby-enum', '>= 0.9.0'` + +``` ruby +RainbowColors.values # ['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'INIDGO', 'VIOLET'] +``` + +See [#29](https://github.com/dblock/ruby-enum/pull/29) for more information. diff --git a/benchmarks/basic.rb b/benchmarks/basic.rb new file mode 100644 index 0000000..32a70df --- /dev/null +++ b/benchmarks/basic.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) + +require 'benchmark' +require 'ruby-enum' + +## +# Ruby::Enum equivalent of a plain Hash/constant lookup. +class Colors + include Ruby::Enum + + define :RED, 'red' + define :GREEN, 'green' + define :BLUE, 'blue' +end + +# Plain Ruby equivalents, doing the same lookups without Ruby::Enum. +PLAIN_HASH = { RED: 'red', GREEN: 'green', BLUE: 'blue' }.freeze + +module PlainConstants + RED = 'red' + GREEN = 'green' + BLUE = 'blue' +end + +n = 1_000_000 + +def benchmark(label, iterations, &block) + time = Benchmark.realtime { iterations.times(&block) } + puts "#{label}: #{time.round(4)}" + time +end + +puts "Running #{n} iterations of each scenario below\n\n" + +puts '--- Constant access ---' +enum_const_time = benchmark('Ruby::Enum constant (Colors::RED)', n) { Colors::RED } +plain_const_time = benchmark('plain Ruby constant (PlainConstants::RED)', n) { PlainConstants::RED } + +puts "\n--- Key to value lookup ---" +enum_value_time = benchmark('Ruby::Enum (Colors.value(:RED))', n) { Colors.value(:RED) } +hash_value_time = benchmark('plain Hash (PLAIN_HASH[:RED])', n) { PLAIN_HASH[:RED] } + +puts "\n--- Value to key lookup ---" +benchmark('Ruby::Enum (Colors.key(\'red\'))', n) { Colors.key('red') } +benchmark('plain Hash (PLAIN_HASH.key(\'red\'))', n) { PLAIN_HASH.key('red') } + +puts "\n--- Existence checks ---" +benchmark('Ruby::Enum (Colors.key?(:RED))', n) { Colors.key?(:RED) } +benchmark('plain Hash (PLAIN_HASH.key?(:RED))', n) { PLAIN_HASH.key?(:RED) } + +puts "\n--- Enumerating all keys/values ---" +benchmark('Ruby::Enum (Colors.keys)', n) { Colors.keys } +benchmark('plain Hash (PLAIN_HASH.keys)', n) { PLAIN_HASH.keys } +benchmark('Ruby::Enum (Colors.values)', n) { Colors.values } +benchmark('plain Hash (PLAIN_HASH.values)', n) { PLAIN_HASH.values } + +puts "\n--- Comparison ---" +puts "Ruby::Enum constant access is #{(enum_const_time / plain_const_time).round(2)}x plain Ruby constant access" +puts "Ruby::Enum .value is #{(enum_value_time / hash_value_time).round(2)}x plain Hash#[]" diff --git a/benchmarks/case.rb b/benchmarks/case.rb new file mode 100644 index 0000000..2c51d0f --- /dev/null +++ b/benchmarks/case.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) + +require 'benchmark' +require 'ruby-enum' + +## +# Test enum +class Color + include Ruby::Enum + include Ruby::Enum::Case + + define :RED, :red + define :GREEN, :green + define :BLUE, :blue +end + +puts 'Running 1.000.000 normal case statements' +case_statement_time = Benchmark.realtime do + 1_000_000.times do + case Color::RED + when Color::RED, Color::GREEN + 'red or green' + when Color::BLUE + 'blue' + end + end +end + +puts 'Running 1.000.000 ruby-enum case statements' +ruby_enum_time = Benchmark.realtime do + 1_000_000.times do + Color.case(Color::RED, + { + [Color::RED, Color::GREEN] => -> { 'red or green' }, + Color::BLUE => -> { 'blue' } + }) + end +end + +puts "ruby-enum case: #{ruby_enum_time.round(4)}" +puts "case statement: #{case_statement_time.round(4)}" + +puts "ruby-enum case is #{(ruby_enum_time / case_statement_time).round(2)} times slower" diff --git a/benchmarks/inheritance.rb b/benchmarks/inheritance.rb new file mode 100644 index 0000000..5e8ec9a --- /dev/null +++ b/benchmarks/inheritance.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) + +require 'benchmark' +require 'ruby-enum' + +## +# Base enum, no inheritance. +class Colors + include Ruby::Enum + + define :RED, 'red' + define :GREEN, 'green' +end + +## +# Subclass, adds its own enum on top of an inherited one (1 level). +class SubColors < Colors + define :BLUE, 'blue' +end + +## +# Sub-subclass, adds its own enum on top of 2 inherited levels. +class SubSubColors < SubColors + define :YELLOW, 'yellow' +end + +n = 1_000_000 + +def benchmark(label, iterations, &block) + time = Benchmark.realtime { iterations.times(&block) } + puts "#{label}: #{time.round(4)}" + time +end + +puts "Running #{n} iterations of each scenario below\n\n" + +puts '--- .value lookups, by depth of inheritance ---' +base_value = benchmark('base class (no inheritance)', n) { Colors.value(:RED) } +sub_value = benchmark('subclass (1 level)', n) { SubColors.value(:RED) } +sub_sub_value = benchmark('sub-subclass (2 levels)', n) { SubSubColors.value(:RED) } + +puts "\n--- Other methods on a subclass (1 level) ---" +benchmark('.keys', n) { SubColors.keys } +benchmark('.key?', n) { SubColors.key?(:RED) } +benchmark('.to_h', n) { SubColors.to_h } +count = 0 +benchmark('.each', n) { SubColors.each { |_k, _v| count += 1 } } + +puts "\n--- Comparison ---" +puts "subclass value is #{(sub_value / base_value).round(2)}x base class value" +puts "sub-subclass value is #{(sub_sub_value / base_value).round(2)}x base class value" diff --git a/lib/config/locales/en.yml b/lib/config/locales/en.yml index 402cedc..c93b29c 100644 --- a/lib/config/locales/en.yml +++ b/lib/config/locales/en.yml @@ -12,3 +12,11 @@ en: \_\_\_include Ruby::Enum\n \_\_\_define %{key}, 'value'\n \_\_end" + duplicate_key: + message: 'Duplicate key.' + summary: "The constant %{name}::%{key} has already been defined." + resolution: "The enumerated key is already defined in class %{name}%. Please use a different name to declare it." + duplicate_value: + message: 'Duplicate value found.' + summary: "The value %{value} has already been defined in %{name}%." + resolution: "The enumerated value %{value} is already defined in class %{name}%. Please use a different value." diff --git a/lib/ruby-enum.rb b/lib/ruby-enum.rb index 7e8c98a..4d9b921 100644 --- a/lib/ruby-enum.rb +++ b/lib/ruby-enum.rb @@ -1,9 +1,25 @@ -require 'i18n' +# frozen_string_literal: true require 'ruby-enum/version' require 'ruby-enum/enum' +require 'ruby-enum/enum/case' +require 'ruby-enum/enum/i18n_mock' -I18n.load_path << File.join(File.dirname(__FILE__), 'config', 'locales', 'en.yml') +# Try to load the I18n gem and provide a mock if it is not available. +begin + require 'i18n' + Ruby::Enum.i18n = I18n +rescue LoadError + # I18n is not available + # :nocov: + # Tests for this loading are in the spec_i18n folder + Ruby::Enum.i18n = Ruby::Enum::I18nMock + # :nocov: +end + +Ruby::Enum.i18n.load_path << File.join(File.dirname(__FILE__), 'config', 'locales', 'en.yml') require 'ruby-enum/errors/base' require 'ruby-enum/errors/uninitialized_constant_error' +require 'ruby-enum/errors/duplicate_key_error' +require 'ruby-enum/errors/duplicate_value_error' diff --git a/lib/ruby-enum/enum.rb b/lib/ruby-enum/enum.rb index e3b8318..b9ef27e 100644 --- a/lib/ruby-enum/enum.rb +++ b/lib/ruby-enum/enum.rb @@ -1,5 +1,12 @@ +# frozen_string_literal: true + module Ruby module Enum + class << self + # Needed for I18n mock + attr_accessor :i18n + end + attr_reader :key, :value def initialize(key, value) @@ -10,6 +17,11 @@ def initialize(key, value) def self.included(base) base.extend Enumerable base.extend ClassMethods + + base.private_class_method(:new) + + base.instance_variable_set(:@_own_enum_hash, {}) + base.instance_variable_set(:@_own_enums_by_value, {}) end module ClassMethods @@ -18,53 +30,182 @@ module ClassMethods # === Parameters # [key] Enumerator key. # [value] Enumerator value. - def define(key, value) - @_enum_hash ||= {} - @_enum_hash[key] = new(key, value) - end + def define(key, value = key) + validate_key!(key) + validate_value!(value) - def const_missing(key) - if @_enum_hash[key] - @_enum_hash[key].value + store_new_instance(key, value) + + if upper?(key.to_s) + const_set key, value else - fail Ruby::Enum::Errors::UninitializedConstantError.new(name: name, key: key) + define_singleton_method(key) { value } end end - # Iterate over all enumerated values. + def store_new_instance(key, value) + new_instance = new(key, value) + _own_enum_hash[key] = new_instance + _own_enums_by_value[value] = new_instance + + # Invalidate memoized, merged hashes since this class' own enums changed. + @_enum_hash = @_enums_by_value = nil + end + + def const_missing(key) + raise Ruby::Enum::Errors::UninitializedConstantError, name: name, key: key + end + + # Iterate over all enumerated values, including those defined in a superclass. # Required for Enumerable mixin def each(&block) - @_enum_hash.each(&block) + _enum_hash.each(&block) end - # Attempt to parse an enumerated value. + # Attempt to parse an enum key and return the + # corresponding value. # # === Parameters - # [s] The string to parse. + # [k] The key string to parse. # - # Returns an enumerated value or nil. - def parse(s) - s = s.to_s.upcase + # Returns the corresponding value or nil. + def parse(k) + k = k.to_s.upcase each do |key, enum| - return enum.value if key.to_s.upcase == s + return enum.value if key.to_s.upcase == k end nil end - # Returns all enum keys. + # Whether the specified key exists in this enum, including those defined in a superclass. + # + # === Parameters + # [k] The string key to check. + # + # Returns true if the key exists, false otherwise. + def key?(k) + _enum_hash.key?(k) + end + + # Gets the string value for the specified key, including those defined in a superclass. + # + # === Parameters + # [k] The key symbol to get the value for. + # + # Returns the corresponding enum instance or nil. + def value(k) + enum = _enum_hash[k] + enum&.value + end + + # Whether the specified value exists in this enum, including those defined in a superclass. + # + # === Parameters + # [k] The string value to check. + # + # Returns true if the value exists, false otherwise. + def value?(v) + _enums_by_value.key?(v) + end + + # Gets the key symbol for the specified value, including those defined in a superclass. + # + # === Parameters + # [v] The string value to parse. + # + # Returns the corresponding key symbol or nil. + def key(v) + enum = _enums_by_value[v] + enum&.key + end + + # Returns all enum keys, including those defined in a superclass. def keys - @_enum_hash.values.map(&:key) + _enum_hash.values.map(&:key) end - # Returns all enum values. + # Returns all enum values, including those defined in a superclass. def values - @_enum_hash.values.map(&:value) + _enum_hash.values.map(&:value) + end + + # Iterate over all enumerated values, including those defined in a superclass. + # Required for Enumerable mixin + def each_value(&_block) + _enum_hash.each_value do |v| + yield v.value + end + end + + # Iterate over all enumerated keys, including those defined in a superclass. + # Required for Enumerable mixin + def each_key(&_block) + _enum_hash.each_value do |v| + yield v.key + end end + # Returns a hash of key:values, including those defined in a superclass. def to_h - Hash[@_enum_hash.map do |key, enum| - [key, enum.value] - end] + _enum_hash.transform_values(&:value) + end + + private + + # Returns this class' own enum hash, defaulting to an empty hash. + # + # A subclass that does not `define` any of its own enums does not have + # its `@_own_enum_hash` instance variable set, since it's only initialized + # in `define` and in the `included` hook. + def _own_enum_hash + @_own_enum_hash ||= {} + end + + # Returns this class' own enums-by-value hash, defaulting to an empty hash. + def _own_enums_by_value + @_own_enums_by_value ||= {} + end + + # Returns the enum hash for this class merged with all of its superclasses, + # with keys defined in this class taking precedence over those inherited + # from a superclass. + def _enum_hash + @_enum_hash ||= if superclass < Ruby::Enum + superclass.send(:_enum_hash).merge(_own_enum_hash) + else + _own_enum_hash + end + end + + # Returns the enums-by-value hash for this class merged with all of its + # superclasses, with values defined in this class taking precedence over + # those inherited from a superclass. + # + # Derived from _enum_hash (keyed by key) rather than merged directly by + # value, so that a superclass' stale value for a key overridden in this + # class doesn't linger, e.g. when a subclass redefines a key with a new + # value, the superclass' old value should no longer be found via value? + # or key. + def _enums_by_value + @_enums_by_value ||= _enum_hash.each_with_object({}) do |(_key, enum), hash| + hash[enum.value] = enum + end + end + + def upper?(s) + !/[[:upper:]]/.match(s).nil? + end + + def validate_key!(key) + return unless _own_enum_hash.key?(key) + + raise Ruby::Enum::Errors::DuplicateKeyError, name: name, key: key + end + + def validate_value!(value) + return unless _own_enums_by_value.key?(value) + + raise Ruby::Enum::Errors::DuplicateValueError, name: name, value: value end end end diff --git a/lib/ruby-enum/enum/case.rb b/lib/ruby-enum/enum/case.rb new file mode 100644 index 0000000..79e1cab --- /dev/null +++ b/lib/ruby-enum/enum/case.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Ruby + module Enum + ## + # Adds a method to an enum class that allows for exhaustive matching on a value. + # + # @example + # class Color + # include Ruby::Enum + # include Ruby::Enum::Case + # + # define :RED, :red + # define :GREEN, :green + # define :BLUE, :blue + # define :YELLOW, :yellow + # end + # + # Color.case(Color::RED, { + # [Color::RED, Color::GREEN] => -> { "red or green" }, + # Color::BLUE => -> { "blue" }, + # Color::YELLOW => -> { "yellow" }, + # }) + # + # Reserves the :else key for a default case: + # Color.case(Color::RED, { + # [Color::RED, Color::GREEN] => -> { "red or green" }, + # else: -> { "blue or yellow" }, + # }) + module Case + def self.included(klass) + klass.extend(ClassMethods) + end + + ## + # @see Ruby::Enum::Case + module ClassMethods + class ValuesNotDefinedError < StandardError + end + + class NotAllCasesHandledError < StandardError + end + + def case(value, cases) + validate_cases(cases) + + filtered_cases = cases.select do |values, _proc| + values = [values] unless values.is_a?(Array) + values.include?(value) + end + + return call_proc(cases[:else], value) if filtered_cases.none? + + results = filtered_cases.map { |_values, proc| call_proc(proc, value) } + + # Return the first result if there is only one result + results.size == 1 ? results.first : results + end + + private + + def call_proc(proc, value) + return if proc.nil? + + if proc.arity == 1 + proc.call(value) + else + proc.call + end + end + + def validate_cases(cases) + all_values = cases.keys.flatten - [:else] + else_defined = cases.key?(:else) + superfluous_values = all_values - values + missing_values = values - all_values + + raise ValuesNotDefinedError, "Value(s) not defined: #{superfluous_values.join(', ')}" if superfluous_values.any? + raise NotAllCasesHandledError, "Not all cases handled: #{missing_values.join(', ')}" if missing_values.any? && !else_defined + end + end + end + end +end diff --git a/lib/ruby-enum/enum/i18n_mock.rb b/lib/ruby-enum/enum/i18n_mock.rb new file mode 100644 index 0000000..b9adbb9 --- /dev/null +++ b/lib/ruby-enum/enum/i18n_mock.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# :nocov: +module Ruby + module Enum + ## + # Mock I18n module in case the i18n gem is not available. + module I18nMock + def self.load_path + [] + end + + def self.translate(key, _options = {}) + key + end + end + end +end +# :nocov: diff --git a/lib/ruby-enum/errors/base.rb b/lib/ruby-enum/errors/base.rb index 33ae6e0..5e2dfdb 100644 --- a/lib/ruby-enum/errors/base.rb +++ b/lib/ruby-enum/errors/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Ruby module Enum module Errors @@ -20,55 +22,55 @@ def compose_message(key, attributes = {}) @summary = create_summary(key, attributes) @resolution = create_resolution(key, attributes) - "\nProblem:\n #{@problem}"\ - "\nSummary:\n #{@summary}" + "\nResolution:\n #{@resolution}" + "\nProblem:\n #{@problem}" \ + "\nSummary:\n #{@summary}" + "\nResolution:\n #{@resolution}" end - private + BASE_KEY = 'ruby.enum.errors.messages' # :nodoc: - BASE_KEY = 'ruby.enum.errors.messages' #:nodoc: + private - # Given the key of the specific error and the options hash, translate the - # message. - # - # === Parameters - # [key] The key of the error in the locales. - # [options] The objects to pass to create the message. - # - # Returns a localized error message string. + # Given the key of the specific error and the options hash, translate the + # message. + # + # === Parameters + # [key] The key of the error in the locales. + # [options] The objects to pass to create the message. + # + # Returns a localized error message string. def translate(key, options) - ::I18n.translate("#{BASE_KEY}.#{key}", { locale: :en }.merge(options)).strip + Ruby::Enum.i18n.translate("#{BASE_KEY}.#{key}", locale: :en, **options).strip end - # Create the problem. - # - # === Parameters - # [key] The error key. - # [attributes] The attributes to interpolate. - # - # Returns the problem. + # Create the problem. + # + # === Parameters + # [key] The error key. + # [attributes] The attributes to interpolate. + # + # Returns the problem. def create_problem(key, attributes) translate("#{key}.message", attributes) end - # Create the summary. - # - # === Parameters - # [key] The error key. - # [attributes] The attributes to interpolate. - # - # Returns the summary. + # Create the summary. + # + # === Parameters + # [key] The error key. + # [attributes] The attributes to interpolate. + # + # Returns the summary. def create_summary(key, attributes) translate("#{key}.summary", attributes) end - # Create the resolution. - # - # === Parameters - # [key] The error key. - # [attributes] The attributes to interpolate. - # - # Returns the resolution. + # Create the resolution. + # + # === Parameters + # [key] The error key. + # [attributes] The attributes to interpolate. + # + # Returns the resolution. def create_resolution(key, attributes) translate("#{key}.resolution", attributes) end diff --git a/lib/ruby-enum/errors/duplicate_key_error.rb b/lib/ruby-enum/errors/duplicate_key_error.rb new file mode 100644 index 0000000..64b0bd7 --- /dev/null +++ b/lib/ruby-enum/errors/duplicate_key_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ruby + module Enum + module Errors + # Error raised when a duplicate enum key is found + class DuplicateKeyError < Base + def initialize(attrs) + super(compose_message('duplicate_key', attrs)) + end + end + end + end +end diff --git a/lib/ruby-enum/errors/duplicate_value_error.rb b/lib/ruby-enum/errors/duplicate_value_error.rb new file mode 100644 index 0000000..b3be267 --- /dev/null +++ b/lib/ruby-enum/errors/duplicate_value_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ruby + module Enum + module Errors + # Error raised when a duplicate enum value is found + class DuplicateValueError < Base + def initialize(attrs) + super(compose_message('duplicate_value', attrs)) + end + end + end + end +end diff --git a/lib/ruby-enum/errors/uninitialized_constant_error.rb b/lib/ruby-enum/errors/uninitialized_constant_error.rb index b5fff8e..42200c7 100644 --- a/lib/ruby-enum/errors/uninitialized_constant_error.rb +++ b/lib/ruby-enum/errors/uninitialized_constant_error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Ruby module Enum module Errors diff --git a/lib/ruby-enum/version.rb b/lib/ruby-enum/version.rb index 075835f..d495750 100644 --- a/lib/ruby-enum/version.rb +++ b/lib/ruby-enum/version.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Ruby module Enum - VERSION = '0.4.1' + VERSION = '1.2.2' end end diff --git a/lib/ruby_enum.rb b/lib/ruby_enum.rb index abda069..c1af8f6 100644 --- a/lib/ruby_enum.rb +++ b/lib/ruby_enum.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + require 'ruby-enum' diff --git a/ruby-enum.gemspec b/ruby-enum.gemspec index a244390..50da6a9 100644 --- a/ruby-enum.gemspec +++ b/ruby-enum.gemspec @@ -1,4 +1,6 @@ -$LOAD_PATH.push File.expand_path('../lib', __FILE__) +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path('lib', __dir__) require 'ruby-enum/version' Gem::Specification.new do |s| @@ -8,10 +10,11 @@ Gem::Specification.new do |s| s.email = 'dblock@dblock.org' s.platform = Gem::Platform::RUBY s.required_rubygems_version = '>= 1.3.6' + s.required_ruby_version = '>= 2.7' s.files = Dir['**/*'] s.require_paths = ['lib'] s.homepage = 'http://github.com/dblock/ruby-enum' s.licenses = ['MIT'] s.summary = 'Enum-like behavior for Ruby.' - s.add_dependency 'i18n' + s.metadata['rubygems_mfa_required'] = 'true' end diff --git a/spec/ruby-enum/enum/case_spec.rb b/spec/ruby-enum/enum/case_spec.rb new file mode 100644 index 0000000..f3b6395 --- /dev/null +++ b/spec/ruby-enum/enum/case_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Case + class Colors + include Ruby::Enum + include Ruby::Enum::Case + + define :RED, :red + define :GREEN, :green + define :BLUE, :blue + end +end + +RSpec.describe Ruby::Enum::Case do + describe '.case' do + context 'when all cases are defined' do + subject { Case::Colors.case(Case::Colors::RED, cases) } + + let(:cases) do + { + [Case::Colors::RED, Case::Colors::GREEN] => -> { 'red or green' }, + Case::Colors::BLUE => -> { 'blue' } + } + end + + it { is_expected.to eq('red or green') } + + context 'when the value is nil' do + subject { Case::Colors.case(nil, cases) } + + it { is_expected.to be_nil } + end + + context 'when the value is empty' do + subject { Case::Colors.case('', cases) } + + it { is_expected.to be_nil } + end + + context 'when the value is the value of the enum' do + subject { Case::Colors.case(:red, cases) } + + it { is_expected.to eq('red or green') } + end + + context 'when the value is used inside the lambda' do + subject { Case::Colors.case(Case::Colors::RED, cases) } + + let(:cases) do + { + [Case::Colors::RED, Case::Colors::GREEN] => ->(color) { "is #{color}" }, + Case::Colors::BLUE => -> { 'blue' } + } + end + + it { is_expected.to eq('is red') } + end + end + + context 'when there are mutliple matches' do + subject do + Case::Colors.case( + Case::Colors::RED, + { + [Case::Colors::RED, Case::Colors::GREEN] => -> { 'red or green' }, + Case::Colors::RED => -> { 'red' }, + Case::Colors::BLUE => -> { 'blue' } + } + ) + end + + it { is_expected.to eq(['red or green', 'red']) } + end + + context 'when not all cases are defined' do + it 'raises an error' do + expect do + Case::Colors.case( + Case::Colors::RED, + { [Case::Colors::RED, Case::Colors::GREEN] => -> { 'red or green' } } + ) + end.to raise_error(Ruby::Enum::Case::ClassMethods::NotAllCasesHandledError) + end + end + + context 'when not all cases are defined but :else is specified (default case)' do + it 'does not raise an error' do + expect do + result = Case::Colors.case( + Case::Colors::BLUE, + { + [Case::Colors::RED, Case::Colors::GREEN] => -> { 'red or green' }, + else: -> { 'blue' } + } + ) + + expect(result).to eq('blue') + end.not_to raise_error + end + end + + context 'when a superfluous case is defined' do + it 'raises an error' do + expect do + Case::Colors.case( + Case::Colors::RED, + { + [Case::Colors::RED, Case::Colors::GREEN] => -> { 'red or green' }, + Case::Colors::BLUE => -> { 'blue' }, + :something => -> { 'green' } + } + ) + end.to raise_error(Ruby::Enum::Case::ClassMethods::ValuesNotDefinedError) + end + end + end +end diff --git a/spec/ruby-enum/enum_spec.rb b/spec/ruby-enum/enum_spec.rb index 864cf78..ac7513b 100644 --- a/spec/ruby-enum/enum_spec.rb +++ b/spec/ruby-enum/enum_spec.rb @@ -1,21 +1,62 @@ +# frozen_string_literal: true + require 'spec_helper' -class Colors - include Ruby::Enum +describe Ruby::Enum do + class Colors + include Ruby::Enum - define :RED, 'red' - define :GREEN, 'green' -end + define :RED, 'red' + define :GREEN, 'green' + end + + class FirstSubclass < Colors + define :ORANGE, 'orange' + end + + class SecondSubclass < FirstSubclass + define :PINK, 'pink' + end + + class OtherSecondSubclass < FirstSubclass + include Ruby::Enum + + define :MAGENTA, 'magenta' + end + + class SubclassWithNoOwnDefines < Colors + end + + class SubclassRedefiningParentKey < Colors + define :RED, 'crimson' + end -describe Ruby::Enum do it 'returns an enum value' do - Colors::RED.should eq 'red' - Colors::GREEN.should eq 'green' + expect(Colors::RED).to eq 'red' + expect(Colors::GREEN).to eq 'green' + end + + context 'when the i18n gem is loaded' do + it 'raises UninitializedConstantError on an invalid constant' do + expect do + Colors::ANYTHING + end.to raise_error Ruby::Enum::Errors::UninitializedConstantError, /The constant Colors::ANYTHING has not been defined./ + end end - it 'raises UninitializedConstantError on an invalid constant' do - expect { Colors::ANYTHING }.to raise_error Ruby::Enum::Errors::UninitializedConstantError + + context 'when the i18n gem is not loaded' do + before do + allow(described_class).to receive(:i18n).and_return(Ruby::Enum::I18nMock) + end + + it 'raises UninitializedConstantError on an invalid constant' do + expect do + Colors::ANYTHING + end.to raise_error Ruby::Enum::Errors::UninitializedConstantError, /ruby.enum.errors.messages.uninitialized_constant.summary/ + end end - context '#each' do + + describe '#each' do it 'iterates over constants' do keys = [] enum_keys = [] @@ -25,48 +66,435 @@ class Colors enum_keys << enum.key enum_values << enum.value end - keys.should eq [:RED, :GREEN] - enum_keys.should eq [:RED, :GREEN] - enum_values.should eq %w(red green) + expect(keys).to eq %i[RED GREEN] + expect(enum_keys).to eq %i[RED GREEN] + expect(enum_values).to eq %w[red green] + end + + context 'when a subclass defines its own enums' do + it 'iterates over the enums defined in the parent class too' do + keys = FirstSubclass.map { |key, _enum| key } + expect(keys).to eq %i[RED GREEN ORANGE] + end end end - context '#map' do + + describe '#map' do it 'maps constants' do key_key_values = Colors.map do |key, enum| [key, enum.key, enum.value] end - key_key_values.count.should eq 2 - key_key_values[0].should eq [:RED, :RED, 'red'] - key_key_values[1].should eq [:GREEN, :GREEN, 'green'] + expect(key_key_values.count).to eq 2 + expect(key_key_values[0]).to eq [:RED, :RED, 'red'] + expect(key_key_values[1]).to eq [:GREEN, :GREEN, 'green'] end end - context '#parse' do + + describe '#parse' do it 'parses exact value' do - Colors.parse('red').should == Colors::RED + expect(Colors.parse('red')).to eq(Colors::RED) end + it 'is case-insensitive' do - Colors.parse('ReD').should == Colors::RED + expect(Colors.parse('ReD')).to eq(Colors::RED) end + it 'returns nil for a null value' do - Colors.parse(nil).should be_nil + expect(Colors.parse(nil)).to be_nil end + it 'returns nil for an invalid value' do - Colors.parse('invalid').should be_nil + expect(Colors.parse('invalid')).to be_nil + end + + context 'when a subclass defines its own enums' do + it 'parses a value defined in the parent class' do + expect(FirstSubclass.parse('red')).to eq('red') + end + end + end + + describe '#key?' do + it 'returns true for valid keys accessed directly' do + Colors.keys.each do |key| # rubocop:disable Style/HashEachMethods + expect(Colors.key?(key)).to be(true) + end + end + + it 'returns true for valid keys accessed via each_keys' do + Colors.each_key do |key| + expect(Colors.key?(key)).to be(true) + end + end + + it 'returns false for invalid keys' do + expect(Colors.key?(:NOT_A_KEY)).to be(false) + end + + context 'when a subclass defines its own enums' do + it 'returns true for keys inherited from the parent class' do + expect(FirstSubclass.key?(:RED)).to be(true) + end end end - context '#keys' do + + describe '#value' do + it 'returns string values for keys' do + Colors.each do |key, enum| + expect(Colors.value(key)).to eq(enum.value) + end + end + + it 'returns nil for an invalid key' do + expect(Colors.value(:NOT_A_KEY)).to be_nil + end + + context 'when a subclass defines its own enums' do + it 'returns the value for a key inherited from the parent class' do + expect(FirstSubclass.value(:RED)).to eq('red') + end + end + end + + describe '#value?' do + it 'returns true for valid values accessed directly' do + Colors.values.each do |value| # rubocop:disable Style/HashEachMethods + expect(Colors.value?(value)).to be(true) + end + end + + it 'returns true for valid values accessed via each_value' do + Colors.each_value do |value| + expect(Colors.value?(value)).to be(true) + end + end + + it 'returns false for invalid values' do + expect(Colors.value?('I am not a value')).to be(false) + end + + context 'when a subclass defines its own enums' do + it 'returns true for values inherited from the parent class' do + expect(FirstSubclass.value?('red')).to be(true) + end + end + end + + describe '#key' do + it 'returns enum instances for values' do + Colors.each do |_, enum| # rubocop:disable Style/HashEachMethods + expect(Colors.key(enum.value)).to eq(enum.key) + end + end + + it 'returns nil for an invalid value' do + expect(Colors.key('invalid')).to be_nil + end + + context 'when a subclass defines its own enums' do + it 'returns the key for a value inherited from the parent class' do + expect(FirstSubclass.key('red')).to eq(:RED) + end + end + end + + describe '#keys' do it 'returns keys' do - Colors.keys.should == [:RED, :GREEN] + expect(Colors.keys).to eq(%i[RED GREEN]) + end + + context 'when a subclass defines its own enums' do + it 'includes keys from the parent class' do + expect(FirstSubclass.keys).to eq(%i[RED GREEN ORANGE]) + end end end - context '#values' do + + describe '#values' do it 'returns values' do - Colors.values.should == %w(red green) + expect(Colors.values).to eq(%w[red green]) + end + + context 'when a subclass is defined' do + it 'returns all values' do + expect(FirstSubclass.values).to eq(%w[red green orange]) + end + end + + context 'when a subclass of a subclass is defined' do + it 'returns all values' do + expect(SecondSubclass.values).to eq(%w[red green orange pink]) + end + end + + context 'when a subclass of a subclass is defined with redundant module inclusion' do + it 'returns all values' do + expect(OtherSecondSubclass.values).to eq(%w[red green orange magenta]) + end + end + + context 'when a subclass defines no enums of its own' do + it 'returns the values from the parent class' do + expect(SubclassWithNoOwnDefines.values).to eq(%w[red green]) + end end end - context '#to_h' do + + describe '#to_h' do it 'returns a hash of key:values' do - Colors.to_h.should == { RED: 'red', GREEN: 'green' } + expect(Colors.to_h).to eq(RED: 'red', GREEN: 'green') + end + + context 'when a subclass defines its own enums' do + it 'includes key:values from the parent class' do + expect(FirstSubclass.to_h).to eq(RED: 'red', GREEN: 'green', ORANGE: 'orange') + end + end + end + + context 'when a duplicate key is used' do + context 'when the i18n gem is loaded' do + it 'raises DuplicateKeyError' do + expect do + Colors.class_eval do + define :RED, 'some' + end + end.to raise_error Ruby::Enum::Errors::DuplicateKeyError, /The constant Colors::RED has already been defined./ + end + end + + context 'when the i18n gem is not loaded' do + before do + allow(described_class).to receive(:i18n).and_return(Ruby::Enum::I18nMock) + end + + it 'raises DuplicateKeyError' do + expect do + Colors.class_eval do + define :RED, 'some' + end + end.to raise_error Ruby::Enum::Errors::DuplicateKeyError, /ruby.enum.errors.messages.duplicate_key.message/ + end + end + end + + context 'when a duplicate value is used' do + context 'when the i18n gem is loaded' do + it 'raises a DuplicateValueError' do + expect do + Colors.class_eval do + define :Other, 'red' + end + end.to raise_error Ruby::Enum::Errors::DuplicateValueError, /The value red has already been defined./ + end + end + + context 'when the i18n gem is not loaded' do + before do + allow(described_class).to receive(:i18n).and_return(Ruby::Enum::I18nMock) + end + + it 'raises a DuplicateValueError' do + expect do + Colors.class_eval do + define :Other, 'red' + end + end.to raise_error Ruby::Enum::Errors::DuplicateValueError, /ruby.enum.errors.messages.duplicate_value.summary/ + end + end + end + + describe 'Reloading enum definition' do + it 'can be lazy reloaded' do + class_body = proc do + include Ruby::Enum + + define :BUZZ_CUT, 'buzz_cut' + end + + hair_styles = Class.new(&class_body) + + expect { hair_styles.class_eval(&class_body) }.not_to raise_error + end + + context 'when a subclass is defined' do + it 'NEEDS to include Ruby::Enum explicitly to be lazy reloaded' do + hair_styles = Class.new do + include Ruby::Enum + + define :BUZZ_CUT, 'buzz_cut' + end + + broken_subclass_body = proc do + define :PONYTAIL, 'ponytail' + end + broken_subclass = Class.new(hair_styles, &broken_subclass_body) + expect { broken_subclass.class_eval(&broken_subclass_body) } + .to raise_error Ruby::Enum::Errors::DuplicateKeyError, /PONYTAIL/ + + subclass_body = proc do + include Ruby::Enum + + define :PONYTAIL, 'ponytail' + end + more_hair_styles = Class.new(hair_styles, &subclass_body) + + expect { more_hair_styles.class_eval(&subclass_body) }.not_to raise_error + expect(more_hair_styles.values).to eq(%w[buzz_cut ponytail]) + end + end + end + + describe 'Given a class that has not defined any enums' do + class EmptyEnums + include Ruby::Enum + end + it do + expect { EmptyEnums::ORANGE }.to raise_error Ruby::Enum::Errors::UninitializedConstantError + end + end + + context 'when a constant is redefined in a global namespace' do + before do + RED = 'black' + end + + it { expect(Colors::RED).to eq 'red' } + end + + describe 'Subclass behavior' do + it 'contains the enums defined in the parent class' do + expect(FirstSubclass::GREEN).to eq 'green' + expect(FirstSubclass::RED).to eq 'red' + end + + it 'contains its own enums' do + expect(FirstSubclass::ORANGE).to eq 'orange' + end + + it 'parent class should not have enums defined in child classes' do + expect { Colors::ORANGE }.to raise_error Ruby::Enum::Errors::UninitializedConstantError + end + + context 'when defining a 2 level depth subclass' do + it 'contains its own enums and all the enums defined in the parent classes' do + expect(SecondSubclass::RED).to eq 'red' + expect(SecondSubclass::GREEN).to eq 'green' + expect(SecondSubclass::ORANGE).to eq 'orange' + expect(SecondSubclass::PINK).to eq 'pink' + end + + describe '#values' do + it 'contains the values from all of the parent classes' do + expect(SecondSubclass.values).to eq(%w[red green orange pink]) + end + end + end + + describe '#values' do + it 'contains the values from the parent class' do + expect(FirstSubclass.values).to eq(%w[red green orange]) + end + end + + context 'when a subclass defines no enums of its own (issue #49)' do + it 'inherits the parent class enums via constants' do + expect(SubclassWithNoOwnDefines::RED).to eq 'red' + expect(SubclassWithNoOwnDefines::GREEN).to eq 'green' + end + + it 'inherits the parent class values' do + expect(SubclassWithNoOwnDefines.values).to eq(%w[red green]) + end + + it 'inherits the parent class enums for keys, key?, value?, key, value, to_h, parse and each' do + expect(SubclassWithNoOwnDefines.keys).to eq(%i[RED GREEN]) + expect(SubclassWithNoOwnDefines.key?(:RED)).to be true + expect(SubclassWithNoOwnDefines.value?('red')).to be true + expect(SubclassWithNoOwnDefines.key('red')).to eq :RED + expect(SubclassWithNoOwnDefines.value(:RED)).to eq 'red' + expect(SubclassWithNoOwnDefines.to_h).to eq(RED: 'red', GREEN: 'green') + expect(SubclassWithNoOwnDefines.parse('red')).to eq 'red' + expect(SubclassWithNoOwnDefines.each.map { |key, _enum| key }).to eq(%i[RED GREEN]) + end + end + + context 'when a subclass redefines a key already used by a parent class' do + it 'does not raise a duplicate key error' do + expect(SubclassRedefiningParentKey::RED).to eq 'crimson' + end + + it "the subclass' own value takes precedence" do + expect(SubclassRedefiningParentKey.value(:RED)).to eq 'crimson' + end + + it "the subclass' own value is used in to_h" do + expect(SubclassRedefiningParentKey.to_h).to eq(RED: 'crimson', GREEN: 'green') + end + + it "does not include the parent's overridden value, only its own redefined value, in values" do + expect(SubclassRedefiningParentKey.values).to eq(%w[crimson green]) + end + + it "does not recognize the parent's overridden value via value? or key" do + expect(SubclassRedefiningParentKey.value?('red')).to be false + expect(SubclassRedefiningParentKey.key('red')).to be_nil + expect(SubclassRedefiningParentKey.value?('crimson')).to be true + expect(SubclassRedefiningParentKey.key('crimson')).to eq :RED + end + + it "the parent class' own value is unaffected" do + expect(Colors::RED).to eq 'red' + expect(Colors.value(:RED)).to eq 'red' + end + + it 'still allows the parent class to raise a duplicate key error for its own re-definitions' do + expect do + Colors.class_eval do + define :RED, 'red' + end + end.to raise_error Ruby::Enum::Errors::DuplicateKeyError, /RED/ + end + end + end + + describe 'default value' do + class Default + include Ruby::Enum + + define :KEY + end + + it 'equals the key' do + expect(Default::KEY).to eq(:KEY) + end + end + + describe 'non constant definitions' do + class States + include Ruby::Enum + + define :created, 'Created' + define :published, 'Published' + define :undefined + end + + it 'behaves like an enum' do + expect(States.created).to eq 'Created' + expect(States.published).to eq 'Published' + expect(States.undefined).to eq :undefined + + expect(States.key?(:created)).to be true + expect(States.key('Created')).to eq :created + + expect(States.value?('Created')).to be true + expect(States.value(:created)).to eq 'Created' + + expect(States.key?(:undefined)).to be true + expect(States.key(:undefined)).to eq :undefined + + expect(States.value?(:undefined)).to be true + expect(States.value(:undefined)).to eq :undefined end end end diff --git a/spec/ruby-enum/version_spec.rb b/spec/ruby-enum/version_spec.rb index 79d67d7..e32d337 100644 --- a/spec/ruby-enum/version_spec.rb +++ b/spec/ruby-enum/version_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'spec_helper' describe Ruby::Enum do it 'has a version' do - Ruby::Enum::VERSION.should_not be_nil + expect(Ruby::Enum::VERSION).not_to be_nil end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 88e39bf..8c82783 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,21 @@ +# frozen_string_literal: true + $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) require 'rubygems' + +require 'simplecov' +require 'simplecov-lcov' + +SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true +SimpleCov::Formatter::LcovFormatter.config.single_report_path = 'coverage/lcov.info' +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter + ]) +SimpleCov.start + require 'rspec' require 'ruby-enum' + +RSpec.configure(&:raise_errors_for_deprecations!) diff --git a/spec_i18n/Gemfile b/spec_i18n/Gemfile new file mode 100644 index 0000000..84d9f8e --- /dev/null +++ b/spec_i18n/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source 'http://rubygems.org' + +gemspec path: '../', name: 'ruby-enum' + +# This Gemfile should not include any gem that has i18n as a dependency. +gem 'rake' +gem 'rspec', '~> 3.0' diff --git a/spec_i18n/Rakefile b/spec_i18n/Rakefile new file mode 100644 index 0000000..9827cdf --- /dev/null +++ b/spec_i18n/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rubygems' + +require 'rspec/core' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.pattern = FileList['spec/**/*_spec.rb'] +end + +task default: %i[spec] diff --git a/spec_i18n/spec/i18n_spec.rb b/spec_i18n/spec/i18n_spec.rb new file mode 100644 index 0000000..4de14ec --- /dev/null +++ b/spec_i18n/spec/i18n_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module I18n + class Colors + include Ruby::Enum + + define :RED, 'red' + define :GREEN, 'green' + end +end + +describe Ruby::Enum do + context 'when the i18n gem is not loaded' do + it 'raises UninitializedConstantError on an invalid constant' do + expect do + I18n::Colors::ANYTHING + end.to raise_error Ruby::Enum::Errors::UninitializedConstantError, /ruby.enum.errors.messages.uninitialized_constant.summary/ + end + + context 'when a duplicate key is used' do + before do + allow(described_class).to receive(:i18n).and_return(Ruby::Enum::I18nMock) + end + + it 'raises DuplicateKeyError' do + expect do + I18n::Colors.class_eval do + define :RED, 'some' + end + end.to raise_error Ruby::Enum::Errors::DuplicateKeyError, /ruby.enum.errors.messages.duplicate_key.message/ + end + end + + context 'when a duplicate value is used' do + before do + allow(described_class).to receive(:i18n).and_return(Ruby::Enum::I18nMock) + end + + it 'raises a DuplicateValueError' do + expect do + I18n::Colors.class_eval do + define :Other, 'red' + end + end.to raise_error Ruby::Enum::Errors::DuplicateValueError, /ruby.enum.errors.messages.duplicate_value.summary/ + end + end + end +end diff --git a/spec_i18n/spec/spec_helper.rb b/spec_i18n/spec/spec_helper.rb new file mode 100644 index 0000000..cf56090 --- /dev/null +++ b/spec_i18n/spec/spec_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) + +require 'rubygems' + +require 'rspec' +require 'ruby-enum'