contraction 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a6acaeb85e36db1453f073fa71abf2c75fbf4079
4
+ data.tar.gz: dcfcd8b39e26bd1c4544f000ba13a3442911ccf6
5
+ SHA512:
6
+ metadata.gz: 40dde6d63f5c70f67064246e74c57ce004475a4a8f7c26660e0e5e5659d9a83ffd342b945606f99f667c6e2221b801d8b3c24041700aed9a3b3aa27166892961
7
+ data.tar.gz: a7be353ccd6f064d4830d58e3cbe71ce4f33c2f80e94dbcf5fc603fbfb68525d88275b56f77302aa93f5d53a6564f17ebcbcffb4e696405aee3db562eab3adae
data/README.md CHANGED
@@ -40,15 +40,50 @@ names of the params for their values, and `return` for the return value. If you
40
40
  provide a type for either the param or the return, type-checking will be
41
41
  enforced as well. A full-qualified type is recommended (`Foo::Bar` instead of `Bar`.)
42
42
 
43
- Warning
44
- =======
43
+ Ruby and the splendiferous always-open class
44
+ ============================================
45
+
46
+ Because a class in Ruby can never really be "fully loaded", there are strange
47
+ cases where Contraction may not build contracts for all the methods that you
48
+ want it to. For example, if some third-party code modifies your class to add
49
+ methods at run-time, after Contraction has already been loaded. In this case,
50
+ if you would like contracts to be enabled for those classes, you can update the
51
+ annotated methods by calling:
52
+
53
+ ```ruby
54
+ class MySuperCoolClass
55
+ ...
56
+
57
+ include Contraction
58
+ end
59
+
60
+ ...
61
+
62
+ MySuperCoolClass.update_contracts
63
+ ```
64
+
65
+ Please bear in mind that you will re-incur the overhead of parsing the RDoc
66
+ docs for each method.
67
+
68
+ Warning about speed/overhead
69
+ ============================
45
70
 
46
71
  This will slow things down. All-in-all you're looking at about an 8x increase
47
72
  in overhead vs just calling a function. It is not recommended that you use this
48
73
  for every method in a deeply-nested code-path. This overhead is more-or-less
49
74
  in-line with other Ruby design-by-contract libraries, however, and with the
50
- added benefit of free documentation.
75
+ added benefit of free documentation. It is recommended that you have some
76
+ concept of environment (staging, dev, production, etc.), and only include
77
+ Contraction in development-like environments:
78
+
79
+ ```ruby
80
+ require 'rubygems'
81
+ require 'contraction'
82
+
83
+ class MySuperCoolClass
84
+ ...
85
+
86
+ include Contraction if Rails.env.development?
87
+ end
88
+ ```
51
89
 
52
- Also, this is not super-heavily tested. I've been using it myself and thought I
53
- would release it to the world, but I don't do a lot of RDoc-fu, so YMMV.
54
- Pull-requests welcome.
data/lib/contract.rb CHANGED
@@ -1,39 +1,76 @@
1
1
  module Contraction
2
- private
3
-
4
2
  class Contract
5
- attr_accessor :type, :name, :message, :contract
6
- def initialize(args={})
7
- @type = args[:type]
8
- @name = args[:name]
9
- @message = args[:message]
10
- @contract = args[:contract] || ''
11
-
12
- create_checkers!
3
+ attr_reader :rules, :mod, :method_name, :params
4
+
5
+ # @params [Array<TypedLine>] rules The individual lines that define the
6
+ # contract.
7
+ def initialize(rules, mod, method_name)
8
+ @rules = rules
9
+ @mod = mod
10
+ @method_name = method_name
11
+
12
+ update_rule_values
13
+ get_method_definition
13
14
  end
14
15
 
15
- def check!(value, named_args)
16
- @type_checker.call(value, named_args) if @type_checker
17
- if @contract_checker
18
- raise ArgumentError.new(contract_message(value)) unless @contract_checker.call(value, named_args)
16
+ # Test weather the arguments to a method are of the correct type, and meet
17
+ # the correct contractual obligations.
18
+ def valid_args?(*method_args)
19
+ return true if @rules.nil?
20
+ named_args = params.each_with_index.inject({}) do |h, (param, index)|
21
+ h[param.to_s] = method_args[index]
22
+ h
23
+ end
24
+
25
+ b = binding
26
+ param_rules.all? do |rule|
27
+ raise ArgumentError.new("#{rule.name} (#{named_args[rule.name].inspect}) must be a #{rule.type}") unless rule.valid?(named_args[rule.name])
28
+ rule.evaluate_in_context(b, method_name, named_args[rule.name])
19
29
  end
20
30
  end
21
31
 
22
- private
32
+ # Tests weather or not the return value is of the correct type and meets
33
+ # the correct contractual obligations.
34
+ def valid_return?(*method_args, result)
35
+ named_args = params.each_with_index.inject({}) do |h, (param, index)|
36
+ h[param] = method_args[index]
37
+ h
38
+ end
23
39
 
24
- def create_checkers!
25
- if type
26
- @type_checker = lambda { |value, named_args| raise ArgumentError.new(type_message(value)) unless value.is_a?(type) }
40
+ return true unless return_rule
41
+ unless return_rule.valid?(result)
42
+ raise ArgumentError.new("Return value of #{method_name} must be a #{return_rule.type}")
43
+ end
44
+ if return_rule.contract
45
+ b = binding
46
+ return_rule.evaluate_in_context(b, method_name, result)
27
47
  end
28
- @contract_checker = eval("lambda { |result, named_args| #{contract} }") unless contract == ''
29
48
  end
30
49
 
31
- def type_message(value)
32
- "#{name} (#{value.inspect}) must be a #{type}"
50
+ private
51
+
52
+ def return_rule
53
+ @return_rule ||= @rules.select { |r| r.is_a?(Contraction::Parser::ReturnLine) }.first
54
+ end
55
+
56
+ def param_rules
57
+ @param_rules ||= @rules.select { |r| r.is_a?(Contraction::Parser::ParamLine) }
58
+ end
59
+
60
+ def get_method_definition
61
+ @params = mod.instance_method(method_name).parameters.map(&:last)
33
62
  end
34
63
 
35
- def contract_message(value)
36
- "#{name} (#{message}) must fullfill #{contract.inspect}, but is #{value.inspect}"
64
+ def update_rule_values
65
+ names = rules.map(&:name).compact.uniq
66
+
67
+ rules.each do |rule|
68
+ names.each do |name|
69
+ rule.contract.gsub!(name, "named_args['#{name}']")
70
+ end
71
+ rule.contract.gsub!('return', 'result')
72
+ end
37
73
  end
38
74
  end
39
75
  end
76
+
data/lib/contraction.rb CHANGED
@@ -1,101 +1,48 @@
1
1
  require 'string'
2
- require 'contract'
2
+ require 'parser'
3
+
3
4
  module Contraction
4
- def self.patch_instance_method(mod, method_name)
5
+ # Call this method to update contracts for any methods that may have been
6
+ # added after the class/module file was loaded by some third-party code. It's
7
+ # unlikely that you will need this method, but I thought I would include it
8
+ # just in case.
9
+ # @param [Class] mod The module or class to update contracts for.
10
+ def self.update_contracts(mod)
5
11
  instance = mod.allocate
6
- args, returns = parse_comments(instance.method(method_name).source_location)
7
-
8
- arg_names = args.map(&:name)
9
- arg_names.each do |name|
10
- returns.contract = returns.contract.gsub(name, "named_args[#{name.inspect}]")
11
- end
12
-
13
- old_method = mod.instance_method(method_name)
14
-
15
- mod.send(:define_method, method_name) do |*method_args|
16
- named_args = args.each_with_index.inject({}) do |h, (arg, index)|
17
- h[arg.name] = method_args[index]
18
- h
19
- end
12
+ instance_methods = (mod.instance_methods - Object.instance_methods - Contraction.instance_methods)
20
13
 
21
- args.each { |arg| arg.check!(named_args[arg.name], named_args) }
22
- result = old_method.bind(self).call(*method_args)
23
- returns.check!(result, named_args)
14
+ instance_methods.each do |method_name|
15
+ file_contents, line_no = read_file_for_method(instance, method_name)
24
16
 
25
- result
17
+ contract = Contraction::Parser.parse(file_contents[0..line_no-2].reverse, mod, method_name)
18
+ define_wrapped_method(mod, method_name, contract)
26
19
  end
27
20
  end
28
21
 
29
- def self.patch_class_method(mod, method_name)
30
- args, returns = parse_comments(mod.method(method_name).source_location)
31
- arg_names = args.map(&:name)
32
- arg_names.each do |name|
33
- returns.contract = returns.contract.gsub(name, "named_args[#{name.inspect}]")
34
- end
35
-
36
- old_method = mod.method(method_name)
37
-
38
- arg_checks = []
39
- result_check = nil
40
- mod.define_singleton_method(method_name) do |*method_args|
41
- named_args = args.each_with_index.inject({}) do |h, (arg, index)|
42
- h[arg.name] = method_args[index]
43
- h
44
- end
45
-
46
- args.each { |arg| arg.check!(named_args[arg.name], named_args) }
47
- result = old_method.call(*method_args)
48
- returns.check!(result, named_args)
49
-
50
- result
51
- end
22
+ # Called by ruby when Contraction is included in a class.
23
+ def self.included(mod)
24
+ update_contracts(mod)
52
25
  end
53
26
 
54
- def self.file_content(filename)
55
- @file_contents ||= {}
56
- @file_contents[filename] ||= File.read(filename)
57
- end
27
+ private
58
28
 
59
- def self.parse_comments(location)
60
- file, line = location
29
+ def self.read_file_for_method(instance, method_name)
30
+ file, line = instance.method(method_name).source_location
61
31
  filename = File.expand_path(file)
62
-
63
- args = []
64
- returns = Contraction::Contract.new()
65
- file_content(filename).split("\n")[0..line-2].reverse.each do |line|
66
- line = line.strip
67
- next if line == ''
68
- break unless line.start_with?('#')
69
- break if line.start_with?('##')
70
-
71
- if m = /^#\s*@return\s+(\[[^\]]+\])?\s*([^{]+)?(\{([^}]+)\})?/.match(line)
72
- type = m[1].to_s.gsub(/(\[|\])/, '')
73
- type = type == '' ? Object : type.constantize
74
- contract = m[4].to_s.strip.gsub('return', "result")
75
- contract = contract == '' ? 'true' : contract
76
- returns = Contraction::Contract.new(type: type, name: 'returns', message: m[2], contract: contract)
77
- elsif m = /^#\s*@param\s+(\[[^\]]+\])?\s*([^\s]+)\s+([^{]+)?(\{([^}]+)\})?/.match(line)
78
- type = m[1].to_s.gsub(/(\[|\])/, '')
79
- type = type == '' ? Object : type.constantize
80
- contract = m[5].to_s.strip.gsub(m[2], "named_args[#{m[2].inspect}]")
81
- contract = contract == '' ? 'true' : contract
82
- args << Contraction::Contract.new(type: type, name: m[2], message: m[3], contract: contract)
83
- end
84
- end
85
- args.reverse!
86
- return [args, returns]
32
+ file_contents = File.read(filename).split("\n")
33
+ return [file_contents, line]
87
34
  end
88
35
 
89
- def self.included(mod)
90
- instance_methods = (mod.instance_methods - Object.instance_methods - Contraction.instance_methods)
91
-
92
- instance_methods.each do |method_name|
93
- patch_instance_method(mod, method_name)
94
- end
36
+ def self.define_wrapped_method(mod, method_name, contract)
37
+ old_method = mod.instance_method(method_name)
95
38
 
96
- class_methods = (mod.methods - Object.methods - Contraction.methods)
97
- class_methods.each do |method_name|
98
- patch_class_method(mod, method_name)
39
+ arg_checks = []
40
+ result_check = nil
41
+ mod.send(:define_method, method_name) do |*method_args|
42
+ contract.valid_args?(*method_args)
43
+ result = old_method.bind(self).call(*method_args)
44
+ contract.valid_return?(*method_args, result)
45
+ result
99
46
  end
100
47
  end
101
48
  end
data/lib/parser.rb ADDED
@@ -0,0 +1,57 @@
1
+ require 'string'
2
+ require 'parser/type'
3
+ require 'parser/lines'
4
+ require 'contract'
5
+
6
+ module Contraction
7
+ module Parser
8
+ RETURN_LINE_REGEX = /^#\s*@return\s+(?<type>\[[^\]]+\])?\s*(?<message>[^{]+)?(?<contract>\{([^}]+)\})?/
9
+ PARAM_LINE_REGEX = /^#\s*@param\s+(?<type>\[[^\]]+\])?\s*(?<name>[^\s]+)\s+(?<message>[^{]+)?(?<contract>\{([^}]+)\})?/
10
+
11
+ # Parses text passed to it for a given method for RDoc @param and @return
12
+ # lines to build contracts.
13
+ # @param [Array,String] text The text to be parsed.
14
+ # @param [Class,Module] mod The class or module that the method is defined
15
+ # in.
16
+ # @param [Symbol,String] method_name The name of the method that the
17
+ # contracts/docs apply to
18
+ # @return [Contract] A Contract object that can be used to evaluate
19
+ # correctness at run-time.
20
+ def self.parse(text, mod, method_name)
21
+ lines = text.is_a?(String) ? text.split(/$/) : text
22
+ results = []
23
+ lines.each do |line|
24
+ line.strip!
25
+ break unless line.start_with? '#'
26
+ break if line.start_with? '##'
27
+ results << parse_line(line.strip)
28
+ end
29
+ results.compact!
30
+
31
+ Contract.new(results, mod, method_name)
32
+ end
33
+
34
+ # Parse a single line of text for @param and @return statements.
35
+ # @param [String] line The line of text to parse
36
+ # @return [TypedLine] An object that represents the parsed line including
37
+ # type information and contract.
38
+ def self.parse_line(line)
39
+ if m = line.match(PARAM_LINE_REGEX)
40
+ args = {
41
+ type: m['type'].to_s.gsub(/(\[|\])/, ''),
42
+ name: m['name'],
43
+ message: m['message'],
44
+ contract: (m['contract'] || 'true').gsub(/(^\{)|(\}$)/, '')
45
+ }
46
+ return ParamLine.new(args)
47
+ elsif m = line.match(RETURN_LINE_REGEX)
48
+ args = {
49
+ type: m['type'].to_s.gsub(/(\[|\])/, ''),
50
+ message: m['message'],
51
+ contract: (m['contract'] || 'true').gsub(/(^\{)|(\}$)/, '')
52
+ }
53
+ return ReturnLine.new(args)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,64 @@
1
+ # FIXME: There is an aweful lot of knowledge about which kind of TypedLine is
2
+ # being used scattered around the system. I need to encapsulate that better;
3
+ # the abstractions are leaking!
4
+ module Contraction
5
+ module Parser
6
+ class TypedLine
7
+ attr_reader :type, :contract, :message, :types
8
+ attr_writer :contract
9
+
10
+ def initialize(args={})
11
+ @type = args[:type]
12
+ @contract = args[:contract]
13
+ @message = args[:message]
14
+ parse_type
15
+ end
16
+
17
+ def parse_type
18
+ parts = type.split(/(\>|\}|\)),/)
19
+ @types = []
20
+ parts.each do |part|
21
+ @types << Type.new(part)
22
+ end
23
+ end
24
+
25
+ def valid?(*value)
26
+ @types.each_with_index.all? do |t, i|
27
+ t.check(value[i])
28
+ end
29
+ end
30
+
31
+ def evaluate_in_context(context, method_name, value)
32
+ return if !contract || contract.to_s.strip == ''
33
+ raise contract_message(value, method_name) unless eval(contract, context)
34
+ end
35
+
36
+ def contract_message(value=nil, method_name=nil)
37
+ raise 'Not Implemented'
38
+ end
39
+ end
40
+
41
+ class ParamLine < TypedLine
42
+ attr_reader :name
43
+
44
+ def initialize(args={})
45
+ super(args)
46
+ @name = args[:name]
47
+ end
48
+
49
+ def contract_message(value, method_name=nil)
50
+ "#{name} (#{message}) must fullfill #{contract.inspect}, but is #{value.inspect}"
51
+ end
52
+ end
53
+
54
+ class ReturnLine < TypedLine
55
+ def name
56
+ nil
57
+ end
58
+
59
+ def contract_message(value, method_name=nil)
60
+ "Return value of #{method_name} (#{message}) must fullfill #{contract.inspect}, but is #{value}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,138 @@
1
+ module Contraction
2
+ module Parser
3
+ class Type
4
+ attr_reader :legal_types, :method_requirements, :length, :key_types, :value_types
5
+
6
+ def initialize(part)
7
+ @legal_types = []
8
+ @method_requirements = []
9
+ @length = -1
10
+ @key_types = []
11
+ @value_types = []
12
+
13
+ parse(part)
14
+ end
15
+
16
+ # Checks weather or not thing is a given type.
17
+ # @param [String] thing A string containing a type definition. For example:
18
+ # Array<String>
19
+ def check(thing)
20
+ check_types(thing) &&
21
+ check_duck_typing(thing) &&
22
+ check_length(thing) &&
23
+ check_hash(thing)
24
+ end
25
+
26
+ private
27
+
28
+ def parse(line)
29
+ parse_typed_container(line) ||
30
+ parse_duck_type(line) ||
31
+ parse_fixed_list(line) ||
32
+ parse_hash(line) ||
33
+ parse_short_hash_or_reference(line) ||
34
+ parse_regular(line)
35
+ end
36
+
37
+ def parse_typed_container(line)
38
+ return unless line.include? '<'
39
+ # It's some kind of container that can only hold certain things
40
+ list = line.match(/\<(?<list>[^\>]+)\>/)['list']
41
+ list.split(',').each do |type|
42
+ @legal_types << Type.new(type.strip)
43
+ end
44
+ true
45
+ end
46
+
47
+ def parse_duck_type(line)
48
+ return unless line =~ /^#/
49
+ # It's a duck-typed object of some kind
50
+ methods = line.split(",").map { |p| p.strip.gsub(/^#/,'').to_sym }
51
+ @method_requirements += methods
52
+ true
53
+ end
54
+
55
+ def parse_fixed_list(line)
56
+ return unless line.include?('(')
57
+ # It's a fixed-length list
58
+ list = line.match(/\((?<list>[^\>]+)\)/)['list']
59
+ parts = list.split(',')
60
+ @length = parts.length
61
+ parts.each do |type|
62
+ @legal_types << Type.new(type.strip)
63
+ end
64
+ true
65
+ end
66
+
67
+ def parse_hash(line)
68
+ return unless line.include? 'Hash{'
69
+ # It's a hash with specific key-value pair types
70
+ parts = line.match(/\{(?<key_types>.+)\s*=\>\s*(?<value_types>[^\}]+)\}/)
71
+ @key_types = parts['key_types'].split(',').map { |t| t.include?('#') ? t.strip.gsub(/^#/, '').to_sym : t.strip.constantize }
72
+ @value_types = parts['value_types'].split(',').map { |t| t.include?('#') ? t.strip.gsub(/^#/, '').to_sym : t.strip.constantize }
73
+ end
74
+
75
+ def parse_short_hash_or_reference(line)
76
+ return unless line.include? '{'
77
+ if parts = line.match(/\{(?<key_types>.+)\s*=\>\s*(?<value_types>[^\}]+)\}/)
78
+ @key_types = parts['key_types'].split(',').map { |t| t.include?('#') ? t.strip.gsub(/^#/, '').to_sym : t.strip.constantize }
79
+ @value_types = parts['value_types'].split(',').map { |t| t.include?('#') ? t.strip.gsub(/^#/, '').to_sym : t.strip.constantize }
80
+ else
81
+ # It's a reference to another documented type defined someplace in
82
+ # the codebase. We can ignore the reference, and treat it like a
83
+ # normal type.
84
+ @legal_types << line.gsub(/\{|\}/, '').constantize
85
+ end
86
+ true
87
+ end
88
+
89
+ def parse_regular(line)
90
+ # It's a regular-ass type.
91
+ @legal_types << line.constantize
92
+ end
93
+
94
+ def check_hash(thing)
95
+ return true if @key_types.empty? or @value_types.empty?
96
+ return false unless thing.is_a?(Hash)
97
+ thing.keys.all? do |k|
98
+ @key_types.any? { |kt| kt.is_a?(Symbol) ? k.respond_to?(kt) : k.is_a?(kt) }
99
+ end &&
100
+ thing.values.all? do |v|
101
+ @value_types.any? { |vt| vt.is_a?(Symbol) ? v.respond_to?(vt) : v.is_a?(vt) }
102
+ end
103
+ end
104
+
105
+ def check_length(thing)
106
+ return true if @length == -1
107
+ thing.length == @length
108
+ end
109
+
110
+ def check_duck_typing(thing)
111
+ return true if @method_requirements.empty?
112
+ @method_requirements.all? do |m|
113
+ thing.respond_to? m
114
+ end
115
+ end
116
+
117
+ def check_types(thing)
118
+ return true if @legal_types.empty?
119
+ if thing.is_a? Enumerable
120
+ types = @legal_types.map { |t| t.respond_to?(:legal_types) ? t.legal_types : t }.flatten
121
+ return thing.all? { |th| types.include?(th.class) }
122
+ else
123
+ @legal_types.any? do |t|
124
+ if t.is_a?(Contraction::Parser::Type)
125
+ # Given the fact that we check enumerables above, we should never be here.
126
+ next false
127
+ end
128
+ if thing.is_a?(Enumerable)
129
+ thing.all? { |th| th.is_a?(t) }
130
+ else
131
+ thing.is_a?(t)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
data/lib/string.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  class String
2
2
  # This is taken strait copy-pasta from ActiveSupport. All praise be to them.
3
+ # @return [Object] The class or module named by the string.
3
4
  def constantize
4
5
  names = self.split('::')
5
6
  names.shift if names.empty? || names.first.empty?
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: contraction
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
5
- prerelease:
4
+ version: 0.2.2
6
5
  platform: ruby
7
6
  authors:
8
7
  - Thomas Luce
9
8
  autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2013-04-19 00:00:00.000000000 Z
11
+ date: 2014-07-18 00:00:00.000000000 Z
13
12
  dependencies: []
14
13
  description: Using RDoc documentation as your contract definition, you get solid code,
15
14
  and good docs. Win-win!
@@ -23,32 +22,31 @@ files:
23
22
  - contraction.rb
24
23
  - lib/contract.rb
25
24
  - lib/contraction.rb
25
+ - lib/parser.rb
26
+ - lib/parser/lines.rb
27
+ - lib/parser/type.rb
26
28
  - lib/string.rb
27
29
  homepage: https://github.com/thomasluce/contraction
28
30
  licenses: []
31
+ metadata: {}
29
32
  post_install_message:
30
33
  rdoc_options: []
31
34
  require_paths:
32
35
  - lib
33
36
  required_ruby_version: !ruby/object:Gem::Requirement
34
- none: false
35
37
  requirements:
36
- - - ! '>='
38
+ - - '>='
37
39
  - !ruby/object:Gem::Version
38
40
  version: '0'
39
- segments:
40
- - 0
41
- hash: 2360464299481397474
42
41
  required_rubygems_version: !ruby/object:Gem::Requirement
43
- none: false
44
42
  requirements:
45
- - - ! '>='
43
+ - - '>='
46
44
  - !ruby/object:Gem::Version
47
45
  version: '0'
48
46
  requirements: []
49
47
  rubyforge_project:
50
- rubygems_version: 1.8.24
48
+ rubygems_version: 2.2.2
51
49
  signing_key:
52
- specification_version: 3
50
+ specification_version: 4
53
51
  summary: A simple desgin-by-contract library
54
52
  test_files: []