Fix input formatter bugs: apostrophe highlighting and line duplication
Fixes two bugs in the input formatter: 1. Single/double quote regex now requires word boundaries: - Contractions like it's, don't, won't no longer trigger highlighting - Only properly quoted text like 'special' or "hello" gets cyan - Mixed input like "it's a 'test' case" only highlights 'test' 2. Visual line calculation fix for exact terminal width: - When text exactly fills terminal width, cursor wraps to next line - Added +1 adjustment to account for this edge case - Extracted calculate_visual_lines() for testability Added 9 new tests covering all edge cases.
This commit is contained in:
@@ -19,8 +19,16 @@ static CAPS_RE: Lazy<Regex> = Lazy::new(|| {
|
|||||||
// ALL CAPS words: 2+ uppercase letters, may include numbers, word boundaries
|
// ALL CAPS words: 2+ uppercase letters, may include numbers, word boundaries
|
||||||
Regex::new(r"\b([A-Z][A-Z0-9]{1,}[A-Z0-9]*)\b").unwrap()
|
Regex::new(r"\b([A-Z][A-Z0-9]{1,}[A-Z0-9]*)\b").unwrap()
|
||||||
});
|
});
|
||||||
static DOUBLE_QUOTE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""([^"]+)""#).unwrap());
|
static DOUBLE_QUOTE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||||
static SINGLE_QUOTE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"'([^']+)'").unwrap());
|
// Double-quoted text: quote must be preceded by whitespace/punctuation or start of string,
|
||||||
|
// and followed by whitespace/punctuation or end of string
|
||||||
|
Regex::new(r#"(?:^|[\s(\[{])"([^"]+)"(?:$|[\s.,;:!?)\]}])"#).unwrap()
|
||||||
|
});
|
||||||
|
static SINGLE_QUOTE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
// Single-quoted text: quote must be preceded by whitespace/punctuation or start of string,
|
||||||
|
// and followed by whitespace/punctuation or end of string (avoids contractions like "it's")
|
||||||
|
Regex::new(r#"(?:^|[\s(\[{])'([^']+)'(?:$|[\s.,;:!?)\]}])"#).unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
/// Pre-process input to add markdown markers before formatting.
|
/// Pre-process input to add markdown markers before formatting.
|
||||||
/// ALL CAPS → **bold**, quoted text → special markers for cyan.
|
/// ALL CAPS → **bold**, quoted text → special markers for cyan.
|
||||||
@@ -77,6 +85,21 @@ pub fn format_input(input: &str) -> String {
|
|||||||
apply_quote_highlighting(&formatted)
|
apply_quote_highlighting(&formatted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate the number of visual lines that text occupies in a terminal.
|
||||||
|
/// Accounts for line wrapping and the cursor position after typing.
|
||||||
|
pub fn calculate_visual_lines(text_len: usize, term_width: usize) -> usize {
|
||||||
|
if term_width == 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let mut visual_lines = text_len.div_ceil(term_width).max(1);
|
||||||
|
// When text exactly fills the terminal width (or a multiple), the cursor
|
||||||
|
// wraps to the next line, so we need to clear one additional line
|
||||||
|
if text_len > 0 && text_len % term_width == 0 {
|
||||||
|
visual_lines += 1;
|
||||||
|
}
|
||||||
|
visual_lines
|
||||||
|
}
|
||||||
|
|
||||||
/// Reprint user input in place with formatting (TTY only).
|
/// Reprint user input in place with formatting (TTY only).
|
||||||
/// Moves cursor up to overwrite original input, then prints formatted version.
|
/// Moves cursor up to overwrite original input, then prints formatted version.
|
||||||
pub fn reprint_formatted_input(input: &str, prompt: &str) {
|
pub fn reprint_formatted_input(input: &str, prompt: &str) {
|
||||||
@@ -88,7 +111,7 @@ pub fn reprint_formatted_input(input: &str, prompt: &str) {
|
|||||||
|
|
||||||
// Calculate visual lines (prompt + input may wrap across terminal rows)
|
// Calculate visual lines (prompt + input may wrap across terminal rows)
|
||||||
let term_width = terminal::size().map(|(w, _)| w as usize).unwrap_or(80);
|
let term_width = terminal::size().map(|(w, _)| w as usize).unwrap_or(80);
|
||||||
let visual_lines = (prompt.len() + input.len()).div_ceil(term_width).max(1);
|
let visual_lines = calculate_visual_lines(prompt.len() + input.len(), term_width);
|
||||||
|
|
||||||
// Move up and clear each line
|
// Move up and clear each line
|
||||||
for _ in 0..visual_lines {
|
for _ in 0..visual_lines {
|
||||||
@@ -187,4 +210,80 @@ mod tests {
|
|||||||
assert!(result.contains("**IO**"));
|
assert!(result.contains("**IO**"));
|
||||||
assert!(result.contains("**DB**"));
|
assert!(result.contains("**DB**"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tests for apostrophe/contraction handling (I1 bug fix)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contraction_not_highlighted() {
|
||||||
|
// Contractions should NOT be treated as quoted text
|
||||||
|
let input = "it's fine";
|
||||||
|
let result = preprocess_input(input);
|
||||||
|
// Should not contain quote markers
|
||||||
|
assert!(!result.contains("\x00qsgl\x00"));
|
||||||
|
assert!(!result.contains("\x00qend\x00"));
|
||||||
|
assert_eq!(result, "it's fine");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_contractions_not_highlighted() {
|
||||||
|
let input = "don't won't can't shouldn't";
|
||||||
|
let result = preprocess_input(input);
|
||||||
|
assert!(!result.contains("\x00qsgl\x00"));
|
||||||
|
assert_eq!(result, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contraction_with_quoted_text() {
|
||||||
|
// Mixed: contraction + actual quoted text
|
||||||
|
// Only 'test' should be highlighted, not the apostrophe in "it's"
|
||||||
|
let input = "it's a 'test' case";
|
||||||
|
let result = preprocess_input(input);
|
||||||
|
assert!(result.contains("\x00qsgl\x00test\x00qend\x00"));
|
||||||
|
// The "it's" should remain unchanged
|
||||||
|
assert!(result.contains("it's"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quoted_at_start_of_string() {
|
||||||
|
let input = "'hello' world";
|
||||||
|
let result = preprocess_input(input);
|
||||||
|
assert!(result.contains("\x00qsgl\x00hello\x00qend\x00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quoted_at_end_of_string() {
|
||||||
|
let input = "say 'goodbye'";
|
||||||
|
let result = preprocess_input(input);
|
||||||
|
assert!(result.contains("\x00qsgl\x00goodbye\x00qend\x00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for visual line calculation (I2 bug fix)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visual_lines_shorter_than_width() {
|
||||||
|
// 50 chars on 80-char terminal = 1 line
|
||||||
|
assert_eq!(calculate_visual_lines(50, 80), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visual_lines_longer_than_width() {
|
||||||
|
// 100 chars on 80-char terminal = 2 lines (wraps once)
|
||||||
|
assert_eq!(calculate_visual_lines(100, 80), 2);
|
||||||
|
// 170 chars on 80-char terminal = 3 lines
|
||||||
|
assert_eq!(calculate_visual_lines(170, 80), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visual_lines_exactly_equals_width() {
|
||||||
|
// 80 chars on 80-char terminal = 2 lines (cursor wraps to next line)
|
||||||
|
assert_eq!(calculate_visual_lines(80, 80), 2);
|
||||||
|
// 160 chars on 80-char terminal = 3 lines (fills 2 lines exactly, cursor on 3rd)
|
||||||
|
assert_eq!(calculate_visual_lines(160, 80), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visual_lines_empty_input() {
|
||||||
|
// Empty input should still be 1 line (the prompt line)
|
||||||
|
assert_eq!(calculate_visual_lines(0, 80), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user