Module: ArchSpec::Formatters::Text

Defined in:
lib/archspec/formatters/text.rb

Overview

Prints diagnostics the way clang and herb do: a severity header with the rule id, the location, a code frame with the offending span underlined, and the evidence as a note.

[error] models must not depend on controllers [dependencies.forbid]

app/models/user.rb:3:3

  2 │ class User
→ 3 │   UsersController
    │   ^~~~~~~~~~~~~~~
  4 │ end

note: User references UsersController

Output to a terminal is colored; a non-TTY or a NO_COLOR environment disables the colors.

Constant Summary collapse

CONTEXT_LINES =
1

Class Method Summary collapse

Class Method Details

.note_for(diagnostic, relative) ⇒ Object

The evidence as a note, or nil when it would only repeat the location shown above it, as parse-error evidence does.



87
88
89
90
91
92
93
# File 'lib/archspec/formatters/text.rb', line 87

def note_for(diagnostic, relative)
  note = diagnostic.evidence.to_s
  return if note.empty? || note == relative

  note = "#{note} (confidence: #{diagnostic.confidence})" unless diagnostic.confidence == :high
  note
end


27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/archspec/formatters/text.rb', line 27

def print(output = $stdout, graph:, diagnostics:)
  if diagnostics.empty?
    output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, " \
                "#{graph.edges.size} facts checked."
    print_census(output, graph)
    return
  end

  style = Style.new(output)
  sources = Hash.new { |hash, path| hash[path] = read_lines(path) }

  diagnostics.each do |diagnostic|
    print_diagnostic(output, style, graph, diagnostic, sources)
  end

  label = diagnostics.size == 1 ? 'architecture violation' : 'architecture violations'
  output.puts style.bold("#{diagnostics.size} #{label} found.")
  print_census(output, graph)
end


115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/archspec/formatters/text.rb', line 115

def print_census(output, graph)
  census = graph.analysis_census
  clauses = []
  if census[:unresolved_constants].positive?
    clauses << "#{census[:unresolved_constants]} unresolved constant references"
  end
  clauses << "#{census[:dynamic_features]} dynamic features" if census[:dynamic_features].positive?
  clauses << "#{census[:unknown_receivers]} calls with unknown receivers" if census[:unknown_receivers].positive?
  census[:rubydex_diagnostics].each do |rule, count|
    clauses << "#{count} RubyDEX #{rule} #{count == 1 ? 'diagnostic' : 'diagnostics'}"
  end
  output.puts "Analysis gaps: #{clauses.join(', ')}." unless clauses.empty?
end


47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/archspec/formatters/text.rb', line 47

def print_diagnostic(output, style, graph, diagnostic, sources)
  location = diagnostic.location
  relative = location.relative_path(graph.root)

  header = "#{style.severity('[error]')} #{style.bold(diagnostic.message)} #{style.faint("[#{diagnostic.rule}]")}"
  output.puts header
  output.puts
  output.puts "#{relative}:#{location.line}:#{location.column}"
  print_frame(output, style, location, sources[location.path])

  if (note = note_for(diagnostic, relative))
    output.puts
    output.puts "  #{style.note('note:')} #{note}"
  end
  output.puts "  #{style.note('reason:')} #{diagnostic.reason}" if diagnostic.reason
  output.puts
end


65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/archspec/formatters/text.rb', line 65

def print_frame(output, style, location, lines)
  target = lines[location.line - 1]
  return unless target

  output.puts
  first = [location.line - CONTEXT_LINES, 1].max
  last = [location.line + CONTEXT_LINES, lines.size].min
  width = last.to_s.length

  (first..last).each do |number|
    text = lines[number - 1]
    if number == location.line
      output.puts "  #{style.marker('')} #{style.faint("#{number.to_s.rjust(width)}")} #{text}"
      output.puts "    #{style.faint("#{' ' * width}")} #{style.marker(underline(location, target))}"
    else
      output.puts "    #{style.faint("#{number.to_s.rjust(width)}")} #{text}"
    end
  end
end

.read_lines(path) ⇒ Object



107
108
109
110
111
112
113
# File 'lib/archspec/formatters/text.rb', line 107

def read_lines(path)
  return [] unless File.file?(path)

  File.readlines(path, chomp: true).map { |line| line.scrub.tr("\t", ' ') }
rescue SystemCallError
  []
end

.underline(location, text) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/archspec/formatters/text.rb', line 95

def underline(location, text)
  span =
    if location.end_line == location.line
      location.end_column - location.column
    else
      text.length - location.column + 1
    end
  span = span.clamp(1, [text.length - location.column + 1, 1].max)

  "#{' ' * (location.column - 1)}^#{'~' * (span - 1)}"
end