Skip to main content

codex_patcher/compiler/
autofix.rs

1//! Auto-fix strategies for common compiler errors.
2//!
3//! This module provides automatic fixes for well-understood error patterns,
4//! particularly E0063 (missing struct fields) which commonly occurs when
5//! upstream dependencies add new required fields.
6
7use crate::compiler::diagnostic::{CompileDiagnostic, Suggestion};
8use crate::edit::Edit;
9use cargo_metadata::diagnostic::Applicability;
10use std::path::Path;
11use thiserror::Error;
12
13#[derive(Error, Debug)]
14pub enum AutofixError {
15    #[error("Cannot auto-fix: {0}")]
16    CannotFix(String),
17
18    #[error("Edit error: {0}")]
19    EditError(#[from] crate::edit::EditError),
20
21    #[error("IO error: {0}")]
22    Io(#[from] std::io::Error),
23
24    #[error("Parse error: {0}")]
25    ParseError(String),
26}
27
28/// Result of attempting to auto-fix a diagnostic.
29#[derive(Debug)]
30pub enum AutofixResult {
31    /// Successfully generated fixes
32    Fixed(Vec<Edit>),
33    /// Cannot auto-fix this diagnostic
34    CannotFix { reason: String },
35}
36
37/// Attempt to auto-fix a diagnostic.
38///
39/// Returns `AutofixResult::Fixed` with edits if fixable, or `CannotFix` with reason.
40#[must_use]
41pub fn try_autofix(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
42    // 1. Check for MachineApplicable suggestions first (compiler knows best)
43    let machine_fixes = diag.machine_applicable_suggestions();
44    if !machine_fixes.is_empty() {
45        let edits = machine_fixes
46            .iter()
47            .filter_map(|s| suggestion_to_edit(s, workspace))
48            .collect::<Vec<_>>();
49
50        if !edits.is_empty() {
51            return AutofixResult::Fixed(edits);
52        }
53    }
54
55    // 2. Pattern-match on error codes for custom fixes
56    match diag.code.as_deref() {
57        Some("E0063") => fix_missing_field(diag, workspace),
58        Some("E0433") => fix_unresolved_module(diag, workspace),
59        _ => AutofixResult::CannotFix {
60            reason: format!("No auto-fix strategy for error code {:?}", diag.code),
61        },
62    }
63}
64
65/// Convert a compiler suggestion to an Edit.
66fn suggestion_to_edit(suggestion: &Suggestion, workspace: &Path) -> Option<Edit> {
67    // Ensure file is within workspace
68    if !suggestion.file.starts_with(workspace) {
69        return None;
70    }
71
72    // Read the file to get the expected text
73    let content = std::fs::read_to_string(&suggestion.file).ok()?;
74
75    // Validate byte range
76    if suggestion.byte_end > content.len() {
77        return None;
78    }
79
80    let expected = &content[suggestion.byte_start..suggestion.byte_end];
81
82    Some(Edit::new(
83        suggestion.file.clone(),
84        suggestion.byte_start,
85        suggestion.byte_end,
86        suggestion.replacement.clone(),
87        expected,
88    ))
89}
90
91/// Fix E0063: missing field in struct initializer.
92///
93/// Parses the error message to extract field name and struct type,
94/// then generates a sensible default value.
95fn fix_missing_field(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
96    // Parse error message: "missing field `field_name` in initializer of `StructName`"
97    let Some((field_name, _struct_name)) = parse_missing_field_message(&diag.message) else {
98        return AutofixResult::CannotFix {
99            reason: format!("Could not parse E0063 message: {}", diag.message),
100        };
101    };
102
103    // Find the primary span (the struct initializer location)
104    let Some(span) = diag.spans.first() else {
105        return AutofixResult::CannotFix {
106            reason: "No source span in diagnostic".to_string(),
107        };
108    };
109
110    // Skip macro expansions - too risky
111    if span.is_macro_expansion {
112        return AutofixResult::CannotFix {
113            reason: "Cannot auto-fix inside macro expansion".to_string(),
114        };
115    }
116
117    // Read the file to find the struct initializer
118    let content = match std::fs::read_to_string(&span.file) {
119        Ok(c) => c,
120        Err(e) => {
121            return AutofixResult::CannotFix {
122                reason: format!("Cannot read file: {}", e),
123            };
124        }
125    };
126
127    // Find the closing brace of the struct initializer
128    // We'll insert our new field before it
129    let Some(insert_info) =
130        find_struct_initializer_insert_point(&content, span.byte_start, span.byte_end)
131    else {
132        return AutofixResult::CannotFix {
133            reason: "Cannot find struct initializer closing brace".to_string(),
134        };
135    };
136
137    // Generate default value based on field name patterns
138    let default_value = infer_default_value(&field_name);
139
140    // Build the field initialization text with proper indentation
141    let field_init = if insert_info.needs_comma_before {
142        format!(
143            ",\n{}{}: {}",
144            insert_info.field_indent, field_name, default_value
145        )
146    } else if insert_info.is_empty_struct {
147        format!(
148            "\n{}{}: {},\n{}",
149            insert_info.field_indent, field_name, default_value, insert_info.closing_brace_indent
150        )
151    } else {
152        format!(
153            "\n{}{}: {},",
154            insert_info.field_indent, field_name, default_value
155        )
156    };
157
158    // Create the edit - insert before the closing brace
159    let expected = &content[insert_info.insert_at..insert_info.insert_at];
160
161    let edit = Edit::new(
162        span.file.clone(),
163        insert_info.insert_at,
164        insert_info.insert_at,
165        field_init,
166        expected,
167    );
168
169    // Verify the file is in workspace
170    if !span.file.starts_with(workspace) {
171        return AutofixResult::CannotFix {
172            reason: "File is outside workspace".to_string(),
173        };
174    }
175
176    AutofixResult::Fixed(vec![edit])
177}
178
179/// Fix E0433: unresolved module or crate path.
180///
181/// We accept compiler suggestions with `MachineApplicable` or `MaybeIncorrect`
182/// applicability, but only when there is exactly one safe candidate edit.
183fn fix_unresolved_module(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
184    let mut candidates: Vec<&Suggestion> = diag
185        .suggestions
186        .iter()
187        .filter(|suggestion| {
188            suggestion.file.starts_with(workspace)
189                && matches!(
190                    suggestion.applicability,
191                    Applicability::MachineApplicable | Applicability::MaybeIncorrect
192                )
193                && is_safe_path_replacement(&suggestion.replacement)
194        })
195        .collect();
196
197    if candidates.is_empty() {
198        return AutofixResult::CannotFix {
199            reason: "No safe suggestion candidates for E0433".to_string(),
200        };
201    }
202
203    // Prefer machine-applicable suggestions when available.
204    let has_machine_applicable = candidates
205        .iter()
206        .any(|s| s.applicability == Applicability::MachineApplicable);
207    if has_machine_applicable {
208        candidates.retain(|s| s.applicability == Applicability::MachineApplicable);
209    }
210
211    let mut edits = Vec::new();
212    for suggestion in candidates {
213        if let Some(edit) = suggestion_to_edit(suggestion, workspace) {
214            if edits.iter().any(|existing: &Edit| {
215                existing.file == edit.file
216                    && existing.byte_start == edit.byte_start
217                    && existing.byte_end == edit.byte_end
218                    && existing.new_text == edit.new_text
219            }) {
220                continue;
221            }
222            edits.push(edit);
223        }
224    }
225
226    if edits.len() != 1 {
227        return AutofixResult::CannotFix {
228            reason: format!("Ambiguous E0433 suggestions: {} candidates", edits.len()),
229        };
230    }
231
232    AutofixResult::Fixed(edits)
233}
234
235fn is_safe_path_replacement(replacement: &str) -> bool {
236    let replacement = replacement.trim();
237    !replacement.is_empty()
238        && replacement
239            .chars()
240            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
241}
242
243/// Parse E0063 error message to extract field name and struct name.
244///
245/// Example: "missing field `windows_sandbox_level` in initializer of `SandboxPolicy`"
246fn parse_missing_field_message(message: &str) -> Option<(String, String)> {
247    // Pattern: "missing field `FIELD` in initializer of `STRUCT`"
248    let field_start = message.find("missing field `")? + "missing field `".len();
249    let field_end = message[field_start..].find('`')? + field_start;
250    let field_name = message[field_start..field_end].to_string();
251
252    let struct_start = message.find("in initializer of `")? + "in initializer of `".len();
253    let struct_end = message[struct_start..].find('`')? + struct_start;
254    let struct_name = message[struct_start..struct_end].to_string();
255
256    Some((field_name, struct_name))
257}
258
259/// Information about where to insert a new field in a struct initializer.
260#[derive(Debug)]
261struct InsertPoint {
262    /// Byte offset where to insert
263    insert_at: usize,
264    /// Whether we need a comma before our new field
265    needs_comma_before: bool,
266    /// Whether the struct initializer is currently empty (just `{}`)
267    is_empty_struct: bool,
268    /// Indentation string for fields (detected from existing fields)
269    field_indent: String,
270    /// Indentation string for the closing brace
271    closing_brace_indent: String,
272}
273
274/// Find the insertion point for a new field in a struct initializer.
275///
276/// Scans backwards from the closing brace to find the right spot.
277fn find_struct_initializer_insert_point(
278    content: &str,
279    span_start: usize,
280    span_end: usize,
281) -> Option<InsertPoint> {
282    // The span typically points to the struct initializer expression
283    // We need to find the closing brace `}`
284    let search_start = span_start.saturating_sub(50); // Look a bit before in case span is partial
285    let search_end = (span_end + 500).min(content.len()); // Look ahead for the closing brace
286
287    let search_region = &content[search_start..search_end];
288
289    // Find matching braces to locate the struct body
290    let mut brace_depth = 0;
291    let mut in_string = false;
292    let mut escape_next = false;
293    let mut last_closing_brace = None;
294    let mut first_opening_brace = None;
295
296    for (i, c) in search_region.char_indices() {
297        if escape_next {
298            escape_next = false;
299            continue;
300        }
301
302        match c {
303            '\\' if in_string => escape_next = true,
304            '"' => in_string = !in_string,
305            '{' if !in_string => {
306                if first_opening_brace.is_none() {
307                    first_opening_brace = Some(search_start + i);
308                }
309                brace_depth += 1;
310            }
311            '}' if !in_string => {
312                brace_depth -= 1;
313                if brace_depth == 0 {
314                    last_closing_brace = Some(search_start + i);
315                    break;
316                }
317            }
318            _ => {}
319        }
320    }
321
322    let closing_brace = last_closing_brace?;
323    let opening_brace = first_opening_brace?;
324
325    // Check if struct is empty (only whitespace between braces)
326    let between_braces = &content[opening_brace + 1..closing_brace];
327    let is_empty = between_braces.trim().is_empty();
328
329    // Find the last non-whitespace character before the closing brace
330    let before_brace = &content[opening_brace + 1..closing_brace];
331    let last_content_char = before_brace.trim_end().chars().last();
332
333    // Determine if we need a comma (last char is not a comma and struct isn't empty)
334    let needs_comma = !is_empty && last_content_char != Some(',');
335
336    // Detect indentation from existing fields or closing brace
337    let (field_indent, closing_brace_indent) =
338        detect_indentation(content, opening_brace, closing_brace);
339
340    Some(InsertPoint {
341        insert_at: closing_brace,
342        needs_comma_before: needs_comma,
343        is_empty_struct: is_empty,
344        field_indent,
345        closing_brace_indent,
346    })
347}
348
349/// Detect the indentation used in a struct initializer.
350///
351/// Returns (field_indent, closing_brace_indent).
352fn detect_indentation(
353    content: &str,
354    opening_brace: usize,
355    closing_brace: usize,
356) -> (String, String) {
357    // Find the indentation of the closing brace by looking at the line it's on
358    let closing_brace_indent = get_line_indent(content, closing_brace);
359
360    // Try to find an existing field's indentation
361    let between_braces = &content[opening_brace + 1..closing_brace];
362
363    // Look for lines that contain a colon (field: value pattern)
364    for line in between_braces.lines() {
365        if line.contains(':') && !line.trim().is_empty() {
366            // Extract leading whitespace
367            let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
368            if !indent.is_empty() {
369                return (indent, closing_brace_indent);
370            }
371        }
372    }
373
374    // No existing fields found - use closing brace indent + 4 spaces
375    let field_indent = format!("{}    ", closing_brace_indent);
376    (field_indent, closing_brace_indent)
377}
378
379/// Get the indentation (leading whitespace) of the line containing the given byte offset.
380fn get_line_indent(content: &str, byte_offset: usize) -> String {
381    // Find the start of the line
382    let line_start = content[..byte_offset]
383        .rfind('\n')
384        .map(|i| i + 1)
385        .unwrap_or(0);
386
387    // Extract leading whitespace from line start to first non-whitespace
388    content[line_start..]
389        .chars()
390        .take_while(|c| *c == ' ' || *c == '\t')
391        .collect()
392}
393
394/// Infer a sensible default value for a field based on its name.
395///
396/// This is a heuristic - we can't know the actual type without more analysis.
397/// We err on the side of `None` for Option-like fields.
398fn infer_default_value(field_name: &str) -> &'static str {
399    // Common naming patterns that suggest Option<T>
400    if field_name.ends_with("_level")
401        || field_name.ends_with("_limit")
402        || field_name.ends_with("_timeout")
403        || field_name.ends_with("_override")
404        || field_name.ends_with("_config")
405        || field_name.ends_with("_policy")
406        || field_name.starts_with("optional_")
407        || field_name.starts_with("maybe_")
408        || field_name.contains("sandbox")
409    {
410        return "None";
411    }
412
413    // Boolean-like fields
414    if field_name.starts_with("is_")
415        || field_name.starts_with("has_")
416        || field_name.starts_with("can_")
417        || field_name.starts_with("should_")
418        || field_name.starts_with("enable")
419        || field_name.starts_with("disable")
420        || field_name.ends_with("_enabled")
421        || field_name.ends_with("_disabled")
422        || field_name.ends_with("_allowed")
423    {
424        return "false";
425    }
426
427    // Collection-like fields
428    if field_name.ends_with("s") && !field_name.ends_with("ss") {
429        // Plural names often indicate Vec/HashSet
430        return "Vec::new()";
431    }
432
433    // Count/size fields
434    if field_name.ends_with("_count")
435        || field_name.ends_with("_size")
436        || field_name.ends_with("_index")
437    {
438        return "0";
439    }
440
441    // String-like fields
442    if field_name.ends_with("_name")
443        || field_name.ends_with("_path")
444        || field_name.ends_with("_url")
445        || field_name.ends_with("_message")
446    {
447        return "String::new()";
448    }
449
450    // Default to None - safest for most new optional fields
451    // Upstream additions are often optional
452    "None"
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use cargo_metadata::diagnostic::{Applicability, DiagnosticLevel};
459    use std::path::PathBuf;
460    use tempfile::TempDir;
461
462    #[test]
463    fn test_parse_missing_field_message() {
464        let msg = "missing field `windows_sandbox_level` in initializer of `SandboxPolicy`";
465        let (field, struct_name) = parse_missing_field_message(msg).unwrap();
466        assert_eq!(field, "windows_sandbox_level");
467        assert_eq!(struct_name, "SandboxPolicy");
468    }
469
470    #[test]
471    fn test_parse_missing_field_message_complex() {
472        let msg = "missing field `foo_bar` in initializer of `some::module::MyStruct`";
473        let (field, struct_name) = parse_missing_field_message(msg).unwrap();
474        assert_eq!(field, "foo_bar");
475        assert_eq!(struct_name, "some::module::MyStruct");
476    }
477
478    #[test]
479    fn test_infer_default_value() {
480        assert_eq!(infer_default_value("windows_sandbox_level"), "None");
481        assert_eq!(infer_default_value("is_enabled"), "false");
482        assert_eq!(infer_default_value("items"), "Vec::new()");
483        assert_eq!(infer_default_value("retry_count"), "0");
484        assert_eq!(infer_default_value("file_name"), "String::new()");
485        assert_eq!(infer_default_value("unknown_field"), "None");
486    }
487
488    #[test]
489    fn test_find_insert_point_simple() {
490        let content = r#"let x = MyStruct {
491        field1: 1,
492        field2: 2,
493    };"#;
494
495        let insert = find_struct_initializer_insert_point(content, 8, 60).unwrap();
496        assert!(!insert.is_empty_struct);
497        assert!(!insert.needs_comma_before); // Last field has comma
498        assert_eq!(insert.field_indent, "        "); // 8 spaces
499        assert_eq!(insert.closing_brace_indent, "    "); // 4 spaces
500    }
501
502    #[test]
503    fn test_find_insert_point_no_trailing_comma() {
504        let content = r#"let x = MyStruct {
505        field1: 1,
506        field2: 2
507    };"#;
508
509        let insert = find_struct_initializer_insert_point(content, 8, 60).unwrap();
510        assert!(!insert.is_empty_struct);
511        assert!(insert.needs_comma_before); // Last field missing comma
512        assert_eq!(insert.field_indent, "        "); // 8 spaces
513    }
514
515    #[test]
516    fn test_find_insert_point_empty() {
517        let content = "let x = MyStruct { };";
518        let insert = find_struct_initializer_insert_point(content, 8, 20).unwrap();
519        assert!(insert.is_empty_struct);
520    }
521
522    #[test]
523    fn test_detect_indentation_tabs() {
524        let content = "let x = MyStruct {\n\t\tfield1: 1,\n\t}";
525        let insert = find_struct_initializer_insert_point(content, 8, 35).unwrap();
526        assert_eq!(insert.field_indent, "\t\t"); // 2 tabs
527        assert_eq!(insert.closing_brace_indent, "\t"); // 1 tab
528    }
529
530    #[test]
531    fn test_detect_indentation_real_world() {
532        // Simulates the real-world case that was failing
533        let content = r#"        self.app_event_tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
534            sandbox_policy: SandboxPolicy::new_read_only_policy(),
535            model: self.model.clone(),
536        }));"#;
537
538        let insert = find_struct_initializer_insert_point(content, 50, 180).unwrap();
539        assert_eq!(insert.field_indent, "            "); // 12 spaces
540        assert_eq!(insert.closing_brace_indent, "        "); // 8 spaces
541    }
542
543    #[test]
544    fn test_fix_e0433_with_single_safe_suggestion() {
545        let workspace = TempDir::new().unwrap();
546        let file = workspace.path().join("mod.rs");
547        let source = "use codex_common::approval_presets::builtin_approval_presets;\n";
548        std::fs::write(&file, source).unwrap();
549
550        let needle = "codex_common";
551        let byte_start = source.find(needle).unwrap();
552        let byte_end = byte_start + needle.len();
553
554        let diag = CompileDiagnostic {
555            code: Some("E0433".to_string()),
556            message: "failed to resolve: use of unresolved module or unlinked crate `codex_common`"
557                .to_string(),
558            level: DiagnosticLevel::Error,
559            spans: vec![],
560            suggestions: vec![Suggestion {
561                file: PathBuf::from(&file),
562                byte_start,
563                byte_end,
564                replacement: "codex_utils_approval_presets".to_string(),
565                applicability: Applicability::MaybeIncorrect,
566                message: "there is a crate with a similar name".to_string(),
567            }],
568            rendered: None,
569        };
570
571        let result = try_autofix(&diag, workspace.path());
572        match result {
573            AutofixResult::Fixed(edits) => {
574                assert_eq!(edits.len(), 1);
575                assert_eq!(edits[0].byte_start, byte_start);
576                assert_eq!(edits[0].byte_end, byte_end);
577                assert_eq!(edits[0].new_text, "codex_utils_approval_presets");
578            }
579            AutofixResult::CannotFix { reason } => panic!("expected fix, got: {reason}"),
580        }
581    }
582
583    #[test]
584    fn test_fix_e0433_rejects_ambiguous_suggestions() {
585        let workspace = TempDir::new().unwrap();
586        let file = workspace.path().join("mod.rs");
587        let source = "use codex_common::foo;\nuse codex_common::bar;\n";
588        std::fs::write(&file, source).unwrap();
589
590        let first = source.find("codex_common").unwrap();
591        let second = source[first + 1..].find("codex_common").unwrap() + first + 1;
592        let end = first + "codex_common".len();
593        let second_end = second + "codex_common".len();
594
595        let diag = CompileDiagnostic {
596            code: Some("E0433".to_string()),
597            message: "failed to resolve".to_string(),
598            level: DiagnosticLevel::Error,
599            spans: vec![],
600            suggestions: vec![
601                Suggestion {
602                    file: PathBuf::from(&file),
603                    byte_start: first,
604                    byte_end: end,
605                    replacement: "codex_core".to_string(),
606                    applicability: Applicability::MaybeIncorrect,
607                    message: "hint".to_string(),
608                },
609                Suggestion {
610                    file: PathBuf::from(&file),
611                    byte_start: second,
612                    byte_end: second_end,
613                    replacement: "codex_utils".to_string(),
614                    applicability: Applicability::MaybeIncorrect,
615                    message: "hint".to_string(),
616                },
617            ],
618            rendered: None,
619        };
620
621        let result = try_autofix(&diag, workspace.path());
622        match result {
623            AutofixResult::CannotFix { reason } => {
624                assert!(reason.contains("Ambiguous E0433 suggestions"));
625            }
626            AutofixResult::Fixed(_) => panic!("expected ambiguous result to be rejected"),
627        }
628    }
629}