diff --git a/docs/gjc-compat/tool-semantics-phase1.md b/docs/gjc-compat/tool-semantics-phase1.md index 4f4d03cf..85a9fd82 100644 --- a/docs/gjc-compat/tool-semantics-phase1.md +++ b/docs/gjc-compat/tool-semantics-phase1.md @@ -222,7 +222,7 @@ pub fn read_file_in_workspace( **Missing**: - ✗ Line range selectors (`:L-L+N` syntax) - ✗ Raw mode (`:raw`) -- ✗ Git merge conflict extraction (`:conflicts`) +- ✓ Git merge conflict extraction (`:conflicts`) - ✗ Truncation footers with recovery hints - ✗ Structural summarization (collapse braces) - ✗ Archive/SQLite support @@ -272,7 +272,8 @@ pub fn read_file_in_workspace( ### Batch 1: READ Tool (Priority P0) - Add line range selector parsing -- Implement `:raw` and `:conflicts` modes +- Implement `:raw` mode +- ✓ Implement `:conflicts` mode - Add truncation footers with recovery hints - Implement structural summarization - Add tests for all selector combinations diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index 696816cc..1ea0d42a 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -247,6 +247,34 @@ pub fn read_file_with_selector( let lines: Vec<&str> = content.lines().collect(); let total_lines = lines.len(); + // Special handling for conflicts selector + if selector == Some("conflicts") { + let conflicts = extract_merge_conflicts(&lines); + let (selected, num_lines) = if conflicts.is_empty() { + (format!("No unresolved git merge conflicts in {}.", path), 1) + } else { + let display_path = absolute_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path); + let summary = format_conflict_summary(&conflicts, display_path); + let summary_lines = summary.lines().count(); + (summary, summary_lines) + }; + + return Ok(ReadFileOutput { + kind: String::from("text"), + file: TextFilePayload { + file_path: absolute_path.to_string_lossy().into_owned(), + content: selected, + num_lines, + start_line: 1, + total_lines, + }, + truncation_footer: None, + }); + } + // Determine which lines to select let (start_index, end_index, truncation_footer) = if let Some(sel) = selector { // Parse and apply selector @@ -281,6 +309,195 @@ pub fn read_file_with_selector( }) } +/// Represents a merge conflict block in a file. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConflictBlock { + start_line: usize, // 1-based line number where <<<<<<< starts + separator_line: usize, // 1-based line number where ======= is + end_line: usize, // 1-based line number where >>>>>>> is + base_line: Option, // 1-based line number where ||||||| is (for 3-way) + ours_label: Option, + base_label: Option, + theirs_label: Option, +} + +/// Scans lines for unresolved git merge conflict blocks. +fn extract_merge_conflicts(lines: &[&str]) -> Vec { + let mut blocks = Vec::new(); + let mut i = 0; + + const OURS_PREFIX: &str = "<<<<<<<"; + const BASE_PREFIX: &str = "|||||||"; + const SEPARATOR: &str = "======="; + const THEIRS_PREFIX: &str = ">>>>>>>"; + + while i < lines.len() { + let line = lines[i]; + + // Check for start of conflict block + if let Some(label) = extract_marker_label(line, OURS_PREFIX) { + let ours_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; + let start_line = i + 1; // Convert to 1-based + let mut separator_line = None; + let mut base_line = None; + let mut base_label = None; + let mut end_line = None; + let mut theirs_label = None; + let mut j = i + 1; + + // Look for separator, optional base marker, and end marker + while j < lines.len() { + let current = lines[j]; + + // Check for base marker (3-way) + if let Some(label) = extract_marker_label(current, BASE_PREFIX) { + if base_line.is_none() { + base_line = Some(j + 1); + base_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; + j += 1; + continue; + } + } + + // Check for separator + if current.trim() == SEPARATOR { + if separator_line.is_none() { + separator_line = Some(j + 1); + j += 1; + continue; + } + } + + // Check for end marker + if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) { + if separator_line.is_some() { + end_line = Some(j + 1); + theirs_label = if label.is_empty() { + None + } else { + Some(label.to_string()) + }; + j += 1; + break; + } + } + + j += 1; + } + + // Only add if we found both separator and end marker + if let (Some(sep_line), Some(end)) = (separator_line, end_line) { + blocks.push(ConflictBlock { + start_line, + separator_line: sep_line, + end_line: end, + base_line, + ours_label, + base_label, + theirs_label, + }); + i = j; + continue; + } + } + + i += 1; + } + + blocks +} + +/// Extract the optional label from a marker line. +/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix, +/// or None otherwise. +fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + if line.starts_with(prefix) { + let rest = &line[prefix.len()..]; + // Accept either just the prefix or prefix + single space + label + if rest.is_empty() { + Some("") + } else if rest.starts_with(' ') { + Some(rest[1..].trim()) + } else { + None + } + } else { + None + } +} + +/// Format conflict blocks as a summary index, like GJC's implementation. +fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String { + let mut output = Vec::new(); + let total = conflicts.len(); + let word = if total == 1 { "conflict" } else { "conflicts" }; + + output.push(format!( + "⚠ {} unresolved {} in {}", + total, word, display_path + )); + + // Extract labels for header + let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref()); + let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref()); + let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref()); + let any_base = conflicts.iter().any(|c| c.base_line.is_some()); + + if let Some(label) = ours_label { + output.push(format!("- ours = {}", label)); + } + if let Some(label) = theirs_label { + output.push(format!("- theirs = {}", label)); + } + if any_base { + let base_name = base_label + .map(|l| l.to_string()) + .unwrap_or_else(|| "(no label)".to_string()); + output.push(format!("- base = {}", base_name)); + } + + output.push( + "NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://\", content })`. Inspect a block by reading `conflict://` (add `/ours` / `/theirs` / `/base` for a single side).".to_string() + ); + output.push( + "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string() + ); + output.push(String::new()); + + // Format each conflict as a one-liner index + let id_width = total.to_string().len(); + for (idx, conflict) in conflicts.iter().enumerate() { + let id = idx + 1; + let range = if conflict.start_line == conflict.end_line { + format!("L{}", conflict.start_line) + } else { + format!("L{}-{}", conflict.start_line, conflict.end_line) + }; + let kind = if conflict.base_line.is_some() { + " (3-way)" + } else { + "" + }; + output.push(format!( + "#{:>width$} {}{}", + id, + range, + kind, + width = id_width + )); + } + + output.join("\n") +} + /// Apply a parsed line selector to file lines and return the selected range. /// /// Returns (start_index, end_index, truncation_footer). @@ -295,7 +512,7 @@ fn apply_line_selector( (0, lines.len(), None) } LineRangeSelector::Conflicts => { - // For now, return entire file (git merge conflict extraction would go here) + // Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached (0, lines.len(), None) } LineRangeSelector::Lines(ranges) => { @@ -1400,4 +1617,74 @@ mod tests { ); assert!(result.is_err()); } + + #[test] + fn read_with_selector_conflicts_no_conflicts() { + let path = temp_path("conflicts-none.txt"); + write_file( + path.to_string_lossy().as_ref(), + "const a = 1;\nconst b = 2;\n", + ) + .expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + assert!(output + .file + .content + .contains("No unresolved git merge conflicts")); + } + + #[test] + fn read_with_selector_conflicts_two_way() { + let path = temp_path("conflicts-two-way.txt"); + let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail"; + write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + + let text = &output.file.content; + // Should report 2 conflicts + assert!(text.contains("2 unresolved conflicts"), "text: {}", text); + // Should have indices for both blocks + assert!(text.contains("#1"), "missing #1: {}", text); + assert!(text.contains("#2"), "missing #2: {}", text); + // Should have line ranges + assert!(text.contains("L1-5"), "missing L1-5: {}", text); + assert!(text.contains("L7-11"), "missing L7-11: {}", text); + } + + #[test] + fn read_with_selector_conflicts_three_way() { + let path = temp_path("conflicts-three-way.txt"); + let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail"; + write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed"); + + let output = super::read_file_with_selector( + path.to_string_lossy().as_ref(), + None, + None, + Some("conflicts"), + ) + .expect("read with conflicts selector should succeed"); + + let text = &output.file.content; + // Should report 1 conflict + assert!(text.contains("1 unresolved conflict"), "text: {}", text); + // Should mark as 3-way + assert!(text.contains("3-way"), "missing 3-way marker: {}", text); + // Should show line range + assert!(text.contains("L2-8"), "missing L2-8: {}", text); + } }