forked from Crockan/MercuryToolbox
1187 lines
40 KiB
Rust
1187 lines
40 KiB
Rust
//! The `diagpick` command extracts diagnostics from logs.
|
|
|
|
use codeindex::{CodeIndexer, IndexedSymbol, detect_language, enclosing_symbol};
|
|
use std::collections::{HashMap, hash_map::Entry};
|
|
use std::ffi::OsString;
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::io::{self, BufRead, Read};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use common::{
|
|
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
|
|
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
|
should_read_stdin,
|
|
};
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
const HELP: &str = "\
|
|
Extract actionable diagnostics from noisy logs.
|
|
|
|
Usage:
|
|
diagpick [OPTIONS] [PATH...]
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
--with-source Attach source context when the file exists
|
|
--with-snippet Alias for --with-source
|
|
--snip Attach source context when the file exists
|
|
--with-definition Alias for --def
|
|
--def Attach the enclosing definition when codeindex supports the file
|
|
--jsonl Alias for --input-format jsonl
|
|
--context <COUNT> Source context lines above and below the hit
|
|
--limit <COUNT> Optional maximum number of diagnostics to emit
|
|
--severity <FILTER> Restrict output to all, error, warning, or note
|
|
--allow-empty Exit 0 when filters leave no diagnostics
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
diagpick .\\fixtures\\diag\\rust-errors.txt
|
|
bat --style=plain --paging=never .\\fixtures\\diag\\unity-errors.txt | diagpick --snip --json | ConvertFrom-Json
|
|
diagpick .\\fixtures\\diag\\rust-errors.txt --def
|
|
diagpick .\\fixtures\\diag\\unity-errors.txt --severity error
|
|
diagpick .\\fixtures\\diag\\unity-errors.txt --severity warning
|
|
diagpick .\\fixtures\\diag\\unity-errors.txt --severity note --allow-empty
|
|
|
|
Notes:
|
|
For Unity Player.log or BepInEx LogOutput.log incident grouping, prefer unitydiag; diagpick is best for compiler-style file:line diagnostics.
|
|
";
|
|
|
|
/// CLI arguments for the `diagpick` binary.
|
|
#[derive(Debug, Clone)]
|
|
struct Cli {
|
|
/// Shared output and stdin policy flags.
|
|
common: CommonArgs,
|
|
/// Attach source context when the referenced file exists.
|
|
with_snippet: bool,
|
|
/// Attach the enclosing definition when the file can be indexed.
|
|
with_definition: bool,
|
|
/// Source context lines above and below the diagnostic line.
|
|
context: usize,
|
|
/// Optional maximum number of diagnostics to emit.
|
|
limit: Option<usize>,
|
|
/// Restrict output to one severity.
|
|
severity: SeverityFilter,
|
|
/// Exit successfully when the filtered diagnostic set is empty.
|
|
allow_empty: bool,
|
|
/// Log files to read when stdin is empty.
|
|
paths: Vec<PathBuf>,
|
|
}
|
|
|
|
/// Diagnostic severity filter values.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SeverityFilter {
|
|
/// Return diagnostics of any severity.
|
|
All,
|
|
/// Return only errors.
|
|
Error,
|
|
/// Return only warnings.
|
|
Warning,
|
|
/// Return only notes.
|
|
Note,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum Severity {
|
|
Error,
|
|
Warning,
|
|
Note,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct SourceLine {
|
|
number: usize,
|
|
text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct SourceSnippet {
|
|
start_line: usize,
|
|
end_line: usize,
|
|
lines: Vec<SourceLine>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct Diagnostic {
|
|
path: String,
|
|
line: usize,
|
|
column: usize,
|
|
severity: Severity,
|
|
code: Option<String>,
|
|
message: String,
|
|
tool_hint: String,
|
|
source: Option<SourceSnippet>,
|
|
snippet: Option<SourceSnippet>,
|
|
enclosing_definition: Option<IndexedSymbol>,
|
|
context_warning: Option<String>,
|
|
}
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
#[must_use]
|
|
pub fn main_entry() -> i32 {
|
|
match parse_cli_from(std::env::args_os()) {
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
print!("{HELP}");
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
println!("diagpick {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
Ok(code) => code.as_i32(),
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
},
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
let mut cli = Cli {
|
|
common: CommonArgs::default(),
|
|
with_snippet: false,
|
|
with_definition: false,
|
|
context: 1,
|
|
limit: None,
|
|
severity: SeverityFilter::All,
|
|
allow_empty: false,
|
|
paths: Vec::new(),
|
|
};
|
|
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
|
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
|
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
cli.common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("input-format") => {
|
|
let value = parser_value_string(&mut parser, "--input-format")?;
|
|
cli.common.input_format = parse_input_format(&value)?;
|
|
}
|
|
Long("color") => {
|
|
let value = parser_value_string(&mut parser, "--color")?;
|
|
cli.common.color = parse_color_choice(&value)?;
|
|
}
|
|
Long("quiet") => cli.common.quiet = true,
|
|
Long("with-source" | "with-snippet" | "snip") => cli.with_snippet = true,
|
|
Long("with-definition" | "def") => cli.with_definition = true,
|
|
Long("jsonl") => cli.common.input_format = InputFormat::Jsonl,
|
|
Long("context") => {
|
|
cli.context =
|
|
parse_usize_flag("--context", &parser_value_string(&mut parser, "--context")?)?;
|
|
}
|
|
Long("limit") => {
|
|
cli.limit = Some(parse_positive_usize_flag(
|
|
"--limit",
|
|
&parser_value_string(&mut parser, "--limit")?,
|
|
)?);
|
|
}
|
|
Long("severity") => {
|
|
cli.severity =
|
|
parse_severity_filter(&parser_value_string(&mut parser, "--severity")?)?;
|
|
}
|
|
Long("allow-empty") => cli.allow_empty = true,
|
|
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((ParseOutcome::Run, cli))
|
|
}
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
value.into_string().map_err(|invalid| {
|
|
CliError::usage(format!(
|
|
"{flag} expects UTF-8 text, got '{}'",
|
|
invalid.to_string_lossy()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
value
|
|
.parse::<usize>()
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
}
|
|
|
|
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
let parsed = parse_usize_flag(flag, value)?;
|
|
if parsed == 0 {
|
|
return Err(CliError::usage(format!("{flag} must be greater than 0")));
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn parse_severity_filter(value: &str) -> Result<SeverityFilter, CliError> {
|
|
match value {
|
|
"all" => Ok(SeverityFilter::All),
|
|
"error" => Ok(SeverityFilter::Error),
|
|
"warning" => Ok(SeverityFilter::Warning),
|
|
"note" => Ok(SeverityFilter::Note),
|
|
other => Err(CliError::usage(format!(
|
|
"invalid --severity value '{other}'; expected all, error, warning, or note"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
let mut diagnostics = load_diagnostics(cli)?;
|
|
diagnostics.retain(|diagnostic| severity_matches(diagnostic.severity, cli.severity));
|
|
if let Some(limit) = cli.limit {
|
|
diagnostics.truncate(limit);
|
|
}
|
|
if cli.with_snippet || cli.with_definition {
|
|
diagnostics = attach_context(
|
|
diagnostics,
|
|
cli.context,
|
|
cli.with_snippet,
|
|
cli.with_definition,
|
|
)?;
|
|
}
|
|
|
|
match cli.common.render_mode() {
|
|
RenderMode::Json => print_json(&diagnostics)?,
|
|
RenderMode::Toon => print_structured(&diagnostics, RenderMode::Toon)?,
|
|
RenderMode::Text => {
|
|
if diagnostics.is_empty() {
|
|
if !cli.common.quiet {
|
|
println!("{}", empty_diagnostics_message(cli.allow_empty));
|
|
}
|
|
} else {
|
|
print!("{}", render_text(&diagnostics));
|
|
}
|
|
}
|
|
}
|
|
|
|
if diagnostics.is_empty() && cli.allow_empty {
|
|
Ok(ExitCode::Success)
|
|
} else {
|
|
Ok(map_result_count(diagnostics.len()))
|
|
}
|
|
}
|
|
|
|
fn load_diagnostics(cli: &Cli) -> Result<Vec<Diagnostic>, CliError> {
|
|
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
|
let mut buffer = String::new();
|
|
io::stdin()
|
|
.read_to_string(&mut buffer)
|
|
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
|
if !buffer.trim().is_empty() {
|
|
return parse_input(&buffer, cli.common.input_format);
|
|
}
|
|
}
|
|
|
|
if cli.paths.is_empty() {
|
|
return Err(CliError::usage(
|
|
"provide at least one log path or pipe log lines into stdin",
|
|
));
|
|
}
|
|
|
|
let mut diagnostics = Vec::new();
|
|
for path in &cli.paths {
|
|
let content = fs::read_to_string(path).map_err(|error| {
|
|
CliError::runtime(format!("failed to read {}: {error}", path.display()))
|
|
})?;
|
|
let mut parsed = parse_input(&content, cli.common.input_format)?;
|
|
resolve_relative_paths(&mut parsed, path);
|
|
diagnostics.extend(parsed);
|
|
}
|
|
Ok(diagnostics)
|
|
}
|
|
|
|
const fn empty_diagnostics_message(allow_empty: bool) -> &'static str {
|
|
if allow_empty {
|
|
"0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first"
|
|
} else {
|
|
"0 actionable diagnostics; if this looks like a generic log or transcript, try logshape or fileprobe first (use --allow-empty to exit 0)"
|
|
}
|
|
}
|
|
|
|
fn resolve_relative_paths(diagnostics: &mut [Diagnostic], log_path: &Path) {
|
|
let resolution_roots = candidate_resolution_roots(log_path);
|
|
for diagnostic in diagnostics {
|
|
diagnostic.path = normalize_path_string(&diagnostic.path);
|
|
let raw_path = Path::new(&diagnostic.path);
|
|
if raw_path.is_absolute() {
|
|
continue;
|
|
}
|
|
if let Some(resolved) = resolve_path_against_roots(raw_path, &resolution_roots) {
|
|
diagnostic.path = normalize_path_string(&resolved.display().to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn candidate_resolution_roots(log_path: &Path) -> Vec<PathBuf> {
|
|
let mut roots = Vec::new();
|
|
let mut current = log_path.parent();
|
|
while let Some(path) = current {
|
|
roots.push(path.to_path_buf());
|
|
current = path.parent();
|
|
}
|
|
roots
|
|
}
|
|
|
|
fn resolve_path_against_roots(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
|
roots
|
|
.iter()
|
|
.map(|root| root.join(path))
|
|
.find(|candidate| candidate.exists())
|
|
}
|
|
|
|
fn parse_input(content: &str, input_format: InputFormat) -> Result<Vec<Diagnostic>, CliError> {
|
|
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
|
|
match input_format {
|
|
InputFormat::Lines | InputFormat::Auto => Ok(parse_text_diagnostics(content)),
|
|
InputFormat::Jsonl => parse_jsonl_diagnostics(content),
|
|
}
|
|
}
|
|
|
|
fn parse_jsonl_diagnostics(content: &str) -> Result<Vec<Diagnostic>, CliError> {
|
|
let mut diagnostics = Vec::new();
|
|
|
|
for (index, line) in io::Cursor::new(content).lines().enumerate() {
|
|
let raw =
|
|
line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?;
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"invalid JSONL diagnostic at line {}: {error}",
|
|
index + 1
|
|
))
|
|
})?;
|
|
diagnostics.push(parse_json_diagnostic(&value, index + 1)?);
|
|
}
|
|
|
|
Ok(diagnostics)
|
|
}
|
|
|
|
fn parse_json_diagnostic(value: &Value, line_number: usize) -> Result<Diagnostic, CliError> {
|
|
let Value::Object(object) = value else {
|
|
return Err(CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} must be an object"
|
|
)));
|
|
};
|
|
|
|
let path = object
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} missing path"
|
|
))
|
|
})?
|
|
.to_string();
|
|
let line = object
|
|
.get("line")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|value| usize::try_from(value).ok())
|
|
.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} missing line"
|
|
))
|
|
})?;
|
|
let column = object
|
|
.get("column")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|value| usize::try_from(value).ok())
|
|
.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} missing column"
|
|
))
|
|
})?;
|
|
let severity = object
|
|
.get("severity")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_severity)
|
|
.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} missing severity"
|
|
))
|
|
})?;
|
|
let message = object
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"JSON diagnostic at line {line_number} missing message"
|
|
))
|
|
})?
|
|
.to_string();
|
|
let code = object
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
let tool_hint = object
|
|
.get("tool_hint")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("jsonl")
|
|
.to_string();
|
|
|
|
Ok(Diagnostic {
|
|
path: normalize_path_string(&path),
|
|
line,
|
|
column,
|
|
severity,
|
|
code,
|
|
message,
|
|
tool_hint,
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
})
|
|
}
|
|
|
|
fn parse_text_diagnostics(content: &str) -> Vec<Diagnostic> {
|
|
let lines = content.lines().collect::<Vec<_>>();
|
|
let mut diagnostics = Vec::new();
|
|
let mut index = 0_usize;
|
|
|
|
while index < lines.len() {
|
|
let line = lines[index];
|
|
if let Some(diagnostic) = parse_csharp_diagnostic(line) {
|
|
diagnostics.push(diagnostic);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if let Some((severity, code, message)) = parse_rust_header(line) {
|
|
if let Some(location_line) = lines.get(index + 1) {
|
|
if let Some((path, line, column)) = parse_rust_location(location_line) {
|
|
diagnostics.push(Diagnostic {
|
|
path,
|
|
line,
|
|
column,
|
|
severity,
|
|
code,
|
|
message,
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let tool_hint = tool_hint_for_tool_diagnostic(line);
|
|
diagnostics.push(Diagnostic {
|
|
path: synthetic_tool_path(tool_hint),
|
|
line: 1,
|
|
column: 1,
|
|
severity,
|
|
code,
|
|
message,
|
|
tool_hint: tool_hint.to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
});
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
diagnostics
|
|
}
|
|
|
|
fn parse_rust_header(line: &str) -> Option<(Severity, Option<String>, String)> {
|
|
let (severity, trimmed) = [
|
|
(Severity::Error, "error"),
|
|
(Severity::Warning, "warning"),
|
|
(Severity::Note, "note"),
|
|
]
|
|
.into_iter()
|
|
.find_map(|(severity, prefix)| line.strip_prefix(prefix).map(|rest| (severity, rest)))?;
|
|
|
|
if let Some(code_and_rest) = trimmed.strip_prefix('[') {
|
|
let (code, message_with_colon) = code_and_rest.split_once("]:")?;
|
|
let message = message_with_colon.trim_start();
|
|
(!message.is_empty()).then(|| (severity, Some(code.to_string()), message.to_string()))
|
|
} else {
|
|
let message = trimmed.strip_prefix(':')?.trim_start();
|
|
(!message.is_empty()).then(|| (severity, None, message.to_string()))
|
|
}
|
|
}
|
|
|
|
fn parse_rust_location(line: &str) -> Option<(String, usize, usize)> {
|
|
let location = line.trim_start().strip_prefix("--> ")?;
|
|
parse_path_line_column(location)
|
|
}
|
|
|
|
fn parse_csharp_diagnostic(line: &str) -> Option<Diagnostic> {
|
|
let (severity, marker) = [
|
|
(Severity::Error, "): error "),
|
|
(Severity::Warning, "): warning "),
|
|
(Severity::Note, "): note "),
|
|
]
|
|
.into_iter()
|
|
.find_map(|(severity, marker)| line.find(marker).map(|index| (severity, (index, marker))))?;
|
|
let (marker_index, marker_text) = marker;
|
|
let location = &line[..marker_index];
|
|
let after_severity = &line[marker_index + marker_text.len()..];
|
|
let left_paren = location.rfind('(')?;
|
|
let path = normalize_path_string(&location[..left_paren]);
|
|
let coordinates = &location[left_paren + 1..];
|
|
let (line_number, column_number) = coordinates.split_once(',')?;
|
|
let line = parse_positive_usize(line_number)?;
|
|
let column = parse_positive_usize(column_number)?;
|
|
let (code, message) = after_severity.split_once(": ")?;
|
|
Some(Diagnostic {
|
|
path,
|
|
line,
|
|
column,
|
|
severity,
|
|
code: Some(code.to_string()),
|
|
message: message.to_string(),
|
|
tool_hint: "csharp".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
})
|
|
}
|
|
|
|
fn parse_severity(raw: &str) -> Option<Severity> {
|
|
match raw {
|
|
"error" => Some(Severity::Error),
|
|
"warning" => Some(Severity::Warning),
|
|
"note" => Some(Severity::Note),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn severity_matches(severity: Severity, filter: SeverityFilter) -> bool {
|
|
match filter {
|
|
SeverityFilter::All => true,
|
|
SeverityFilter::Error => severity == Severity::Error,
|
|
SeverityFilter::Warning => severity == Severity::Warning,
|
|
SeverityFilter::Note => severity == Severity::Note,
|
|
}
|
|
}
|
|
|
|
fn attach_context(
|
|
diagnostics: Vec<Diagnostic>,
|
|
context: usize,
|
|
with_snippet: bool,
|
|
with_definition: bool,
|
|
) -> Result<Vec<Diagnostic>, CliError> {
|
|
let mut cache = HashMap::<String, Option<String>>::new();
|
|
let mut definition_cache = HashMap::<String, Vec<IndexedSymbol>>::new();
|
|
let mut indexer = CodeIndexer::new();
|
|
diagnostics
|
|
.into_iter()
|
|
.map(|diagnostic| {
|
|
attach_source_cached(
|
|
diagnostic,
|
|
context,
|
|
with_snippet,
|
|
with_definition,
|
|
&mut cache,
|
|
&mut definition_cache,
|
|
&mut indexer,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn attach_source(mut diagnostic: Diagnostic, context: usize) -> Result<Diagnostic, CliError> {
|
|
let mut cache = HashMap::<String, Option<String>>::new();
|
|
let mut definition_cache = HashMap::<String, Vec<IndexedSymbol>>::new();
|
|
let mut indexer = CodeIndexer::new();
|
|
diagnostic = attach_source_cached(
|
|
diagnostic,
|
|
context,
|
|
true,
|
|
false,
|
|
&mut cache,
|
|
&mut definition_cache,
|
|
&mut indexer,
|
|
)?;
|
|
Ok(diagnostic)
|
|
}
|
|
|
|
fn attach_source_cached(
|
|
mut diagnostic: Diagnostic,
|
|
context: usize,
|
|
with_snippet: bool,
|
|
with_definition: bool,
|
|
cache: &mut HashMap<String, Option<String>>,
|
|
definition_cache: &mut HashMap<String, Vec<IndexedSymbol>>,
|
|
indexer: &mut CodeIndexer,
|
|
) -> Result<Diagnostic, CliError> {
|
|
if is_virtual_tool_path(&diagnostic.path) {
|
|
return Ok(diagnostic);
|
|
}
|
|
|
|
let source_text = match cache.entry(diagnostic.path.clone()) {
|
|
Entry::Occupied(entry) => entry.into_mut(),
|
|
Entry::Vacant(entry) => {
|
|
let path = Path::new(&diagnostic.path);
|
|
if path.exists() {
|
|
let source_text = fs::read_to_string(path).map_err(|error| {
|
|
CliError::runtime(format!("failed to read {}: {error}", diagnostic.path))
|
|
})?;
|
|
entry.insert(Some(source_text))
|
|
} else {
|
|
entry.insert(None)
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(source_text) = source_text {
|
|
let lines = source_text.lines().collect::<Vec<_>>();
|
|
let snippet = build_source_snippet(&lines, diagnostic.line, context);
|
|
if with_snippet || with_definition {
|
|
diagnostic.source.clone_from(&snippet);
|
|
diagnostic.snippet = snippet;
|
|
}
|
|
if with_definition && detect_language(Path::new(&diagnostic.path)).is_some() {
|
|
if !definition_cache.contains_key(&diagnostic.path) {
|
|
let cached_file = indexer.index_file(Path::new(&diagnostic.path), source_text)?;
|
|
definition_cache.insert(diagnostic.path.clone(), cached_file.symbols().to_vec());
|
|
}
|
|
diagnostic.enclosing_definition = definition_cache
|
|
.get(&diagnostic.path)
|
|
.and_then(|symbols| {
|
|
enclosing_symbol(symbols, diagnostic.line, Some(diagnostic.column))
|
|
})
|
|
.cloned();
|
|
}
|
|
} else if with_snippet || with_definition {
|
|
diagnostic.context_warning = Some(format!(
|
|
"source lookup failed for {}; rerun from the repo root or feed absolute paths",
|
|
diagnostic.path
|
|
));
|
|
}
|
|
|
|
Ok(diagnostic)
|
|
}
|
|
|
|
fn build_source_snippet(lines: &[&str], line: usize, context: usize) -> Option<SourceSnippet> {
|
|
if lines.is_empty() || line == 0 {
|
|
return None;
|
|
}
|
|
|
|
let start_line = line.saturating_sub(context).max(1);
|
|
let end_line = (line + context).min(lines.len());
|
|
(start_line <= end_line).then(|| SourceSnippet {
|
|
start_line,
|
|
end_line,
|
|
lines: lines[start_line - 1..end_line]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(offset, text)| SourceLine {
|
|
number: start_line + offset,
|
|
text: (*text).to_owned(),
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
fn parse_path_line_column(input: &str) -> Option<(String, usize, usize)> {
|
|
let mut parts = input.rsplitn(3, ':');
|
|
let column = parse_positive_usize(parts.next()?)?;
|
|
let line = parse_positive_usize(parts.next()?)?;
|
|
let path = normalize_path_string(parts.next()?);
|
|
Some((path, line, column))
|
|
}
|
|
|
|
fn parse_positive_usize(input: &str) -> Option<usize> {
|
|
let trimmed = input.trim();
|
|
(!trimmed.is_empty())
|
|
.then_some(trimmed)
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
}
|
|
|
|
fn normalize_path_string(path: &str) -> String {
|
|
let trimmed = path.strip_prefix(r"\\?\").unwrap_or(path);
|
|
if cfg!(windows) {
|
|
trimmed.replace('/', "\\")
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
fn tool_hint_for_tool_diagnostic(text: &str) -> &'static str {
|
|
if text.contains("package ID specification")
|
|
|| text.contains("did not match any packages")
|
|
|| text.contains("Cargo.toml")
|
|
|| text.contains("manifest path")
|
|
{
|
|
"cargo"
|
|
} else {
|
|
"tool"
|
|
}
|
|
}
|
|
|
|
fn synthetic_tool_path(tool_hint: &str) -> String {
|
|
format!("<{tool_hint}>")
|
|
}
|
|
|
|
fn is_virtual_tool_path(path: &str) -> bool {
|
|
path.starts_with('<') && path.ends_with('>')
|
|
}
|
|
|
|
fn render_text(diagnostics: &[Diagnostic]) -> String {
|
|
let mut rendered = String::new();
|
|
|
|
for (index, diagnostic) in diagnostics.iter().enumerate() {
|
|
if index > 0 {
|
|
rendered.push('\n');
|
|
}
|
|
writeln!(
|
|
rendered,
|
|
"severity={} code={} path={} line={} column={} tool={} message={}",
|
|
severity_label(diagnostic.severity),
|
|
diagnostic.code.as_deref().unwrap_or("-"),
|
|
diagnostic.path,
|
|
diagnostic.line,
|
|
diagnostic.column,
|
|
diagnostic.tool_hint,
|
|
diagnostic.message
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
|
|
if let Some(source) = diagnostic.snippet.as_ref().or(diagnostic.source.as_ref()) {
|
|
writeln!(
|
|
rendered,
|
|
"source_lines={}:{}",
|
|
source.start_line, source.end_line
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
for line in &source.lines {
|
|
writeln!(rendered, "{}: {}", line.number, line.text)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
}
|
|
if let Some(definition) = &diagnostic.enclosing_definition {
|
|
writeln!(
|
|
rendered,
|
|
"definition={} lines={}:{}",
|
|
definition.qualified_name, definition.start_line, definition.end_line
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
for (offset, line) in definition.text.lines().enumerate() {
|
|
writeln!(rendered, "{}: {}", definition.start_line + offset, line)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
}
|
|
if let Some(warning) = &diagnostic.context_warning {
|
|
writeln!(rendered, "context_warning={warning}")
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
}
|
|
|
|
rendered
|
|
}
|
|
|
|
const fn severity_label(severity: Severity) -> &'static str {
|
|
match severity {
|
|
Severity::Error => "error",
|
|
Severity::Warning => "warning",
|
|
Severity::Note => "note",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use common::ColorChoice;
|
|
use tempfile::tempdir;
|
|
|
|
use super::*;
|
|
|
|
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
|
|
CommonArgs {
|
|
json,
|
|
format: None,
|
|
input_format,
|
|
color: ColorChoice::Never,
|
|
quiet: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_rust_and_csharp_text_diagnostics() {
|
|
let rust = parse_text_diagnostics(
|
|
"error[E0425]: cannot find value `x` in this scope\n --> src/main.rs:12:7\n",
|
|
);
|
|
assert_eq!(rust.len(), 1);
|
|
assert_eq!(rust[0].code.as_deref(), Some("E0425"));
|
|
assert_eq!(rust[0].tool_hint, "rustc");
|
|
|
|
let csharp = parse_text_diagnostics(
|
|
"fixtures\\reading\\sample.cs(9,17): error CS0103: The name 'speeed' does not exist in the current context\n",
|
|
);
|
|
assert_eq!(csharp.len(), 1);
|
|
assert_eq!(csharp[0].code.as_deref(), Some("CS0103"));
|
|
assert_eq!(csharp[0].tool_hint, "csharp");
|
|
|
|
let windows_rust =
|
|
parse_text_diagnostics("warning: unused variable\n --> C:\\repo\\src\\main.rs:8:3\n");
|
|
assert_eq!(windows_rust.len(), 1);
|
|
assert_eq!(windows_rust[0].path, "C:\\repo\\src\\main.rs");
|
|
|
|
let windows_csharp = parse_text_diagnostics(
|
|
"C:\\repo\\Game\\Scripts\\Demo.cs(4,21): warning CS0168: The variable 'unusedValue' is declared but never used\n",
|
|
);
|
|
assert_eq!(windows_csharp.len(), 1);
|
|
assert_eq!(windows_csharp[0].path, "C:\\repo\\Game\\Scripts\\Demo.cs");
|
|
}
|
|
|
|
#[test]
|
|
fn jsonl_parsing_and_source_attachment_cover_helpers() {
|
|
let jsonl = parse_input(
|
|
"{\"path\":\"demo.rs\",\"line\":4,\"column\":2,\"severity\":\"warning\",\"message\":\"unused\",\"tool_hint\":\"rustc\"}\n",
|
|
InputFormat::Jsonl,
|
|
)
|
|
.expect("jsonl diagnostics");
|
|
assert_eq!(jsonl.len(), 1);
|
|
assert_eq!(jsonl[0].severity, Severity::Warning);
|
|
assert!(severity_matches(Severity::Warning, SeverityFilter::All));
|
|
assert!(!severity_matches(Severity::Note, SeverityFilter::Error));
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
let source_path = temp.path().join("demo.rs");
|
|
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture");
|
|
let attached = attach_source(
|
|
Diagnostic {
|
|
path: source_path.display().to_string(),
|
|
line: 2,
|
|
column: 1,
|
|
severity: Severity::Warning,
|
|
code: None,
|
|
message: "unused".to_string(),
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
},
|
|
1,
|
|
)
|
|
.expect("source attach");
|
|
assert_eq!(
|
|
attached.source.as_ref().map(|source| source.start_line),
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn run_reports_usage_errors_without_input() {
|
|
let error = run(&Cli {
|
|
common: common_args(false, InputFormat::Auto),
|
|
with_snippet: false,
|
|
with_definition: false,
|
|
context: 1,
|
|
limit: None,
|
|
severity: SeverityFilter::All,
|
|
allow_empty: false,
|
|
paths: Vec::new(),
|
|
})
|
|
.expect_err("missing input should fail");
|
|
assert!(matches!(
|
|
error,
|
|
CliError::Usage(message)
|
|
if message.contains("provide at least one log path")
|
|
));
|
|
|
|
let rendered = render_text(&[Diagnostic {
|
|
path: "demo.rs".to_string(),
|
|
line: 4,
|
|
column: 2,
|
|
severity: Severity::Error,
|
|
code: Some("E1".to_string()),
|
|
message: "boom".to_string(),
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
}]);
|
|
assert!(rendered.contains("severity=error"));
|
|
}
|
|
|
|
#[test]
|
|
fn json_and_text_parsers_cover_error_and_note_paths() {
|
|
let note = parse_text_diagnostics("note: try a different value\n --> src/main.rs:8:3\n");
|
|
assert_eq!(note.len(), 1);
|
|
assert_eq!(note[0].severity, Severity::Note);
|
|
assert_eq!(severity_label(Severity::Note), "note");
|
|
assert_eq!(parse_severity("boom"), None);
|
|
|
|
let invalid_json = parse_input("{\"path\":\"demo.rs\"}\n", InputFormat::Jsonl)
|
|
.expect_err("missing fields should fail");
|
|
assert!(matches!(
|
|
invalid_json,
|
|
CliError::Runtime(message) if message.contains("missing line")
|
|
));
|
|
|
|
let non_object =
|
|
parse_json_diagnostic(&serde_json::json!("demo"), 4).expect_err("string should fail");
|
|
assert!(matches!(
|
|
non_object,
|
|
CliError::Runtime(message) if message.contains("must be an object")
|
|
));
|
|
|
|
let invalid_capture =
|
|
parse_text_diagnostics("demo.cs(9,nope): error CS0103: Missing column value\n");
|
|
assert!(invalid_capture.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn attach_source_render_text_and_run_cover_more_branches() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let source_path = temp.path().join("demo.rs");
|
|
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("fixture");
|
|
|
|
let attached = attach_source(
|
|
Diagnostic {
|
|
path: source_path.display().to_string(),
|
|
line: 2,
|
|
column: 1,
|
|
severity: Severity::Warning,
|
|
code: Some("W1".to_string()),
|
|
message: "unused".to_string(),
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
},
|
|
1,
|
|
)
|
|
.expect("source attach");
|
|
let rendered = render_text(std::slice::from_ref(&attached));
|
|
assert!(rendered.contains("source_lines=1:2"));
|
|
assert!(rendered.contains("2: let value = 1;"));
|
|
|
|
let missing = attach_source(
|
|
Diagnostic {
|
|
path: temp.path().join("missing.rs").display().to_string(),
|
|
line: 1,
|
|
column: 1,
|
|
severity: Severity::Error,
|
|
code: None,
|
|
message: "missing".to_string(),
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
},
|
|
2,
|
|
)
|
|
.expect("missing path should be ignored");
|
|
assert!(missing.source.is_none());
|
|
assert!(missing.context_warning.is_some());
|
|
|
|
let log_path = temp.path().join("diag.log");
|
|
fs::write(
|
|
&log_path,
|
|
"warning: unused variable\n --> src/main.rs:8:3\n",
|
|
)
|
|
.expect("log");
|
|
let exit = run(&Cli {
|
|
common: common_args(false, InputFormat::Auto),
|
|
with_snippet: false,
|
|
with_definition: false,
|
|
context: 1,
|
|
limit: Some(1),
|
|
severity: SeverityFilter::Error,
|
|
allow_empty: false,
|
|
paths: vec![log_path],
|
|
})
|
|
.expect("warning filtered out");
|
|
assert_eq!(exit, ExitCode::NoResults);
|
|
|
|
let empty_render = render_text(&[]);
|
|
assert!(empty_render.is_empty());
|
|
assert!(empty_diagnostics_message(false).contains("logshape"));
|
|
assert!(empty_diagnostics_message(false).contains("fileprobe"));
|
|
assert!(empty_diagnostics_message(false).contains("--allow-empty"));
|
|
assert!(!empty_diagnostics_message(true).contains("--allow-empty"));
|
|
}
|
|
|
|
#[test]
|
|
fn allow_empty_promotes_filtered_zero_results_to_success() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let log_path = temp.path().join("diag.log");
|
|
fs::write(
|
|
&log_path,
|
|
"warning: unused variable\n --> src/main.rs:8:3\n",
|
|
)
|
|
.expect("log");
|
|
let exit = run(&Cli {
|
|
common: common_args(false, InputFormat::Auto),
|
|
with_snippet: false,
|
|
with_definition: false,
|
|
context: 1,
|
|
limit: None,
|
|
severity: SeverityFilter::Error,
|
|
allow_empty: true,
|
|
paths: vec![log_path],
|
|
})
|
|
.expect("allow empty");
|
|
assert_eq!(exit, ExitCode::Success);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_rejects_zero_limit() {
|
|
let error = parse_cli_from(["diagpick", "--limit", "0", "demo.log"])
|
|
.expect_err("zero limit should fail");
|
|
|
|
assert!(matches!(
|
|
error,
|
|
CliError::Usage(message) if message.contains("--limit must be greater than 0")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn run_applies_limit_before_source_context_attachment() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let source = temp.path().join("first.rs");
|
|
let unreadable = temp.path().join("second.rs");
|
|
fs::write(&source, "fn first() {}\n").expect("source");
|
|
fs::create_dir(&unreadable).expect("unreadable directory");
|
|
let log_path = temp.path().join("diag.log");
|
|
fs::write(
|
|
&log_path,
|
|
format!(
|
|
"error: first\n --> {}:1:1\nerror: second\n --> {}:1:1\n",
|
|
source.display(),
|
|
unreadable.display()
|
|
),
|
|
)
|
|
.expect("log");
|
|
|
|
let exit = run(&Cli {
|
|
common: common_args(false, InputFormat::Auto),
|
|
with_snippet: true,
|
|
with_definition: false,
|
|
context: 1,
|
|
limit: Some(1),
|
|
severity: SeverityFilter::All,
|
|
allow_empty: false,
|
|
paths: vec![log_path],
|
|
})
|
|
.expect("limited source attachment");
|
|
|
|
assert_eq!(exit, ExitCode::Success);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_accepts_aliases_and_jsonl_shortcut() {
|
|
let (_, cli) = parse_cli_from([
|
|
"diagpick",
|
|
"--with-snippet",
|
|
"--with-definition",
|
|
"--jsonl",
|
|
"demo.log",
|
|
])
|
|
.expect("cli");
|
|
assert!(cli.with_snippet);
|
|
assert!(cli.with_definition);
|
|
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
|
|
assert_eq!(cli.paths, vec![PathBuf::from("demo.log")]);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_relative_paths_walks_log_ancestors() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let repo_root = temp.path().join("repo");
|
|
let source_path = repo_root.join("src").join("main.rs");
|
|
fs::create_dir_all(source_path.parent().expect("parent")).expect("src dir");
|
|
fs::create_dir_all(repo_root.join("logs")).expect("logs dir");
|
|
fs::write(&source_path, "fn main() {}\n").expect("source");
|
|
let log_path = repo_root.join("logs").join("build.log");
|
|
fs::write(&log_path, "warning: demo\n --> src/main.rs:1:1\n").expect("log");
|
|
|
|
let mut diagnostics = vec![Diagnostic {
|
|
path: "src/main.rs".to_string(),
|
|
line: 1,
|
|
column: 1,
|
|
severity: Severity::Warning,
|
|
code: None,
|
|
message: "demo".to_string(),
|
|
tool_hint: "rustc".to_string(),
|
|
source: None,
|
|
snippet: None,
|
|
enclosing_definition: None,
|
|
context_warning: None,
|
|
}];
|
|
resolve_relative_paths(&mut diagnostics, &log_path);
|
|
assert_eq!(PathBuf::from(&diagnostics[0].path), source_path);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_fallback_and_windows_path_normalization_are_stable() {
|
|
let parsed = parse_text_diagnostics(
|
|
"error: package ID specification `missing-crate` did not match any packages\n",
|
|
);
|
|
assert_eq!(parsed.len(), 1);
|
|
assert_eq!(parsed[0].tool_hint, "cargo");
|
|
assert_eq!(parsed[0].path, "<cargo>");
|
|
assert_eq!(parsed[0].line, 1);
|
|
assert_eq!(parsed[0].column, 1);
|
|
|
|
let normalized = normalize_path_string(r"\\?\C:/repo/src/main.rs");
|
|
if cfg!(windows) {
|
|
assert_eq!(normalized, r"C:\repo\src\main.rs");
|
|
} else {
|
|
assert_eq!(normalized, "C:/repo/src/main.rs");
|
|
}
|
|
}
|
|
}
|