drep/cli/render.rs
1//! Output shared by the commands that report findings.
2//!
3//! `check` and `lint-docs` print the same two things in the same way: a
4//! finding line, and the "could not be analyzed" block that carries the exit-2
5//! contract. They were transcribed copies, so `lint-docs` inherited a fixed
6//! bug by luck rather than by construction: the blank line above the failure
7//! block is emitted only when a findings block precedes it, because emitting it
8//! unconditionally opened every clean-but-unanalyzed run with a stray empty
9//! line.
10//!
11//! What each command still owns is its own format's shape - `check`'s JSON
12//! wire types, `lint-docs`' report-only footer - and the one deliberate
13//! difference in the finding line, which is passed as an argument rather than
14//! copied.
15
16use std::collections::BTreeMap;
17use std::io::Write;
18use std::path::PathBuf;
19
20use anyhow::Result;
21
22use crate::analysis::findings::Finding;
23use crate::analysis::result::FailureReason;
24
25/// The longest finding message a text line carries.
26const MESSAGE_MAX: usize = 200;
27
28/// The longest kind a text line carries. Real ones are a rule or category
29/// name; the longest are linter rule paths of a few dozen characters.
30const KIND_MAX: usize = 80;
31
32/// The longest suggestion a text line carries. Longer than a message because
33/// a suggestion is often a short code fragment.
34const SUGGESTION_MAX: usize = 400;
35
36/// One line of text output for a finding.
37///
38/// `source` is the layer that produced it (`"tool"`, `"llm"`), rendered as a
39/// prefix inside the brackets. `check` passes one because two layers write into
40/// one list and they gate differently; `lint-docs` passes `None`, because it
41/// has a single source and a constant tag on every line says nothing.
42///
43/// The kind and message are excerpted: a tool's diagnostics and a model's
44/// answer are text drep did not write, and JSON decodes `\u001b` into a real
45/// escape, so a raw print hands the terminal an escape sequence, a newline
46/// forges a line of its own, and a multi-kilobyte field hands it a flood. A
47/// model's `category` becomes the kind, and the code under review can steer
48/// the model. The file name is the repository's, and is printed through
49/// [`crate::text::path_label`] for the same reason.
50pub fn finding_line(source: Option<&str>, f: &Finding) -> String {
51 let column = f.column.map(|c| format!(":{c}")).unwrap_or_default();
52 let kind = crate::text::excerpt(&f.kind, KIND_MAX);
53 let tag = match source {
54 Some(source) => format!("{source}/{kind}"),
55 None => kind,
56 };
57 format!(
58 "{}:{}{}: {} [{}] {}",
59 crate::text::path_label(&f.file_path),
60 f.line,
61 column,
62 f.severity.as_str(),
63 tag,
64 crate::text::excerpt(&f.message, MESSAGE_MAX)
65 )
66}
67
68/// The suggestion line that follows a finding, when it has one.
69///
70/// Excerpted for the same reason as the message in [`finding_line`]: a
71/// model's suggestion is its own text, and printed raw an escape sequence in
72/// it reaches the terminal and a newline in it prints a forged finding line.
73pub fn suggestion_line(f: &Finding) -> Option<String> {
74 f.suggestion.as_deref().map(|suggestion| {
75 format!(
76 " suggestion: {}",
77 crate::text::excerpt(suggestion, SUGGESTION_MAX)
78 )
79 })
80}
81
82/// Write [`suggestion_line`], when there is one.
83///
84/// Written immediately after the finding it belongs to. Printing every finding
85/// first and every suggestion afterwards detaches them: with two findings, the
86/// first suggestion appears below the second finding and reads as if it
87/// belonged to it.
88pub fn write_suggestion<W: Write>(out: &mut W, f: &Finding) -> Result<()> {
89 if let Some(line) = suggestion_line(f) {
90 writeln!(out, "{line}")?;
91 }
92 Ok(())
93}
94
95/// The "N file(s) could not be analyzed" block.
96///
97/// `findings_above` decides the separating blank line - see the module doc.
98/// Each file is named through [`crate::text::path_label_of`], as in
99/// [`finding_line`].
100pub fn write_failures<W: Write>(
101 out: &mut W,
102 failures: &BTreeMap<PathBuf, FailureReason>,
103 findings_above: bool,
104) -> Result<()> {
105 if failures.is_empty() {
106 return Ok(());
107 }
108 if findings_above {
109 writeln!(out)?;
110 }
111 writeln!(out, "{} file(s) could not be analyzed:", failures.len())?;
112 for (path, reason) in failures {
113 writeln!(
114 out,
115 " {}: {}",
116 crate::text::path_label_of(path),
117 reason.one_line()
118 )?;
119 }
120 Ok(())
121}