chutney 2.2.1 → 3.1.0

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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +16 -0
  3. data/.rubocop.yml +10 -3
  4. data/Gemfile +2 -0
  5. data/LICENSE.txt +1 -1
  6. data/README.md +7 -1
  7. data/Rakefile +8 -8
  8. data/chutney.gemspec +13 -6
  9. data/config/{chutney.yml → chutney_defaults.yml} +2 -0
  10. data/config/cucumber.yml +1 -0
  11. data/docs/index.md +1 -1
  12. data/docs/usage/rules.md +34 -64
  13. data/exe/chutney +23 -3
  14. data/lib/chutney.rb +18 -14
  15. data/lib/chutney/configuration.rb +9 -2
  16. data/lib/chutney/formatter.rb +6 -5
  17. data/lib/chutney/formatter/json_formatter.rb +2 -0
  18. data/lib/chutney/formatter/pie_formatter.rb +10 -13
  19. data/lib/chutney/formatter/rainbow_formatter.rb +13 -13
  20. data/lib/chutney/issue.rb +2 -0
  21. data/lib/chutney/linter.rb +92 -86
  22. data/lib/chutney/linter/avoid_full_stop.rb +4 -4
  23. data/lib/chutney/linter/avoid_outline_for_single_example.rb +7 -5
  24. data/lib/chutney/linter/avoid_scripting.rb +8 -6
  25. data/lib/chutney/linter/avoid_typographers_quotes.rb +16 -14
  26. data/lib/chutney/linter/background_does_more_than_setup.rb +8 -7
  27. data/lib/chutney/linter/background_requires_multiple_scenarios.rb +7 -4
  28. data/lib/chutney/linter/bad_scenario_name.rb +6 -4
  29. data/lib/chutney/linter/empty_feature_file.rb +2 -0
  30. data/lib/chutney/linter/file_name_differs_feature_name.rb +7 -5
  31. data/lib/chutney/linter/givens_after_background.rb +7 -8
  32. data/lib/chutney/linter/invalid_file_name.rb +3 -1
  33. data/lib/chutney/linter/invalid_step_flow.rb +9 -9
  34. data/lib/chutney/linter/missing_example_name.rb +9 -9
  35. data/lib/chutney/linter/missing_feature_description.rb +5 -4
  36. data/lib/chutney/linter/missing_feature_name.rb +5 -4
  37. data/lib/chutney/linter/missing_scenario_name.rb +4 -6
  38. data/lib/chutney/linter/missing_test_action.rb +4 -2
  39. data/lib/chutney/linter/missing_verification.rb +4 -2
  40. data/lib/chutney/linter/required_tags_starts_with.rb +7 -6
  41. data/lib/chutney/linter/same_tag_different_case.rb +37 -0
  42. data/lib/chutney/linter/same_tag_for_all_scenarios.rb +20 -19
  43. data/lib/chutney/linter/scenario_names_match.rb +6 -6
  44. data/lib/chutney/linter/tag_used_multiple_times.rb +3 -1
  45. data/lib/chutney/linter/too_clumsy.rb +4 -2
  46. data/lib/chutney/linter/too_long_step.rb +6 -4
  47. data/lib/chutney/linter/too_many_different_tags.rb +10 -8
  48. data/lib/chutney/linter/too_many_steps.rb +6 -4
  49. data/lib/chutney/linter/too_many_tags.rb +5 -3
  50. data/lib/chutney/linter/unique_scenario_names.rb +5 -5
  51. data/lib/chutney/linter/unknown_variable.rb +15 -15
  52. data/lib/chutney/linter/unused_variable.rb +15 -16
  53. data/lib/chutney/linter/use_background.rb +20 -19
  54. data/lib/chutney/linter/use_outline.rb +15 -14
  55. data/lib/chutney/version.rb +3 -1
  56. data/lib/config/locales/en.yml +3 -0
  57. data/spec/chutney_spec.rb +11 -9
  58. data/spec/spec_helper.rb +2 -0
  59. metadata +21 -16
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'delegate'
2
- module Chutney
4
+
5
+ module Chutney
3
6
  # gherkin_lint configuration object
4
7
  class Configuration < SimpleDelegator
5
8
  def initialize(path)
@@ -18,7 +21,11 @@ module Chutney
18
21
  end
19
22
 
20
23
  def load_user_configuration
21
- config_file = Dir.glob(File.join(Dir.pwd, '**', '.chutney.yml')).first
24
+ config_files = ['chutney.yml', '.chutney.yml'].map do |fname|
25
+ Dir.glob(File.join(Dir.pwd, '**', fname))
26
+ end.flatten
27
+
28
+ config_file = config_files.first
22
29
  merge_config(config_file) if !config_file.nil? && File.exist?(config_file)
23
30
  end
24
31
 
@@ -1,19 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Chutney
2
4
  # base class for all formatters
3
5
  class Formatter
4
6
  attr_accessor :results
5
-
7
+
6
8
  def initialize
7
9
  @results = {}
8
- end
9
-
10
+ end
11
+
10
12
  def files
11
13
  results.map { |k, _v| k }
12
14
  end
13
-
15
+
14
16
  def files_with_issues
15
17
  results.filter { |_k, v| v.any? { |r| r[:issues].count.positive? } }
16
18
  end
17
-
18
19
  end
19
20
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Chutney
2
4
  # Plain old JSON formatter
3
5
  class JSONFormatter < Formatter
@@ -1,13 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pastel'
2
4
  require 'tty/pie'
3
5
 
4
6
  module Chutney
5
7
  # format results as pie charts
6
8
  class PieFormatter < Formatter
7
- def initialize
8
- super
9
- end
10
-
11
9
  def format
12
10
  data = top_offences.map do |offence|
13
11
  {
@@ -19,14 +17,14 @@ module Chutney
19
17
  end
20
18
  print_report(data)
21
19
  end
22
-
20
+
23
21
  def print_report(data)
24
22
  return if data.empty?
25
23
 
26
24
  print TTY::Pie.new(data: data, radius: 8, legend: { format: '%<label>s %<name>s %<value>i' })
27
25
  puts
28
26
  end
29
-
27
+
30
28
  def top_offences
31
29
  offence = Hash.new(0)
32
30
  files_with_issues.each do |_file, linter|
@@ -36,9 +34,9 @@ module Chutney
36
34
  end
37
35
  offence.reject { |_k, v| v.zero? }.sort_by { |_linter, count| -count }
38
36
  end
39
-
37
+
40
38
  def char_loop
41
- @char_looper ||= Fiber.new do
39
+ @char_looper ||= Fiber.new do
42
40
  chars = %w[• x + @ * / -]
43
41
  current = 0
44
42
  loop do
@@ -49,10 +47,10 @@ module Chutney
49
47
  end
50
48
  @char_looper.resume
51
49
  end
52
-
50
+
53
51
  def colour_loop
54
- @colour_looper ||= Fiber.new do
55
- colours = %i[bright_cyan bright_magenta bright_yellow bright_green]
52
+ @colour_looper ||= Fiber.new do
53
+ colours = %i[bright_cyan bright_magenta bright_yellow bright_green]
56
54
  current = 0
57
55
  loop do
58
56
  current = 0 if current >= colours.count
@@ -62,7 +60,7 @@ module Chutney
62
60
  end
63
61
  @colour_looper.resume
64
62
  end
65
-
63
+
66
64
  def put_summary
67
65
  pastel = Pastel.new
68
66
  print "#{files.count} features inspected, "
@@ -72,6 +70,5 @@ module Chutney
72
70
  puts pastel.red("#{files_with_issues.count} taste nasty")
73
71
  end
74
72
  end
75
-
76
73
  end
77
74
  end
@@ -1,39 +1,40 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pastel'
2
4
 
3
5
  module Chutney
4
6
  # pretty formatter
5
7
  class RainbowFormatter < Formatter
6
-
7
8
  def initialize
8
9
  super
9
-
10
+
10
11
  @pastel = Pastel.new
11
12
  end
12
-
13
- def format
13
+
14
+ def format
14
15
  files_with_issues.each do |file, linter|
15
16
  put_file(file)
16
17
  linter.filter { |l| !l[:issues].empty? }.each do |linter_with_issues|
17
-
18
18
  put_linter(linter_with_issues)
19
- linter_with_issues[:issues].each { |i| put_issue(i) }
19
+ linter_with_issues[:issues].each { |i| put_issue(file, i) }
20
20
  end
21
21
  end
22
22
  put_summary
23
23
  end
24
-
24
+
25
25
  def put_file(file)
26
26
  puts @pastel.cyan(file.to_s)
27
27
  end
28
-
28
+
29
29
  def put_linter(linter)
30
30
  puts @pastel.red(" #{linter[:linter]}")
31
31
  end
32
-
33
- def put_issue(issue)
34
- puts " #{@pastel.dim(issue.dig(:location, :line))} #{issue[:message]}"
32
+
33
+ def put_issue(file, issue)
34
+ puts " #{issue[:message]}"
35
+ puts " #{@pastel.dim file.to_s}:#{@pastel.dim(issue.dig(:location, :line))}"
35
36
  end
36
-
37
+
37
38
  def put_summary
38
39
  print "#{files.count} features inspected, "
39
40
  if files_with_issues.count.zero?
@@ -42,6 +43,5 @@ module Chutney
42
43
  puts @pastel.red("#{files_with_issues.count} taste nasty")
43
44
  end
44
45
  end
45
-
46
46
  end
47
47
  end
data/lib/chutney/issue.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'term/ansicolor'
2
4
 
3
5
  module Chutney
@@ -1,12 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # gherkin utilities
2
4
 
3
5
  module Chutney
4
6
  # base class for all linters
5
7
  class Linter
6
8
  attr_accessor :issues
7
- attr_reader :filename
8
- attr_reader :configuration
9
-
9
+ attr_reader :filename, :configuration
10
+
10
11
  Lint = Struct.new(:message, :gherkin_type, :location, :feature, :scenario, :step, keyword_init: true)
11
12
 
12
13
  def self.descendants
@@ -18,77 +19,81 @@ module Chutney
18
19
  @filename = filename
19
20
  @issues = []
20
21
  @configuration = configuration
21
- language = @content.dig(:feature, :language) || 'en'
22
- @dialect = Gherkin::Dialect.for(language)
23
22
  end
24
23
 
25
24
  def lint
26
25
  raise 'not implemented'
27
26
  end
28
-
27
+
29
28
  def and_word?(word)
30
- @dialect.and_keywords.include?(word)
29
+ dialect_word(:and).include?(word)
31
30
  end
32
-
31
+
33
32
  def background_word?(word)
34
- @dialect.background_keywords.include?(word)
33
+ dialect_word(:background).include?(word)
35
34
  end
36
-
35
+
37
36
  def but_word?(word)
38
- @dialect.but_keywords.include?(word)
37
+ dialect_word(:but).include?(word)
39
38
  end
40
-
39
+
41
40
  def examples_word?(word)
42
- @dialect.example_keywords.include?(word)
41
+ dialect_word(:examples).include?(word)
43
42
  end
44
-
43
+
45
44
  def feature_word?(word)
46
- @dialect.feature_keywords.include?(word)
45
+ dialect_word(:feature).include?(word)
47
46
  end
48
-
47
+
49
48
  def given_word?(word)
50
- @dialect.given_keywords.include?(word)
49
+ dialect_word(:given).include?(word)
51
50
  end
52
-
51
+
53
52
  def scenario_outline_word?(word)
54
- @dialect.scenario_outline_keywords.include?(word)
53
+ dialect_word(:scenarioOutline).include?(word)
55
54
  end
56
-
55
+
57
56
  def then_word?(word)
58
- @dialect.then_keywords.include?(word)
57
+ dialect_word(:then).include?(word)
59
58
  end
60
-
59
+
61
60
  def when_word?(word)
62
- @dialect.when_keywords.include?(word)
61
+ dialect_word(:when).include?(word)
62
+ end
63
+
64
+ def dialect_word(word)
65
+ CukeModeler::Parsing.dialects[dialect][word.to_s].map(&:strip)
66
+ end
67
+
68
+ def dialect
69
+ @content.feature&.parsing_data&.dig(:language) || 'en'
63
70
  end
64
-
65
- def tags_for(element)
66
- return [] unless element.include? :tags
67
71
 
68
- element[:tags].map { |tag| tag[:name][1..-1] }
72
+ def tags_for(element)
73
+ element.tags.map { |tag| tag.name[1..] }
69
74
  end
70
-
71
- def add_issue(message, feature = nil, scenario = nil, step = nil)
75
+
76
+ def add_issue(message, feature = nil, scenario = nil, item = nil)
72
77
  issues << Lint.new(
73
78
  message: message,
74
- gherkin_type: type(feature, scenario, step),
75
- location: location(feature, scenario, step),
76
- feature: feature ? feature[:name] : nil,
77
- scenario: scenario ? scenario[:name] : nil,
78
- step: step ? step[:text] : nil
79
+ gherkin_type: type(feature, scenario, item),
80
+ location: location(feature, scenario, item),
81
+ feature: feature&.name,
82
+ scenario: scenario&.name,
83
+ step: item&.parsing_data&.dig(:name)
79
84
  ).to_h
80
85
  end
81
-
86
+
82
87
  def location(feature, scenario, step)
83
88
  if step
84
- step[:location]
89
+ step.parsing_data[:location]
85
90
  elsif scenario
86
- scenario[:location]
87
- else
88
- feature ? feature[:location] : 0
91
+ scenario.parsing_data.dig(:scenario, :location) || scenario.parsing_data.dig(:background, :location)
92
+ else
93
+ feature ? feature.parsing_data[:location] : { line: 0, column: 0 }
89
94
  end
90
95
  end
91
-
96
+
92
97
  def type(_feature, scenario, step)
93
98
  if step
94
99
  :step
@@ -99,101 +104,102 @@ module Chutney
99
104
 
100
105
  def feature
101
106
  if block_given?
102
- yield(@content[:feature]) if @content[:feature]
107
+ yield(@content.feature) if @content.feature
103
108
  else
104
- @content[:feature]
109
+ @content.feature
105
110
  end
106
111
  end
107
-
108
- def elements
112
+
113
+ def elements
109
114
  return [] unless feature
110
-
111
- if block_given?
112
- feature[:children].each do |child|
115
+
116
+ if block_given?
117
+ feature.children.each do |child|
113
118
  next if off_switch?(child)
114
-
119
+
115
120
  yield(feature, child)
116
121
  end
117
122
  else
118
- feature[:children]
123
+ feature.children
119
124
  end
120
125
  end
121
126
 
122
127
  def off_switch?(element = feature)
123
- off_switch = element[:tags]
124
- .then { |tags| tags || [] }
125
- .filter { |tag| tag[:type] == :Tag }
126
- .filter { |tag| tag[:name] == "@disable#{linter_name}" }
127
- .count
128
- .positive?
128
+ off_switch = element.tags
129
+ .map(&:name)
130
+ .then { |tags| tags || [] }
131
+ .filter { |tag_name| tag_name == "@disable#{linter_name}" }
132
+ .count
133
+ .positive?
129
134
  off_switch ||= off_switch?(feature) unless element == feature
130
135
  off_switch
131
136
  end
132
-
137
+
133
138
  def background
139
+ return unless feature&.background
140
+ return if off_switch?(feature)
141
+
134
142
  if block_given?
135
- elements do |feature, child|
136
- next unless child[:type] == :Background
137
-
138
- yield(feature, child)
139
- end
143
+ yield(feature, feature.background)
140
144
  else
141
- elements.filter { |child| child[:type] == :Background }
145
+ feature.background
142
146
  end
143
147
  end
144
-
148
+
145
149
  def scenarios
146
150
  if block_given?
147
- elements do |feature, child|
148
- next unless %i[ScenarioOutline Scenario].include? child[:type]
149
-
150
- yield(feature, child)
151
+ feature&.tests&.each do |test|
152
+ next if off_switch?(test)
153
+
154
+ yield(feature, test)
151
155
  end
156
+
152
157
  else
153
- elements.filter { |child| %i[ScenarioOutline Scenario].include? child[:type] }
158
+ feature&.tests
154
159
  end
155
160
  end
156
-
161
+
157
162
  def filled_scenarios
158
163
  if block_given?
159
164
  scenarios do |feature, scenario|
160
- next unless scenario.include? :steps
161
- next if scenario[:steps].empty?
162
-
165
+ next if scenario.steps.empty?
166
+
163
167
  yield(feature, scenario)
164
168
  end
165
169
  else
166
- scenarios.filter { |s| !s[:steps].empty? }
170
+ scenarios ? scenarios.filter { |s| !s.steps.empty? } : []
167
171
  end
168
172
  end
169
-
173
+
170
174
  def steps
171
- elements do |feature, child|
172
- next unless child.include? :steps
173
-
174
- child[:steps].each { |step| yield(feature, child, step) }
175
+ feature&.tests&.each do |test|
176
+ next if off_switch?(test)
177
+
178
+ test.steps.each do |step|
179
+ yield(feature, test, step)
180
+ end
175
181
  end
176
182
  end
177
183
 
178
184
  def self.linter_name
179
185
  name.split('::').last
180
186
  end
181
-
187
+
182
188
  def linter_name
183
189
  self.class.linter_name
184
190
  end
185
191
 
186
192
  def render_step(step)
187
- value = "#{step[:keyword]}#{step[:text]}"
188
- value += render_step_argument step[:argument] if step.include? :argument
193
+ value = "#{step.keyword} #{step.text}"
194
+ value += render_step_argument(step.block) if step.block
189
195
  value
190
196
  end
191
-
197
+
192
198
  def render_step_argument(argument)
193
- return "\n#{argument[:content]}" if argument[:type] == :DocString
194
-
195
- result = argument[:rows].map do |row|
196
- "|#{row[:cells].map { |cell| cell[:value] }.join '|'}|"
199
+ return "\n#{argument.content}" if argument.is_a?(CukeModeler::DocString)
200
+
201
+ result = argument.rows.map do |row|
202
+ "|#{row.cells.map(&:value).join '|'}|"
197
203
  end.join "\n"
198
204
  "\n#{result}"
199
205
  end