LCOV - code coverage report
Current view: top level - dashql-core/src - cursor.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 95.6 % 296 283
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! The script cursor and the location lookups it builds on: the symbol, comment and AST node at a
       2              : //! text offset and the path from a node to its statement root.
       3              : 
       4              : use crate::analyzer::{AnalyzedScript, ExprId, ScopeId, TableRefId};
       5              : use crate::catalog::CatalogSnapshot;
       6              : use dashql_buffers::cursor::RelativeSymbolPosition;
       7              : use dashql_buffers::parser::{NodeType, SymbolSpan};
       8              : use dashql_parser::names::NameId;
       9              : use dashql_parser::parser::context::resolve_text_span;
      10              : use dashql_parser::parser::ParsedScript;
      11              : use dashql_parser::scanner::{ScannedScript, Symbol};
      12              : use dashql_parser::symbol_kind::*;
      13              : use std::sync::Arc;
      14              : 
      15              : /// The state of a script that a cursor points into (the parts of `Script` the cursor reads)
      16              : #[derive(Clone, Copy)]
      17              : pub struct ScriptView<'a> {
      18              :     /// The scanned script, if scanned
      19              :     pub scanned: Option<&'a Arc<ScannedScript>>,
      20              :     /// The parsed script, if parsed
      21              :     pub parsed: Option<&'a Arc<ParsedScript>>,
      22              :     /// The analyzed script, if analyzed, possibly from an older parsed script
      23              :     pub analyzed: Option<&'a Arc<AnalyzedScript>>,
      24              :     /// The current snapshot of the catalog, for scripts without analysis
      25              :     pub catalog: &'a CatalogSnapshot,
      26              : }
      27              : 
      28              : impl<'a> ScriptView<'a> {
      29              :     /// The catalog snapshot the script was analyzed against, the current one without analysis. The
      30              :     /// catalog locations of the analysis refer to this snapshot.
      31         1052 :     pub fn catalog(&self) -> &'a CatalogSnapshot {
      32         1052 :         self.analyzed.map_or(self.catalog, |analyzed| &analyzed.snapshot)
      33         1052 :     }
      34              : 
      35              :     /// The analyzed script if it was analyzed from the current parsed script
      36          373 :     pub fn current_analysis(&self) -> Option<&'a Arc<AnalyzedScript>> {
      37          373 :         match (self.parsed, self.analyzed) {
      38          373 :             (Some(p), Some(a)) if Arc::ptr_eq(p, &a.parsed) => Some(a),
      39            0 :             _ => None,
      40              :         }
      41          373 :     }
      42              : }
      43              : 
      44              : /// A scanner symbol and the position of a text offset relative to it
      45              : #[derive(Clone, Copy, Debug)]
      46              : pub struct SymbolLocation {
      47              :     /// The symbol index
      48              :     pub symbol_id: u32,
      49              :     /// The scanner symbol
      50              :     pub symbol: Symbol,
      51              :     /// The text offset
      52              :     pub text_offset: u32,
      53              :     /// The position of the text offset relative to the symbol
      54              :     pub relative_pos: RelativeSymbolPosition,
      55              : }
      56              : 
      57              : impl SymbolLocation {
      58         1399 :     pub fn is_dot(&self) -> bool {
      59         1399 :         self.symbol.kind == S_DOT || self.symbol.kind == S_DOT_STAR_LA
      60         1399 :     }
      61         1474 :     pub fn is_trailing_dot(&self) -> bool {
      62         1474 :         self.symbol.kind == S_DOT_TRAILING
      63         1474 :     }
      64              : }
      65              : 
      66              : /// The symbol at a text offset and its predecessor
      67              : #[derive(Clone, Copy, Debug)]
      68              : pub struct LocationInfo {
      69              :     /// The last symbol that does not begin after the text offset
      70              :     pub current: SymbolLocation,
      71              :     /// The previous symbol, which might end at the text offset
      72              :     pub previous: Option<SymbolLocation>,
      73              : }
      74              : 
      75              : /// The position of a text offset relative to a symbol
      76         1078 : fn relative_position(symbols: &[Symbol], text_offset: u32, symbol_id: u32) -> RelativeSymbolPosition {
      77         1078 :     let Some(symbol) = symbols.get(symbol_id as usize) else {
      78            0 :         return RelativeSymbolPosition::AFTER_SYMBOL;
      79              :     };
      80         1078 :     let begin = symbol.location.offset;
      81         1078 :     let end = symbol.location.offset + symbol.location.length;
      82         1078 :     if text_offset < begin {
      83           54 :         RelativeSymbolPosition::BEFORE_SYMBOL
      84         1024 :     } else if text_offset == begin {
      85           35 :         RelativeSymbolPosition::BEGIN_OF_SYMBOL
      86          989 :     } else if text_offset == end {
      87          489 :         RelativeSymbolPosition::END_OF_SYMBOL
      88          500 :     } else if text_offset < end {
      89           29 :         RelativeSymbolPosition::MID_OF_SYMBOL
      90              :     } else {
      91          471 :         RelativeSymbolPosition::AFTER_SYMBOL
      92              :     }
      93         1078 : }
      94              : 
      95              : /// Find the symbol at a text offset
      96          545 : pub fn find_symbol(scanned: &ScannedScript, text_offset: usize) -> LocationInfo {
      97          545 :     let symbols = &scanned.symbols;
      98          545 :     let text_offset = text_offset.min(scanned.text.len()) as u32;
      99          545 :     let location = |id: u32| SymbolLocation {
     100         1078 :         symbol_id: id,
     101         1078 :         symbol: symbols[id as usize],
     102         1078 :         text_offset,
     103         1078 :         relative_pos: relative_position(symbols, text_offset, id),
     104         1078 :     };
     105              :     // The last symbol that does not begin after the offset
     106         2136 :     let mut id = symbols.partition_point(|s| s.location.offset <= text_offset).saturating_sub(1) as u32;
     107              :     // Hit EOF? Use the last symbol before it
     108          545 :     if symbols[id as usize].kind == S_YYEOF {
     109          399 :         if id == 0 {
     110              :             // The very first symbol is EOF, the script is empty
     111            6 :             let current = SymbolLocation {
     112            6 :                 symbol_id: 0,
     113            6 :                 symbol: symbols[0],
     114            6 :                 text_offset,
     115            6 :                 relative_pos: RelativeSymbolPosition::BEFORE_SYMBOL,
     116            6 :             };
     117            6 :             return LocationInfo { current, previous: None };
     118          393 :         }
     119          393 :         id -= 1;
     120          146 :     }
     121          539 :     let current = location(id);
     122          539 :     let previous = location(id.saturating_sub(1));
     123          539 :     LocationInfo {
     124          539 :         current,
     125          539 :         previous: Some(previous),
     126          539 :     }
     127          545 : }
     128              : 
     129              : /// Is a text offset strictly inside a comment?
     130              : ///
     131              : /// The begin of a comment is never inside. A line comment ends at its newline, which is not part of
     132              : /// its span, a block comment ends with its closing `*/`.
     133          499 : pub fn is_inside_comment(scanned: &ScannedScript, text_offset: usize) -> bool {
     134          499 :     let text_offset = text_offset as u32;
     135          499 :     let i = scanned.comments.partition_point(|c| c.offset <= text_offset);
     136          499 :     if i == 0 {
     137          469 :         return false;
     138           30 :     }
     139           30 :     let comment = scanned.comments[i - 1];
     140           30 :     let begin = comment.offset;
     141           30 :     let end = begin + comment.length;
     142           30 :     if text_offset <= begin {
     143            1 :         return false;
     144           29 :     }
     145           29 :     let text = scanned.text.as_bytes();
     146           29 :     let is_line_comment = comment.length >= 2 && text.get(begin as usize) == Some(&b'-') && text.get(begin as usize + 1) == Some(&b'-');
     147           29 :     if is_line_comment {
     148            4 :         text_offset <= end
     149              :     } else {
     150           25 :         text_offset < end
     151              :     }
     152          499 : }
     153              : 
     154              : /// Find the statement and the innermost AST node at a text offset
     155          553 : pub fn find_node_at_offset(parsed: &ParsedScript, text_offset: usize) -> Option<(u32, u32)> {
     156          553 :     if parsed.statements.is_empty() {
     157          148 :         return None;
     158          405 :     }
     159          405 :     let scanned = &parsed.scanned;
     160         2453 :     let resolve = |span: SymbolSpan| resolve_text_span(&scanned.text, &scanned.symbols, span);
     161          405 :     let text_offset = text_offset as u64;
     162              :     // The predecessor of the first statement that begins after the offset
     163          405 :     let mut statement_id = 0;
     164          820 :     while statement_id < parsed.statements.len() {
     165          442 :         let root = parsed.statements[statement_id].root;
     166          442 :         let ts = resolve(*parsed.nodes[root as usize].symbol_span());
     167          442 :         if ts.offset as u64 > text_offset {
     168           27 :             break;
     169          415 :         }
     170          415 :         statement_id += 1;
     171              :     }
     172          405 :     if statement_id == 0 {
     173           27 :         return None;
     174          378 :     }
     175          378 :     statement_id -= 1;
     176              :     // Traverse down the AST
     177          378 :     let mut iter = parsed.statements[statement_id].root;
     178              :     loop {
     179         1653 :         let node = &parsed.nodes[iter as usize];
     180         1653 :         if node.children_count() == 0 {
     181          307 :             break;
     182         1346 :         }
     183              :         // Prefer a child that contains the offset over a child that ends at it.
     184              :         // AST nodes have holes, e.g. a select clause has no node for the FROM keyword.
     185         1346 :         let mut child_exact = None;
     186         1346 :         let mut child_end = None;
     187         2011 :         for i in 0..node.children_count() {
     188         2011 :             let ci = node.children_begin_or_value() + i;
     189         2011 :             let ts = resolve(*parsed.nodes[ci as usize].symbol_span());
     190         2011 :             let begin = ts.offset as u64;
     191         2011 :             let end = begin + ts.length as u64;
     192         2011 :             if begin <= text_offset {
     193         1877 :                 if end > text_offset {
     194          251 :                     child_exact = Some(ci);
     195         1650 :                 } else if end == text_offset {
     196         1041 :                     child_end = Some(ci);
     197         1058 :                 }
     198          134 :             }
     199              :         }
     200         1346 :         match child_exact.or(child_end) {
     201         1275 :             Some(child) => iter = child,
     202           71 :             None => break,
     203              :         }
     204              :     }
     205          378 :     Some((statement_id as u32, iter))
     206          553 : }
     207              : 
     208              : /// Collect the path from a node to its statement root and the name scopes on that path
     209          373 : pub fn follow_path_upwards(analyzed: &AnalyzedScript, ast_node_id: u32, path: &mut Vec<u32>, scopes: &mut Vec<ScopeId>) {
     210          373 :     path.clear();
     211          373 :     scopes.clear();
     212          373 :     let nodes = &analyzed.parsed.nodes;
     213          373 :     let mut node = ast_node_id;
     214              :     loop {
     215         1623 :         path.push(node);
     216         1623 :         if let Some(&scope) = analyzed.name_scopes_by_root_node.get(&node) {
     217          387 :             scopes.push(scope);
     218         1236 :         }
     219         1623 :         let parent = nodes[node as usize].parent();
     220              :         // Statement roots are their own parents
     221         1623 :         if parent == node {
     222          373 :             break;
     223         1250 :         }
     224         1250 :         node = parent;
     225              :     }
     226          373 : }
     227              : 
     228              : /// The kind of a name path component
     229              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     230              : pub enum NameComponentType {
     231              :     Name,
     232              :     Star,
     233              :     TrailingDot,
     234              :     Index,
     235              : }
     236              : 
     237              : /// A component of a name path
     238              : #[derive(Clone, Copy, Debug)]
     239              : pub struct NameComponent {
     240              :     /// The symbol span of the component
     241              :     pub loc: SymbolSpan,
     242              :     /// The kind of the component
     243              :     pub component_type: NameComponentType,
     244              :     /// The name id, set for name components only
     245              :     pub name: Option<NameId>,
     246              : }
     247              : 
     248              : /// What the cursor points at
     249              : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
     250              : pub enum CursorContext {
     251              :     #[default]
     252              :     None,
     253              :     /// A table reference
     254              :     TableRef { table_reference_id: TableRefId, at_alias: bool },
     255              :     /// A column reference
     256              :     ColumnRef { expression_id: ExprId },
     257              :     /// An `alias.` that the parser recovered without a column ref node
     258              :     AliasDot,
     259              : }
     260              : 
     261              : /// A cursor in a script
     262              : pub struct ScriptCursor<'a> {
     263              :     /// The script the cursor points into
     264              :     pub script: ScriptView<'a>,
     265              :     /// The UTF-8 byte offset of the cursor in the script text
     266              :     pub text_offset: usize,
     267              :     /// The symbol at the cursor, if the script was scanned
     268              :     pub scanner_location: Option<LocationInfo>,
     269              :     /// The statement at the cursor
     270              :     pub statement_id: Option<u32>,
     271              :     /// The innermost AST node at the cursor
     272              :     pub ast_node_id: Option<u32>,
     273              :     /// The AST nodes from the cursor node to the statement root
     274              :     pub ast_path_to_root: Vec<u32>,
     275              :     /// The name scopes that contain the cursor, innermost first
     276              :     pub name_scopes: Vec<ScopeId>,
     277              :     /// What the cursor points at
     278              :     pub context: CursorContext,
     279              : }
     280              : 
     281              : impl<'a> ScriptCursor<'a> {
     282              :     /// Place a cursor in a script
     283          545 :     pub fn place(script: ScriptView<'a>, text_offset: usize) -> Self {
     284          545 :         let mut cursor = ScriptCursor {
     285          545 :             script,
     286          545 :             text_offset,
     287          545 :             scanner_location: None,
     288          545 :             statement_id: None,
     289          545 :             ast_node_id: None,
     290          545 :             ast_path_to_root: Vec::new(),
     291          545 :             name_scopes: Vec::new(),
     292          545 :             context: CursorContext::None,
     293          545 :         };
     294          545 :         if let Some(scanned) = script.scanned {
     295          545 :             cursor.scanner_location = Some(find_symbol(scanned, text_offset));
     296          545 :         }
     297          545 :         let Some(parsed) = script.parsed else { return cursor };
     298              : 
     299          545 :         let mut ast_node = find_node_at_offset(parsed, text_offset);
     300              :         // A trailing dot may span into following whitespace. Anchor the lookup at the dot so that the
     301              :         // cursor stays associated with the qualifier.
     302          545 :         if let Some(location) = &cursor.scanner_location {
     303          545 :             if location.current.is_trailing_dot() {
     304            8 :                 if let Some(qualifier) = find_node_at_offset(parsed, location.current.symbol.location.offset as usize) {
     305            5 :                     ast_node = Some(qualifier);
     306            8 :                 }
     307          537 :             }
     308            0 :         }
     309          545 :         if let Some((statement_id, node_id)) = ast_node {
     310          373 :             cursor.statement_id = Some(statement_id);
     311          373 :             cursor.ast_node_id = Some(node_id);
     312          373 :             if let Some(analyzed) = script.current_analysis() {
     313          373 :                 follow_path_upwards(analyzed, node_id, &mut cursor.ast_path_to_root, &mut cursor.name_scopes);
     314          373 :                 cursor.find_context(analyzed, parsed);
     315          373 :             }
     316          172 :         }
     317          545 :         cursor.recover_dot_context();
     318          545 :         cursor
     319          545 :     }
     320              : 
     321              :     /// Find a table or column ref in the innermost scope
     322          373 :     fn find_context(&mut self, analyzed: &AnalyzedScript, parsed: &ParsedScript) {
     323          373 :         let Some(&innermost) = self.name_scopes.first() else { return };
     324          373 :         let scope = &analyzed.name_scopes[innermost as usize];
     325         1029 :         for &node_id in &self.ast_path_to_root {
     326         1029 :             let mut matched = false;
     327         1029 :             match parsed.nodes[node_id as usize].node_type() {
     328              :                 NodeType::OBJECT_SQL_COLUMN_REF => {
     329          113 :                     matched = true;
     330          150 :                     for &expr in &scope.expressions {
     331          150 :                         let expression = &analyzed.expressions[expr as usize];
     332          150 :                         if expression.ast_node_id == node_id && expression.column_ref().is_some() {
     333          112 :                             self.context = CursorContext::ColumnRef {
     334          112 :                                 expression_id: expression.expression_id,
     335          112 :                             };
     336          112 :                         }
     337              :                     }
     338              :                 }
     339              :                 NodeType::OBJECT_SQL_TABLEREF => {
     340          161 :                     matched = true;
     341          171 :                     for &table_ref_id in &scope.table_references {
     342          169 :                         let table_ref = &analyzed.table_references[table_ref_id as usize];
     343          169 :                         if table_ref.ast_node_id != node_id {
     344           16 :                             continue;
     345          153 :                         }
     346          153 :                         let mut at_alias = false;
     347          153 :                         if let Some((_, alias_loc)) = &table_ref.alias {
     348           25 :                             let scanned = &parsed.scanned;
     349           25 :                             let alias_ts = resolve_text_span(&scanned.text, &scanned.symbols, *alias_loc);
     350           25 :                             at_alias = self.text_offset >= alias_ts.offset as usize;
     351          129 :                         }
     352          153 :                         self.context = CursorContext::TableRef {
     353          153 :                             table_reference_id: table_ref.table_reference_id.object,
     354          153 :                             at_alias,
     355          153 :                         };
     356              :                     }
     357              :                 }
     358          755 :                 _ => {}
     359              :             }
     360              :             // Stop at the root of the innermost scope
     361         1029 :             if matched || node_id == scope.ast_node_id {
     362          373 :                 break;
     363          656 :             }
     364              :         }
     365          373 :     }
     366              : 
     367              :     /// The dot symbol at or before the cursor
     368          289 :     pub fn dot_symbol(location: &LocationInfo, current: &SymbolLocation) -> Option<SymbolLocation> {
     369          289 :         if current.is_dot() || current.is_trailing_dot() {
     370           17 :             return Some(*current);
     371          272 :         }
     372          272 :         location.previous.filter(|p| p.is_dot() || p.is_trailing_dot())
     373          289 :     }
     374              : 
     375              :     /// In incomplete SQL, the parser can omit the column ref node of `alias.` although name
     376              :     /// resolution registered the alias in a scope. Recover a synthetic column ref context.
     377          545 :     fn recover_dot_context(&mut self) {
     378          545 :         if self.context != CursorContext::None {
     379          265 :             return;
     380          280 :         }
     381          280 :         let (Some(location), Some(analyzed), Some(scanned)) = (self.scanner_location, self.script.analyzed, self.script.scanned) else {
     382            0 :             return;
     383              :         };
     384          280 :         let Some(dot) = Self::dot_symbol(&location, &location.current) else { return };
     385           12 :         let qualifier = qualifier_before(&scanned.text, dot.symbol.location.offset as usize);
     386           12 :         let qualifier_text = &scanned.text[qualifier.0..qualifier.1];
     387              : 
     388           12 :         let mut best_scope = None;
     389           12 :         for &scope in &self.name_scopes {
     390            7 :             if analyzed.name_scopes[scope as usize].source(qualifier_text).is_some() {
     391            6 :                 best_scope = Some(scope);
     392            6 :                 break;
     393            1 :             }
     394              :         }
     395              :         // Prefer the narrowest scope containing the cursor, the same alias can occur in nested queries
     396           12 :         if best_scope.is_none() && self.name_scopes.is_empty() {
     397            5 :             let mut best_length = usize::MAX;
     398            5 :             let parsed = &analyzed.parsed;
     399            5 :             for scope in &analyzed.name_scopes {
     400            5 :                 if scope.source(qualifier_text).is_none() {
     401            0 :                     continue;
     402            5 :                 }
     403            5 :                 let span = resolve_text_span(&scanned.text, &scanned.symbols, *parsed.nodes[scope.ast_node_id as usize].symbol_span());
     404            5 :                 let scope_end = span.offset as usize + span.length as usize;
     405            5 :                 let contains_cursor = span.offset as usize <= self.text_offset && self.text_offset <= scope_end;
     406            5 :                 if contains_cursor && (span.length as usize) < best_length {
     407            4 :                     best_scope = Some(scope.name_scope_id);
     408            4 :                     best_length = span.length as usize;
     409            4 :                 } else if best_scope.is_none() && self.statement_id.is_none_or(|s| scope.ast_statement_id == Some(s)) {
     410            1 :                     best_scope = Some(scope.name_scope_id);
     411            1 :                 }
     412              :             }
     413            7 :         }
     414           12 :         if let Some(scope) = best_scope {
     415           11 :             if self.name_scopes.is_empty() {
     416            5 :                 self.name_scopes.push(scope);
     417           10 :             }
     418           11 :             self.context = CursorContext::AliasDot;
     419            1 :         }
     420          545 :     }
     421              : 
     422              :     /// Read the name path at the cursor
     423           92 :     pub fn read_cursor_name_path(&self, name_path_loc: &mut SymbolSpan) -> Vec<NameComponent> {
     424           92 :         let (Some(analyzed), Some(parsed)) = (self.script.analyzed, self.script.parsed) else {
     425            0 :             return Vec::new();
     426              :         };
     427           92 :         let name_node = match self.context {
     428           57 :             CursorContext::TableRef { table_reference_id, .. } => analyzed.table_references[table_reference_id as usize]
     429           57 :                 .inner
     430           57 :                 .as_ref()
     431           57 :                 .and_then(|r| r.table_name.ast_node_id),
     432           35 :             CursorContext::ColumnRef { expression_id } => {
     433           35 :                 analyzed.expressions[expression_id as usize].column_ref().and_then(|c| c.column_name.ast_node_id)
     434              :             }
     435            0 :             _ => None,
     436              :         };
     437           92 :         let Some(name_node) = name_node else { return Vec::new() };
     438           92 :         let nodes = &parsed.nodes;
     439           92 :         let node = &nodes[name_node as usize];
     440           92 :         if node.node_type() != NodeType::ARRAY {
     441            0 :             return Vec::new();
     442           92 :         }
     443           92 :         *name_path_loc = *node.symbol_span();
     444           92 :         let begin = node.children_begin_or_value() as usize;
     445           92 :         let mut components = Vec::new();
     446          203 :         for child in &nodes[begin..begin + node.children_count() as usize] {
     447          203 :             let component_type = match child.node_type() {
     448          174 :                 NodeType::NAME => NameComponentType::Name,
     449            0 :                 NodeType::OBJECT_SQL_INDIRECTION_STAR => NameComponentType::Star,
     450            0 :                 NodeType::OBJECT_SQL_INDIRECTION_INDEX => NameComponentType::Index,
     451           29 :                 NodeType::OBJECT_EXT_TRAILING_DOT => NameComponentType::TrailingDot,
     452            0 :                 _ => return Vec::new(),
     453              :             };
     454          203 :             components.push(NameComponent {
     455          203 :                 loc: *child.symbol_span(),
     456          203 :                 component_type,
     457          203 :                 name: (component_type == NameComponentType::Name).then(|| child.children_begin_or_value()),
     458              :             });
     459          203 :             if component_type == NameComponentType::TrailingDot {
     460           29 :                 return components;
     461          174 :             }
     462              :         }
     463           63 :         components
     464           92 :     }
     465              : }
     466              : 
     467              : /// The identifier before a dot at a byte offset: skips dots, then takes `[A-Za-z0-9_]*`.
     468              : /// Returns the byte range.
     469           21 : pub fn qualifier_before(text: &str, dot_offset: usize) -> (usize, usize) {
     470           21 :     let bytes = text.as_bytes();
     471           21 :     let mut end = dot_offset.min(bytes.len());
     472           21 :     while end > 0 && bytes[end - 1] == b'.' {
     473            0 :         end -= 1;
     474            0 :     }
     475           21 :     let mut begin = end;
     476           65 :     while begin > 0 && (bytes[begin - 1].is_ascii_alphanumeric() || bytes[begin - 1] == b'_') {
     477           44 :         begin -= 1;
     478           44 :     }
     479           21 :     (begin, end)
     480           21 : }
        

Generated by: LCOV version 2.0-1