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 : }
|