diff --git a/crates/wright-analyzer/src/analysis.rs b/crates/wright-analyzer/src/analysis.rs index 09e36ea2..b527b273 100644 --- a/crates/wright-analyzer/src/analysis.rs +++ b/crates/wright-analyzer/src/analysis.rs @@ -1,15 +1,25 @@ use serde::{Deserialize, Serialize}; -/// The severity of a finding. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// The severity of a finding, ordered from least to most severe. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Severity { - Warning, Info, + Warning, Error, } impl Severity { + /// Reads a serialized severity label. `notice` is an alias of `info`; a + /// missing or unknown label is a warning. + pub fn from_label(label: Option<&str>) -> Self { + match label { + Some("error") => Self::Error, + Some("info" | "notice") => Self::Info, + _ => Self::Warning, + } + } + pub fn as_str(self) -> &'static str { match self { Self::Error => "error", diff --git a/crates/wright-analyzer/src/canonical/service.rs b/crates/wright-analyzer/src/canonical/service.rs index adae9dc8..c8b2d428 100644 --- a/crates/wright-analyzer/src/canonical/service.rs +++ b/crates/wright-analyzer/src/canonical/service.rs @@ -145,6 +145,10 @@ impl<'a> SemanticService<'a> { &self.findings } + pub fn symbol_count(&self) -> usize { + self.index.symbols().count() + } + #[hotpath::measure] pub fn references_for_all_symbols(&self) -> JsonValue { JsonValue::Array(self.references_json().clone()) diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index 5d9946df..8ac3e955 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -153,6 +153,8 @@ pub enum PositionResolution<'a> { pub struct SemanticIndex { symbols: Vec, references: Vec, + /// Positions in `references`, grouped by referenced symbol index. + by_symbol: Vec>, next_value_id: ValueId, } @@ -227,6 +229,7 @@ impl SemanticIndex { let mut index = Self { symbols, references: Vec::new(), + by_symbol: Vec::new(), next_value_id: 0, }; index.add_missing_declarations(); @@ -245,6 +248,17 @@ impl SemanticIndex { index.walk_action(value, rule, action, program); } } + let groups = index + .references + .iter() + .map(|reference| reference.symbol.index() + 1) + .max() + .unwrap_or(0) + .max(index.symbols.len()); + index.by_symbol = vec![Vec::new(); groups]; + for (position, reference) in index.references.iter().enumerate() { + index.by_symbol[reference.symbol.index()].push(position); + } index } @@ -255,10 +269,14 @@ impl SemanticIndex { self.symbols.get(id.index()) } pub fn references(&self, symbol: SymbolId) -> Vec<&Reference> { - self.references - .iter() - .filter(|reference| reference.symbol == symbol) - .collect() + self.by_symbol + .get(symbol.index()) + .map_or_else(Vec::new, |positions| { + positions + .iter() + .map(|&position| &self.references[position]) + .collect() + }) } /// Resolve a 1-based `line`/`col` to the symbol whose identifier @@ -290,15 +308,10 @@ impl SemanticIndex { } } pub(super) fn references_for_all_symbols(&self) -> Vec> { - let mut grouped = (0..self.symbols.len()) - .map(|_| Vec::new()) - .collect::>(); - for reference in &self.references { - if let Some(references) = grouped.get_mut(reference.symbol.index()) { - references.push(reference); - } - } - grouped + self.symbols + .iter() + .map(|symbol| self.references(symbol.id)) + .collect() } pub fn usage(&self, symbol: SymbolId) -> UsageSummary { let mut usage = UsageSummary::default(); diff --git a/crates/wright-cli/src/lint/report.rs b/crates/wright-cli/src/lint/report.rs index e13ba50d..831a3f03 100644 --- a/crates/wright-cli/src/lint/report.rs +++ b/crates/wright-cli/src/lint/report.rs @@ -58,7 +58,6 @@ impl ResultPresentation for LintPackageResult { fn metadata(&self) -> Option { Some(metadata(self)) } - fn render_body(&self, _ctx: &RenderContext<'_>) {} } fn package_record(row: &LintPackageRow) -> String { diff --git a/crates/wright-cli/src/plain.rs b/crates/wright-cli/src/plain.rs index 531c12f8..12df46d3 100644 --- a/crates/wright-cli/src/plain.rs +++ b/crates/wright-cli/src/plain.rs @@ -14,20 +14,20 @@ use crate::present::{ diagnostics_on_stdout, display_location_path, edge_caller, finding_files, finding_severity_rank, findings_severity_counts, fix_status_counts, inline_location, is_real_source_path, lookup_hint_args, notable_callers, query_items, render_node, rule_address, - rule_name, severity_rank, summary_status, tree_rule, + rule_name, shown_diagnostics, summary_status, tree_rule, }; pub(crate) fn render( envelope: &Envelope, ctx: &RenderContext<'_>, ) { - if envelope.result.is_artifact_stream() { + if let Some(artifact) = envelope.result.artifact() { let mut err = Vec::new(); diagnostic_records(envelope, ctx, &mut err); for line in err { eprintln!("{line}"); } - envelope.result.render_body(ctx); + print!("{artifact}"); return; } let (out, err) = render_lines(envelope, ctx); @@ -126,19 +126,15 @@ fn diagnostic_records( ctx: &RenderContext<'_>, err: &mut Vec, ) { - let mut ordered: Vec<&Diagnostic> = envelope.diagnostics.iter().collect(); - ordered.sort_by_key(|diagnostic| severity_rank(diagnostic.severity)); - let total = ordered.len(); - let bounded = !matches!(envelope.command.as_str(), "compile" | "convert"); - let limit = if ctx.presentation.all || !bounded { - usize::MAX - } else { - 20 - }; - for diagnostic in ordered.iter().take(limit) { + let (shown, withheld) = shown_diagnostics( + &envelope.command, + &envelope.diagnostics, + ctx.presentation.all, + ); + for diagnostic in shown { err.push(diagnostic_record(diagnostic)); } - if bounded && !ctx.presentation.all && total > 20 { + if let Some(total) = withheld { err.push(truncated_record( &[("diagnostics", 20, total)], &expand_all(envelope), @@ -149,7 +145,11 @@ fn diagnostic_records( .as_ref() .filter(|selection| selection.total > 0) { - let mut fields = vec![format!("diagnostics={total}/{}", selection.total)]; + let mut fields = vec![format!( + "diagnostics={}/{}", + envelope.diagnostics.len(), + selection.total + )]; if let Some(by_code) = by_code_field(&selection.by_code) { fields.push(by_code); } diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 26d9d4cb..9de9ab47 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -28,7 +28,7 @@ pub(crate) use common::{ DETAIL_COMMANDS, block_flags, call_fan_counts, cfg_block_stats, cfg_edge_count, diagnostic_counts, diagnostic_files, finding_files, findings_severity_counts, fix_status_counts, fix_status_parts, group_runs, lookup_hint_args, normalize_finding_spans, - notable_callers, shown_of, span_suffix, + notable_callers, shown_diagnostics, shown_of, span_suffix, }; use crate::plain; @@ -386,18 +386,6 @@ fn dim(value: &str, color: bool) -> String { } } -struct Marks { - head: String, - pad: String, -} - -fn marks() -> Marks { - Marks { - head: " ".to_string(), - pad: " ".to_string(), - } -} - struct GithubAnnotation { kind: &'static str, props: Vec, @@ -480,8 +468,8 @@ fn render_github( "::group::{}", escape_workflow_data(&format!("wright {} — {status}{headline}", envelope.command)) ); - if envelope.result.is_artifact_stream() { - envelope.result.render_body(&github_context(ctx.subject)); + if let Some(artifact) = envelope.result.artifact() { + print!("{artifact}"); } else { let (out, err) = plain::render_lines(envelope, ctx); for line in out.iter().chain(err.iter()) { @@ -500,22 +488,6 @@ fn render_github( emit_summary(envelope, ctx, &annotations); } -fn github_context(subject: Option<&str>) -> RenderContext<'_> { - RenderContext { - presentation: Presentation { - format: OutputFormat::Text, - renderer: Renderer::GithubActions, - color: false, - all: false, - interactive: false, - width: MAX_WIDTH, - }, - elapsed: Duration::ZERO, - source_base: None, - subject, - } -} - fn diagnostic_annotation(diagnostic: &wright_driver::Diagnostic) -> Option { let kind = match diagnostic.severity { Severity::Error => "error", @@ -648,20 +620,8 @@ impl From for SummaryStatus { } } -fn severity_level(severity: Severity) -> u8 { - match severity { - Severity::Info => 0, - Severity::Warning => 1, - Severity::Error => 2, - } -} - fn finding_severity(finding: &serde_json::Value) -> Severity { - match finding.get("severity").and_then(serde_json::Value::as_str) { - Some("error") => Severity::Error, - Some("info") | Some("notice") => Severity::Info, - _ => Severity::Warning, - } + Severity::from_label(finding.get("severity").and_then(serde_json::Value::as_str)) } fn max_finding_severity( @@ -675,7 +635,7 @@ fn max_finding_severity( .map_or(&[][..], Vec::as_slice) .iter() .map(finding_severity) - .max_by_key(|severity| severity_level(*severity)), + .max(), } } @@ -683,9 +643,9 @@ pub(crate) trait ResultPresentation { fn metadata(&self) -> Option { None } - fn render_body(&self, ctx: &RenderContext<'_>); - fn is_artifact_stream(&self) -> bool { - false + /// Text the command streams to stdout in place of a report. + fn artifact(&self) -> Option<&str> { + None } fn github_findings(&self) -> Vec { Vec::new() @@ -708,28 +668,21 @@ impl ResultPresentation for CompileResult { output.written_to, output.sha256 )) } - fn is_artifact_stream(&self) -> bool { + fn artifact(&self) -> Option<&str> { self.output .as_ref() - .is_some_and(|o| o.written_to == "stdout") - } - fn render_body(&self, _ctx: &RenderContext<'_>) { - render_compile(self); + .filter(|output| output.written_to == "stdout") + .map(|output| output.text.as_str()) } } impl ResultPresentation for ConvertResult { - fn is_artifact_stream(&self) -> bool { - true - } - fn render_body(&self, _ctx: &RenderContext<'_>) { - render_convert(self); + fn artifact(&self) -> Option<&str> { + Some(&self.text) } } -impl ResultPresentation for CheckResult { - fn render_body(&self, _ctx: &RenderContext<'_>) {} -} +impl ResultPresentation for CheckResult {} impl ResultPresentation for LintResult { fn metadata(&self) -> Option { @@ -767,10 +720,6 @@ impl ResultPresentation for LintResult { } Some(metadata) } - fn render_body(&self, ctx: &RenderContext<'_>) { - render_lint(self, ctx); - render_lint_fixes(self, ctx); - } fn github_findings(&self) -> Vec { self.findings.as_array().cloned().unwrap_or_default() } @@ -840,9 +789,6 @@ impl ResultPresentation for EssentialResult { essential_metadata(&self.result) }) } - fn render_body(&self, ctx: &RenderContext<'_>) { - render_essential(self, ctx); - } fn max_finding_severity(&self) -> Option { if let Some(selection) = &self.selection { return selection.max_severity; @@ -860,7 +806,7 @@ impl ResultPresentation for EssentialResult { .find(|(label, _)| group[label].as_u64().unwrap_or(0) > 0) .map(|(_, severity)| severity) }) - .max_by_key(|severity| severity_level(*severity)) + .max() } } @@ -892,46 +838,10 @@ pub(crate) fn query_items<'a>(value: &'a serde_json::Value, key: &str) -> &'a [s .map_or(&[][..], Vec::as_slice) } -fn print_query_withheld(value: &serde_json::Value, noun: &str) { - print_truncation(value); - if let Some(withheld) = value["selection"]["withheld"] - .as_u64() - .filter(|withheld| *withheld > 0) - { - println!(" ... {withheld} {noun} withheld (--max)"); - } -} - impl ResultPresentation for RulesResult { fn metadata(&self) -> Option { Some(format!("{} rule(s)", query_items(&self.0, "rules").len())) } - fn render_body(&self, _ctx: &RenderContext<'_>) { - let rules = query_items(&self.0, "rules"); - println!("\nRules"); - if rules.is_empty() { - println!(" none"); - return; - } - for rule in rules { - let marks = marks(); - let name = rule_name(rule); - let event = rule["event"].as_str().unwrap_or("?"); - println!( - "{}{name} [{event}]{}", - marks.head, - span_suffix(rule, " --> ") - ); - let content = &rule["content"]; - for condition in content["conditions"].as_array().into_iter().flatten() { - println!("{}if {}", marks.pad, render_node(&condition["value"])); - } - for action in content["actions"].as_array().into_iter().flatten() { - println!("{}{}", marks.pad, render_node(action)); - } - } - print_query_withheld(&self.0, "rule(s)"); - } } pub(crate) fn render_node(node: &serde_json::Value) -> String { @@ -960,33 +870,6 @@ impl ResultPresentation for SymbolsResult { query_items(&self.0, "symbols").len() )) } - fn render_body(&self, _ctx: &RenderContext<'_>) { - let symbols = query_items(&self.0, "symbols"); - println!("\nSymbols"); - if symbols.is_empty() { - println!(" none"); - return; - } - let mut groups: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new(); - for symbol in symbols { - let kind = symbol["kind"].as_str().unwrap_or("symbol"); - match groups.iter_mut().find(|(k, _)| *k == kind) { - Some((_, members)) => members.push(symbol), - None => groups.push((kind, vec![symbol])), - } - } - for (kind, members) in groups { - println!(" {} ({})", symbol_kind_heading(kind), members.len()); - for symbol in members.iter() { - let name = symbol["name"] - .as_str() - .filter(|name| !name.is_empty()) - .unwrap_or(""); - println!(" {name}{}", span_suffix(symbol, " --> ")); - } - } - print_query_withheld(&self.0, "symbol(s)"); - } } impl ResultPresentation for RefsResult { @@ -1001,38 +884,6 @@ impl ResultPresentation for RefsResult { count(&self.0, "rules"), )) } - fn render_body(&self, _ctx: &RenderContext<'_>) { - let references = self.0["references"] - .as_array() - .map_or(&[][..], Vec::as_slice); - println!( - "\nReferences to {} ({})", - self.0["symbol"].as_str().unwrap_or(""), - self.0["kind"].as_str().unwrap_or("symbol"), - ); - if references.is_empty() { - println!(" none"); - return; - } - for reference in references.iter() { - let owner = rule_address(&reference["owningRule"]) - .map(String::from) - .or_else(|| reference["rule"].as_u64().map(|rule| rule.to_string())); - let context = match (owner, reference["action"].as_u64()) { - (Some(rule), Some(action)) => format!(" (rule {rule}, action {action})"), - (Some(rule), None) => format!(" (rule {rule})"), - _ => String::new(), - }; - let location = span_suffix(reference, " --> "); - println!( - " {}{}{}", - reference["kind"].as_str().unwrap_or("reference"), - location, - context - ); - } - print_query_withheld(&self.0, "reference(s)"); - } } impl ResultPresentation for CfgResult { @@ -1044,110 +895,12 @@ impl ResultPresentation for CfgResult { cfg_edge_count(blocks) )) } - fn render_body(&self, ctx: &RenderContext<'_>) { - let blocks = self.0["blocks"].as_array().map_or(&[][..], Vec::as_slice); - match ctx.subject { - Some(rule) => println!("\nControl-flow graph of rule \"{rule}\""), - None => println!("\nControl-flow graph"), - } - if blocks.is_empty() { - println!(" none"); - return; - } - let (loops, waits, calls) = cfg_block_stats(blocks); - println!( - " {} block(s), {} edge(s): {} loop header(s), {} wait block(s), {} call site(s)", - blocks.len(), - cfg_edge_count(blocks), - loops, - waits, - calls - ); - println!( - " entry: block {}, exit: block {}", - self.0["entry"].as_u64().unwrap_or(0), - self.0["exit"].as_u64().unwrap_or(0) - ); - for block in blocks { - let marks = marks(); - let flags = block_flags(block); - let flags = if flags.is_empty() { - String::new() - } else { - format!(" [{}]", flags.join(" ")) - }; - println!( - "{}block {} ({}): {} action(s){}", - marks.head, - block["id"].as_u64().unwrap_or(0), - block["kind"].as_str().unwrap_or("block"), - array_len(&block["actions"]), - flags - ); - if let Some(successors) = block["successors"] - .as_array() - .filter(|successors| !successors.is_empty()) - { - let successors = successors - .iter() - .map(|successor| { - format!( - "{} ({})", - successor["to"].as_u64().unwrap_or(0), - successor["kind"].as_str().unwrap_or("edge") - ) - }) - .collect::>() - .join(", "); - println!("{}-> {successors}", marks.pad); - } - } - print_query_withheld(&self.0, "block(s)"); - } } impl ResultPresentation for CallGraphResult { fn metadata(&self) -> Option { Some(format!("{} edge(s)", query_items(&self.0, "edges").len())) } - fn render_body(&self, _ctx: &RenderContext<'_>) { - let edges = query_items(&self.0, "edges"); - println!("\nCall graph"); - if edges.is_empty() { - println!(" none"); - return; - } - let (fan_in, fan_out) = call_fan_counts(edges); - println!( - " {} edge(s): {} calling rule(s), {} subroutine(s) called", - edges.len(), - fan_out.len(), - fan_in.len() - ); - let most_called = notable_callers(&fan_in); - if !most_called.is_empty() { - println!("\n Most-called subroutines"); - for (name, n) in most_called.iter() { - println!(" {name}: {n} caller(s)"); - } - } - let busiest = notable_callers(&fan_out); - if !busiest.is_empty() { - println!("\n Busiest callers"); - for (name, n) in busiest.iter() { - println!(" {name}: calls {n} subroutine(s)"); - } - } - println!("\n Edges"); - for edge in edges.iter() { - println!( - " {} -> {}", - edge_caller(edge), - edge["callee"].as_str().unwrap_or("") - ); - } - print_query_withheld(&self.0, "edge(s)"); - } } impl ResultPresentation for CostResult { @@ -1171,98 +924,6 @@ impl ResultPresentation for CostResult { count(exact, "waitActions"), )) } - fn render_body(&self, _ctx: &RenderContext<'_>) { - if self.result.get("rule").is_some_and(|rule| rule.is_object()) { - render_cost_rule_body(&self.result); - return; - } - let exact = &self.result["exact"]; - println!("\nGenerated resources (exact)"); - match exact["emittedBytes"].as_u64() { - Some(bytes) => println!(" emitted bytes: {bytes}"), - None => println!( - " emitted bytes: unavailable — {}", - exact["emittedBytesUnavailable"] - .as_str() - .unwrap_or("the emitter does not model this program") - ), - } - println!(" actions: {}", count(exact, "programActions")); - println!(" rules: {}", count(exact, "programRules")); - println!(" waits: {}", count(exact, "waitActions")); - - let elements = &self.result["elementCount"]; - if let Some(total) = elements["total"].as_u64() { - println!( - " {total} element(s) of the {}-element budget [exact] — canonical structure, not a statement of client importability", - count(elements, "budget"), - ); - for rule in elements["rules"].as_array().into_iter().flatten() { - println!( - " {} element(s) {}{}", - count(rule, "count"), - rule_name(rule), - span_suffix(rule, " "), - ); - } - if let Some(note) = elements["truncation"]["rules"].as_object() { - println!( - " showing {} of {}; --all to see every item", - note["shown"], note["total"] - ); - } - } else { - let reason = self.result["elementCountUnavailable"] - .as_str() - .unwrap_or("counter does not model this program"); - match elements["budget"].as_u64() { - Some(budget) => println!( - " element count unavailable: {reason} — the {budget}-element budget still applies" - ), - None => println!(" element count unavailable: {reason}"), - } - } - - let mut findings = self.result["findings"] - .as_array() - .cloned() - .unwrap_or_default(); - findings.sort_by_key(|finding| match finding["severity"].as_str() { - Some("error") => 0u8, - Some("warning") => 1, - _ => 2, - }); - println!("\nStatic findings (evidence: static)"); - if findings.is_empty() { - println!(" none"); - } - for group in group_runs(&findings) { - let first = &group[0]; - let severity = first["severity"].as_str().unwrap_or("info"); - let code = first["code"].as_str().unwrap_or("finding"); - let message = first["message"].as_str().unwrap_or_default(); - match group.len() { - 1 => println!(" {severity}[{code}]: {message}"), - count => println!(" {severity}[{code}] ({count} occurrences): {message}"), - } - for finding in group.iter() { - if let Some(location) = finding.get("span").and_then(inline_location) { - println!(" --> {location}"); - } - } - } - print_truncation(&self.result); - if let Some(withheld) = self.result["selection"]["withheld"] - .as_u64() - .filter(|withheld| *withheld > 0) - { - println!(" ... {withheld} finding(s) withheld (--max)"); - } - let skipped = array_len(&self.result["skipped"]); - if skipped > 0 { - println!(" {skipped} rule evaluation(s) skipped"); - } - } fn github_findings(&self) -> Vec { self.result["findings"] .as_array() @@ -1274,61 +935,6 @@ impl ResultPresentation for CostResult { } } -fn render_cost_rule_body(result: &serde_json::Value) { - let rule = &result["rule"]; - let marks = marks(); - println!("\nElement cost"); - println!( - "{}{} · {} element(s){}", - marks.head, - rule_name(rule), - count(rule, "count"), - span_suffix(rule, " · "), - ); - let statements = result["statements"] - .as_array() - .map_or(&[][..], Vec::as_slice); - let by_node = result["byNode"].as_array().map_or(&[][..], Vec::as_slice); - println!("\nCostliest statements"); - if statements.is_empty() { - println!(" none"); - } - for statement in statements { - println!( - "{}{} element(s) {}{}", - marks.head, - count(statement, "count"), - statement["name"].as_str().unwrap_or("?"), - span_suffix(statement, " "), - ); - } - if let Some((shown, total)) = shown_of(result, "statements") { - println!(" showing {shown} of {total}; --all to see every item"); - } - println!("\nBy node name"); - if by_node.is_empty() { - println!(" none"); - } - for entry in by_node { - let occurrences = entry["occurrences"].as_u64().unwrap_or(1); - let times = if occurrences > 1 { - format!(" ×{occurrences}") - } else { - String::new() - }; - println!( - "{}{} element(s) {}{}", - marks.head, - count(entry, "count"), - entry["name"].as_str().unwrap_or("?"), - times, - ); - } - if let Some((shown, total)) = shown_of(result, "byNode") { - println!(" showing {shown} of {total}; --all to see every item"); - } -} - pub(crate) fn gate( envelope: &mut Envelope, threshold: Severity, @@ -1336,8 +942,7 @@ pub(crate) fn gate( if envelope.exit != wright_driver::result::exit::SUCCESS { return; } - let met = max_envelope_severity(envelope) - .is_some_and(|severity| severity_level(severity) >= severity_level(threshold)); + let met = max_envelope_severity(envelope).is_some_and(|severity| severity >= threshold); if met { envelope.ok = false; envelope.exit = wright_driver::result::exit::SOURCE_ERROR; @@ -1354,12 +959,12 @@ fn max_envelope_severity( .diagnostics .iter() .map(|diagnostic| diagnostic.severity) - .max_by_key(|severity| severity_level(*severity)), + .max(), }; diagnostics .into_iter() .chain(envelope.result.max_finding_severity()) - .max_by_key(|severity| severity_level(*severity)) + .max() } pub(crate) fn summary_status( @@ -1444,7 +1049,7 @@ fn emit_summary( if !annotations.is_empty() { section.push('\n'); } - if envelope.result.is_artifact_stream() { + if envelope.result.artifact().is_some() { section.push_str("The command's artifact streamed to stdout.\n"); } else { let (out, err) = plain::render_lines(envelope, ctx); @@ -1527,20 +1132,6 @@ fn span_position(span: &serde_json::Value, edge: &str, coord: &str) -> Option String { format!("{} [static]", notes.join("; ")) } -fn lint_footer_parts(files: usize, skipped: usize, ctx: &RenderContext<'_>) -> Vec { - let mut parts = Vec::new(); - if files > 0 { - parts.push(format!("{files} file(s) affected")); - } - if skipped > 0 { - parts.push(format!("{skipped} rule evaluation(s) skipped")); - } - if ctx.presentation.interactive { - parts.push(format!("{} ms", ctx.elapsed.as_millis())); - } - parts -} - pub(crate) fn finding_severity_rank(finding: &serde_json::Value) -> u8 { - match finding.get("severity").and_then(serde_json::Value::as_str) { - Some("error") => 0, - Some("info") | Some("notice") => 2, - _ => 1, - } -} - -fn render_lint_fixes(result: &LintResult, ctx: &RenderContext<'_>) { - let Some(fixes) = &result.fixes else { - return; - }; - println!("\nLint fixes"); - if fixes.is_empty() { - println!(" none offered"); - return; - } - for fix in fixes { - let marks = marks(); - let status = match fix.status { - wright_driver::result::LintFixStatus::Preview => "fix", - wright_driver::result::LintFixStatus::Applied => "applied", - wright_driver::result::LintFixStatus::Refused => "refused", - }; - println!("{}{status}[{}]: {}", marks.head, fix.code, fix.summary); - if let Some(span) = fix.span.as_ref().filter(|span| span.is_object()) { - render_finding_location(span, ctx, &marks.pad, false); - } - for preview in fix.preview.iter().flatten() { - println!("{}{}", marks.pad, preview.source); - render_diff_lines( - &preview.original, - &preview.new_text, - &format!("{} ", marks.pad), - ); - } - for diagnostic in &fix.diagnostics { - println!( - "{}refused — {}: {}", - marks.pad, diagnostic.code, diagnostic.message - ); - } - } -} - -fn render_lint(result: &LintResult, ctx: &RenderContext<'_>) { - let mut findings = result.findings.as_array().cloned().unwrap_or_default(); - findings.sort_by_key(finding_severity_rank); - println!("\nLint findings"); - if findings.is_empty() { - println!(" none"); - } - for group in group_runs(&findings) { - let marks = marks(); - render_finding_group(group, ctx, &marks); - } - if let Some(selection) = &result.selection - && selection.withheld > 0 - { - println!(" ... {} finding(s) withheld (--max)", selection.withheld); - } - let files = finding_files(&findings); - let parts = lint_footer_parts(files.len(), array_len(&result.skipped), ctx); - if !parts.is_empty() { - println!(" {}", dim(&parts.join(" · "), ctx.presentation.color)); - } -} - -fn render_essential(result: &EssentialResult, ctx: &RenderContext<'_>) { - let value = &result.result; - if value.get("hotspots").is_some() { - render_essential_analyze(value, ctx); - } else if value.get("program").is_some() { - let p = &value["program"]; - println!( - "\nProgram overview\n {} file(s), {} rule(s), {} global variable(s), {} player variable(s), {} subroutine(s)", - count(p, "files"), - count(p, "rules"), - count(p, "globalVariables"), - count(p, "playerVariables"), - count(p, "subroutines") - ); - println!("\nRules"); - for rule in value["rules"].as_array().into_iter().flatten() { - match rule_address(rule) { - Some(name) => println!(" {name}"), - None => println!(" rule {}", rule["id"]), - } - } - } else { - let mut findings = value["findings"].clone(); - normalize_finding_spans(&mut findings); - render_lint( - &LintResult { - findings, - selection: result.selection.clone(), - ..Default::default() - }, - ctx, - ); - } - if value.get("program").is_some() && value.get("hotspots").is_none() { - println!("\nDetail commands\n {}", DETAIL_COMMANDS.join("\n ")); - } - print_truncation(value); - if let Some(expand) = value["expand"].as_str() { - println!( - " {}", - dim(&expand.replace(" / all: true", ""), ctx.presentation.color) - ); - } + severity_rank(finding_severity(finding)) } fn finding_location_span(finding: &serde_json::Value) -> Option { @@ -1794,193 +1262,6 @@ fn finding_location_span(finding: &serde_json::Value) -> Option) { - let p = &value["program"]; - println!( - "\nProgram overview\n {} file(s), {} rule(s), {} global variable(s), {} player variable(s), {} subroutine(s), {} persistent object(s)", - count(p, "files"), - count(p, "rules"), - count(p, "globalVariables"), - count(p, "playerVariables"), - count(p, "subroutines"), - count(&value["counts"], "persistentObjects") - ); - println!(" evidence: [static] parsed program inventory"); - let cost = &value["cost"]; - println!("\nWorkshop cost"); - if let Some(elements) = cost["elementCount"].as_u64() { - println!( - " {elements} element(s) [exact] — canonical element count; structural size, not runtime cost" - ); - } else { - println!( - " element count unavailable: {}", - cost["unavailableReason"] - .as_str() - .unwrap_or("counter does not model this program") - ); - } - println!( - " {} action(s), {} condition(s), {} wait(s) [static]", - count(&cost["counts"], "actions"), - count(&cost["counts"], "conditions"), - count(&cost["counts"], "waits") - ); - let element_total = cost["elementCount"].as_u64(); - println!( - "\nHotspots\n {}", - if element_total.is_some() { - "ranked by Workshop element count [exact counts; the ranking itself is a heuristic]" - } else { - "ranked by control-flow size [heuristic: blocks + edges]" - } - ); - for rule in value["hotspots"].as_array().into_iter().flatten() { - let flow = &rule["controlFlow"]; - let (blocks, edges) = (count(flow, "blocks"), count(flow, "edges")); - let risks = count(rule, "risks"); - let location = location_suffix(rule.get("span")); - match rule["elements"].as_u64() { - Some(elements) => { - let share = element_total - .filter(|total| *total > 0) - .map(|total| format!(" ({:.1}%)", elements as f64 * 100.0 / total as f64)) - .unwrap_or_default(); - println!( - " \"{}\": {elements} element(s){share}, {blocks} block(s), {edges} edge(s), {risks} risk(s){location}", - rule_name(rule), - ); - } - None => println!( - " \"{}\": {blocks} block(s), {edges} edge(s), {risks} risk(s){location}", - rule_name(rule), - ), - } - } - let c = &value["complexity"]; - println!( - "\nComplexity\n {} block(s), {} edge(s), {} loop block(s), {} wait block(s) [static]", - count(c, "blocks"), - count(c, "edges"), - count(c, "loopBlocks"), - count(c, "waitBlocks") - ); - if value["conditionTrees"] - .as_array() - .is_some_and(|trees| !trees.is_empty()) - { - println!(" top condition tree(s) by element count [exact]"); - } - for tree in value["conditionTrees"].as_array().into_iter().flatten() { - println!( - " \"{}\" condition {}: {} element(s){}", - tree_rule(tree), - count(tree, "index") + 1, - count(tree, "elements"), - location_suffix(tree.get("span")) - ); - } - println!( - "\nPerformance and stability risks\n heuristic/static indicators — not measured runtime cost or behavior" - ); - let risks = value["risks"].as_array().map_or(&[][..], Vec::as_slice); - for risk in risks { - let marks = marks(); - println!( - "{}{}[{}] (evidence: {}): {}", - marks.head, - risk["severity"].as_str().unwrap_or("info"), - risk["code"].as_str().unwrap_or("finding"), - risk["evidence"].as_str().unwrap_or("static"), - risk["message"].as_str().unwrap_or_default() - ); - if let Some(span) = finding_location_span(risk) { - let path = span - .get("path") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let line = span_position(&span, "start", "line").unwrap_or(0); - let col = span_position(&span, "start", "col").unwrap_or(0); - println!("{} --> {path}:{line}:{col}", marks.pad); - } - } - if count(&value["persistentObjects"], "total") > 0 { - println!( - " {}", - persistent_object_summary(&value["persistentObjects"]) - ); - } - if value["risks"].as_array().is_some_and(Vec::is_empty) - && count(&value["persistentObjects"], "total") == 0 - { - println!(" none"); - } - if let Some(withheld) = value["selection"]["withheld"].as_u64().filter(|n| *n > 0) { - println!(" ... {withheld} finding(s) withheld (--max)"); - } - println!( - "\nState and coupling\n Top variables (heuristic ranking: rules touched, then reads + writes)" - ); - for variable in value["variables"].as_array().into_iter().flatten() { - let u = &variable["usage"]; - println!( - " {} {}: {} rule(s), {} read(s), {} write(s) [static]", - variable["kind"].as_str().unwrap_or("variable"), - variable["name"].as_str().unwrap_or(""), - count(u, "rules"), - count(u, "reads"), - count(u, "writes") - ); - } -} - -fn render_finding_group(group: &[serde_json::Value], ctx: &RenderContext<'_>, marks: &Marks) { - let first = &group[0]; - let code = first["code"].as_str().unwrap_or("finding"); - let msg = first["message"].as_str().unwrap_or_default(); - let sev = finding_severity_label(first, ctx.presentation.color); - if group.len() == 1 { - println!("{}{sev}[{code}]: {msg}", marks.head); - if let Some(span) = first.get("span").filter(|span| span.is_object()) { - render_finding_location(span, ctx, &marks.pad, true); - } - } else { - println!( - "{}{sev}[{code}] ({} findings): {msg}", - marks.head, - group.len() - ); - let located = renderable_group_spans(group); - for span in located.iter() { - render_finding_location(span, ctx, &marks.pad, false); - } - } - let mut notes = vec![format!( - "evidence: {}", - first["evidence"].as_str().unwrap_or("exact") - )]; - if let Some(boundedness) = first.get("boundedness").and_then(serde_json::Value::as_str) { - notes.push(format!("boundedness: {boundedness}")); - } - if let Some(note) = unmapped_positions_note(&unmapped_positions(group)) { - notes.push(note); - } - println!( - "{}{}", - marks.pad, - dim(&format!("= {}", notes.join(" · ")), ctx.presentation.color) - ); -} - fn renderable_group_spans(group: &[serde_json::Value]) -> Vec<&serde_json::Value> { group .iter() @@ -2087,10 +1368,10 @@ fn finding_severity_label(finding: &serde_json::Value, color: bool) -> String { .and_then(serde_json::Value::as_str) .unwrap_or("warning"); if color { - let code = match severity { - "error" => "31", - "info" | "notice" => "36", - _ => "33", + let code = match finding_severity(finding) { + Severity::Error => "31", + Severity::Warning => "33", + Severity::Info => "36", }; format!("\x1b[{code}m{severity}\x1b[0m") } else { @@ -2546,36 +1827,6 @@ mod tests { assert_eq!(rank(serde_json::Value::Null), 1); } - #[test] - fn lint_footer_parts_carry_files_skipped_and_interactive_elapsed() { - let ctx = |interactive| RenderContext { - presentation: Presentation { - format: OutputFormat::Text, - renderer: Renderer::Terminal, - color: false, - interactive, - all: false, - width: MAX_WIDTH, - }, - elapsed: Duration::from_millis(42), - source_base: None, - subject: None, - }; - assert_eq!( - lint_footer_parts(2, 1, &ctx(true)), - vec![ - "2 file(s) affected".to_string(), - "1 rule evaluation(s) skipped".to_string(), - "42 ms".to_string() - ] - ); - assert_eq!( - lint_footer_parts(2, 0, &ctx(false)), - vec!["2 file(s) affected".to_string()] - ); - assert!(lint_footer_parts(0, 0, &ctx(false)).is_empty()); - } - #[test] fn finding_location_resolves_reported_paths_against_the_source_base() { let root = std::env::temp_dir().join(format!("wright-finding-{}", std::process::id())); diff --git a/crates/wright-cli/src/present/common.rs b/crates/wright-cli/src/present/common.rs index 1fffd7a7..c4b904bd 100644 --- a/crates/wright-cli/src/present/common.rs +++ b/crates/wright-cli/src/present/common.rs @@ -10,6 +10,28 @@ use super::{ inline_location, is_real_source_path, severity_rank, }; +const DIAGNOSTIC_LIMIT: usize = 20; + +/// Diagnostics in severity order. Unless `all` is set or the command streams +/// an artifact, at most [`DIAGNOSTIC_LIMIT`] are shown; the total is returned +/// when some were withheld. +pub(crate) fn shown_diagnostics<'a>( + command: &str, + diagnostics: &'a [wright_driver::Diagnostic], + all: bool, +) -> (Vec<&'a wright_driver::Diagnostic>, Option) { + let mut ordered: Vec<_> = diagnostics.iter().collect(); + ordered.sort_by_key(|diagnostic| severity_rank(diagnostic.severity)); + let bounded = !all && !matches!(command, "compile" | "convert"); + if bounded && ordered.len() > DIAGNOSTIC_LIMIT { + let total = ordered.len(); + ordered.truncate(DIAGNOSTIC_LIMIT); + (ordered, Some(total)) + } else { + (ordered, None) + } +} + pub(crate) const DETAIL_COMMANDS: &[&str] = &[ "wright inspect symbols", "wright inspect refs ", diff --git a/crates/wright-cli/src/present/terminal.rs b/crates/wright-cli/src/present/terminal.rs index 7da49049..410f161c 100644 --- a/crates/wright-cli/src/present/terminal.rs +++ b/crates/wright-cli/src/present/terminal.rs @@ -14,8 +14,8 @@ use super::{ fix_status_counts, fix_status_parts, group_runs, is_real_source_path, location_suffix, lookup_hint_args, normalize_finding_spans, notable_callers, persistent_object_summary, query_items, render_diff_lines, render_finding_location, render_node, renderable_group_spans, - rule_address, rule_name, severity_rank, shown_of, source_context, span_suffix, summary_status, - symbol_kind_heading, tree_rule, unmapped_positions, unmapped_positions_note, + rule_address, rule_name, shown_diagnostics, shown_of, source_context, span_suffix, + summary_status, symbol_kind_heading, tree_rule, unmapped_positions, unmapped_positions_note, }; fn tag_word(envelope: &Envelope) -> String { @@ -135,9 +135,6 @@ fn dnote(ctx: &RenderContext<'_>, depth: usize, text: &str, to_stdout: bool) { } pub(crate) trait TerminalReport { - fn artifact(&self) -> Option<&str> { - None - } fn headline(&self) -> Option { None } @@ -258,16 +255,12 @@ fn diagnostics( envelope: &Envelope, to_stdout: bool, ) { - let mut ordered: Vec<&wright_driver::Diagnostic> = envelope.diagnostics.iter().collect(); - ordered.sort_by_key(|diagnostic| severity_rank(diagnostic.severity)); - let total = ordered.len(); - let bounded = !matches!(envelope.command.as_str(), "compile" | "convert"); - let limit = if ctx.presentation.all || !bounded { - usize::MAX - } else { - 20 - }; - for (index, diagnostic) in ordered.into_iter().take(limit).enumerate() { + let (shown, withheld) = shown_diagnostics( + &envelope.command, + &envelope.diagnostics, + ctx.presentation.all, + ); + for (index, diagnostic) in shown.into_iter().enumerate() { if index > 0 { dprint("", to_stdout); } @@ -285,7 +278,7 @@ fn diagnostics( ); } } - if bounded && !ctx.presentation.all && total > 20 { + if let Some(total) = withheld { dline( ctx, 1, @@ -591,12 +584,6 @@ impl TerminalReport for CheckResult { } impl TerminalReport for CompileResult { - fn artifact(&self) -> Option<&str> { - self.output - .as_ref() - .filter(|output| output.written_to == "stdout") - .map(|output| output.text.as_str()) - } fn headline(&self) -> Option { self.output .as_ref() @@ -607,9 +594,6 @@ impl TerminalReport for CompileResult { } impl TerminalReport for ConvertResult { - fn artifact(&self) -> Option<&str> { - Some(&self.text) - } fn body(&self, _ctx: &RenderContext<'_>) {} } diff --git a/crates/wright-driver/src/cache.rs b/crates/wright-driver/src/cache.rs index ce1d23c4..85c19b85 100644 --- a/crates/wright-driver/src/cache.rs +++ b/crates/wright-driver/src/cache.rs @@ -269,10 +269,6 @@ impl CostSnapshot { /// /// Every filesystem failure is swallowed: the comparison and the write are /// conveniences that never touch the verdict or exit code (decision 10). -pub fn compare_and_record(run: &CostRun, snapshot: &CostSnapshot) -> Option { - compare_and_record_in(run, snapshot, CacheEnvironment::from_env()) -} - pub(crate) fn compare_and_record_in( run: &CostRun, snapshot: &CostSnapshot, @@ -289,15 +285,9 @@ pub(crate) fn compare_and_record_in( let comparison = if let Some(baseline) = &run.baseline { read_baseline(&dir, baseline, &key).map(|record| { - let payload: CostPayload = - serde_json::from_value(record.payload).unwrap_or(CostPayload { - total: 0, - budget: 0, - rules: Vec::new(), - }); compare( &snapshot.payload(), - &payload, + &cost_payload(record), format!("baseline:{baseline}"), ) }) @@ -305,13 +295,11 @@ pub(crate) fn compare_and_record_in( None } else { read_record(&dir, &file).map(|record| { - let payload: CostPayload = - serde_json::from_value(record.payload).unwrap_or(CostPayload { - total: 0, - budget: 0, - rules: Vec::new(), - }); - compare(&snapshot.payload(), &payload, "previous".to_string()) + compare( + &snapshot.payload(), + &cost_payload(record), + "previous".to_string(), + ) }) }; @@ -331,15 +319,20 @@ pub(crate) fn compare_and_record_in( comparison } +/// An undecodable payload compares as an empty program. +fn cost_payload(record: Record) -> CostPayload { + serde_json::from_value(record.payload).unwrap_or(CostPayload { + total: 0, + budget: 0, + rules: Vec::new(), + }) +} + /// Whether the explicitly named `--baseline` has a usable record under /// `.wright/` for this run's key — present, readable, a known /// version/kind, and keyed to this input, kind, locale and profile. The /// caller decides what an unusable one means: a usage error outside CI, /// silence under it (ADR-0030 decision 14). -pub fn baseline_exists(run: &CostRun) -> bool { - baseline_exists_in(run, CacheEnvironment::from_env()) -} - pub(crate) fn baseline_exists_in(run: &CostRun, environment: CacheEnvironment) -> bool { if environment.disabled(run.no_cache) { return false; diff --git a/crates/wright-driver/src/config.rs b/crates/wright-driver/src/config.rs index dd531e42..22f16911 100644 --- a/crates/wright-driver/src/config.rs +++ b/crates/wright-driver/src/config.rs @@ -175,13 +175,6 @@ impl LintPackageConfig { } impl SessionConfig { - pub fn from_path(path: impl Into) -> SessionConfig { - SessionConfig { - input: InputSpec::Path(path.into()), - ..SessionConfig::default() - } - } - pub(crate) fn changed_fields(&self, other: &SessionConfig) -> Vec<&'static str> { macro_rules! changed_fields { ($changed:ident; $($field:ident),+ $(,)?) => { diff --git a/crates/wright-driver/src/diag.rs b/crates/wright-driver/src/diag.rs index 2a44f53d..4fdd08c8 100644 --- a/crates/wright-driver/src/diag.rs +++ b/crates/wright-driver/src/diag.rs @@ -96,20 +96,6 @@ pub struct Diagnostic { pub fields: serde_json::Map, } -/// Build a source span from the IR source model, resolving the file path. -pub fn span_from_ir( - span: Option, - files: &[workshop_rs::source::SourceFile], -) -> Option { - let span = span?; - let file = span.file.index(); - let path = files - .get(file) - .map(|source_file| source_file.path.clone()) - .unwrap_or_else(|| format!("")); - Some(source_span(span, path)) -} - pub(crate) fn source_span(span: workshop_rs::source::Span, path: String) -> SourceSpan { SourceSpan { file: span.file.index(), diff --git a/crates/wright-driver/src/essential.rs b/crates/wright-driver/src/essential.rs index dd05e0c4..9fd28095 100644 --- a/crates/wright-driver/src/essential.rs +++ b/crates/wright-driver/src/essential.rs @@ -1,5 +1,9 @@ +use std::cmp::Reverse; + use serde_json::{Value, json}; +use crate::Severity; + pub const DEFAULT_ITEMS: usize = 20; const DEFAULT_FINDING_BYTES: usize = 5500; @@ -9,19 +13,17 @@ fn list<'a>(value: &'a Value, key: &str) -> &'a [Value] { fn number(value: &Value, key: &str) -> u64 { value[key].as_u64().unwrap_or(0) } -fn severity(value: &Value) -> u8 { - match value["severity"].as_str() { - Some("error") => 0, - Some("info" | "notice") => 2, - _ => 1, - } +fn severity(value: &Value) -> Severity { + Severity::from_label(value["severity"].as_str()) } fn counts(items: &[Value]) -> Value { - let mut n = [0usize; 3]; - for item in items { - n[severity(item) as usize] += 1; - } - let mut group = json!({"total":items.len(),"error":n[0],"warning":n[1],"info":n[2]}); + let count = |wanted| items.iter().filter(|item| severity(item) == wanted).count(); + let mut group = json!({ + "total": items.len(), + "error": count(Severity::Error), + "warning": count(Severity::Warning), + "info": count(Severity::Info), + }); if let Some(by_code) = by_code_counts(items) { group["byCode"] = by_code; } @@ -73,7 +75,7 @@ fn findings(items: &[Value]) -> Vec { item }) .collect::>(); - items.sort_by_key(severity); + items.sort_by_key(|item| Reverse(severity(item))); items } @@ -321,7 +323,7 @@ pub fn cost(result: &Value, all: bool) -> Value { /// Preserve the complete-set verdict while bounding its displayed diagnostics. pub fn envelope(result: &mut Value, all: bool) { if let Some(diagnostics) = result["diagnostics"].as_array_mut() { - diagnostics.sort_by_key(severity); + diagnostics.sort_by_key(|item| Reverse(severity(item))); } cap(result, "diagnostics", DEFAULT_ITEMS, all); } diff --git a/crates/wright-driver/src/input.rs b/crates/wright-driver/src/input.rs index 31bf2454..8a0c1553 100644 --- a/crates/wright-driver/src/input.rs +++ b/crates/wright-driver/src/input.rs @@ -52,7 +52,7 @@ pub(crate) fn disk_fingerprint( let InputSpec::Path(path) = &config.input else { return DiskFingerprint::Detached; }; - let path = absolute_from(&resolved.cwd, path); + let path = resolved.cwd.join(path); if resolved.kind == SourceKind::Opy { let root = if path.is_dir() { path @@ -138,7 +138,7 @@ fn invocation_cwd() -> Result { fn resolve_path(path: &Path, config: &SessionConfig) -> Result { let cwd = invocation_cwd()?; - let path = absolute_from(&cwd, path); + let path = cwd.join(path); let metadata = std::fs::metadata(&path).map_err(|e| { Diagnostic::error( "input-io", @@ -236,21 +236,13 @@ fn resolve_directory( }) } -fn absolute_from(cwd: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } -} - /// The resolved project root: the configured `root` resolved against `cwd`, /// or `fallback` when unset. fn config_root(config: &SessionConfig, cwd: &Path, fallback: impl FnOnce() -> PathBuf) -> PathBuf { config .root .as_deref() - .map(|root| absolute_from(cwd, root)) + .map(|root| cwd.join(root)) .unwrap_or_else(fallback) } @@ -385,7 +377,7 @@ fn resolve_text( ) -> Result { let text = strip_bom(text); let cwd = invocation_cwd()?; - let path = path.map(|path| absolute_from(&cwd, path)); + let path = path.map(|path| cwd.join(path)); let kind = match config.kind { SourceKind::Auto => match &path { Some(path) => kind_from_extension(path)?, diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index f0d9cd59..be1a77cf 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -61,9 +61,6 @@ pub use workshop_compat::{ }; pub use wright_transform::Profile; -/// The driver crate name reported in result metadata. -pub const DRIVER_NAME: &str = "wright-driver"; - /// The stable embedding-API contract name (#56). pub const EMBEDDING_CONTRACT: &str = "wright-embedding/v1"; diff --git a/crates/wright-driver/src/select.rs b/crates/wright-driver/src/select.rs index 8a7039ca..cd4d3ac6 100644 --- a/crates/wright-driver/src/select.rs +++ b/crates/wright-driver/src/select.rs @@ -51,22 +51,6 @@ pub struct SelectionOutcome { pub max_severity: Option, } -fn severity_rank(severity: Severity) -> u8 { - match severity { - Severity::Info => 0, - Severity::Warning => 1, - Severity::Error => 2, - } -} - -fn parse_severity(value: Option<&str>) -> Severity { - match value { - Some("error") => Severity::Error, - Some("info") | Some("notice") => Severity::Info, - _ => Severity::Warning, - } -} - impl FindingSelection { /// Whether any selection dimension is set. pub fn is_active(&self) -> bool { @@ -148,7 +132,7 @@ impl FindingSelection { findings, |finding| { ( - parse_severity(finding.get("severity").and_then(|v| v.as_str())), + Severity::from_label(finding.get("severity").and_then(|v| v.as_str())), finding.get("code").and_then(|v| v.as_str()).unwrap_or(""), crate::service::json_span_path(finding), ) @@ -171,16 +155,12 @@ impl FindingSelection { .as_deref() .map(|file| FileMatch::new(file, file_bases)); let total = items.len(); - let max_severity = items - .iter() - .map(|item| key(item).0) - .max_by_key(|severity| severity_rank(*severity)); + let max_severity = items.iter().map(|item| key(item).0).max(); let mut kept: Vec = items .into_iter() .filter(|item| { let (severity, code, path) = key(item); - self.severity - .is_none_or(|min| severity_rank(severity) >= severity_rank(min)) + self.severity.is_none_or(|min| severity >= min) && self.rule.as_ref().is_none_or(|rule| rule == code) && file_matcher .as_ref() @@ -268,13 +248,7 @@ fn finish_bases(config: &SessionConfig, loaded: Option<&Loaded>) -> Vec return file_bases(&loaded.input); } let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let absolute = |path: &Path| { - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } - }; + let absolute = |path: &Path| cwd.join(path); let mut bases = vec![cwd.clone()]; if let Some(root) = &config.root { bases.push(absolute(root)); @@ -352,14 +326,7 @@ pub(crate) fn file_bases(input: &ResolvedInput) -> Vec { fn canonical_forms(path: &Path, bases: &[PathBuf]) -> BTreeSet { bases .iter() - .filter_map(|base| { - let full = if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) - }; - full.canonicalize().ok() - }) + .filter_map(|base| base.join(path).canonicalize().ok()) .collect() } diff --git a/crates/wright-driver/src/service/mod.rs b/crates/wright-driver/src/service/mod.rs index 4481f949..9b409757 100644 --- a/crates/wright-driver/src/service/mod.rs +++ b/crates/wright-driver/src/service/mod.rs @@ -7,9 +7,7 @@ use crate::session::Provenance; #[cfg(not(target_arch = "wasm32"))] use crate::source_provider::document_sources; use crate::{CompilerSession, Loaded}; -use wright_analyzer::canonical::{ - BLOCK_KINDS, ReferenceKind, SemanticIndex, SemanticService, SymbolKind, -}; +use wright_analyzer::canonical::{BLOCK_KINDS, ReferenceKind, SemanticService, SymbolKind}; /// A structured tool error. pub use wright_analyzer::service::ErrorInfo as ToolErrorInfo; /// A tool response: a structured owned result or a structured error. @@ -821,7 +819,6 @@ impl<'a> ToolService<'a> { fn project(&self) -> serde_json::Value { let loaded = &self.snapshot().loaded; - let index = SemanticIndex::build(&loaded.program); let findings = wright_analyzer::canonical::analyze( &loaded.program, &wright_analyzer::registry::LintConfig::default(), @@ -831,7 +828,7 @@ impl<'a> ToolService<'a> { "inputIdentity": loaded.input.identity, "files": loaded.source_files.len().max(1), "sourceFiles": loaded.source_files.as_slice(), "globalVariables": loaded.program.global_variables.len(), "playerVariables": loaded.program.player_variables.len(), "subroutines": loaded.program.subroutines.len(), - "rules": loaded.program.rules.len(), "symbols": index.symbols().count(), "findings": findings.len(), + "rules": loaded.program.rules.len(), "symbols": self.semantic().symbol_count(), "findings": findings.len(), "settings": loaded.program.settings.as_ref().map(settings::settings_json), }) } diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 90b4a135..1d58ca9b 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -566,12 +566,7 @@ impl CompilerSession { source_files = map.files().iter().map(|f| provider_uri_path(f)).collect(); let mut unreadable = Vec::new(); for (index, member) in source_files.iter().enumerate() { - let member_path = Path::new(member); - let member_path = if member_path.is_absolute() { - member_path.to_path_buf() - } else { - resolved.root.join(member_path) - }; + let member_path = resolved.root.join(member); match std::fs::read_to_string(&member_path) { Ok(text) => { program.set_file_source( diff --git a/crates/wright-driver/src/session/edit.rs b/crates/wright-driver/src/session/edit.rs index 97fd2362..e12e081b 100644 --- a/crates/wright-driver/src/session/edit.rs +++ b/crates/wright-driver/src/session/edit.rs @@ -100,8 +100,7 @@ impl CompilerSession { loop { let sources = current_path_sources_without_stdin(&self.config); let candidate = self - .fix_outcomes(&envelope.result.findings, sources.as_ref()) - .into_iter() + .fix_outcome_iter(&envelope.result.findings, sources.as_ref()) .find(|(outcome, _)| outcome.status == LintFixStatus::Preview); let Some((outcome, resolved)) = candidate else { break; @@ -185,17 +184,25 @@ impl CompilerSession { findings: &serde_json::Value, sources: Option<&BTreeMap>, ) -> Vec<(LintFixOutcome, Option)> { - let Some(findings) = findings.as_array() else { - return Vec::new(); - }; + self.fix_outcome_iter(findings, sources).collect() + } + + /// Validates fixes lazily, so a caller that needs only the first + /// preview does not validate every remaining fix. + fn fix_outcome_iter<'a>( + &'a self, + findings: &'a serde_json::Value, + sources: Option<&'a BTreeMap>, + ) -> impl Iterator)> + 'a { #[cfg(not(target_arch = "wasm32"))] let provider = self.provider_fix_context(); #[cfg(target_arch = "wasm32")] let provider: Option> = None; findings - .iter() - .filter_map(|finding| self.fix_outcome(finding, sources, provider.as_ref())) - .collect() + .as_array() + .into_iter() + .flatten() + .filter_map(move |finding| self.fix_outcome(finding, sources, provider.as_ref())) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index 809b05fe..c1bc0a8e 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -63,12 +63,7 @@ impl SourceTarget { cwd: impl Into, ) -> Self { let cwd = cwd.into(); - let entry = entry.into(); - let entry = if entry.is_absolute() { - entry - } else { - cwd.join(entry) - }; + let entry = cwd.join(entry.into()); Self { language, entry, diff --git a/crates/wright-driver/src/source_provider/columns.rs b/crates/wright-driver/src/source_provider/columns.rs index 3dfd6172..ad51d5d2 100644 --- a/crates/wright-driver/src/source_provider/columns.rs +++ b/crates/wright-driver/src/source_provider/columns.rs @@ -39,14 +39,7 @@ pub(crate) fn provider_member( })?; return Ok((url.to_string(), path)); } - let path = { - let path = std::path::PathBuf::from(member); - if path.is_absolute() { - path - } else { - base.join(path) - } - }; + let path = base.join(member); url::Url::from_file_path(&path) .map(|url| (url.to_string(), path)) .map_err(|()| { diff --git a/crates/wright-lpp/src/lib.rs b/crates/wright-lpp/src/lib.rs index a1078111..1dd1e30b 100644 --- a/crates/wright-lpp/src/lib.rs +++ b/crates/wright-lpp/src/lib.rs @@ -26,9 +26,6 @@ pub const LPP_ARTIFACT_NEGOTIATION_PROTOCOL_VERSION: &str = "1.4"; /// The LPP 1.5 version that adds name and signature lookup on `lpp/lookup`. pub const LPP_LOOKUP_PROTOCOL_VERSION: &str = "1.5"; -/// The LPP 1.6 version that adds `lpp/resolveIds` canonical id resolution. -pub const LPP_RESOLVE_IDS_PROTOCOL_VERSION: &str = "1.6"; - /// Every protocol version this client implements, in ascending order — /// the client's side of the highest-mutual-version computation LPP §19.2 /// requires after a `protocolVersionMismatch` (#638). 1.6 is excluded: diff --git a/crates/wright-lsp/src/main.rs b/crates/wright-lsp/src/main.rs index 52b76053..cb07284c 100644 --- a/crates/wright-lsp/src/main.rs +++ b/crates/wright-lsp/src/main.rs @@ -109,31 +109,25 @@ fn run(opy_provider: Option) -> Result { if !matches!(method.as_str(), "initialize" | "shutdown" | "exit") && lifecycle != Lifecycle::Running { - if id.is_some() { - let (code, reason, refusal) = match lifecycle { - Lifecycle::Waiting => { - (-32002, "the server is not initialized", "not-initialized") - } - Lifecycle::Shutdown => (-32600, "the server is shut down", "invalid-request"), - Lifecycle::Running => unreachable!("checked above"), - }; - write_error(&mut writer, id, code, reason, refusal)?; - } + let (code, reason, refusal) = match lifecycle { + Lifecycle::Waiting => (-32002, "the server is not initialized", "not-initialized"), + Lifecycle::Shutdown => (-32600, "the server is shut down", "invalid-request"), + Lifecycle::Running => unreachable!("checked above"), + }; + reply_error(&mut writer, id, code, reason, refusal)?; continue; } match method.as_str() { "initialize" => { if lifecycle != Lifecycle::Waiting { - if id.is_some() { - write_error( - &mut writer, - id, - -32600, - "the server is already initialized", - "already-initialized", - )?; - } + reply_error( + &mut writer, + id, + -32600, + "the server is already initialized", + "already-initialized", + )?; continue; } let Some(init) = read_params::(&mut writer, &id, params)? else { @@ -153,43 +147,32 @@ fn run(opy_provider: Option) -> Result { } lifecycle = Lifecycle::Running; let opy_support = service.opy_provider_support(); - if id.is_some() { - write_response( - &mut writer, - id, - serde_json::to_value(initialize_result( - versioned_workspace_edits, - opy_support, - )) + write_response( + &mut writer, + id, + serde_json::to_value(initialize_result(versioned_workspace_edits, opy_support)) .unwrap(), - )?; - } + )?; } "initialized" => { - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "shutdown" => { match lifecycle { Lifecycle::Running => lifecycle = Lifecycle::Shutdown, Lifecycle::Waiting => { - if id.is_some() { - write_error( - &mut writer, - id, - -32002, - "the server is not initialized", - "not-initialized", - )?; - } + reply_error( + &mut writer, + id, + -32002, + "the server is not initialized", + "not-initialized", + )?; continue; } Lifecycle::Shutdown => {} } - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "exit" => return Ok(u8::from(lifecycle != Lifecycle::Shutdown)), "textDocument/didOpen" => { @@ -208,9 +191,7 @@ fn run(opy_provider: Option) -> Result { .with_language_id(params.text_document.language_id); service.store.open(document); publish_affected_diagnostics(&mut writer, &mut service, &mut ownership, &uri)?; - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "textDocument/didChange" => { let Some(params) = @@ -232,9 +213,7 @@ fn run(opy_provider: Option) -> Result { if applied { publish_affected_diagnostics(&mut writer, &mut service, &mut ownership, &uri)?; } - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "textDocument/didClose" => { let Some(params) = @@ -256,14 +235,10 @@ fn run(opy_provider: Option) -> Result { } } publish_affected_diagnostics(&mut writer, &mut service, &mut ownership, &uri)?; - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "textDocument/didSave" => { - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } "textDocument/hover" => { let Some(params) = read_params::(&mut writer, &id, params)? else { @@ -377,7 +352,7 @@ fn run(opy_provider: Option) -> Result { // version the provider validated; serving it would let a // client apply the edit to a buffer that moved while the // rename was pending without ever detecting it. - write_error( + reply_error( &mut writer, id, -32803, @@ -401,7 +376,7 @@ fn run(opy_provider: Option) -> Result { Ok(edit) => { write_response(&mut writer, id, serde_json::to_value(edit).unwrap())? } - Err(uri) => write_error( + Err(uri) => reply_error( &mut writer, id, -32803, @@ -412,14 +387,12 @@ fn run(opy_provider: Option) -> Result { )?, }, RenameOutcome::Refused { code, message } => { - write_error(&mut writer, id, -32803, &message, &code)?; + reply_error(&mut writer, id, -32803, &message, &code)?; } } } _ => { - if id.is_some() { - write_response(&mut writer, id, Value::Null)?; - } + write_response(&mut writer, id, Value::Null)?; } } } @@ -601,13 +574,32 @@ fn write_msg(writer: &mut impl Write, val: Value) -> Result<(), String> { .map_err(|e| e.to_string()) } +/// Answers a request; a notification (no `id`) gets no response. fn write_response(writer: &mut impl Write, id: Option, result: Value) -> Result<(), String> { + if id.is_none() { + return Ok(()); + } write_msg( writer, serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result }), ) } +/// Refuses a request; a notification (no `id`) gets no response. +fn reply_error( + writer: &mut impl Write, + id: Option, + code: i64, + message: &str, + refusal_code: &str, +) -> Result<(), String> { + if id.is_none() { + return Ok(()); + } + write_error(writer, id, code, message, refusal_code) +} + +/// Writes an error even without an `id`, as a malformed frame requires. fn write_error( writer: &mut impl Write, id: Option, @@ -638,9 +630,7 @@ fn read_params( match parse_params(params) { Ok(parsed) => Ok(Some(parsed)), Err(message) => { - if id.is_some() { - write_error(writer, id.clone(), -32602, &message, "invalid-params")?; - } + reply_error(writer, id.clone(), -32602, &message, "invalid-params")?; Ok(None) } }