chutney 1.6.3 → 2.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +3 -3
  4. data/README.md +44 -30
  5. data/Rakefile +10 -24
  6. data/chutney.gemspec +13 -10
  7. data/config/{default.yml → chutney.yml} +6 -3
  8. data/docs/.keep +0 -0
  9. data/exe/chutney +28 -22
  10. data/img/chutney.svg +852 -0
  11. data/img/formatters.png +0 -0
  12. data/lib/chutney.rb +61 -85
  13. data/lib/chutney/configuration.rb +6 -7
  14. data/lib/chutney/formatter.rb +21 -0
  15. data/lib/chutney/formatter/json_formatter.rb +8 -0
  16. data/lib/chutney/formatter/pie_formatter.rb +78 -0
  17. data/lib/chutney/formatter/rainbow_formatter.rb +47 -0
  18. data/lib/chutney/linter.rb +145 -113
  19. data/lib/chutney/linter/avoid_full_stop.rb +12 -0
  20. data/lib/chutney/linter/avoid_outline_for_single_example.rb +3 -6
  21. data/lib/chutney/linter/avoid_scripting.rb +10 -15
  22. data/lib/chutney/linter/background_does_more_than_setup.rb +7 -10
  23. data/lib/chutney/linter/background_requires_multiple_scenarios.rb +2 -3
  24. data/lib/chutney/linter/bad_scenario_name.rb +4 -11
  25. data/lib/chutney/linter/file_name_differs_feature_name.rb +5 -10
  26. data/lib/chutney/linter/givens_after_background.rb +17 -0
  27. data/lib/chutney/linter/invalid_file_name.rb +14 -6
  28. data/lib/chutney/linter/invalid_step_flow.rb +18 -18
  29. data/lib/chutney/linter/missing_example_name.rb +13 -11
  30. data/lib/chutney/linter/missing_feature_description.rb +2 -9
  31. data/lib/chutney/linter/missing_feature_name.rb +3 -12
  32. data/lib/chutney/linter/missing_scenario_name.rb +3 -7
  33. data/lib/chutney/linter/missing_test_action.rb +3 -6
  34. data/lib/chutney/linter/missing_verification.rb +3 -4
  35. data/lib/chutney/linter/required_tags_starts_with.rb +20 -5
  36. data/lib/chutney/linter/same_tag_for_all_scenarios.rb +24 -28
  37. data/lib/chutney/linter/scenario_names_match.rb +6 -8
  38. data/lib/chutney/linter/tag_used_multiple_times.rb +6 -13
  39. data/lib/chutney/linter/too_clumsy.rb +4 -4
  40. data/lib/chutney/linter/too_long_step.rb +8 -18
  41. data/lib/chutney/linter/too_many_different_tags.rb +13 -34
  42. data/lib/chutney/linter/too_many_steps.rb +10 -6
  43. data/lib/chutney/linter/too_many_tags.rb +11 -10
  44. data/lib/chutney/linter/unique_scenario_names.rb +19 -13
  45. data/lib/chutney/linter/unknown_variable.rb +5 -6
  46. data/lib/chutney/linter/unused_variable.rb +6 -7
  47. data/lib/chutney/linter/use_background.rb +11 -29
  48. data/lib/chutney/linter/use_outline.rb +21 -15
  49. data/lib/chutney/version.rb +1 -1
  50. data/lib/config/locales/en.yml +93 -0
  51. data/spec/chutney_spec.rb +54 -62
  52. data/spec/spec_helper.rb +103 -0
  53. metadata +75 -44
  54. data/Guardfile +0 -3
  55. data/lib/chutney/linter/avoid_period.rb +0 -19
  56. data/lib/chutney/linter/be_declarative.rb +0 -49
  57. data/lib/chutney/linter/tag_collector.rb +0 -10
  58. data/lib/chutney/linter/tag_constraint.rb +0 -35
  59. data/spec/configuration_spec.rb +0 -58
  60. data/spec/required_tags_starts_with_spec.rb +0 -74
  61. data/spec/shared_contexts/file_exists.rb +0 -12
  62. data/spec/shared_contexts/gherkin_linter.rb +0 -14
Binary file
@@ -1,13 +1,14 @@
1
- require 'gherkin/parser'
1
+ require 'amatch'
2
+ require 'chutney/configuration'
2
3
  require 'chutney/linter'
4
+ require 'chutney/linter/avoid_full_stop'
3
5
  require 'chutney/linter/avoid_outline_for_single_example'
4
- require 'chutney/linter/avoid_period'
5
6
  require 'chutney/linter/avoid_scripting'
6
7
  require 'chutney/linter/background_does_more_than_setup'
7
8
  require 'chutney/linter/background_requires_multiple_scenarios'
8
9
  require 'chutney/linter/bad_scenario_name'
9
- require 'chutney/linter/be_declarative'
10
10
  require 'chutney/linter/file_name_differs_feature_name'
11
+ require 'chutney/linter/givens_after_background'
11
12
  require 'chutney/linter/invalid_file_name'
12
13
  require 'chutney/linter/invalid_step_flow'
13
14
  require 'chutney/linter/missing_example_name'
@@ -30,103 +31,78 @@ require 'chutney/linter/unknown_variable'
30
31
  require 'chutney/linter/unused_variable'
31
32
  require 'chutney/linter/use_background'
32
33
  require 'chutney/linter/use_outline'
33
- require 'multi_json'
34
+ require 'forwardable'
35
+ require 'gherkin/dialect'
36
+ require 'gherkin/parser'
37
+ require 'i18n'
34
38
  require 'set'
35
- require 'chutney/configuration'
39
+ require 'yaml'
36
40
 
37
41
  module Chutney
38
42
  # gherkin linter
39
43
  class ChutneyLint
44
+ extend Forwardable
40
45
  attr_accessor :verbose
41
- default_file = File.expand_path('..', __dir__), '**/config', 'default.yml'
42
- DEFAULT_CONFIG = Dir.glob(File.join(default_file)).first.freeze
43
- LINTER = Linter.descendants
44
-
45
- def initialize(path = nil)
46
- @files = {}
47
- @linter = []
48
- @config = Configuration.new path || DEFAULT_CONFIG
49
- @verbose = false
50
- end
51
-
52
- def enabled(linter_name, value)
53
- @config.config[linter_name]['Enabled'] = value if @config.config.key? linter_name
46
+ attr_reader :files
47
+ attr_reader :results
48
+
49
+ def_delegators :@files, :<<, :clear, :delete, :include?
50
+
51
+ def initialize(*files)
52
+ @files = files
53
+ @results = Hash.new { |h, k| h[k] = [] }
54
+ i18n_paths = Dir[File.expand_path(File.join(__dir__, 'config/locales')) + '/*.yml']
55
+ return if I18n.load_path.include?(i18n_paths)
56
+
57
+ I18n.load_path << i18n_paths
54
58
  end
55
-
56
- def enable(enabled_linters)
57
- enabled_linters.each do |linter|
58
- enabled linter, true
59
- end
60
- end
61
-
62
- def disable(disabled_linters)
63
- disabled_linters.each do |linter|
64
- enabled linter, false
65
- end
66
- end
67
-
68
- # Testing feature
69
- def disable_all
70
- @config.config.each do |member|
71
- @config.config[member[0]]['Enabled'] = false
59
+
60
+ def configuration
61
+ unless @config
62
+ default_file = [File.expand_path('..', __dir__), '**/config', 'chutney.yml']
63
+ config_file = Dir.glob(File.join(default_file)).first.freeze
64
+ @config = Configuration.new(config_file)
72
65
  end
66
+ @config
73
67
  end
74
-
75
- def set_linter
76
- @linter = []
77
- LINTER.each do |linter|
78
- new_linter = linter.new
79
- linter_enabled = @config.config[new_linter.class.name.split('::').last]['Enabled']
80
- evaluate_members(new_linter) if linter_enabled
81
- @linter.push new_linter if linter_enabled
82
- end
68
+
69
+ def configuration=(config)
70
+ @config = config
83
71
  end
84
-
85
- def evaluate_members(linter)
86
- @config.config[linter.class.name.split('::').last].each do |member, value|
87
- next if member.downcase.casecmp('enabled').zero?
88
-
89
- member = member.downcase.to_sym
90
- raise 'Member not found! Check the YAML' unless linter.respond_to? member
91
-
92
- linter.public_send(member, value)
72
+
73
+ def analyse
74
+ files.each do |f|
75
+ lint(f)
93
76
  end
77
+ @results
94
78
  end
95
-
96
- def analyze(file)
97
- @files[file] = parse file
98
- end
99
-
100
- def parse(file)
101
- to_json File.read(file)
102
- end
103
-
104
- def report
105
- issues = @linter.map do |linter|
106
- linter.lint_files(@files, disable_tags)
107
- linter.issues
108
- end.flatten
109
-
110
- print issues
111
- return 0 if issues.select { |issue| issue.class == Error }.empty?
112
-
113
- -1
79
+ # alias for non-british English
80
+ # https://dictionary.cambridge.org/dictionary/english/analyse
81
+ alias analyze analyse
82
+
83
+ def linters
84
+ @linters ||= Linter.descendants.filter { |l| configuration.dig(l.linter_name, 'Enabled') }
114
85
  end
115
-
116
- def disable_tags
117
- LINTER.map { |lint| "disable#{lint.new.class.name.split('::').last}" }
86
+
87
+ def linters=(*linters)
88
+ @linters = linters
118
89
  end
119
-
120
- def to_json(input)
121
- parser = Gherkin::Parser.new
122
- scanner = Gherkin::TokenScanner.new input
123
-
124
- parser.parse(scanner)
90
+
91
+ private
92
+
93
+ def parse(text)
94
+ @parser ||= Gherkin::Parser.new
95
+ scanner = Gherkin::TokenScanner.new(text)
96
+ @parser.parse(scanner)
125
97
  end
126
-
127
- def print(issues)
128
- puts 'There are no issues' if issues.empty? && @verbose
129
- issues.each { |issue| puts issue.render }
98
+
99
+ def lint(file)
100
+ parsed = parse(File.read(file))
101
+ linters.each do |linter_class|
102
+ linter = linter_class.new(file, parsed, configuration[linter_class.linter_name])
103
+ linter.lint
104
+ @results[file] << { linter: linter.linter_name, issues: linter.issues }
105
+ end
130
106
  end
131
107
  end
132
108
  end
@@ -1,13 +1,12 @@
1
- require 'yaml'
2
- module Chutney
1
+ require 'delegate'
2
+ module Chutney
3
3
  # gherkin_lint configuration object
4
- class Configuration
5
- attr_reader :config
6
-
4
+ class Configuration < SimpleDelegator
7
5
  def initialize(path)
8
6
  @path = path
9
- @config = load_configuration || ''
7
+ @config = load_configuration || {}
10
8
  load_user_configuration
9
+ super(@config)
11
10
  end
12
11
 
13
12
  def configuration_path
@@ -15,7 +14,7 @@ module Chutney
15
14
  end
16
15
 
17
16
  def load_configuration
18
- YAML.load_file configuration_path || '' if File.exist? configuration_path
17
+ YAML.load_file configuration_path || '' if configuration_path
19
18
  end
20
19
 
21
20
  def load_user_configuration
@@ -0,0 +1,21 @@
1
+ module Chutney
2
+ # base class for all formatters
3
+ class Formatter
4
+ attr_accessor :results
5
+
6
+ def initialize
7
+ @results = {}
8
+ end
9
+
10
+ def format(results); end
11
+
12
+ def files
13
+ results.map { |k, _v| k }
14
+ end
15
+
16
+ def files_with_issues
17
+ results.filter { |_k, v| v.any? { |r| r[:issues].count.positive? } }
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ module Chutney
2
+ # Plain old JSON formatter
3
+ class JSONFormatter < Formatter
4
+ def format
5
+ puts @results.to_json
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,78 @@
1
+ require 'pastel'
2
+ require 'tty/pie'
3
+
4
+ module Chutney
5
+ # format results as pie charts
6
+ class PieFormatter < Formatter
7
+ def initialize
8
+ super
9
+ end
10
+
11
+ def format
12
+ data = top_offences.map do |offence|
13
+ {
14
+ name: offence.first,
15
+ value: offence.last,
16
+ color: colour_loop,
17
+ fill: char_loop
18
+ }
19
+ end
20
+ print_report(data)
21
+ end
22
+
23
+ def print_report(data)
24
+ unless data.empty?
25
+ print TTY::Pie.new(data: data, radius: 8, legend: { format: '%<label>s %<name>s %<value>i' })
26
+ puts
27
+ end
28
+ # put_summary
29
+ end
30
+
31
+ def top_offences
32
+ offence = Hash.new(0)
33
+ files_with_issues.each do |_file, linter|
34
+ linter.each do |lint|
35
+ offence[lint[:linter]] += lint[:issues].count
36
+ end
37
+ end
38
+ offence.reject { |_k, v| v.zero? }.sort_by { |_linter, count| -count }
39
+ end
40
+
41
+ def char_loop
42
+ @char_looper ||= Fiber.new do
43
+ chars = %w[• x + @ * / -]
44
+ current = 0
45
+ loop do
46
+ current = 0 if current >= chars.count
47
+ Fiber.yield chars[current]
48
+ current += 1
49
+ end
50
+ end
51
+ @char_looper.resume
52
+ end
53
+
54
+ def colour_loop
55
+ @colour_looper ||= Fiber.new do
56
+ colours = %i[bright_cyan bright_magenta bright_yellow bright_green]
57
+ current = 0
58
+ loop do
59
+ current = 0 if current >= colours.count
60
+ Fiber.yield colours[current]
61
+ current += 1
62
+ end
63
+ end
64
+ @colour_looper.resume
65
+ end
66
+
67
+ def put_summary
68
+ pastel = Pastel.new
69
+ print "#{files.count} features inspected, "
70
+ if files_with_issues.count.zero?
71
+ puts pastel.green('all taste delicious')
72
+ else
73
+ puts pastel.red("#{files_with_issues.count} taste nasty")
74
+ end
75
+ end
76
+
77
+ end
78
+ end
@@ -0,0 +1,47 @@
1
+ require 'pastel'
2
+
3
+ module Chutney
4
+ # pretty formatter
5
+ class RainbowFormatter < Formatter
6
+
7
+ def initialize
8
+ super
9
+
10
+ @pastel = Pastel.new
11
+ end
12
+
13
+ def format
14
+ files_with_issues.each do |file, linter|
15
+ put_file(file)
16
+ linter.filter { |l| !l[:issues].empty? }.each do |linter_with_issues|
17
+
18
+ put_linter(linter_with_issues)
19
+ linter_with_issues[:issues].each { |i| put_issue(i) }
20
+ end
21
+ end
22
+ put_summary
23
+ end
24
+
25
+ def put_file(file)
26
+ puts @pastel.cyan(file.to_s)
27
+ end
28
+
29
+ def put_linter(linter)
30
+ puts @pastel.red(" #{linter[:linter]}")
31
+ end
32
+
33
+ def put_issue(issue)
34
+ puts " #{@pastel.dim(issue.dig(:location, :line))} #{issue[:message]}"
35
+ end
36
+
37
+ def put_summary
38
+ print "#{files.count} features inspected, "
39
+ if files_with_issues.count.zero?
40
+ puts @pastel.green('all taste delicious')
41
+ else
42
+ puts @pastel.red("#{files_with_issues.count} taste nasty")
43
+ end
44
+ end
45
+
46
+ end
47
+ end
@@ -1,149 +1,181 @@
1
- require 'chutney/issue'
2
-
3
1
  # gherkin utilities
4
2
  module Chutney
5
3
  # base class for all linters
6
4
  class Linter
7
- attr_reader :issues
5
+ attr_accessor :issues
6
+ attr_reader :filename
7
+ attr_reader :configuration
8
+
9
+ Lint = Struct.new(:message, :gherkin_type, :location, :feature, :scenario, :step, keyword_init: true)
8
10
 
9
11
  def self.descendants
10
12
  ObjectSpace.each_object(::Class).select { |klass| klass < self }
11
13
  end
12
14
 
13
- def initialize
15
+ def initialize(filename, content, configuration)
16
+ @content = content
17
+ @filename = filename
14
18
  @issues = []
15
- @files = {}
19
+ @configuration = configuration
20
+ language = @content.dig(:feature, :language) || 'en'
21
+ @dialect = Gherkin::Dialect.for(language)
16
22
  end
17
23
 
18
- def features
19
- @files.each do |file, content|
20
- feature = content[:feature]
21
- next if feature.nil?
22
-
23
- yield(file, feature)
24
+ def lint
25
+ raise 'not implemented'
26
+ end
27
+
28
+ def and_word?(word)
29
+ @dialect.and_keywords.include?(word)
30
+ end
31
+
32
+ def background_word?(word)
33
+ @dialect.background_keywords.include?(word)
34
+ end
35
+
36
+ def but_word?(word)
37
+ @dialect.but_keywords.include?(word)
38
+ end
39
+
40
+ def examples_word?(word)
41
+ @dialect.example_keywords.include?(word)
42
+ end
43
+
44
+ def feature_word?(word)
45
+ @dialect.feature_keywords.include?(word)
46
+ end
47
+
48
+ def given_word?(word)
49
+ @dialect.given_keywords.include?(word)
50
+ end
51
+
52
+ def scenario_outline_word?(word)
53
+ @dialect.scenario_outline_keywords.include?(word)
54
+ end
55
+
56
+ def then_word?(word)
57
+ @dialect.then_keywords.include?(word)
58
+ end
59
+
60
+ def when_word?(word)
61
+ @dialect.when_keywords.include?(word)
62
+ end
63
+
64
+ def tags_for(element)
65
+ return [] unless element.include? :tags
66
+
67
+ element[:tags].map { |tag| tag[:name][1..-1] }
68
+ end
69
+
70
+ def add_issue(message, feature, scenario = nil, step = nil)
71
+ issues << Lint.new(
72
+ message: message,
73
+ gherkin_type: type(feature, scenario, step),
74
+ location: location(feature, scenario, step),
75
+ feature: feature[:name],
76
+ scenario: scenario ? scenario[:name] : nil,
77
+ step: step ? step[:text] : nil
78
+ ).to_h
79
+ end
80
+
81
+ def location(feature, scenario, step)
82
+ if step
83
+ step[:location]
84
+ else
85
+ scenario ? scenario[:location] : feature[:location]
24
86
  end
25
87
  end
26
-
27
- def files
28
- @files.each_key { |file| yield file }
88
+
89
+ def type(_feature, scenario, step)
90
+ if step
91
+ :step
92
+ else
93
+ scenario ? :scenario : :feature
94
+ end
29
95
  end
30
96
 
31
- def scenarios
32
- elements do |file, feature, scenario|
33
- next if scenario[:type] == :Background
34
-
35
- yield(file, feature, scenario)
97
+ def feature
98
+ if block_given?
99
+ yield(@content[:feature]) if @content[:feature]
100
+ else
101
+ @content[:feature]
36
102
  end
37
103
  end
38
-
39
- def filled_scenarios
40
- scenarios do |file, feature, scenario|
41
- next unless scenario.include? :steps
42
- next if scenario[:steps].empty?
104
+
105
+ def elements
106
+ if block_given?
107
+ feature[:children].each do |child|
108
+ next if off_switch?(child)
43
109
 
44
- yield(file, feature, scenario)
110
+ yield(feature, child)
111
+ end
112
+ else
113
+ feature[:children]
45
114
  end
46
115
  end
47
-
48
- def steps
49
- elements do |file, feature, scenario|
50
- next unless scenario.include? :steps
51
-
52
- scenario[:steps].each { |step| yield(file, feature, scenario, step) }
116
+
117
+ def off_switch?(element = feature)
118
+ off_switch = element[:tags]
119
+ .then { |tags| tags || [] }
120
+ .filter { |tag| tag[:type] == :Tag }
121
+ .filter { |tag| tag[:name] == "@disable#{linter_name}" }
122
+ .count
123
+ .positive?
124
+ off_switch ||= off_switch?(feature) unless element == feature
125
+ off_switch
126
+ end
127
+
128
+ def background
129
+ if block_given?
130
+ elements do |feature, child|
131
+ next unless child[:type] == :Background
132
+
133
+ yield(feature, child)
134
+ end
135
+ else
136
+ elements.filter { |child| child[:type] == :Background }
53
137
  end
54
138
  end
55
-
56
- def backgrounds
57
- elements do |file, feature, scenario|
58
- next unless scenario[:type] == :Background
139
+
140
+ def scenarios
141
+ if block_given?
142
+ elements do |feature, child|
143
+ next unless %i[ScenarioOutline Scenario].include? child[:type]
59
144
 
60
- yield(file, feature, scenario)
145
+ yield(feature, child)
146
+ end
147
+ else
148
+ elements.filter { |child| %i[ScenarioOutline Scenario].include? child[:type] }
61
149
  end
62
150
  end
63
-
64
- def elements
65
- @files.each do |file, content|
66
- feature = content[:feature]
67
- next if feature.nil?
68
- next unless feature.key? :children
151
+
152
+ def filled_scenarios
153
+ if block_given?
154
+ scenarios do |feature, scenario|
155
+ next unless scenario.include? :steps
156
+ next if scenario[:steps].empty?
69
157
 
70
- feature[:children].each do |scenario|
71
- yield(file, feature, scenario)
158
+ yield(feature, scenario)
72
159
  end
160
+ else
161
+ scenarios.filter { |s| !s[:steps].empty? }
73
162
  end
74
163
  end
75
-
76
- def name
77
- self.class.name.split('::').last
78
- end
79
-
80
- def lint_files(files, tags_to_suppress)
81
- @files = files
82
- @files = filter_tag(@files, "disable#{name}")
83
- @files = suppress_tags(@files, tags_to_suppress)
84
- lint
85
- end
86
-
87
- def filter_tag(data, tag)
88
- return data.reject { |item| tag?(item, tag) }.map { |item| filter_tag(item, tag) } if data.class == Array
89
- return {} if (data.class == Hash) && (data.include? :feature) && tag?(data[:feature], tag)
90
- return data unless data.respond_to? :each_pair
91
-
92
- result = {}
93
- data.each_pair { |key, value| result[key] = filter_tag(value, tag) }
94
- result
95
- end
96
-
97
- def tag?(data, tag)
98
- return false if data.class != Hash
99
- return false unless data.include? :tags
100
-
101
- data[:tags].map { |item| item[:name] }.include? "@#{tag}"
102
- end
103
-
104
- def suppress_tags(data, tags)
105
- return data.map { |item| suppress_tags(item, tags) } if data.class == Array
106
- return data unless data.class == Hash
107
-
108
- result = {}
109
-
110
- data.each_pair do |key, value|
111
- value = suppress(value, tags) if key == :tags
112
- result[key] = suppress_tags(value, tags)
164
+
165
+ def steps
166
+ elements do |feature, child|
167
+ next unless child.include? :steps
168
+
169
+ child[:steps].each { |step| yield(feature, child, step) }
113
170
  end
114
- result
115
171
  end
116
172
 
117
- def suppress(data, tags)
118
- data.reject { |item| tags.map { |tag| "@#{tag}" }.include? item[:name] }
173
+ def self.linter_name
174
+ name.split('::').last
119
175
  end
120
-
121
- def lint
122
- raise 'not implemented'
123
- end
124
-
125
- def reference(file, feature = nil, scenario = nil, step = nil)
126
- return file if feature.nil? || feature[:name].empty?
127
-
128
- result = "#{file} (#{line(feature, scenario, step)}): #{feature[:name]}"
129
- result += ".#{scenario[:name]}" unless scenario.nil? || scenario[:name].empty?
130
- result += " step: #{step[:text]}" unless step.nil?
131
- result
132
- end
133
-
134
- def line(feature, scenario, step)
135
- line = feature.nil? ? nil : feature[:location][:line]
136
- line = scenario[:location][:line] unless scenario.nil?
137
- line = step[:location][:line] unless step.nil?
138
- line
139
- end
140
-
141
- def add_error(references, description = nil)
142
- @issues.push Error.new(name, references, description)
143
- end
144
-
145
- def add_warning(references, description = nil)
146
- @issues.push Warning.new(name, references, description)
176
+
177
+ def linter_name
178
+ self.class.linter_name
147
179
  end
148
180
 
149
181
  def render_step(step)
@@ -151,7 +183,7 @@ module Chutney
151
183
  value += render_step_argument step[:argument] if step.include? :argument
152
184
  value
153
185
  end
154
-
186
+
155
187
  def render_step_argument(argument)
156
188
  return "\n#{argument[:content]}" if argument[:type] == :DocString
157
189