LCOV - code coverage report
Current view: top level - dashql-core-shell/src - prompt_buffer.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 99.7 % 312 311
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! The editable prompt of the shell.
       2              : //!
       3              : //! The prompt is a script whose cursor moves by extended grapheme clusters. Grapheme offsets are
       4              : //! those of the whole text, which is short and segmented on demand.
       5              : 
       6              : use super::unicode::{grapheme_boundaries, is_valid, next_grapheme, previous_grapheme};
       7              : use dashql_core::catalog::Catalog;
       8              : use dashql_core::script::Script;
       9              : 
      10              : /// A grapheme position: the byte and code point offsets of a grapheme cluster boundary
      11              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      12              : struct Position {
      13              :     /// The byte offset of the boundary
      14              :     bytes: usize,
      15              :     /// The code point offset of the boundary
      16              :     codepoints: usize,
      17              :     /// The index of the grapheme cluster that starts at the boundary
      18              :     graphemes: usize,
      19              : }
      20              : 
      21              : /// The grapheme boundaries of a text
      22              : struct Positions {
      23              :     /// The byte offsets of the boundaries, including the end
      24              :     bytes: Vec<usize>,
      25              :     /// The code point offsets of the boundaries, including the end
      26              :     codepoints: Vec<usize>,
      27              : }
      28              : 
      29              : impl Positions {
      30         2301 :     fn of(text: &str) -> Self {
      31         2301 :         let bytes = grapheme_boundaries(text);
      32         2301 :         let mut codepoints = Vec::with_capacity(bytes.len());
      33         2301 :         let mut count = 0;
      34         2301 :         let mut previous = 0;
      35        62517 :         for &b in &bytes {
      36        62796 :             count += text.as_bytes()[previous..b].iter().filter(|&&c| (c & 0xC0) != 0x80).count();
      37        62517 :             codepoints.push(count);
      38        62517 :             previous = b;
      39              :         }
      40         2301 :         Self { bytes, codepoints }
      41         2301 :     }
      42              : 
      43         1962 :     fn count(&self) -> usize {
      44         1962 :         self.bytes.len() - 1
      45         1962 :     }
      46              : 
      47         2248 :     fn position(&self, idx: usize) -> Position {
      48         2248 :         Position { bytes: self.bytes[idx], codepoints: self.codepoints[idx], graphemes: idx }
      49         2248 :     }
      50              : 
      51              :     /// The start of a grapheme cluster, the end for indexes past the end
      52         1824 :     fn resolve(&self, idx: usize) -> Position {
      53         1824 :         self.position(idx.min(self.count()))
      54         1824 :     }
      55              : 
      56              :     /// The boundary at an exact byte offset
      57          158 :     fn boundary(&self, byte: usize) -> Option<Position> {
      58          158 :         self.bytes.binary_search(&byte).ok().map(|idx| self.position(idx))
      59          158 :     }
      60              : 
      61              :     /// The first boundary at or after a byte offset
      62          273 :     fn at_or_after(&self, byte: usize) -> Position {
      63          273 :         let byte = byte.min(*self.bytes.last().unwrap());
      64         1502 :         self.position(self.bytes.partition_point(|&b| b < byte))
      65          273 :     }
      66              : 
      67              :     /// The boundary at a byte offset that callers expect to be one. A line break inside a CR LF cluster has no
      68              :     /// boundary; take the boundary before it.
      69           24 :     fn unchecked_boundary(&self, byte: usize) -> Position {
      70           24 :         self.boundary(byte)
      71           24 :             .unwrap_or_else(|| self.position(self.bytes.partition_point(|&b| b <= byte).saturating_sub(1)))
      72           24 :     }
      73              : }
      74              : 
      75          122 : fn is_word_grapheme(grapheme: &str) -> bool {
      76          122 :     grapheme.chars().next().is_some_and(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || c == '_')
      77          122 : }
      78              : 
      79              : /// The prompt of a shell
      80              : pub struct PromptBuffer {
      81              :     /// The script that holds the prompt text
      82              :     script: Script,
      83              :     /// The cursor position in grapheme clusters
      84              :     cursor_grapheme_offset: usize,
      85              :     /// The number of text changes
      86              :     revision: u64,
      87              : }
      88              : 
      89              : impl PromptBuffer {
      90          129 :     pub fn new(catalog: &Catalog) -> Self {
      91          129 :         Self { script: Script::new(catalog), cursor_grapheme_offset: 0, revision: 0 }
      92          129 :     }
      93              : 
      94         2282 :     fn positions(&self) -> Positions {
      95         2282 :         Positions::of(&self.script.to_text())
      96         2282 :     }
      97              : 
      98              :     /// The script of the prompt
      99            1 :     pub fn script(&self) -> &Script {
     100            1 :         &self.script
     101            1 :     }
     102              : 
     103          619 :     pub fn script_mut(&mut self) -> &mut Script {
     104          619 :         &mut self.script
     105          619 :     }
     106              : 
     107              :     /// The prompt text
     108         1591 :     pub fn text(&self) -> String {
     109         1591 :         self.script.to_text()
     110         1591 :     }
     111              : 
     112          440 :     pub fn revision(&self) -> u64 {
     113          440 :         self.revision
     114          440 :     }
     115              : 
     116              :     /// The number of grapheme clusters
     117          124 :     pub fn grapheme_count(&self) -> usize {
     118          124 :         self.positions().count()
     119          124 :     }
     120              : 
     121         1542 :     pub fn cursor_byte_offset(&self) -> usize {
     122         1542 :         self.positions().resolve(self.cursor_grapheme_offset).bytes
     123         1542 :     }
     124              : 
     125            1 :     pub fn cursor_codepoint_offset(&self) -> usize {
     126            1 :         self.positions().resolve(self.cursor_grapheme_offset).codepoints
     127            1 :     }
     128              : 
     129              :     /// Replace the text and move the cursor to the end
     130           91 :     pub fn set_text(&mut self, text: &[u8]) -> bool {
     131           91 :         let Ok(text) = std::str::from_utf8(text) else {
     132            2 :             return false;
     133              :         };
     134           89 :         if text != self.script.to_text() {
     135           78 :             self.script.replace_text(text);
     136           78 :             self.revision += 1;
     137           78 :         }
     138           89 :         self.cursor_grapheme_offset = self.grapheme_count();
     139           89 :         true
     140           91 :     }
     141              : 
     142              :     /// Insert text at the cursor
     143          241 :     pub fn insert(&mut self, text: &[u8]) -> bool {
     144          241 :         if text.is_empty() {
     145            3 :             return false;
     146          238 :         }
     147          238 :         let Ok(text) = std::str::from_utf8(text) else {
     148            3 :             return false;
     149              :         };
     150          235 :         let cursor = self.positions().resolve(self.cursor_grapheme_offset);
     151          235 :         let desired_byte_offset = cursor.bytes + text.len();
     152          235 :         self.script.insert_text_at(cursor.codepoints, text);
     153          235 :         self.revision += 1;
     154          235 :         self.cursor_grapheme_offset = self.positions().at_or_after(desired_byte_offset).graphemes;
     155          235 :         true
     156          241 :     }
     157              : 
     158           37 :     pub fn move_left(&mut self) -> bool {
     159           37 :         if self.cursor_grapheme_offset == 0 {
     160            1 :             return false;
     161           36 :         }
     162           36 :         self.cursor_grapheme_offset -= 1;
     163           36 :         true
     164           37 :     }
     165              : 
     166           26 :     pub fn move_right(&mut self) -> bool {
     167           26 :         if self.cursor_grapheme_offset >= self.grapheme_count() {
     168           18 :             return false;
     169            8 :         }
     170            8 :         self.cursor_grapheme_offset += 1;
     171            8 :         true
     172           26 :     }
     173              : 
     174            8 :     pub fn move_to_start(&mut self) -> bool {
     175            8 :         if self.cursor_grapheme_offset == 0 {
     176            1 :             return false;
     177            7 :         }
     178            7 :         self.cursor_grapheme_offset = 0;
     179            7 :         true
     180            8 :     }
     181              : 
     182            6 :     pub fn move_to_end(&mut self) -> bool {
     183            6 :         let end = self.grapheme_count();
     184            6 :         if self.cursor_grapheme_offset == end {
     185            3 :             return false;
     186            3 :         }
     187            3 :         self.cursor_grapheme_offset = end;
     188            3 :         true
     189            6 :     }
     190              : 
     191            6 :     pub fn move_to_line_start(&mut self) -> bool {
     192            6 :         let text = self.text();
     193            6 :         let cursor = self.cursor_byte_offset();
     194           67 :         let begin = text.as_bytes()[..cursor].iter().rposition(|&c| c == b'\n').map_or(0, |i| i + 1);
     195            6 :         self.move_to_byte_offset(begin)
     196            6 :     }
     197              : 
     198            4 :     pub fn move_to_line_end(&mut self) -> bool {
     199            4 :         let text = self.text();
     200            4 :         let cursor = self.cursor_byte_offset();
     201            4 :         let end = find_from(text.as_bytes(), b'\n', cursor).unwrap_or(text.len());
     202            4 :         self.move_to_byte_offset(end)
     203            4 :     }
     204              : 
     205              :     /// The byte offset of the word start left of the cursor
     206           14 :     fn word_left(&self, text: &str, mut cursor: usize) -> usize {
     207           19 :         while cursor > 0 {
     208           17 :             let previous = previous_grapheme(text, cursor);
     209           17 :             if is_word_grapheme(&text[previous..cursor]) {
     210           12 :                 break;
     211            5 :             }
     212            5 :             cursor = previous;
     213              :         }
     214           50 :         while cursor > 0 {
     215           46 :             let previous = previous_grapheme(text, cursor);
     216           46 :             if !is_word_grapheme(&text[previous..cursor]) {
     217           10 :                 break;
     218           36 :             }
     219           36 :             cursor = previous;
     220              :         }
     221           14 :         cursor
     222           14 :     }
     223              : 
     224           14 :     pub fn move_word_left(&mut self) -> bool {
     225           14 :         let text = self.text();
     226           14 :         let original = self.cursor_byte_offset();
     227           14 :         let cursor = self.word_left(&text, original);
     228           14 :         cursor != original && self.move_to_byte_offset(cursor)
     229           14 :     }
     230              : 
     231           12 :     pub fn move_word_right(&mut self) -> bool {
     232           12 :         let text = self.text();
     233           12 :         let original = self.cursor_byte_offset();
     234           12 :         let mut cursor = original;
     235           13 :         while cursor < text.len() && !is_word_grapheme(&text[cursor..next_grapheme(&text, cursor)]) {
     236            1 :             cursor = next_grapheme(&text, cursor);
     237            1 :         }
     238           50 :         while cursor < text.len() && is_word_grapheme(&text[cursor..next_grapheme(&text, cursor)]) {
     239           38 :             cursor = next_grapheme(&text, cursor);
     240           38 :         }
     241           12 :         cursor != original && self.move_to_byte_offset(cursor)
     242           12 :     }
     243              : 
     244            8 :     pub fn move_up(&mut self) -> bool {
     245            8 :         let text = self.text();
     246            8 :         let bytes = text.as_bytes();
     247            8 :         let positions = Positions::of(&text);
     248            8 :         let cursor = positions.resolve(self.cursor_grapheme_offset).bytes;
     249           31 :         let Some(line_begin) = bytes[..cursor].iter().rposition(|&c| c == b'\n') else {
     250            3 :             return false;
     251              :         };
     252            5 :         let previous_line_end = line_begin;
     253           34 :         let previous_line_begin = bytes[..previous_line_end].iter().rposition(|&c| c == b'\n').map_or(0, |i| i + 1);
     254            5 :         let column = self.cursor_grapheme_offset - positions.unchecked_boundary(line_begin + 1).graphemes;
     255            5 :         let previous_line_column = positions.unchecked_boundary(previous_line_begin).graphemes;
     256            5 :         let previous_line_length = positions.unchecked_boundary(previous_line_end).graphemes - previous_line_column;
     257            5 :         self.cursor_grapheme_offset = previous_line_column + column.min(previous_line_length);
     258            5 :         true
     259            8 :     }
     260              : 
     261            4 :     pub fn move_down(&mut self) -> bool {
     262            4 :         let text = self.text();
     263            4 :         let bytes = text.as_bytes();
     264            4 :         let positions = Positions::of(&text);
     265            4 :         let cursor = positions.resolve(self.cursor_grapheme_offset).bytes;
     266            4 :         let Some(next_line_break) = find_from(bytes, b'\n', cursor) else {
     267            1 :             return false;
     268              :         };
     269           12 :         let line_begin = bytes[..cursor].iter().rposition(|&c| c == b'\n').map_or(0, |i| i + 1);
     270            3 :         let next_line_begin = next_line_break + 1;
     271            3 :         let next_line_end = find_from(bytes, b'\n', next_line_begin).unwrap_or(bytes.len());
     272            3 :         let column = self.cursor_grapheme_offset.wrapping_sub(positions.unchecked_boundary(line_begin).graphemes);
     273            3 :         let next_line_column = positions.unchecked_boundary(next_line_begin).graphemes;
     274            3 :         let next_line_length = positions.unchecked_boundary(next_line_end).graphemes - next_line_column;
     275            3 :         self.cursor_grapheme_offset = next_line_column + column.min(next_line_length);
     276            3 :         true
     277            4 :     }
     278              : 
     279              :     /// Move the cursor to a grapheme boundary
     280           52 :     pub fn move_to_byte_offset(&mut self, byte_offset: usize) -> bool {
     281           52 :         match self.positions().boundary(byte_offset) {
     282           51 :             Some(position) => {
     283           51 :                 self.cursor_grapheme_offset = position.graphemes;
     284           51 :                 true
     285              :             }
     286            1 :             None => false,
     287              :         }
     288           52 :     }
     289              : 
     290            8 :     pub fn delete_backward(&mut self) -> bool {
     291            8 :         if self.cursor_grapheme_offset == 0 {
     292            1 :             return false;
     293            7 :         }
     294            7 :         let positions = self.positions();
     295            7 :         let begin = positions.resolve(self.cursor_grapheme_offset - 1);
     296            7 :         let end = positions.resolve(self.cursor_grapheme_offset);
     297            7 :         self.script.erase_text_range(begin.codepoints, end.codepoints - begin.codepoints);
     298            7 :         self.cursor_grapheme_offset -= 1;
     299            7 :         self.revision += 1;
     300            7 :         true
     301            8 :     }
     302              : 
     303            7 :     pub fn delete_forward(&mut self) -> bool {
     304            7 :         let positions = self.positions();
     305            7 :         if self.cursor_grapheme_offset >= positions.count() {
     306            3 :             return false;
     307            4 :         }
     308            4 :         let begin = positions.resolve(self.cursor_grapheme_offset);
     309            4 :         let end = positions.resolve(self.cursor_grapheme_offset + 1);
     310            4 :         self.script.erase_text_range(begin.codepoints, end.codepoints - begin.codepoints);
     311            4 :         self.revision += 1;
     312            4 :         true
     313            7 :     }
     314              : 
     315            5 :     pub fn delete_to_line_start(&mut self, deleted: Option<&mut String>) -> bool {
     316            5 :         let text = self.text();
     317            5 :         let end = self.cursor_byte_offset();
     318           36 :         let begin = text.as_bytes()[..end].iter().rposition(|&c| c == b'\n').map_or(0, |i| i + 1);
     319            5 :         if begin == end {
     320            1 :             return false;
     321            4 :         }
     322            4 :         if let Some(deleted) = deleted {
     323            4 :             *deleted = text[begin..end].to_string();
     324            4 :         }
     325            4 :         self.replace_byte_range(begin, end - begin, "")
     326            5 :     }
     327              : 
     328            6 :     pub fn delete_to_line_end(&mut self, deleted: Option<&mut String>) -> bool {
     329            6 :         let text = self.text();
     330            6 :         let begin = self.cursor_byte_offset();
     331            6 :         let end = match find_from(text.as_bytes(), b'\n', begin) {
     332            3 :             Some(line_break) if line_break == begin => line_break + 1,
     333            1 :             Some(line_break) => line_break,
     334            3 :             None => text.len(),
     335              :         };
     336            6 :         if begin == end {
     337            2 :             return false;
     338            4 :         }
     339            4 :         if let Some(deleted) = deleted {
     340            4 :             *deleted = text[begin..end].to_string();
     341            4 :         }
     342            4 :         self.replace_byte_range(begin, end - begin, "")
     343            6 :     }
     344              : 
     345            5 :     pub fn delete_word_backward(&mut self, deleted: Option<&mut String>) -> bool {
     346            5 :         let end = self.cursor_byte_offset();
     347            5 :         if !self.move_word_left() {
     348            1 :             return false;
     349            4 :         }
     350            4 :         let begin = self.cursor_byte_offset();
     351            4 :         if let Some(deleted) = deleted {
     352            4 :             *deleted = self.text()[begin..end].to_string();
     353            4 :         }
     354            4 :         self.replace_byte_range(begin, end - begin, "")
     355            5 :     }
     356              : 
     357            5 :     pub fn delete_word_forward(&mut self, deleted: Option<&mut String>) -> bool {
     358            5 :         let begin = self.cursor_byte_offset();
     359            5 :         if !self.move_word_right() {
     360            1 :             return false;
     361            4 :         }
     362            4 :         let end = self.cursor_byte_offset();
     363            4 :         if let Some(deleted) = deleted {
     364            4 :             *deleted = self.text()[begin..end].to_string();
     365            4 :         }
     366            4 :         self.replace_byte_range(begin, end - begin, "")
     367            5 :     }
     368              : 
     369            7 :     pub fn transpose_characters(&mut self) -> bool {
     370            7 :         let text = self.text();
     371            7 :         let positions = Positions::of(&text);
     372            7 :         let count = positions.count();
     373            7 :         if count < 2 || self.cursor_grapheme_offset == 0 {
     374            3 :             return false;
     375            4 :         }
     376            4 :         let left_index = if self.cursor_grapheme_offset == count { count - 2 } else { self.cursor_grapheme_offset - 1 };
     377            4 :         let right_index = left_index + 1;
     378            4 :         let begin = positions.resolve(left_index).bytes;
     379            4 :         let middle = positions.resolve(right_index).bytes;
     380            4 :         let end = positions.resolve(right_index + 1).bytes;
     381            4 :         let mut replacement = text[middle..end].to_string();
     382            4 :         replacement.push_str(&text[begin..middle]);
     383            4 :         self.replace_byte_range(begin, end - begin, &replacement)
     384            7 :     }
     385              : 
     386              :     /// Replace a byte range on grapheme boundaries and move the cursor behind the replacement
     387           41 :     pub fn replace_byte_range(&mut self, byte_offset: usize, byte_length: usize, text: &str) -> bool {
     388           41 :         let positions = self.positions();
     389           38 :         let (Some(begin), Some(end)) =
     390           41 :             (positions.boundary(byte_offset), positions.boundary(byte_offset.saturating_add(byte_length)))
     391              :         else {
     392            3 :             return false;
     393              :         };
     394           38 :         let deleted = end.codepoints - begin.codepoints;
     395           38 :         if deleted != 0 {
     396           35 :             self.script.erase_text_range(begin.codepoints, deleted);
     397           35 :         }
     398           38 :         if !text.is_empty() {
     399           22 :             self.script.insert_text_at(begin.codepoints, text);
     400           30 :         }
     401           38 :         self.revision += 1;
     402           38 :         self.cursor_grapheme_offset = self.positions().at_or_after(byte_offset + text.len()).graphemes;
     403           38 :         true
     404           41 :     }
     405              : 
     406              :     /// Replace a byte range with bytes that must be valid UTF-8
     407            2 :     pub fn replace_byte_range_bytes(&mut self, byte_offset: usize, byte_length: usize, text: &[u8]) -> bool {
     408            2 :         if !is_valid(text) {
     409            1 :             return false;
     410            1 :         }
     411            1 :         self.replace_byte_range(byte_offset, byte_length, std::str::from_utf8(text).unwrap())
     412            2 :     }
     413              : }
     414              : 
     415              : /// Find a byte at or after an offset
     416         2220 : pub(super) fn find_from(text: &[u8], needle: u8, from: usize) -> Option<usize> {
     417         2220 :     if from > text.len() {
     418            0 :         return None;
     419         2220 :     }
     420        21924 :     text[from..].iter().position(|&c| c == needle).map(|i| i + from)
     421         2220 : }
        

Generated by: LCOV version 2.0-1