archspec 0.5.0 → 1.0.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.
data/lib/archspec/cli.rb CHANGED
@@ -16,15 +16,20 @@ module ArchSpec
16
16
  extend self
17
17
 
18
18
  CONFIG_FILE = 'Archspec.rb'
19
+ USAGE_ERROR_STATUS = 64
19
20
  TEMPLATE = <<~RUBY
20
21
  architecture :rails
21
22
  RUBY
22
23
 
24
+ class UsageError < Error; end
25
+
23
26
  def run(argv, output: $stdout, error: $stderr)
24
27
  argv = argv.dup
25
28
  command = argv.shift || 'check'
26
29
 
27
30
  case command
31
+ when 'help', '--help', '-h'
32
+ help(argv, output)
28
33
  when 'init'
29
34
  init(argv, output)
30
35
  when 'check'
@@ -32,47 +37,91 @@ module ArchSpec
32
37
  when 'explain'
33
38
  explain(argv, output)
34
39
  when 'version', '--version', '-v'
40
+ raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
41
+
35
42
  output.puts ArchSpec::VERSION
36
43
  0
37
44
  else
38
- error.puts "Unknown command: #{command}"
39
- error.puts usage
40
- 64
45
+ raise UsageError, "unknown command: #{command}"
41
46
  end
47
+ rescue OptionParser::ParseError, UsageError => e
48
+ error.puts "archspec: error: #{e.message}"
49
+ error.puts usage(command)
50
+ USAGE_ERROR_STATUS
42
51
  rescue Error => e
43
- error.puts e.message
52
+ error.puts "archspec: error: #{e.message}"
44
53
  1
45
54
  end
46
55
 
47
56
  private
48
57
 
58
+ def help(argv, output)
59
+ subject = argv.shift
60
+ raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
61
+ if subject && !%w[init check explain version].include?(subject)
62
+ raise UsageError, "unknown command: #{subject}"
63
+ end
64
+
65
+ output.puts usage(subject)
66
+ 0
67
+ end
68
+
49
69
  def init(argv, output)
50
- force = argv.delete('--force')
70
+ options = { force: false, help: false }
71
+ parser = OptionParser.new do |opts|
72
+ opts.banner = usage('init').strip
73
+ opts.on('--force', 'Overwrite an existing file') { options[:force] = true }
74
+ opts.on('-h', '--help', 'Show this help') { options[:help] = true }
75
+ end
76
+ parser.parse!(argv)
77
+
78
+ if options[:help]
79
+ output.puts parser
80
+ return 0
81
+ end
82
+
83
+ raise UsageError, "unexpected argument: #{argv[1]}" if argv.length > 1
84
+
51
85
  path = argv.shift || CONFIG_FILE
52
86
 
53
- raise Error, "#{path} already exists. Use --force to overwrite it." if File.exist?(path) && !force
87
+ if File.exist?(path) && !options[:force]
88
+ raise Error, "#{path} already exists (use --force to overwrite)"
89
+ end
54
90
 
55
91
  File.write(path, TEMPLATE)
56
92
  output.puts "Created #{path}"
57
93
  0
94
+ rescue SystemCallError => e
95
+ raise Error, "could not create #{path}: #{e.message}"
58
96
  end
59
97
 
60
98
  def check(argv, output)
61
99
  options = {
62
100
  config: CONFIG_FILE,
63
101
  format: 'text',
64
- update_todo: false
102
+ update_todo: false,
103
+ help: false
65
104
  }
66
105
 
67
106
  parser = OptionParser.new do |opts|
68
- opts.on('--config PATH') { |value| options[:config] = value }
69
- opts.on('--format FORMAT') { |value| options[:format] = value }
70
- opts.on('--update-todo') { options[:update_todo] = true }
107
+ opts.banner = usage('check').strip
108
+ opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
109
+ opts.on('--format FORMAT', 'Output text or json') { |value| options[:format] = value }
110
+ opts.on('--update-todo', 'Replace the configured todo with current violations') do
111
+ options[:update_todo] = true
112
+ end
113
+ opts.on('-h', '--help', 'Show this help') { options[:help] = true }
71
114
  end
72
115
  parser.parse!(argv)
73
116
 
74
- raise Error, 'Cannot combine --update-todo with path arguments.' if options[:update_todo] && argv.any?
117
+ if options[:help]
118
+ output.puts parser
119
+ return 0
120
+ end
121
+
122
+ raise Error, 'cannot combine --update-todo with path arguments' if options[:update_todo] && argv.any?
75
123
 
124
+ formatter = formatter_for(options[:format])
76
125
  definition, root = load_definition(options[:config])
77
126
  graph = Analyzer.analyze(definition, root: root)
78
127
  todo_path = todo_path_for(definition, root)
@@ -83,27 +132,38 @@ module ArchSpec
83
132
  if options[:update_todo]
84
133
  unless todo_path
85
134
  raise Error,
86
- "No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
135
+ "no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
87
136
  end
88
137
 
89
- Todo.write(todo_path, diagnostics, root: root)
90
- output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
138
+ # Syntax errors are never an accepted baseline; they must be fixed.
139
+ accepted = diagnostics.reject { |diagnostic| diagnostic.rule == 'parser.syntax' }
140
+ Todo.write(todo_path, accepted, root: root)
141
+ label = accepted.size == 1 ? 'violation' : 'violations'
142
+ output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{accepted.size} #{label}."
91
143
  return 0
92
144
  end
93
145
 
94
- formatter_for(options[:format]).print(output, graph: graph, diagnostics: diagnostics)
146
+ formatter.print(output, graph: graph, diagnostics: diagnostics)
95
147
  diagnostics.empty? ? 0 : 1
96
148
  end
97
149
 
98
150
  def explain(argv, output)
99
- options = { config: CONFIG_FILE }
151
+ options = { config: CONFIG_FILE, help: false }
100
152
  parser = OptionParser.new do |opts|
101
- opts.on('--config PATH') { |value| options[:config] = value }
153
+ opts.banner = usage('explain').strip
154
+ opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
155
+ opts.on('-h', '--help', 'Show this help') { options[:help] = true }
102
156
  end
103
157
  parser.parse!(argv)
104
158
 
159
+ if options[:help]
160
+ output.puts parser
161
+ return 0
162
+ end
163
+
105
164
  subject = argv.shift
106
- raise Error, 'Usage: archspec explain PATH_OR_CONSTANT' unless subject
165
+ raise UsageError, 'missing PATH_OR_CONSTANT' unless subject
166
+ raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
107
167
 
108
168
  definition, root = load_definition(options[:config])
109
169
  graph = Analyzer.analyze(definition, root: root)
@@ -112,19 +172,25 @@ module ArchSpec
112
172
  end
113
173
 
114
174
  def load_definition(config_path)
115
- raise Error, "Missing #{config_path}. Run `archspec init` first." unless File.exist?(config_path)
175
+ raise Error, "no #{config_path} found; run `archspec init` first" unless File.exist?(config_path)
116
176
 
117
- ArchSpec.last_definition = nil
118
177
  absolute_config = File.expand_path(config_path)
119
- config_dir = File.dirname(absolute_config)
120
178
  definition = Definition.new
121
- definition.base_dir = config_dir
179
+ definition.base_dir = File.dirname(absolute_config)
122
180
  definition.extend(DSL::Context)
123
181
  definition.instance_eval(File.read(absolute_config), absolute_config)
124
- definition = ArchSpec.last_definition || definition
125
- definition.base_dir ||= config_dir
126
182
 
127
- [definition, definition.absolute_root(config_dir)]
183
+ if definition.component_specs.empty? && definition.rules.empty?
184
+ raise Error, "#{config_path} declared no components or rules; the file's top level is already " \
185
+ 'the DSL, so do not wrap declarations in ArchSpec.define'
186
+ end
187
+
188
+ [definition, definition.absolute_root]
189
+ rescue Error
190
+ raise
191
+ rescue SyntaxError, LoadError, StandardError => e
192
+ detail = e.message.lines.first&.strip || e.class.name
193
+ raise Error, "could not load #{config_path}: #{detail}"
128
194
  end
129
195
 
130
196
  def scope_to_paths(diagnostics, paths, root)
@@ -151,18 +217,32 @@ module ArchSpec
151
217
  when 'json'
152
218
  Formatters::JSON
153
219
  else
154
- raise Error, "Unknown format: #{name.inspect}"
220
+ raise UsageError, "unknown format: #{name.inspect}"
155
221
  end
156
222
  end
157
223
 
158
- def usage
159
- <<~TEXT
160
- Usage:
161
- archspec init [PATH] [--force]
162
- archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
163
- archspec explain PATH_OR_CONSTANT [--config PATH]
164
- archspec version
165
- TEXT
224
+ def usage(command = nil)
225
+ case command.to_s
226
+ when 'init'
227
+ 'Usage: archspec init [PATH] [--force]'
228
+ when 'check'
229
+ 'Usage: archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]'
230
+ when 'explain'
231
+ 'Usage: archspec explain PATH_OR_CONSTANT [--config PATH]'
232
+ when 'version'
233
+ 'Usage: archspec version'
234
+ when ''
235
+ <<~TEXT
236
+ Usage:
237
+ archspec init [PATH] [--force]
238
+ archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
239
+ archspec explain PATH_OR_CONSTANT [--config PATH]
240
+ archspec version
241
+ archspec help [COMMAND]
242
+ TEXT
243
+ else
244
+ usage
245
+ end
166
246
  end
167
247
  end
168
248
  end
@@ -36,6 +36,8 @@ module ArchSpec
36
36
  path: location.relative_path(root),
37
37
  line: location.line,
38
38
  column: location.column,
39
+ end_line: location.end_line,
40
+ end_column: location.end_column,
39
41
  evidence: evidence,
40
42
  confidence: confidence.to_s
41
43
  }
data/lib/archspec/dsl.rb CHANGED
@@ -8,6 +8,16 @@ module ArchSpec
8
8
  # An +Archspec.rb+ file is evaluated in this context, so every method here is
9
9
  # a top-level call in that file.
10
10
  module DSL
11
+ # Raises when any of +names+ is not a declared component. Shared by the
12
+ # top-level DSL and the component proxies.
13
+ def self.assert_known_components!(definition, names, for_rule:)
14
+ unknown = Array(names).flatten.compact.map(&:to_sym).reject { |name| definition.component?(name) }.uniq.sort
15
+ return if unknown.empty?
16
+
17
+ label = unknown.length == 1 ? 'component' : 'components'
18
+ raise Error, "#{for_rule} references unknown #{label}: #{unknown.join(', ')}"
19
+ end
20
+
11
21
  # The top-level DSL. Declare the project, its components, an architecture
12
22
  # preset, and global rules.
13
23
  #
@@ -120,6 +130,7 @@ module ArchSpec
120
130
  #
121
131
  # Rule id: +dependencies.no_cycles+.
122
132
  def no_cycles(among: nil)
133
+ DSL.assert_known_components!(self, among, for_rule: 'no_cycles') if among
123
134
  add_rule(Rules::NoCyclesRule.new(among: among))
124
135
  end
125
136
 
@@ -162,6 +173,7 @@ module ArchSpec
162
173
  #
163
174
  # Rule id: +dependencies.allow+.
164
175
  def can_only_use(*targets)
176
+ DSL.assert_known_components!(definition, targets, for_rule: "#{name}.can_only_use")
165
177
  add_rule(Rules::AllowDependenciesRule.new(name, targets))
166
178
  self
167
179
  end
@@ -173,6 +185,7 @@ module ArchSpec
173
185
  #
174
186
  # Rule id: +dependencies.forbid+.
175
187
  def cannot_use(*targets)
188
+ DSL.assert_known_components!(definition, targets, for_rule: "#{name}.cannot_use")
176
189
  add_rule(Rules::ForbidDependenciesRule.new(name, targets))
177
190
  self
178
191
  end
@@ -185,6 +198,7 @@ module ArchSpec
185
198
  #
186
199
  # Rule id: +dependencies.consumers+.
187
200
  def can_only_be_used_by(*consumers)
201
+ DSL.assert_known_components!(definition, consumers, for_rule: "#{name}.can_only_be_used_by")
188
202
  add_rule(Rules::AllowedConsumersRule.new(name, consumers))
189
203
  self
190
204
  end
@@ -288,6 +302,8 @@ module ArchSpec
288
302
  #
289
303
  # Rule id: +protocol.must_implement+.
290
304
  def must_implement(*methods)
305
+ raise Error, 'must_implement requires at least one method' if methods.flatten.compact.empty?
306
+
291
307
  methods.each do |method_name|
292
308
  add_rule(Rules::MustImplementRule.new(name, method_name))
293
309
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ # Raised for configuration and usage errors, such as an unknown architecture
5
+ # name or a malformed rule option.
6
+ class Error < StandardError; end
7
+ end
@@ -5,14 +5,12 @@ module ArchSpec
5
5
  extend self
6
6
 
7
7
  def evaluate(definition, graph, todo: Todo.empty)
8
- (parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
9
- .reject { |diagnostic| graph.suppressed?(diagnostic) }
10
- .reject { |diagnostic| todo.include?(diagnostic) }
11
- .sort_by do |diagnostic|
12
- [diagnostic.location.path, diagnostic.location.line, diagnostic.rule,
13
- diagnostic.message, diagnostic.evidence]
14
- end
15
- .uniq { |diagnostic| [diagnostic.rule, diagnostic.message, diagnostic.location.path, diagnostic.location.line] }
8
+ diagnostics = parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) }
9
+
10
+ diagnostics
11
+ .reject { |diagnostic| graph.suppressed?(diagnostic) || todo.include?(diagnostic) }
12
+ .sort_by { |d| [d.location.path, d.location.line, d.rule, d.message, d.evidence] }
13
+ .uniq { |d| [d.rule, d.message, d.location.path, d.location.line] }
16
14
  end
17
15
 
18
16
  private
@@ -3,79 +3,109 @@
3
3
  module ArchSpec
4
4
  module Formatters
5
5
  # Renders <tt>archspec explain</tt>: why a file or constant belongs to its
6
- # components, and the facts ArchSpec found for it. Raises ArchSpec::Error
7
- # when the subject matches no file and no constant.
6
+ # components, and the facts ArchSpec found for it, in the same visual
7
+ # language as the check output. Raises ArchSpec::Error when the subject
8
+ # matches no file and no constant.
8
9
  module Explanation
9
10
  module_function
10
11
 
11
12
  def print(output = $stdout, graph:, subject:)
13
+ style = Style.new(output)
12
14
  path = File.expand_path(subject, graph.root)
13
15
 
14
16
  if graph.files.key?(path)
15
- explain_file(output, graph, path)
17
+ explain_file(output, style, graph, path)
16
18
  else
17
- explain_constant(output, graph, subject)
19
+ explain_constant(output, style, graph, subject)
18
20
  end
19
21
  end
20
22
 
21
- def explain_file(output, graph, path)
23
+ def explain_file(output, style, graph, path)
22
24
  file = graph.files.fetch(path)
23
- output.puts file.relative_path
24
- output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
25
- print_parse_errors(output, file)
26
- print_component_reasons(output, graph.component_assignment_reasons_for_path(path))
27
- print_suppressions(output, file)
28
- output.puts ' outgoing facts:'
29
-
30
- graph.edges.select { |edge| edge.from_path == path }.each do |edge|
31
- output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
32
- end
25
+ output.puts style.bold(file.relative_path)
26
+ output.puts
27
+ output.puts " #{style.note('defined constants:')} #{graph.constants_for_path(path).map(&:name).join(', ')}"
28
+ print_parse_errors(output, style, file)
29
+ print_component_reasons(output, style, graph.component_assignment_reasons_for_path(path))
30
+ print_suppressions(output, style, file)
31
+ print_facts(output, style, graph.edges.select { |edge| edge.from_path == path })
33
32
  end
34
33
 
35
- def explain_constant(output, graph, subject)
34
+ def explain_constant(output, style, graph, subject)
36
35
  constants = graph.constants_named(subject)
37
- raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
38
-
39
- constants.each do |constant|
40
- output.puts constant.name
41
- output.puts " kind: #{constant.kind}"
42
- output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
43
- print_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
44
- output.puts " superclass: #{constant.superclass || '(none)'}"
45
- output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
46
- output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
36
+ raise Error, "no file or constant found for #{subject.inspect}" if constants.empty?
37
+
38
+ constants.each_with_index do |constant, index|
39
+ output.puts unless index.zero?
40
+ output.puts style.bold(constant.name)
41
+ output.puts
42
+ output.puts " #{style.note('kind:')} #{constant.kind}"
43
+ output.puts " #{style.note('file:')} #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
44
+ print_component_reasons(
45
+ output, style,
46
+ graph.component_assignment_reasons_for_constant(constant.name, path: constant.path)
47
+ )
48
+ output.puts " #{style.note('superclass:')} #{constant.superclass || '(none)'}"
49
+ output.puts " #{style.note('instance methods:')} #{constant.instance_methods.to_a.sort.join(', ')}"
50
+ output.puts " #{style.note('class methods:')} #{constant.class_methods.to_a.sort.join(', ')}"
47
51
  end
48
52
  end
49
53
 
50
- def print_component_reasons(output, assignments)
54
+ def print_component_reasons(output, style, assignments)
51
55
  if assignments.empty?
52
- output.puts ' components: (none)'
56
+ output.puts " #{style.note('components:')} (none)"
53
57
  return
54
58
  end
55
59
 
56
- output.puts ' components:'
60
+ output.puts " #{style.note('components:')}"
57
61
  assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
58
62
  output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
59
63
  end
60
64
  end
61
65
 
62
- def print_suppressions(output, file)
66
+ def print_suppressions(output, style, file)
63
67
  return if file.suppressions.empty?
64
68
 
65
- output.puts ' suppressions:'
66
- file.suppressions.each do |suppression|
69
+ output.puts " #{style.note('suppressions:')}"
70
+ in_gutters(file.suppressions.map { |suppression| line_range(suppression) }) do |gutter, index|
71
+ suppression = file.suppressions[index]
67
72
  rule = suppression.rule || '*'
68
73
  reason = suppression.reason ? " -- #{suppression.reason}" : ''
69
- output.puts " #{rule} on line #{line_range(suppression)}#{reason}"
74
+ output.puts " #{style.faint(gutter)} #{rule}#{reason}"
70
75
  end
71
76
  end
72
77
 
73
- def print_parse_errors(output, file)
78
+ def print_parse_errors(output, style, file)
74
79
  return if file.parse_errors.empty?
75
80
 
76
- output.puts ' parse errors:'
77
- file.parse_errors.each do |parse_error|
78
- output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
81
+ output.puts " #{style.note('parse errors:')}"
82
+ locations = file.parse_errors.map { |error| "#{error.location.line}:#{error.location.column}" }
83
+ in_gutters(locations) do |gutter, index|
84
+ output.puts " #{style.faint(gutter)} #{file.parse_errors[index].message}"
85
+ end
86
+ end
87
+
88
+ def print_facts(output, style, facts)
89
+ if facts.empty?
90
+ output.puts " #{style.note('outgoing facts:')} (none)"
91
+ return
92
+ end
93
+
94
+ output.puts " #{style.note('outgoing facts:')}"
95
+ locations = facts.map { |edge| "#{edge.location.line}:#{edge.location.column}" }
96
+ in_gutters(locations) do |gutter, index|
97
+ edge = facts[index]
98
+ output.puts " #{style.faint(gutter)} #{edge.verb} #{edge.to}"
99
+ end
100
+ end
101
+
102
+ # Yields each label right-justified to the widest one, with the frame
103
+ # gutter bar appended, so columns line up like the check output.
104
+ def in_gutters(labels)
105
+ width = labels.map(&:length).max
106
+
107
+ labels.each_with_index do |label, index|
108
+ yield "#{label.rjust(width)} │", index
79
109
  end
80
110
  end
81
111
 
@@ -83,7 +113,7 @@ module ArchSpec
83
113
  if suppression.end_line == Float::INFINITY
84
114
  "#{suppression.start_line}-EOF"
85
115
  elsif suppression.start_line == suppression.end_line
86
- suppression.start_line
116
+ suppression.start_line.to_s
87
117
  else
88
118
  "#{suppression.start_line}-#{suppression.end_line}"
89
119
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Formatters
5
+ # ANSI styling for terminal output, shared by the formatters. Enabled only
6
+ # when the output is a TTY and NO_COLOR is unset.
7
+ class Style
8
+ def initialize(output)
9
+ @enabled = output.respond_to?(:tty?) && output.tty? && ENV['NO_COLOR'].to_s.empty?
10
+ end
11
+
12
+ def bold(text)
13
+ paint(text, '1')
14
+ end
15
+
16
+ def severity(text)
17
+ paint(text, '1;31')
18
+ end
19
+
20
+ def marker(text)
21
+ paint(text, '1;31')
22
+ end
23
+
24
+ def note(text)
25
+ paint(text, '1;36')
26
+ end
27
+
28
+ def faint(text)
29
+ paint(text, '2')
30
+ end
31
+
32
+ private
33
+
34
+ def paint(text, code)
35
+ @enabled ? "\e[#{code}m#{text}\e[0m" : text
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,26 +2,110 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Formatters
5
+ # Prints diagnostics the way clang and herb do: a severity header with the
6
+ # rule id, the location, a code frame with the offending span underlined,
7
+ # and the evidence as a note.
8
+ #
9
+ # [error] models must not depend on controllers [dependencies.forbid]
10
+ #
11
+ # app/models/user.rb:3:3
12
+ #
13
+ # 2 │ class User
14
+ # → 3 │ UsersController
15
+ # │ ^~~~~~~~~~~~~~~
16
+ # 4 │ end
17
+ #
18
+ # note: User references UsersController
19
+ #
20
+ # Output to a terminal is colored; a non-TTY or a NO_COLOR environment
21
+ # disables the colors.
5
22
  module Text
23
+ CONTEXT_LINES = 1
24
+
6
25
  module_function
7
26
 
8
27
  def print(output = $stdout, graph:, diagnostics:)
9
28
  if diagnostics.empty?
10
- output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, #{graph.edges.size} facts checked."
29
+ output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, " \
30
+ "#{graph.edges.size} facts checked."
11
31
  return
12
32
  end
13
33
 
14
- output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? 'violation' : 'violations'}"
15
- output.puts
34
+ style = Style.new(output)
35
+ sources = Hash.new { |hash, path| hash[path] = read_lines(path) }
16
36
 
17
37
  diagnostics.each do |diagnostic|
18
- output.puts "[#{diagnostic.rule}] #{diagnostic.location.relative_path(graph.root)}:#{diagnostic.location.line}:#{diagnostic.location.column}"
19
- output.puts " #{diagnostic.message}"
20
- output.puts " evidence: #{diagnostic.evidence}"
21
- output.puts " confidence: #{diagnostic.confidence}"
22
- output.puts " id: #{diagnostic.fingerprint(root: graph.root)}"
38
+ print_diagnostic(output, style, graph, diagnostic, sources)
39
+ end
40
+
41
+ label = diagnostics.size == 1 ? 'architecture violation' : 'architecture violations'
42
+ output.puts style.bold("#{diagnostics.size} #{label} found.")
43
+ end
44
+
45
+ def print_diagnostic(output, style, graph, diagnostic, sources)
46
+ location = diagnostic.location
47
+ relative = location.relative_path(graph.root)
48
+
49
+ output.puts "#{style.severity('[error]')} #{style.bold(diagnostic.message)} #{style.faint("[#{diagnostic.rule}]")}"
50
+ output.puts
51
+ output.puts "#{relative}:#{location.line}:#{location.column}"
52
+ print_frame(output, style, location, sources[location.path])
53
+
54
+ if (note = note_for(diagnostic, relative))
23
55
  output.puts
56
+ output.puts " #{style.note('note:')} #{note}"
24
57
  end
58
+ output.puts
59
+ end
60
+
61
+ def print_frame(output, style, location, lines)
62
+ target = lines[location.line - 1]
63
+ return unless target
64
+
65
+ output.puts
66
+ first = [location.line - CONTEXT_LINES, 1].max
67
+ last = [location.line + CONTEXT_LINES, lines.size].min
68
+ width = last.to_s.length
69
+
70
+ (first..last).each do |number|
71
+ text = lines[number - 1]
72
+ if number == location.line
73
+ output.puts " #{style.marker('→')} #{style.faint("#{number.to_s.rjust(width)} │")} #{text}"
74
+ output.puts " #{style.faint("#{' ' * width} │")} #{style.marker(underline(location, target))}"
75
+ else
76
+ output.puts " #{style.faint("#{number.to_s.rjust(width)} │")} #{text}"
77
+ end
78
+ end
79
+ end
80
+
81
+ # The evidence as a note, or nil when it would only repeat the location
82
+ # shown above it, as parse-error evidence does.
83
+ def note_for(diagnostic, relative)
84
+ note = diagnostic.evidence.to_s
85
+ return if note.empty? || note == relative
86
+
87
+ note = "#{note} (confidence: #{diagnostic.confidence})" unless diagnostic.confidence == :high
88
+ note
89
+ end
90
+
91
+ def underline(location, text)
92
+ span =
93
+ if location.end_line == location.line
94
+ location.end_column - location.column
95
+ else
96
+ text.length - location.column + 1
97
+ end
98
+ span = span.clamp(1, [text.length - location.column + 1, 1].max)
99
+
100
+ "#{' ' * (location.column - 1)}^#{'~' * (span - 1)}"
101
+ end
102
+
103
+ def read_lines(path)
104
+ return [] unless File.file?(path)
105
+
106
+ File.readlines(path, chomp: true).map { |line| line.scrub.tr("\t", ' ') }
107
+ rescue SystemCallError
108
+ []
25
109
  end
26
110
  end
27
111
  end