LCOV - code coverage report
Current view: top level - dashql-cli/src - lint.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 94.4 % 107 101
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Scans, parses and analyzes scripts with dashql-core and collects their diagnostics
       2              : 
       3              : use dashql_core_session::batch::{check_script, DiagnosticSeverity, ProcessingStage};
       4              : use dashql_core::catalog::{Catalog, CatalogLayer};
       5              : 
       6              : /// A script that should be linted
       7              : #[derive(Clone, Debug, PartialEq, Eq)]
       8              : pub struct Input {
       9              :     /// The path relative to the invoking directory, `<stdin>` for standard input
      10              :     pub id: String,
      11              :     /// The script text
      12              :     pub text: String,
      13              : }
      14              : 
      15              : /// The stage that reported a diagnostic, `Failure` if processing failed
      16              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      17              : pub enum Stage {
      18              :     Scan,
      19              :     Parse,
      20              :     Analyze,
      21              :     Failure,
      22              : }
      23              : 
      24              : impl Stage {
      25           11 :     pub fn name(self) -> &'static str {
      26           11 :         match self {
      27            0 :             Stage::Scan => "scan",
      28            6 :             Stage::Parse => "parse",
      29            4 :             Stage::Analyze => "analyze",
      30            1 :             Stage::Failure => "failure",
      31              :         }
      32           11 :     }
      33              : }
      34              : 
      35              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      36              : pub enum Severity {
      37              :     Error,
      38              :     Warning,
      39              : }
      40              : 
      41              : impl Severity {
      42           11 :     pub fn name(self) -> &'static str {
      43           11 :         match self {
      44            9 :             Severity::Error => "error",
      45            2 :             Severity::Warning => "warning",
      46              :         }
      47           11 :     }
      48              : }
      49              : 
      50              : /// Where a diagnostic points into its script
      51              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      52              : pub struct Location {
      53              :     /// 1-based line and column, the column counts code points
      54              :     pub line: usize,
      55              :     /// The 1-based column in code points
      56              :     pub column: usize,
      57              :     /// UTF-8 byte offset and length in the script
      58              :     pub offset: usize,
      59              :     /// The UTF-8 byte length in the script
      60              :     pub length: usize,
      61              : }
      62              : 
      63              : /// A diagnostic reported for a script
      64              : #[derive(Clone, Debug, PartialEq, Eq)]
      65              : pub struct Diagnostic {
      66              :     /// The stage that reported the diagnostic
      67              :     pub stage: Stage,
      68              :     /// Whether the diagnostic is an error or a warning
      69              :     pub severity: Severity,
      70              :     /// The diagnostic message
      71              :     pub message: String,
      72              :     /// Absent for failures
      73              :     pub location: Option<Location>,
      74              : }
      75              : 
      76              : impl Diagnostic {
      77            0 :     fn failure(message: String) -> Self {
      78            0 :         Diagnostic { stage: Stage::Failure, severity: Severity::Error, message, location: None }
      79            0 :     }
      80              : }
      81              : 
      82              : /// Resolve a UTF-8 byte offset to a 1-based line and column, the column counts code points
      83           27 : pub fn line_column(text: &str, offset: usize) -> (usize, usize) {
      84           27 :     let mut line = 1;
      85           27 :     let mut column = 1;
      86          351 :     for &byte in &text.as_bytes()[..offset.min(text.len())] {
      87          351 :         if byte == b'\n' {
      88           10 :             line += 1;
      89           10 :             column = 1;
      90          341 :         } else if byte & 0xc0 != 0x80 {
      91          334 :             // Continuation bytes belong to the code point that was already counted
      92          334 :             column += 1;
      93          334 :         }
      94              :     }
      95           27 :     (line, column)
      96           27 : }
      97              : 
      98              : /// Runs scripts through the DashQL scanner, parser and analyzer against a catalog of schema scripts
      99              : #[derive(Default)]
     100              : pub struct Linter {
     101              :     /// The catalog holding the loaded schema scripts, earlier schemas win on name conflicts
     102              :     catalog: Catalog,
     103              : }
     104              : 
     105              : impl Linter {
     106           18 :     pub fn new() -> Self {
     107           18 :         Self::default()
     108           18 :     }
     109              : 
     110              :     /// Add a schema script (e.g. CREATE TABLE statements) to the catalog that later scripts resolve names against.
     111              :     /// Returns the diagnostics of the schema script itself, a schema with errors is not added.
     112            6 :     pub fn add_schema(&mut self, input: &Input) -> Vec<Diagnostic> {
     113            6 :         let checked = check_script(&self.catalog, &input.text, ProcessingStage::ANALYZE);
     114            6 :         let mut diagnostics = to_diagnostics(&input.text, &checked);
     115            6 :         if let (false, Some(analyzed)) = (checked.has_errors(), checked.script.analyzed) {
     116            4 :             if let Err(e) = self.catalog.load_script(analyzed, CatalogLayer::CONNECTION) {
     117            0 :                 diagnostics.push(Diagnostic::failure(e.message));
     118            4 :             }
     119            2 :         }
     120            6 :         diagnostics
     121            6 :     }
     122              : 
     123              :     /// Lint a script against the loaded schemas
     124           18 :     pub fn lint(&self, input: &Input) -> Vec<Diagnostic> {
     125           18 :         to_diagnostics(&input.text, &check_script(&self.catalog, &input.text, ProcessingStage::ANALYZE))
     126           18 :     }
     127              : }
     128              : 
     129           24 : fn to_diagnostics(text: &str, checked: &dashql_core_session::batch::CheckedScript) -> Vec<Diagnostic> {
     130           24 :     let mut out: Vec<Diagnostic> = checked
     131           24 :         .diagnostics
     132           24 :         .iter()
     133           24 :         .map(|d| {
     134           12 :             let offset = d.span.offset as usize;
     135           12 :             let (line, column) = line_column(text, offset);
     136              :             Diagnostic {
     137           12 :                 stage: match d.stage {
     138            1 :                     ProcessingStage::SCAN => Stage::Scan,
     139            7 :                     ProcessingStage::PARSE => Stage::Parse,
     140            4 :                     _ => Stage::Analyze,
     141              :                 },
     142           12 :                 severity: if d.severity == DiagnosticSeverity::WARNING { Severity::Warning } else { Severity::Error },
     143           12 :                 message: d.message.clone(),
     144           12 :                 location: Some(Location { line, column, offset, length: d.span.length as usize }),
     145              :             }
     146           12 :         })
     147           24 :         .collect();
     148           24 :     if let Some(failure) = &checked.failure {
     149            0 :         out.push(Diagnostic::failure(failure.clone()));
     150           24 :     }
     151           24 :     out
     152           24 : }
     153              : 
     154              : #[cfg(test)]
     155              : mod tests {
     156              :     use super::*;
     157              : 
     158            4 :     fn lint_one(linter: &Linter, text: &str) -> Vec<Diagnostic> {
     159            4 :         linter.lint(&Input { id: "test.sql".into(), text: text.into() })
     160            4 :     }
     161              : 
     162              :     #[test]
     163            1 :     fn resolves_byte_offsets_to_lines_and_code_point_columns() {
     164            1 :         let text = "ab\näöc\nx";
     165            1 :         assert_eq!(line_column(text, 0), (1, 1));
     166            1 :         assert_eq!(line_column(text, 3), (2, 1));
     167              :         // ä and ö take two bytes each
     168            1 :         assert_eq!(line_column(text, 7), (2, 3));
     169            1 :         assert_eq!(line_column(text, 9), (3, 1));
     170            1 :         assert_eq!(line_column(text, 100), (3, 2));
     171            1 :     }
     172              : 
     173              :     #[test]
     174            1 :     fn reports_nothing_for_a_clean_script() {
     175            1 :         assert_eq!(lint_one(&Linter::new(), "select 1;\nselect a from b where c = 42;"), vec![]);
     176            1 :     }
     177              : 
     178              :     #[test]
     179            1 :     fn reports_scanner_errors() {
     180            1 :         let diagnostics = lint_one(&Linter::new(), "select 'unterminated");
     181            1 :         assert!(diagnostics.iter().any(|d| d.stage == Stage::Scan && d.severity == Severity::Error));
     182            1 :     }
     183              : 
     184              :     #[test]
     185            1 :     fn reports_parser_errors_with_their_position() {
     186            1 :         let diagnostics = lint_one(&Linter::new(), "select 1;\nselect 1 +;");
     187            1 :         let parse = diagnostics.iter().find(|d| d.stage == Stage::Parse).unwrap();
     188            1 :         assert_eq!(parse.severity, Severity::Error);
     189            1 :         let location = parse.location.unwrap();
     190            1 :         assert_eq!((location.line, location.column), (2, 11));
     191            1 :         assert!(!parse.message.is_empty());
     192            1 :     }
     193              : 
     194              :     #[test]
     195            1 :     fn reports_analyzer_errors_against_a_schema() {
     196            1 :         let mut linter = Linter::new();
     197            1 :         let schema = "create table a (id integer); create table b (id integer);";
     198            1 :         assert_eq!(linter.add_schema(&Input { id: "schema.sql".into(), text: schema.into() }), vec![]);
     199            1 :         let diagnostics = lint_one(&linter, "select id from a, b;");
     200            1 :         assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
     201            1 :         assert_eq!((diagnostics[0].stage, diagnostics[0].severity), (Stage::Analyze, Severity::Error));
     202            1 :         let location = diagnostics[0].location.unwrap();
     203            1 :         assert_eq!((location.line, location.column), (1, 8));
     204            1 :     }
     205              : 
     206              :     #[test]
     207            1 :     fn skips_schemas_with_errors() {
     208            1 :         let mut linter = Linter::new();
     209            1 :         let diagnostics = linter.add_schema(&Input { id: "schema.sql".into(), text: "create table a (id integer".into() });
     210            1 :         assert!(diagnostics.iter().any(|d| d.severity == Severity::Error));
     211            1 :         assert_eq!(linter.catalog.entries_ranked().count(), 0);
     212            1 :     }
     213              : }
        

Generated by: LCOV version 2.0-1