contraction 0.1.2 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
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: []