Skip to content

Commit b9432a3

Browse files
olwangclaude
andcommitted
rsscript: rss fix — apply machine-applicable structured edits
Structured-fix half of the P1-2 tooling item. diagnostic::Fix gains a concrete edit payload FixEdit { span, replacement } (insertion when zero-length, otherwise a replacement), serialized into `rss check --json` / `rss ide --json diagnostics`. The high-value machine-applicable fixes (add_data_effect, add_constructor_field_ effect) now emit real edits via Diagnostic::with_fix_edit. New `rss fix [--write] [--json] [--interface ...] <file.rss>`: plans the machine-applicable edits, applies them bottom-to-top so earlier edits don't shift later positions, conservatively skips overlapping edits, previews by default and writes with --write. Verified end-to-end (cli_fix.rs: four missing `read`s across two lines -> clean check) plus a lib-level edit-generation guard in checker_frontend. The request/response analysis server already exists as `rss ide --json` and now carries the fix edits; a persistent LSP-protocol daemon remains as follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 028356a commit b9432a3

10 files changed

Lines changed: 448 additions & 18 deletions

File tree

crates/rsscript/src/checks/body.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::text_util::{
55
use std::collections::{HashMap, HashSet};
66

77
use crate::analyzer::Analyzer;
8-
use crate::diagnostic::{Diagnostic, Span, code};
8+
use crate::diagnostic::{Diagnostic, FixEdit, Span, code};
99
use crate::hir::{
1010
CallResolution, FieldInfo, HirBindingKind, HirBlock, HirCallArg, HirExpr, HirMatchArm, HirStmt,
1111
HirTypeKind, ParamEffect, ResolvedCalleeKind,
@@ -3761,10 +3761,10 @@ fn constructor_field_effect_diagnostic(
37613761
"missing constructor field effect",
37623762
)
37633763
.with_cause(cause)
3764-
.with_fix(
3764+
.with_fix_edit(
37653765
"add_constructor_field_effect",
37663766
format!("Write `{field_name}: {expected} ...` in the constructor."),
3767-
"machine-applicable",
3767+
FixEdit::insert_before(hir_expr_span(value), format!("{expected} ")),
37683768
),
37693769
);
37703770
}

crates/rsscript/src/checks/calls.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::text_util::{
44
use std::collections::{HashMap, HashSet};
55

66
use crate::analyzer::Analyzer;
7-
use crate::diagnostic::{Diagnostic, Span, code};
7+
use crate::diagnostic::{Diagnostic, FixEdit, Span, code};
88
use crate::hir::{
99
CallResolution, FunctionSig, HirBindingKind, HirBlock, HirCallArg, HirExpr, HirStmt, ParamSig,
1010
ResolvedCalleeKind,
@@ -1269,10 +1269,10 @@ fn check_call_args(
12691269
"missing data effect",
12701270
)
12711271
.with_cause("Non-Copy parameters require an explicit `read`, `mut`, or `take` call-site effect.")
1272-
.with_fix(
1272+
.with_fix_edit(
12731273
"add_data_effect",
12741274
format!("Write `{name}: {expected} ...` at the call site."),
1275-
"machine-applicable",
1275+
FixEdit::insert_before(hir_expr_span(&arg.value), format!("{expected} ")),
12761276
),
12771277
);
12781278
}

crates/rsscript/src/cli/fix.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use std::fs;
2+
use std::process::ExitCode;
3+
4+
use rsscript::{Diagnostic, FixEdit, analyze_source_with_interfaces, standard_package_interfaces};
5+
use serde_json::json;
6+
7+
use super::{print_usage, read_interface_sources, required_flag_value};
8+
9+
#[derive(Debug)]
10+
struct FixOptions<'a> {
11+
json: bool,
12+
write: bool,
13+
path: Option<&'a str>,
14+
interfaces: Vec<&'a str>,
15+
}
16+
17+
fn parse_fix_args(args: &[String]) -> Result<FixOptions<'_>, String> {
18+
let mut json = false;
19+
let mut write = false;
20+
let mut path = None;
21+
let mut interfaces = Vec::new();
22+
let mut index = 0;
23+
while let Some(arg) = args.get(index) {
24+
match arg.as_str() {
25+
"--json" => json = true,
26+
"--write" => write = true,
27+
"--interface" => {
28+
index += 1;
29+
interfaces.push(required_flag_value(args, index, "--interface")?);
30+
}
31+
other if other.starts_with("--") => {
32+
return Err(format!("unknown argument `{other}`."));
33+
}
34+
other if path.is_none() => path = Some(other),
35+
other => return Err(format!("unexpected extra path `{other}`.")),
36+
}
37+
index += 1;
38+
}
39+
Ok(FixOptions {
40+
json,
41+
write,
42+
path,
43+
interfaces,
44+
})
45+
}
46+
47+
/// A concrete edit ready to apply, paired with the diagnostic code it resolves.
48+
struct PlannedEdit {
49+
code: String,
50+
title: String,
51+
edit: FixEdit,
52+
}
53+
54+
fn plan_edits(diagnostics: &[Diagnostic]) -> Vec<PlannedEdit> {
55+
let mut planned = Vec::new();
56+
for diagnostic in diagnostics {
57+
for fix in &diagnostic.fixes {
58+
if fix.applicability != "machine-applicable" {
59+
continue;
60+
}
61+
let Some(edit) = &fix.edit else { continue };
62+
planned.push(PlannedEdit {
63+
code: diagnostic.code.clone(),
64+
title: fix.title.clone(),
65+
edit: edit.clone(),
66+
});
67+
}
68+
}
69+
planned
70+
}
71+
72+
/// Apply edits to `source`, returning the rewritten text and the edits actually
73+
/// applied. Edits are applied bottom-to-top (latest position first) so earlier
74+
/// edits never shift the positions of later ones. Overlapping edits on the same
75+
/// line are conservatively skipped (the first one in source order wins).
76+
fn apply_edits(source: &str, edits: &[PlannedEdit]) -> (String, Vec<usize>, Vec<usize>) {
77+
// Index edits, then order by (line, column) descending.
78+
let mut order: Vec<usize> = (0..edits.len()).collect();
79+
order.sort_by(|&a, &b| {
80+
let (ea, eb) = (&edits[a].edit.span, &edits[b].edit.span);
81+
eb.line.cmp(&ea.line).then(eb.column.cmp(&ea.column))
82+
});
83+
84+
let mut lines: Vec<Vec<char>> = source.lines().map(|line| line.chars().collect()).collect();
85+
let trailing_newline = source.ends_with('\n');
86+
87+
// Track applied character ranges per line to reject overlaps.
88+
let mut claimed: Vec<(usize, usize, usize)> = Vec::new(); // (line, start, end)
89+
let mut applied = Vec::new();
90+
let mut skipped = Vec::new();
91+
92+
for &i in &order {
93+
let span = &edits[i].edit.span;
94+
if span.line == 0 || span.line > lines.len() {
95+
skipped.push(i);
96+
continue;
97+
}
98+
let line = &mut lines[span.line - 1];
99+
let start = span.column.saturating_sub(1);
100+
if start > line.len() {
101+
skipped.push(i);
102+
continue;
103+
}
104+
let end = (start + span.length).min(line.len());
105+
let overlaps = claimed.iter().any(|&(l, s, e)| {
106+
if l != span.line {
107+
return false;
108+
}
109+
if start == end {
110+
// Insertion: only conflicts if strictly inside a replaced range.
111+
start > s && start < e
112+
} else {
113+
// Replacement: conflicts if the character ranges intersect.
114+
start < e && end > s
115+
}
116+
});
117+
if overlaps {
118+
skipped.push(i);
119+
continue;
120+
}
121+
let replacement: Vec<char> = edits[i].edit.replacement.chars().collect();
122+
line.splice(start..end, replacement);
123+
claimed.push((span.line, start, end));
124+
applied.push(i);
125+
}
126+
127+
let mut rebuilt = lines
128+
.into_iter()
129+
.map(|line| line.into_iter().collect::<String>())
130+
.collect::<Vec<_>>()
131+
.join("\n");
132+
if trailing_newline {
133+
rebuilt.push('\n');
134+
}
135+
(rebuilt, applied, skipped)
136+
}
137+
138+
fn analyze(path: &str, source: &str, interfaces: &[(&str, &str)]) -> Vec<Diagnostic> {
139+
let mut combined = standard_package_interfaces().to_vec();
140+
combined.extend(interfaces.iter().copied());
141+
analyze_source_with_interfaces(path, source, &combined)
142+
}
143+
144+
pub(crate) fn run_fix(args: &[String]) -> ExitCode {
145+
let options = match parse_fix_args(args) {
146+
Ok(options) => options,
147+
Err(error) => {
148+
eprintln!("{error}");
149+
return ExitCode::from(2);
150+
}
151+
};
152+
let Some(path) = options.path else {
153+
print_usage();
154+
return ExitCode::from(2);
155+
};
156+
let source = match fs::read_to_string(path) {
157+
Ok(source) => source,
158+
Err(error) => {
159+
eprintln!("failed to read {path}: {error}");
160+
return ExitCode::from(2);
161+
}
162+
};
163+
let interfaces = match read_interface_sources(&options.interfaces) {
164+
Ok(interfaces) => interfaces,
165+
Err(error) => {
166+
eprintln!("{error}");
167+
return ExitCode::from(2);
168+
}
169+
};
170+
let interface_refs: Vec<(&str, &str)> = interfaces
171+
.iter()
172+
.map(|interface| (interface.path.as_str(), interface.contents.as_str()))
173+
.collect();
174+
175+
let diagnostics = analyze(path, &source, &interface_refs);
176+
let planned = plan_edits(&diagnostics);
177+
let (rewritten, applied, skipped) = apply_edits(&source, &planned);
178+
179+
if options.write
180+
&& !applied.is_empty()
181+
&& let Err(error) = fs::write(path, &rewritten)
182+
{
183+
eprintln!("failed to write {path}: {error}");
184+
return ExitCode::from(2);
185+
}
186+
187+
if options.json {
188+
let fixes: Vec<_> = applied
189+
.iter()
190+
.map(|&i| {
191+
let edit = &planned[i];
192+
json!({
193+
"code": edit.code,
194+
"title": edit.title,
195+
"line": edit.edit.span.line,
196+
"column": edit.edit.span.column,
197+
"replacement": edit.edit.replacement,
198+
})
199+
})
200+
.collect();
201+
println!(
202+
"{}",
203+
json!({
204+
"ok": true,
205+
"path": path,
206+
"written": options.write && !applied.is_empty(),
207+
"applied": fixes,
208+
"skipped": skipped.len(),
209+
"remaining_diagnostics": diagnostics.len().saturating_sub(applied.len()),
210+
})
211+
);
212+
return ExitCode::SUCCESS;
213+
}
214+
215+
if planned.is_empty() {
216+
println!("{path}: no machine-applicable fixes");
217+
return ExitCode::SUCCESS;
218+
}
219+
for &i in &applied {
220+
let edit = &planned[i];
221+
println!(
222+
" {}:{}:{} [{}] {}",
223+
path, edit.edit.span.line, edit.edit.span.column, edit.code, edit.title
224+
);
225+
}
226+
if options.write {
227+
println!("applied {} fix(es) to {path}", applied.len());
228+
} else {
229+
println!(
230+
"{} fix(es) applicable; re-run with `--write` to apply",
231+
applied.len()
232+
);
233+
}
234+
if !skipped.is_empty() {
235+
println!(
236+
"{} overlapping fix(es) skipped (re-run after applying)",
237+
skipped.len()
238+
);
239+
}
240+
ExitCode::SUCCESS
241+
}

crates/rsscript/src/cli/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod bench;
1212
mod check;
1313
mod dev;
1414
mod eval;
15+
mod fix;
1516
mod fmt;
1617
mod ide;
1718
mod lint;
@@ -33,6 +34,7 @@ pub fn run() -> ExitCode {
3334
"check" => check::run_check(&args[2..]),
3435
"dev" => dev::run_dev(&args[2..]),
3536
"eval" => eval::run_eval(&args[2..]),
37+
"fix" => fix::run_fix(&args[2..]),
3638
"ide" => ide::run_ide(&args[2..]),
3739
"lint" => lint::run_lint(&args[2..]),
3840
"native" => native::run_native(&args[2..]),
@@ -276,6 +278,9 @@ pub(crate) fn print_usage() {
276278
" rss dev [--lint] [--run] [--release] [--json] [--once] [--core|--no-core] [--interface <file.rssi> ...] <file-or-package-directory>"
277279
);
278280
eprintln!(" rss eval [--json] <file.rss> [-- <args>...]");
281+
eprintln!(
282+
" rss fix [--write] [--json] [--interface <file.rssi> ...] <file.rss> # apply machine-applicable fixes"
283+
);
279284
eprintln!(" rss fmt <file.rss> # writes formatted source to stdout");
280285
eprintln!(" rss new <package-name>");
281286
eprintln!(

crates/rsscript/src/diagnostic.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,47 @@ pub struct Span {
160160
pub length: usize,
161161
}
162162

163+
/// A concrete source edit attached to a fix: replace the `length` characters of
164+
/// `span` (starting at `span.line`:`span.column`) with `replacement`. A zero-length
165+
/// span is a pure insertion at that position. This is what makes a fix
166+
/// machine-applicable by `rss fix` — without an edit a fix is advisory only.
167+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168+
pub struct FixEdit {
169+
pub span: Span,
170+
pub replacement: String,
171+
}
172+
173+
impl FixEdit {
174+
/// Insert `text` immediately before the start of `span` (no characters removed).
175+
pub fn insert_before(span: &Span, text: impl Into<String>) -> Self {
176+
Self {
177+
span: Span {
178+
file: span.file.clone(),
179+
line: span.line,
180+
column: span.column,
181+
length: 0,
182+
},
183+
replacement: text.into(),
184+
}
185+
}
186+
187+
/// Replace the exact characters covered by `span` with `text`.
188+
pub fn replace(span: &Span, text: impl Into<String>) -> Self {
189+
Self {
190+
span: span.clone(),
191+
replacement: text.into(),
192+
}
193+
}
194+
}
195+
163196
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164197
pub struct Fix {
165198
pub kind: String,
166199
pub title: String,
167200
pub applicability: String,
201+
/// Concrete edit for machine-applicable fixes; `None` for advisory fixes.
202+
#[serde(default, skip_serializing_if = "Option::is_none")]
203+
pub edit: Option<FixEdit>,
168204
}
169205

170206
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -235,6 +271,23 @@ impl Diagnostic {
235271
kind: kind.into(),
236272
title: title.into(),
237273
applicability: applicability.into(),
274+
edit: None,
275+
});
276+
self
277+
}
278+
279+
/// Attach a fix that carries a concrete, machine-applicable source edit.
280+
pub fn with_fix_edit(
281+
mut self,
282+
kind: impl Into<String>,
283+
title: impl Into<String>,
284+
edit: FixEdit,
285+
) -> Self {
286+
self.fixes.push(Fix {
287+
kind: kind.into(),
288+
title: title.into(),
289+
applicability: "machine-applicable".to_string(),
290+
edit: Some(edit),
238291
});
239292
self
240293
}

crates/rsscript/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub use capability::{
4545
};
4646
pub use core_index::core_package_index_json;
4747
pub use diagnostic::{
48-
Diagnostic, DiagnosticExplanation, Severity, Span, explain_diagnostic_code,
48+
Diagnostic, DiagnosticExplanation, Fix, FixEdit, Severity, Span, explain_diagnostic_code,
4949
format_diagnostic_explanation, format_diagnostics_human, format_diagnostics_json,
5050
format_diagnostics_json_with_source,
5151
};

0 commit comments

Comments
 (0)