ui_guardrails 1.2.0 → 1.3.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pathname"
4
4
  require_relative "report/style"
5
+ require_relative "report/finding"
5
6
 
6
7
  module Guardrails
7
8
  class StimulusAudit
@@ -56,16 +57,92 @@ module Guardrails
56
57
  result
57
58
  end
58
59
 
60
+ # Detector-agnostic view of `result` (see Report::Finding). Unlike
61
+ # the text report, findings carry locations: every view line that
62
+ # references an orphaned controller, and the JS file behind a dead
63
+ # one — the names alone don't tell you where to go.
64
+ def categories(result)
65
+ [
66
+ Report::Category.new(
67
+ name: "stimulus orphaned", severity: :warning, framing: ORPHANED_FRAMING.join(" "),
68
+ findings: result.orphaned.map { |name|
69
+ Report::Finding.new(
70
+ category: "stimulus orphaned", severity: :warning,
71
+ title: "stimulus orphaned: #{name}", suggestion: orphaned_suggestion(name),
72
+ locations: reference_locations(name)
73
+ )
74
+ }
75
+ ),
76
+ Report::Category.new(
77
+ name: "stimulus dead", severity: :warning, framing: DEAD_FRAMING.join(" "),
78
+ findings: result.dead.map { |name|
79
+ Report::Finding.new(
80
+ category: "stimulus dead", severity: :warning,
81
+ title: "stimulus dead: #{name}", suggestion: dead_suggestion(name),
82
+ locations: controller_locations(name)
83
+ )
84
+ }
85
+ )
86
+ ].reject { |c| c.findings.empty? }
87
+ end
88
+
59
89
  private
60
90
 
61
- def collect_defined_controllers
62
- paths = CONTROLLER_BASES.flat_map do |base|
91
+ ORPHANED_FRAMING = [
92
+ "data-controller=\"…\" references a Stimulus controller, but no matching",
93
+ "*_controller.{js,ts} file exists. Either create the controller or",
94
+ "remove the reference."
95
+ ].freeze
96
+
97
+ DEAD_FRAMING = [
98
+ "*_controller.{js,ts} file exists, but no view references it via",
99
+ "data-controller=\"…\". Either wire the controller into a template",
100
+ "or delete the file."
101
+ ].freeze
102
+
103
+ def orphaned_suggestion(name)
104
+ "create app/javascript/controllers/#{name}_controller.js or remove the data-controller=\"#{name}\" reference"
105
+ end
106
+
107
+ def dead_suggestion(name)
108
+ "reference it via data-controller=\"#{name}\" in a view, or delete the JS file"
109
+ end
110
+
111
+ def controller_paths
112
+ CONTROLLER_BASES.flat_map do |base|
63
113
  absolute = @root.join(base)
64
114
  next [] unless absolute.exist?
65
115
 
66
116
  Dir.glob(absolute.join(CONTROLLER_GLOB))
67
117
  end
68
- paths.map { |path| controller_name_from_path(path) }.compact.uniq
118
+ end
119
+
120
+ def controller_locations(name)
121
+ controller_paths.select { |path| controller_name_from_path(path) == name }.sort.map do |path|
122
+ Report::Location.new(file: Pathname(path).relative_path_from(@root).to_s)
123
+ end
124
+ end
125
+
126
+ # Line-level lookup, only run for the (usually few) orphaned names
127
+ # — the main pass reads whole files and keeps names only. A
128
+ # reference the patterns only match across lines (a multi-line
129
+ # `data: { controller: ... }` hash) falls back to a file-level
130
+ # location rather than dropping out.
131
+ def reference_locations(name)
132
+ VIEW_PATTERNS.flat_map { |pattern| Dir.glob(@root.join(pattern)) }.sort.flat_map do |path|
133
+ content = File.read(path, encoding: Encoding::UTF_8)
134
+ next [] unless extract_names(content).include?(name)
135
+
136
+ relative = Pathname(path).relative_path_from(@root).to_s
137
+ lines = content.each_line.with_index(1).filter_map do |line, number|
138
+ Report::Location.new(file: relative, line: number) if extract_names(line).include?(name)
139
+ end
140
+ lines.empty? ? [Report::Location.new(file: relative)] : lines
141
+ end
142
+ end
143
+
144
+ def collect_defined_controllers
145
+ controller_paths.map { |path| controller_name_from_path(path) }.compact.uniq
69
146
  end
70
147
 
71
148
  # Derive a Stimulus controller identifier from a file path. We anchor
@@ -95,7 +172,10 @@ module Guardrails
95
172
  end
96
173
 
97
174
  def extract_referenced(file)
98
- content = File.read(file, encoding: Encoding::UTF_8)
175
+ extract_names(File.read(file, encoding: Encoding::UTF_8))
176
+ end
177
+
178
+ def extract_names(content)
99
179
  [DATA_CONTROLLER_PATTERN, RUBY_DATA_CONTROLLER_PATTERN].flat_map do |pattern|
100
180
  content.scan(pattern).flat_map { |captures| captures[0].strip.split(/\s+/) }
101
181
  end
@@ -111,13 +191,11 @@ module Guardrails
111
191
  :warning,
112
192
  "stimulus orphaned (#{result.orphaned.length} #{noun})"
113
193
  )
114
- @output.puts " data-controller=\"…\" references a Stimulus controller, but no matching"
115
- @output.puts " *_controller.{js,ts} file exists. Either create the controller or"
116
- @output.puts " remove the reference."
194
+ ORPHANED_FRAMING.each { |line| @output.puts " #{line}" }
117
195
  result.orphaned.each do |name|
118
196
  @output.puts ""
119
197
  @output.puts " #{@style.severity(:warning, "stimulus orphaned: #{name}")}"
120
- @output.puts " #{@style.suggestion("create app/javascript/controllers/#{name}_controller.js or remove the data-controller=\"#{name}\" reference")}"
198
+ @output.puts " #{@style.suggestion(orphaned_suggestion(name))}"
121
199
  end
122
200
  end
123
201
 
@@ -128,13 +206,11 @@ module Guardrails
128
206
  :warning,
129
207
  "stimulus dead (#{result.dead.length} #{noun})"
130
208
  )
131
- @output.puts " *_controller.{js,ts} file exists, but no view references it via"
132
- @output.puts " data-controller=\"…\". Either wire the controller into a template"
133
- @output.puts " or delete the file."
209
+ DEAD_FRAMING.each { |line| @output.puts " #{line}" }
134
210
  result.dead.each do |name|
135
211
  @output.puts ""
136
212
  @output.puts " #{@style.severity(:warning, "stimulus dead: #{name}")}"
137
- @output.puts " #{@style.suggestion("reference it via data-controller=\"#{name}\" in a view, or delete the JS file")}"
213
+ @output.puts " #{@style.suggestion(dead_suggestion(name))}"
138
214
  end
139
215
  end
140
216
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "rbconfig"
5
+
6
+ module Guardrails
7
+ class TUI
8
+ # Builds the command that opens a Report::Location in the user's
9
+ # editor, at the right line when the editor has a way to say so.
10
+ #
11
+ # Editor choice: $GUARDRAILS_EDITOR, then $VISUAL, then $EDITOR;
12
+ # with none set, the OS opener (`open` / `xdg-open`), which loses
13
+ # the line number but still gets you to the file.
14
+ #
15
+ # Always returns an argv array (never a shell string) — paths come
16
+ # from the audited project's filenames, and they go nowhere near a
17
+ # shell.
18
+ module Editor
19
+ # file:line:col as one argument, behind a --goto flag.
20
+ GOTO_FLAG = %w[code code-insiders codium cursor windsurf].freeze
21
+ # file:line:col as one bare argument.
22
+ COLON_SUFFIX = %w[zed subl sublime_text hx].freeze
23
+ # +line before the file.
24
+ PLUS_LINE = %w[vi vim nvim nano emacs emacsclient micro kak].freeze
25
+ # --line N before the file.
26
+ LINE_FLAG = %w[rubymine idea mine].freeze
27
+
28
+ # `code --wait` is the usual $EDITOR value for git's benefit. Here
29
+ # it would freeze the TUI until the editor tab closes.
30
+ WAIT_FLAGS = %w[--wait -w -W].freeze
31
+ TERMINAL_EDITORS = (PLUS_LINE + %w[hx]).freeze
32
+
33
+ module_function
34
+
35
+ def command(location, root:, env: ENV, host_os: RbConfig::CONFIG["host_os"])
36
+ path = File.join(root.to_s, location.file)
37
+ editor = [env["GUARDRAILS_EDITOR"], env["VISUAL"], env["EDITOR"]].find { |v| v && !v.strip.empty? }
38
+ return opener(path, host_os) unless editor
39
+
40
+ parts = Shellwords.split(editor)
41
+ name = File.basename(parts.first)
42
+ parts -= WAIT_FLAGS unless TERMINAL_EDITORS.include?(name)
43
+ parts + target_args(name, path, location)
44
+ end
45
+
46
+ # Terminal editors need the TUI to get out of the way (leave the
47
+ # alternate screen, restore cooked mode) while they run.
48
+ def terminal?(command)
49
+ TERMINAL_EDITORS.include?(File.basename(command.first))
50
+ end
51
+
52
+ def opener(path, host_os = RbConfig::CONFIG["host_os"])
53
+ case host_os
54
+ when /darwin/ then ["open", path]
55
+ when /linux|bsd/ then ["xdg-open", path]
56
+ end
57
+ end
58
+
59
+ def target_args(name, path, location)
60
+ position = [path, location.line, location.line && location.column].compact.join(":")
61
+ if GOTO_FLAG.include?(name) then ["--goto", position]
62
+ elsif COLON_SUFFIX.include?(name) then [position]
63
+ elsif name == "mate" then location.line ? ["-l", location.line.to_s, path] : [path]
64
+ elsif PLUS_LINE.include?(name) then location.line ? ["+#{location.line}", path] : [path]
65
+ elsif LINE_FLAG.include?(name) then location.line ? ["--line", location.line.to_s, path] : [path]
66
+ else [path]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ class TUI
5
+ # Turns raw terminal bytes into key events: symbols for named keys
6
+ # (:up, :enter, :escape, …), one-character strings for printable
7
+ # input.
8
+ #
9
+ # Reads with read_nonblock into its own buffer rather than
10
+ # getc/getch. Arrow keys arrive as a multi-byte escape sequence,
11
+ # and telling a lone Esc from the start of one needs a "is more
12
+ # coming?" check — which IO.select can't answer correctly if Ruby's
13
+ # buffered reader has already swallowed the rest of the sequence.
14
+ class Keys
15
+ ESCAPE_SEQUENCES = {
16
+ "[A" => :up, "[B" => :down, "[C" => :right, "[D" => :left,
17
+ "OA" => :up, "OB" => :down, "OC" => :right, "OD" => :left,
18
+ "[H" => :home, "[F" => :end, "OH" => :home, "OF" => :end,
19
+ "[1~" => :home, "[4~" => :end, "[7~" => :home, "[8~" => :end,
20
+ "[5~" => :page_up, "[6~" => :page_down, "[Z" => :tab
21
+ }.freeze
22
+
23
+ CONTROL = {
24
+ "\r" => :enter, "\n" => :enter, "\t" => :tab,
25
+ "\x7F" => :backspace, "\b" => :backspace, "\x03" => :ctrl_c
26
+ }.freeze
27
+
28
+ # How long a lone Esc waits for the rest of a sequence before
29
+ # it's taken at face value. Long enough for a slow SSH hop,
30
+ # short enough that Esc-to-go-back doesn't feel laggy.
31
+ ESCAPE_TIMEOUT = 0.05
32
+
33
+ def initialize(input)
34
+ @input = input
35
+ @buffer = +""
36
+ end
37
+
38
+ # Next key event; nil if `timeout` seconds pass with no input
39
+ # (the main loop uses the nil to notice terminal resizes); :eof
40
+ # once the input has hung up.
41
+ def next(timeout: nil)
42
+ fill(timeout) if @buffer.empty?
43
+ return :eof if @eof && @buffer.empty?
44
+ return nil if @buffer.empty?
45
+
46
+ fill(ESCAPE_TIMEOUT) if @buffer == "\e"
47
+ parse
48
+ end
49
+
50
+ private
51
+
52
+ def parse
53
+ return parse_escape if @buffer.start_with?("\e")
54
+
55
+ char = @buffer.slice!(0)
56
+ CONTROL.fetch(char) { char.match?(/[[:print:]]/) ? char : :unknown }
57
+ end
58
+
59
+ def parse_escape
60
+ rest = @buffer[1..]
61
+ sequence = ESCAPE_SEQUENCES.keys.find { |s| rest.start_with?(s) }
62
+ if sequence
63
+ @buffer.slice!(0, 1 + sequence.length)
64
+ ESCAPE_SEQUENCES[sequence]
65
+ elsif rest.empty?
66
+ @buffer.clear
67
+ :escape
68
+ else
69
+ # Unrecognized sequence (function key, mouse report, paste
70
+ # bracket): drop all of it rather than leaking its tail into
71
+ # the filter box as literal characters.
72
+ @buffer.clear
73
+ :unknown
74
+ end
75
+ end
76
+
77
+ def fill(timeout)
78
+ return unless ready?(timeout)
79
+
80
+ chunk = @input.read_nonblock(256, exception: false)
81
+ return if chunk == :wait_readable
82
+
83
+ # A selectable input that reads nil has hung up (terminal
84
+ # closed). Surface it so the main loop exits instead of spinning.
85
+ return @eof = true if chunk.nil?
86
+
87
+ @buffer << chunk.dup.force_encoding(Encoding::UTF_8).scrub("")
88
+ end
89
+
90
+ # StringIO (specs) has no file descriptor to select on; it's
91
+ # "ready" whenever it has bytes left.
92
+ def ready?(timeout)
93
+ return !@input.eof? unless @input.respond_to?(:fileno) && @input.fileno
94
+
95
+ !IO.select([@input], nil, nil, timeout).nil?
96
+ rescue NotImplementedError
97
+ !@input.eof?
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../report/style"
4
+
5
+ module Guardrails
6
+ class TUI
7
+ # Renders a State into exactly `height` terminal lines. Pure: takes
8
+ # state and dimensions, returns strings, touches no terminal — the
9
+ # main loop owns writing them out. (It does write `scroll` and
10
+ # `page_size` back onto the state, since both depend on how tall
11
+ # the list region turned out to be.)
12
+ #
13
+ # Layout, top to bottom:
14
+ #
15
+ # title bar totals by severity, grouping / filter
16
+ # breadcrumb where you are in the drill-down
17
+ # ─────
18
+ # [info block] detail screens only: title, suggestion, framing
19
+ # list the rows State built for this frame
20
+ # ─────
21
+ # [preview] findings + detail: source around the location
22
+ # ─────
23
+ # hints keys for this screen, or the filter prompt
24
+ class Screen
25
+ ATTRIBUTES = { bold: 1, dim: 2, reverse: 7 }.freeze
26
+ COLORS = { red: 31, green: 32, yellow: 33, cyan: 36 }.freeze
27
+ SEVERITY = Report::Style::SEVERITY_FORMAT
28
+
29
+ MAX_SOURCE_BYTES = 2 * 1024 * 1024
30
+
31
+ HELP = [
32
+ ["↑ ↓ j k", "move"],
33
+ ["PgUp PgDn space", "move a page"],
34
+ ["g G", "jump to top / bottom"],
35
+ ["⏎ → l", "open the highlighted row"],
36
+ ["esc ← h", "go back (at the top level: clear the filter)"],
37
+ ["tab", "switch grouping: by category ↔ by file"],
38
+ ["/", "filter by category, title, suggestion, or file"],
39
+ ["o", "open the highlighted location in your editor"],
40
+ ["w", "write the HTML report and open it in a browser"],
41
+ ["r", "re-run the audit"],
42
+ ["q", "quit"]
43
+ ].freeze
44
+
45
+ def initialize(state:, root:, width:, height:, color: true, sources: {})
46
+ @state = state
47
+ @root = root.to_s
48
+ @width = [width, 20].max
49
+ @height = [height, 8].max
50
+ @color = color
51
+ @sources = sources
52
+ end
53
+
54
+ def lines
55
+ body_height = @height - 5
56
+ body = @state.help? ? help_lines : body_lines(body_height)
57
+ body = body.first(body_height)
58
+ body += [""] * (body_height - body.length)
59
+
60
+ [title_bar, breadcrumb, rule, *body, rule(position_label), footer]
61
+ end
62
+
63
+ private
64
+
65
+ # ---- chrome -----------------------------------------------------
66
+
67
+ def title_bar
68
+ totals = @state.totals
69
+ left = [[" Guardrails", %i[bold]], [" — #{pluralize(totals.values.sum, 'finding')} ", []]]
70
+ totals.each do |severity, count|
71
+ next if count.zero?
72
+
73
+ format = SEVERITY.fetch(severity)
74
+ left << ["#{format[:glyph]} #{count} ", [:bold, format[:color]]]
75
+ end
76
+ right = @state.filter.empty? ? "by #{@state.grouping}" : "by #{@state.grouping} · filter: #{@state.filter}"
77
+ justify(left, [[right, %i[dim]]])
78
+ end
79
+
80
+ def breadcrumb
81
+ crumbs = @state.breadcrumb
82
+ segments = [[" ", []]]
83
+ crumbs.each_with_index do |crumb, i|
84
+ segments << [" ▸ ", %i[dim]] unless i.zero?
85
+ segments << [crumb, i == crumbs.length - 1 ? %i[bold] : %i[dim]]
86
+ end
87
+ compose(segments)
88
+ end
89
+
90
+ def rule(label = nil)
91
+ return paint("─" * @width, :dim) if label.nil? || label.empty?
92
+
93
+ tail = " #{label} ──"
94
+ paint("#{'─' * [@width - tail.length, 0].max}#{tail}", :dim)
95
+ end
96
+
97
+ def position_label
98
+ return nil if @state.help? || @state.frame.cursor.nil?
99
+
100
+ selectable = @state.rows.each_index.select { |i| @state.rows[i].selectable? }
101
+ "#{selectable.index(@state.frame.cursor).to_i + 1}/#{selectable.length}"
102
+ end
103
+
104
+ def footer
105
+ if @state.filtering?
106
+ compose([[" / ", %i[bold cyan]], [@state.filter, %i[bold]], ["█", %i[dim]],
107
+ [" ⏎ keep esc clear", %i[dim]]])
108
+ elsif @state.notice
109
+ compose([[" #{@state.notice}", %i[yellow]]])
110
+ else
111
+ compose([[" #{hints}", %i[dim]]])
112
+ end
113
+ end
114
+
115
+ def hints
116
+ case @state.frame.kind
117
+ when :rollup, :files
118
+ other = @state.grouping == :category ? "by file" : "by category"
119
+ "↑↓ move ⏎ open tab #{other} / filter w web report r re-run ? help q quit"
120
+ when :findings
121
+ "↑↓ move ⏎ details o editor esc back / filter w web report ? help q quit"
122
+ when :detail
123
+ "↑↓ location ⏎ open in editor esc back w web report ? help q quit"
124
+ end
125
+ end
126
+
127
+ # ---- body -------------------------------------------------------
128
+
129
+ def body_lines(height)
130
+ return empty_lines if @state.rows.empty? && @state.frame.kind != :detail
131
+
132
+ case @state.frame.kind
133
+ when :findings then list_with_preview(height, [])
134
+ when :detail then list_with_preview(height, info_block(height / 2))
135
+ else list_lines(height)
136
+ end
137
+ end
138
+
139
+ # Splits the body between the list and a source preview. The
140
+ # preview is worth having only when there's room for context
141
+ # around the line, so short terminals get the list alone.
142
+ def list_with_preview(height, info)
143
+ remaining = height - info.length
144
+ preview_height = remaining >= 12 ? [remaining / 2, 14].min : 0
145
+ list_height = remaining - preview_height - (preview_height.zero? ? 0 : 1)
146
+
147
+ out = info + list_lines(list_height)
148
+ out += [rule, *preview_lines(preview_height)] unless preview_height.zero?
149
+ out
150
+ end
151
+
152
+ def list_lines(height)
153
+ return [] if height <= 0
154
+
155
+ @state.page_size = [height - 1, 1].max
156
+ rows = @state.rows
157
+ scroll = scroll_for(rows, height)
158
+ visible = rows[scroll, height] || []
159
+ out = visible.each_with_index.map { |row, i| row_line(row, selected: scroll + i == @state.frame.cursor) }
160
+ out + [""] * (height - out.length)
161
+ end
162
+
163
+ def scroll_for(rows, height)
164
+ frame = @state.frame
165
+ cursor = frame.cursor || 0
166
+ scroll = frame.scroll || 0
167
+ scroll = cursor if cursor < scroll
168
+ scroll = cursor - height + 1 if cursor >= scroll + height
169
+ # Keep a leading header on screen when the cursor sits on the
170
+ # first item beneath it.
171
+ scroll = 0 if cursor < height && rows[0...cursor].none?(&:selectable?)
172
+ frame.scroll = scroll.clamp(0, [rows.length - height, 0].max)
173
+ end
174
+
175
+ def row_line(row, selected:)
176
+ return header_line(row) unless row.selectable?
177
+
178
+ format = row.severity && SEVERITY.fetch(row.severity)
179
+ marker = selected ? " › " : " "
180
+ glyph = format ? "#{format[:glyph]} " : ""
181
+ right = [row.meta, row.flag && "[#{row.flag}]"].compact.reject(&:empty?).join(" ")
182
+
183
+ if selected
184
+ paint(plain_justified("#{marker}#{glyph}#{sanitize(row.label)}", right), :reverse, :bold)
185
+ else
186
+ justify(
187
+ [[marker, []], [glyph, [:bold, format && format[:color]]], [sanitize(row.label), []]],
188
+ [[right, %i[dim]]]
189
+ )
190
+ end
191
+ end
192
+
193
+ def header_line(row)
194
+ format = SEVERITY.fetch(row.severity)
195
+ compose([[" #{format[:glyph]} #{format[:label]}", [:bold, format[:color]]], [" — #{row.label}", %i[dim]]])
196
+ end
197
+
198
+ def empty_lines
199
+ message = if @state.filter.empty?
200
+ ["✓ No findings — the audit is clean.", %i[green bold]]
201
+ else
202
+ ["No findings match “#{@state.filter}”. Esc clears the filter.", %i[dim]]
203
+ end
204
+ ["", compose([[" ", []], message])]
205
+ end
206
+
207
+ def help_lines
208
+ ["", compose([[" Keys", %i[bold]]]), ""] +
209
+ HELP.map { |keys, what| compose([[" #{keys.ljust(20)}", %i[cyan]], [what, []]]) } +
210
+ ["", compose([[" Editor: $GUARDRAILS_EDITOR, then $VISUAL, then $EDITOR. Any key closes this.", %i[dim]]])]
211
+ end
212
+
213
+ # ---- detail info block --------------------------------------------
214
+
215
+ def info_block(max_height)
216
+ finding = @state.selected_finding
217
+ format = SEVERITY.fetch(finding.severity)
218
+ out = wrap(sanitize(finding.title), @width - 14).first(3).each_with_index.map do |text, i|
219
+ tag = i.zero? ? "[#{format[:label].downcase}]".ljust(10) : " " * 10
220
+ compose([[" ", []], [tag, [:bold, format[:color]]], [" #{text}", %i[bold]]])
221
+ end
222
+ wrap(finding.suggestion.to_s, @width - 6).first(3).each_with_index do |text, i|
223
+ out << compose([[i.zero? ? " → " : " ", %i[cyan]], [sanitize(text), []]])
224
+ end
225
+ finding.details.each do |label, value|
226
+ wrap("#{label}: #{sanitize(value.to_s)}", @width - 4).first(3).each { |text| out << compose([[" #{text}", []]]) }
227
+ end
228
+ framing = @state.category_for(finding)&.framing
229
+ if framing
230
+ out << ""
231
+ wrap(framing, @width - 4).first(4).each { |text| out << compose([[" #{text}", %i[dim]]]) }
232
+ end
233
+ out << ""
234
+ out << compose([[locations_heading(finding), %i[bold]]])
235
+ out.last([max_height, 3].max)
236
+ end
237
+
238
+ def locations_heading(finding)
239
+ count = finding.locations.length
240
+ count.zero? ? " No file locations for this finding." : " #{pluralize(count, 'location')}"
241
+ end
242
+
243
+ # ---- source preview ---------------------------------------------
244
+
245
+ def preview_lines(height)
246
+ finding = @state.selected_finding
247
+ location = @state.selected_location
248
+ return [""] * height unless finding
249
+
250
+ out = []
251
+ if @state.frame.kind == :findings && finding.suggestion
252
+ wrap(finding.suggestion, @width - 6).first(2).each_with_index do |text, i|
253
+ out << compose([[i.zero? ? " → " : " ", %i[cyan]], [sanitize(text), []]])
254
+ end
255
+ end
256
+ out << compose([[" #{location || 'no file location'}", %i[dim]]])
257
+ out += source_lines(location, height - out.length) if location
258
+ out.first(height) + [""] * [height - out.length, 0].max
259
+ end
260
+
261
+ def source_lines(location, height)
262
+ return [] if height <= 0
263
+
264
+ source = read_source(location.file)
265
+ return [compose([[" (#{source})", %i[dim]]])] if source.is_a?(String)
266
+
267
+ target = location.line
268
+ first = target ? (target - (height / 2)).clamp(1, [source.length - height + 1, 1].max) : 1
269
+ gutter = (first + height).to_s.length
270
+ source[first - 1, height].to_a.each_with_index.map do |text, i|
271
+ number = first + i
272
+ hit = number == target
273
+ compose([
274
+ [hit ? " ▸ " : " ", %i[bold yellow]],
275
+ ["#{number.to_s.rjust(gutter)} │ ", hit ? %i[bold] : %i[dim]],
276
+ [sanitize(text), hit ? %i[bold] : []]
277
+ ])
278
+ end
279
+ end
280
+
281
+ # Array of lines, or a String explaining why there aren't any.
282
+ def read_source(file)
283
+ @sources[file] ||= begin
284
+ path = File.join(@root, file)
285
+ if !File.file?(path) then "file not found"
286
+ elsif File.size(path) > MAX_SOURCE_BYTES then "file too large to preview"
287
+ else
288
+ raw = File.binread(path)
289
+ raw.include?("\0") ? "binary file" : raw.force_encoding(Encoding::UTF_8).scrub("?").lines(chomp: true)
290
+ end
291
+ end
292
+ end
293
+
294
+ # ---- text plumbing ------------------------------------------------
295
+
296
+ # File contents and snippets end up on screen verbatim, so strip
297
+ # anything a terminal would act on rather than display — a view
298
+ # containing a literal escape sequence mustn't be able to move
299
+ # the cursor or recolor the UI.
300
+ def sanitize(text)
301
+ text.to_s.gsub("\t", " ").gsub(/[[:cntrl:]]/, "")
302
+ end
303
+
304
+ def paint(text, *styles)
305
+ codes = styles.flatten.compact.filter_map { |s| ATTRIBUTES[s] || (@color ? COLORS[s] : nil) }
306
+ codes.empty? ? text : "\e[#{codes.join(';')}m#{text}\e[0m"
307
+ end
308
+
309
+ # Segments are [text, styles] pairs. Truncation happens on the
310
+ # plain text *before* painting, so an ANSI sequence is never cut
311
+ # in half and never counts toward the width.
312
+ def compose(segments, width = @width)
313
+ remaining = width
314
+ segments.filter_map do |text, styles|
315
+ next if remaining <= 0 || text.nil? || text.empty?
316
+
317
+ text = "#{text[0, [remaining - 1, 0].max]}…" if text.length > remaining
318
+ remaining -= text.length
319
+ paint(text, *styles)
320
+ end.join
321
+ end
322
+
323
+ # Left segments, then right segments flush against the right
324
+ # edge. The right side wins when space is tight (it's the count /
325
+ # location — the part you scan down), capped at 3/5 of the width.
326
+ def justify(left, right)
327
+ right_text = right.sum { |text, _| text.to_s.length }
328
+ right_width = [right_text, @width * 3 / 5].min
329
+ left_width = @width - right_width - 2
330
+ left_text = left.sum { |text, _| text.to_s.length }
331
+ gap = [@width - [left_text, left_width].min - right_width - 1, 1].max
332
+
333
+ "#{compose(left, left_width)}#{' ' * gap}#{compose(right, right_width)}"
334
+ end
335
+
336
+ def plain_justified(left, right)
337
+ right = truncate(right, @width * 3 / 5)
338
+ left = truncate(left, @width - right.length - 2)
339
+ "#{left}#{' ' * [@width - left.length - right.length - 1, 1].max}#{right} "
340
+ end
341
+
342
+ def truncate(text, limit)
343
+ text.length <= limit ? text : "#{text[0, [limit - 1, 0].max]}…"
344
+ end
345
+
346
+ def wrap(text, width)
347
+ width = [width, 10].max
348
+ text.to_s.split(/\s+/).each_with_object([+""]) do |word, lines|
349
+ if lines.last.empty? then lines.last << word
350
+ elsif lines.last.length + 1 + word.length <= width then lines.last << " " << word
351
+ else lines << +word
352
+ end
353
+ end.reject(&:empty?)
354
+ end
355
+
356
+ def pluralize(count, singular)
357
+ "#{count} #{count == 1 ? singular : "#{singular}s"}"
358
+ end
359
+ end
360
+ end
361
+ end