diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index b6660a95..b94cc474 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -20,6 +20,7 @@ pub mod green_contract; mod hooks; mod json; mod lane_events; +pub mod line_range_selector; pub mod lsp_client; mod mcp; mod mcp_client; diff --git a/rust/crates/runtime/src/line_range_selector.rs b/rust/crates/runtime/src/line_range_selector.rs new file mode 100644 index 00000000..cd2abe5f --- /dev/null +++ b/rust/crates/runtime/src/line_range_selector.rs @@ -0,0 +1,342 @@ +/// GJC-compatible line range selector parsing for the read tool. +/// +/// Supports selector syntax: +/// - `:50` = start at line 50 +/// - `:50-200` = lines 50-200 inclusive +/// - `:50+150` = 150 lines starting at 50 +/// - `:50+1` = exactly one line +/// - `:5-16,960-973` = multiple ranges +/// - `:raw` = full verbatim +/// - `:conflicts` = git merge conflicts only + +use std::num::ParseIntError; + +/// Parsed line range specification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LineRangeSelector { + /// Full file verbatim (`:raw`) + Raw, + /// Git merge conflict sections only (`:conflicts`) + Conflicts, + /// Line range(s) (`:L-L+N` syntax) + Lines(Vec), + /// No selector (full file, may be truncated by default budget) + None, +} + +/// A single line range, zero-indexed internally but displayed as 1-indexed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LineRange { + /// Start line (1-indexed, user-facing) + pub start_line: usize, + /// End line inclusive (1-indexed, user-facing) + pub end_line: usize, +} + +impl LineRange { + /// Create a new line range (1-indexed). + pub fn new(start_line: usize, end_line: usize) -> Self { + Self { start_line, end_line } + } + + /// Convert to zero-indexed range for internal use. + pub fn zero_indexed(&self) -> (usize, usize) { + (self.start_line.saturating_sub(1), self.end_line.saturating_sub(1)) + } + + /// Get the number of lines in this range. + pub fn len(&self) -> usize { + if self.start_line <= self.end_line { + self.end_line - self.start_line + 1 + } else { + 0 + } + } + + /// Check if this range is valid. + pub fn is_valid(&self) -> bool { + self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line + } +} + +/// Parse error types for line range selectors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + InvalidFormat(String), + InvalidNumber(ParseIntError), + InvalidRange(String), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg), + ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e), + ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg), + } + } +} + +impl std::error::Error for ParseError {} + +impl From for ParseError { + fn from(e: ParseIntError) -> Self { + ParseError::InvalidNumber(e) + } +} + +/// Parse a line range selector string. +/// +/// Examples: +/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])` +/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])` +/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])` +/// - `"raw"` → `LineRangeSelector::Raw` +/// - `"conflicts"` → `LineRangeSelector::Conflicts` +pub fn parse_selector(selector: &str) -> Result { + if selector.is_empty() { + return Ok(LineRangeSelector::None); + } + + match selector { + "raw" => Ok(LineRangeSelector::Raw), + "conflicts" => Ok(LineRangeSelector::Conflicts), + _ => parse_line_ranges(selector), + } +} + +/// Parse comma-separated line ranges. +fn parse_line_ranges(selector: &str) -> Result { + let mut ranges = Vec::new(); + + for range_str in selector.split(',') { + let range_str = range_str.trim(); + if range_str.is_empty() { + continue; + } + + let range = parse_single_range(range_str)?; + ranges.push(range); + } + + if ranges.is_empty() { + Ok(LineRangeSelector::None) + } else { + // Merge overlapping/adjacent ranges + ranges.sort_by_key(|r| r.start_line); + let merged = merge_ranges(ranges); + Ok(LineRangeSelector::Lines(merged)) + } +} + +/// Parse a single line range in formats: "50", "50-200", "50+150". +fn parse_single_range(range_str: &str) -> Result { + // Try "50+150" format first (start+count) + if let Some(plus_idx) = range_str.find('+') { + let start_str = &range_str[..plus_idx]; + let count_str = &range_str[plus_idx + 1..]; + + if start_str.is_empty() || count_str.is_empty() { + return Err(ParseError::InvalidFormat(range_str.to_string())); + } + + let start = start_str.parse::()?; + let count = count_str.parse::()?; + + if start == 0 || count == 0 { + return Err(ParseError::InvalidRange( + "Line numbers and counts must be >= 1".to_string(), + )); + } + + return Ok(LineRange::new(start, start + count - 1)); + } + + // Try "50-200" format (start-end) + if let Some(dash_idx) = range_str.find('-') { + // Skip leading dash (negative numbers aren't valid here) + if dash_idx == 0 { + return Err(ParseError::InvalidFormat(range_str.to_string())); + } + + let start_str = &range_str[..dash_idx]; + let end_str = &range_str[dash_idx + 1..]; + + if start_str.is_empty() || end_str.is_empty() { + return Err(ParseError::InvalidFormat(range_str.to_string())); + } + + let start = start_str.parse::()?; + let end = end_str.parse::()?; + + if start == 0 || end == 0 { + return Err(ParseError::InvalidRange( + "Line numbers must be >= 1".to_string(), + )); + } + + if start > end { + return Err(ParseError::InvalidRange(format!( + "Start line {} cannot be greater than end line {}", + start, end + ))); + } + + return Ok(LineRange::new(start, end)); + } + + // Try plain "50" format (single line) + let line_num = range_str.parse::()?; + if line_num == 0 { + return Err(ParseError::InvalidRange( + "Line number must be >= 1".to_string(), + )); + } + + Ok(LineRange::new(line_num, line_num)) +} + +/// Merge overlapping and adjacent ranges. +fn merge_ranges(ranges: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + + for range in ranges { + if let Some(last) = merged.last_mut() { + // If ranges overlap or are adjacent, merge them + if range.start_line <= last.end_line + 1 { + last.end_line = last.end_line.max(range.end_line); + } else { + merged.push(range); + } + } else { + merged.push(range); + } + } + + merged +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_raw() { + assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw); + } + + #[test] + fn test_parse_conflicts() { + assert_eq!( + parse_selector("conflicts").unwrap(), + LineRangeSelector::Conflicts + ); + } + + #[test] + fn test_parse_single_line() { + match parse_selector("50").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_line, 50); + assert_eq!(ranges[0].end_line, 50); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_parse_range_start_dash_end() { + match parse_selector("50-200").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_line, 50); + assert_eq!(ranges[0].end_line, 200); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_parse_range_start_plus_count() { + match parse_selector("50+150").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_line, 50); + assert_eq!(ranges[0].end_line, 199); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_parse_multiple_ranges() { + match parse_selector("5-16,960-973").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 2); + assert_eq!(ranges[0].start_line, 5); + assert_eq!(ranges[0].end_line, 16); + assert_eq!(ranges[1].start_line, 960); + assert_eq!(ranges[1].end_line, 973); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_merge_overlapping_ranges() { + // "5-16,10-20" should merge into "5-20" + match parse_selector("5-16,10-20").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_line, 5); + assert_eq!(ranges[0].end_line, 20); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_merge_adjacent_ranges() { + // "5-16,17-20" should merge into "5-20" + match parse_selector("5-16,17-20").unwrap() { + LineRangeSelector::Lines(ranges) => { + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start_line, 5); + assert_eq!(ranges[0].end_line, 20); + } + _ => panic!("Expected Lines variant"), + } + } + + #[test] + fn test_invalid_range_start_greater_than_end() { + let result = parse_selector("200-50"); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_zero_line() { + let result = parse_selector("0"); + assert!(result.is_err()); + } + + #[test] + fn test_empty_selector() { + assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None); + } + + #[test] + fn test_line_range_zero_indexed() { + let range = LineRange::new(50, 200); + let (start, end) = range.zero_indexed(); + assert_eq!(start, 49); + assert_eq!(end, 199); + } + + #[test] + fn test_line_range_len() { + assert_eq!(LineRange::new(50, 200).len(), 151); + assert_eq!(LineRange::new(50, 50).len(), 1); + } +}