···11+# Contributor Covenant Code of Conduct
22+33+## Our Pledge
44+55+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
66+77+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
88+99+## Our Standards
1010+1111+Examples of behavior that contributes to a positive environment for our community include:
1212+1313+* Demonstrating empathy and kindness toward other people
1414+* Being respectful of differing opinions, viewpoints, and experiences
1515+* Giving and gracefully accepting constructive feedback
1616+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
1717+* Focusing on what is best not just for us as individuals, but for the overall community
1818+1919+Examples of unacceptable behavior include:
2020+2121+* The use of sexualized language or imagery, and sexual attention or
2222+ advances of any kind
2323+* Trolling, insulting or derogatory comments, and personal or political attacks
2424+* Public or private harassment
2525+* Publishing others' private information, such as a physical or email
2626+ address, without their explicit permission
2727+* Other conduct which could reasonably be considered inappropriate in a
2828+ professional setting
2929+3030+## Enforcement Responsibilities
3131+3232+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
3333+3434+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
3535+3636+## Scope
3737+3838+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
3939+4040+## Enforcement
4141+4242+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at joel@drapper.me. All complaints will be reviewed and investigated promptly and fairly.
4343+4444+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
4545+4646+## Enforcement Guidelines
4747+4848+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
4949+5050+### 1. Correction
5151+5252+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
5353+5454+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
5555+5656+### 2. Warning
5757+5858+**Community Impact**: A violation through a single incident or series of actions.
5959+6060+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
6161+6262+### 3. Temporary Ban
6363+6464+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
6565+6666+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
6767+6868+### 4. Permanent Ban
6969+7070+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
7171+7272+**Consequence**: A permanent ban from any sort of public interaction within the community.
7373+7474+## Attribution
7575+7676+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
7777+available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
7878+7979+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
8080+8181+[homepage]: https://www.contributor-covenant.org
8282+8383+For answers to common questions about this code of conduct, see the FAQ at
8484+https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
+9
Gemfile
···11+# frozen_string_literal: true
22+33+source "https://rubygems.org"
44+55+# Specify your gem's dependencies in strict_attributes.gemspec
66+gemspec
77+88+gem "zeitwerk"
99+gem "green_dots", git: "https://github.com/joeldrapper/green_dots.git"
···11+The MIT License (MIT)
22+33+Copyright (c) 2023 Joel Drapper
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in
1313+all copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121+THE SOFTWARE.
+112
README.md
···11+# Literal
22+33+## Basic Usage
44+55+### Mixin
66+77+```ruby
88+class User
99+ include Literal
1010+1111+ attribute :name, String
1212+ attribute :age, Integer
1313+end
1414+```
1515+1616+### Struct
1717+1818+```ruby
1919+class Person < Literal::Struct
2020+ attribute :name, String
2121+ attribute :age, Integer
2222+end
2323+```
2424+2525+### Data
2626+2727+```ruby
2828+class Person < Literal::Data
2929+ attribute :name, String
3030+ attribute :age, Integer
3131+end
3232+```
3333+3434+## Special Types
3535+3636+### Union
3737+3838+```ruby
3939+_Union(String, Symbol)
4040+```
4141+4242+### Boolean
4343+4444+```ruby
4545+_Boolean
4646+```
4747+4848+### Maybe
4949+5050+```ruby
5151+_Maybe(String)
5252+```
5353+5454+### Array
5555+5656+```ruby
5757+_Array(String)
5858+```
5959+6060+### Set
6161+6262+```ruby
6363+_Set(String)
6464+```
6565+6666+### Enumerable
6767+6868+```ruby
6969+_Enumerable(String)
7070+```
7171+7272+### Tuple
7373+An Enumerable containing exactly the specified types in order.
7474+7575+```ruby
7676+_Tuple(String, Integer)
7777+```
7878+7979+### Hash
8080+8181+```ruby
8282+_Hash(String, Integer)
8383+```
8484+8585+### Interface
8686+```ruby
8787+_Interface(:to_s)
8888+```
8989+9090+### Class
9191+9292+```ruby
9393+_Class(RuntimeError)
9494+```
9595+9696+### Module
9797+9898+```ruby
9999+_Module(Enumerable)
100100+```
101101+102102+### Integer
103103+You can of course just use `Integer` to specify an integer type. The special type `_Integer` allows you to limit that type with a range, while verifying that it's an integer and not something else that matches the range such as a float.
104104+105105+```
106106+_Integer(18..)
107107+```
108108+109109+You can use these types together.
110110+```ruby
111111+_Maybe(Union(String, Symbol, Interface(:to_s), Interface(:to_str), Tuple(String, Symbol)))
112112+```
···11+#!/usr/bin/env ruby
22+# frozen_string_literal: true
33+44+require "bundler/setup"
55+require "literal"
66+77+# You can add fixtures and/or initialization code here to make experimenting
88+# with your gem easier. You can also use a different console, if you like.
99+1010+require "irb"
1111+IRB.start(__FILE__)
+8
bin/setup
···11+#!/usr/bin/env bash
22+set -euo pipefail
33+IFS=$'\n\t'
44+set -vx
55+66+bundle install
77+88+# Do any other automated setup that you need to do here
+17
lib/literal.rb
···11+# frozen_string_literal: true
22+33+require_relative "literal/version"
44+55+module Literal
66+ Loader = Zeitwerk::Loader.for_gem.tap(&:setup)
77+88+ module Error; end
99+1010+ class TypeError < ::TypeError
1111+ include Error
1212+ end
1313+1414+ class ArgumentError < ::ArgumentError
1515+ include Error
1616+ end
1717+end
+33
lib/literal/attributes.rb
···11+module Literal::Attributes
22+ extend Literal::Types
33+44+ def attribute(name, type, reader: false, writer: :private)
55+ __attributes__ << name
66+77+ writer_name = :"#{name}="
88+ ivar_name = :"@#{name}"
99+1010+ define_method writer_name do |value|
1111+ raise Literal::TypeError, "Expected `#{value.inspect}` to be a `#{type.inspect}`." unless type === value
1212+ instance_variable_set(ivar_name, value)
1313+ end
1414+1515+ private writer_name unless writer == :public
1616+1717+ if reader
1818+ attr_reader name
1919+ private name unless reader == :public
2020+ end
2121+2222+ name
2323+ end
2424+2525+ def __attributes__
2626+ return @__attributes__ if defined?(@__attributes__)
2727+ @__attributes__ = superclass.is_a?(self) ? superclass.__attributes__.dup : []
2828+ end
2929+3030+ def self.extended(base)
3131+ base.include(Literal::Initializer)
3232+ end
3333+end
+12
lib/literal/data.rb
···11+class Literal::Data < Literal::Struct
22+ def initialize(...)
33+ super
44+ @attributes.each(&:freeze)
55+ @attributes.freeze
66+ freeze
77+ end
88+99+ def dup(**attributes)
1010+ self.class.new(**@attributes.merge(attributes))
1111+ end
1212+end
+11
lib/literal/initializer.rb
···11+module Literal::Initializer
22+ def initialize(**attributes)
33+ self.class.__attributes__.each do |name|
44+ attributes[name] ||= nil
55+ end
66+77+ attributes.each do |name, value|
88+ send("#{name}=", value)
99+ end
1010+ end
1111+end
+22
lib/literal/model.rb
···11+class Literal::Model
22+ extend Literal::Types
33+ include Literal::Initializer
44+55+ def self.__attributes__
66+ return @required_attributes if defined?(@required_attributes)
77+ @required_attributes = superclass.is_a?(self) ? superclass.required_attributes.dup : []
88+ end
99+1010+ def self.attribute(name, type)
1111+ __attributes__ << name
1212+1313+ writer_name = :"#{name}="
1414+1515+ define_method writer_name do |value|
1616+ raise Literal::TypeError, "Expected #{name}: `#{value.inspect}` to be: `#{type.inspect}`." unless type === value
1717+ super(value)
1818+ end
1919+2020+ name
2121+ end
2222+end
+35
lib/literal/struct.rb
···11+class Literal::Struct
22+ extend Literal::Types
33+ include Literal::Initializer
44+55+ def initialize(...)
66+ @attributes = {}
77+ super
88+ end
99+1010+ def self.__attributes__
1111+ return @__attributes__ if defined?(@__attributes__)
1212+ @__attributes__ = superclass.is_a?(self) ? superclass.__attributes__.dup : []
1313+ end
1414+1515+ def self.attribute(name, type, writer: :private)
1616+ __attributes__ << name
1717+1818+ writer_name = :"#{name}="
1919+2020+ define_method writer_name do |value|
2121+ raise Literal::TypeError, "Expected #{name}: `#{value.inspect}` to be: `#{type.inspect}`." unless type === value
2222+ @attributes[name] = value
2323+ end
2424+2525+ define_method name do
2626+ @attributes[name]
2727+ end
2828+2929+ name
3030+ end
3131+3232+ def to_h
3333+ @attributes.dup
3434+ end
3535+end
+56
lib/literal/types.rb
···11+module Literal::Types
22+ def _Union(*types)
33+ raise Literal::ArgumentError, "Union type must have at least two types." if types.size < 2
44+ Literal::Types::UnionType.new(*types)
55+ end
66+77+ def _Array(type)
88+ Literal::Types::ArrayType.new(type)
99+ end
1010+1111+ def _Set(type)
1212+ Literal::Types::SetType.new(type)
1313+ end
1414+1515+ def _Enumerable(type)
1616+ Literal::Types::EnumerableType.new(type)
1717+ end
1818+1919+ def _Hash(key_type, value_type)
2020+ Literal::Types::HashType.new(key_type, value_type)
2121+ end
2222+2323+ def _Interface(*methods)
2424+ raise Literal::ArgumentError, "Interface type must have at least one method." if methods.size < 1
2525+ Literal::Types::InterfaceType.new(*methods)
2626+ end
2727+2828+ def _Maybe(type)
2929+ _Union(type, nil)
3030+ end
3131+3232+ def _Any
3333+ Literal::Types::AnyType
3434+ end
3535+3636+ def _Boolean
3737+ Literal::Types::BooleanType
3838+ end
3939+4040+ def _Class(type)
4141+ Literal::Types::ClassType.new(type)
4242+ end
4343+4444+ def _Tuple(*types)
4545+ raise Literal::ArgumentError, "Tuple type must have at least one type." if types.size < 1
4646+ Literal::Types::TupleType.new(*types)
4747+ end
4848+4949+ def _Integer(range)
5050+ Literal::Types::IntegerType.new(range)
5151+ end
5252+5353+ def _Float(range)
5454+ Literal::Types::FloatType.new(range)
5555+ end
5656+end
+5
lib/literal/types/any_type.rb
···11+module Literal::Types::AnyType
22+ def self.===(value)
33+ true
44+ end
55+end
+13
lib/literal/types/array_type.rb
···11+class Literal::Types::ArrayType
22+ def initialize(type)
33+ @type = type
44+ end
55+66+ def inspect
77+ "Array(#{@type.inspect})"
88+ end
99+1010+ def ===(value)
1111+ value.is_a?(::Array) && value.all? { |item| @type === item }
1212+ end
1313+end
···11+class Literal::Types::SetType
22+ def initialize(type)
33+ @type = type
44+ end
55+66+ def inspect
77+ "Set(#{@type.inspect})"
88+ end
99+1010+ def ===(value)
1111+ value.is_a?(::Set) && value.all? { |item| @type === item }
1212+ end
1313+end
+13
lib/literal/types/tuple_type.rb
···11+class Literal::Types::TupleType
22+ def initialize(*types)
33+ @types = types
44+ end
55+66+ def inspect
77+ "Tuple(#{@types.map(&:inspect).join(", ")})"
88+ end
99+1010+ def ===(value)
1111+ value.is_a?(::Enumerable) && value.size == @types.size && value.zip(@types).all? { |value, type| type === value }
1212+ end
1313+end
+13
lib/literal/types/union_type.rb
···11+class Literal::Types::UnionType
22+ def initialize(*types)
33+ @types = types
44+ end
55+66+ def inspect
77+ "Union(#{@types.map(&:inspect).join(", ")})"
88+ end
99+1010+ def ===(value)
1111+ @types.any? { |type| type === value }
1212+ end
1313+end
+5
lib/literal/version.rb
···11+# frozen_string_literal: true
22+33+module Literal
44+ VERSION = "0.1.0"
55+end
+37
literal.gemspec
···11+# frozen_string_literal: true
22+33+require_relative "lib/literal/version"
44+55+Gem::Specification.new do |spec|
66+ spec.name = "literal"
77+ spec.version = Literal::VERSION
88+ spec.authors = ["Joel Drapper"]
99+ spec.email = ["joel@drapper.me"]
1010+1111+ spec.summary = "Strict Attributes is a gem that allows you to define strict attributes on your models."
1212+ spec.description = "Strict Attributes is a gem that allows you to define strict attributes on your models."
1313+ spec.homepage = "https://github.com/joeldrapper/literal"
1414+ spec.license = "MIT"
1515+ spec.required_ruby_version = ">= 2.6.0"
1616+1717+ spec.metadata["homepage_uri"] = spec.homepage
1818+ spec.metadata["source_code_uri"] = "https://github.com/joeldrapper/literal"
1919+ spec.metadata["changelog_uri"] = "https://github.com/joeldrapper/literal/blob/master/CHANGELOG.md"
2020+2121+ # Specify which files should be added to the gem when it is released.
2222+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
2323+ spec.files = Dir.chdir(__dir__) do
2424+ `git ls-files -z`.split("\x0").reject do |f|
2525+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
2626+ end
2727+ end
2828+ spec.bindir = "exe"
2929+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
3030+ spec.require_paths = ["lib"]
3131+3232+ # Uncomment to register a new dependency of your gem
3333+ # spec.add_dependency "example-gem", "~> 1.0"
3434+3535+ # For more information and examples about making a new gem, check out our
3636+ # guide at: https://bundler.io/guides/creating_gem.html
3737+end
+18
test/literal/struct.rb
···11+class Person < Literal::Struct
22+ attribute :name, String
33+ attribute :age, Integer
44+end
55+66+test do
77+ expect {
88+ Person.new(name: "John", age: 42)
99+ }.not_to_raise
1010+end
1111+1212+test do
1313+ expect {
1414+ Person.new(name: 1, age: "Hello")
1515+ }.to_raise(Literal::TypeError) do |error|
1616+ expect(error.message) == "Expected name: `1` to be: `String`."
1717+ end
1818+end