Skip to content

Commit a201b39

Browse files
committed
Merge wip/cli-colour: colour diagnostics and highlight converted SQL on a terminal
2 parents b1b42bb + fef04a5 commit a201b39

26 files changed

Lines changed: 618 additions & 139 deletions

File tree

‎.github/demos/check-convert.gif‎

18.5 KB
Loading

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ publish = false
4141
rust-version = "1.88"
4242

4343
[workspace.dependencies]
44+
anstream = "1.0"
4445
arrow = { version = "60", default-features = false, features = ["ipc"] }
4546
clap = { version = "4.5", features = ["derive"] }
4647
duckdb = "=1.20000.0"

‎crates/sqlquack-core/src/diagnostics/constants.rs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! The diagnostic code registry, the one source of every `SQ` code; append only, see codes.lock.
22
3-
use crate::diagnostics::models::CodeEntry;
3+
use crate::diagnostics::models::{CodeEntry, Palette};
44

55
/// The prefix of a parse refusal's reason, before the parser's own message.
66
pub const UNPARSED_PREFIX: &str = "the SQL does not parse: ";
@@ -37,6 +37,36 @@ pub const GUARD_PREFIX: &str = "E_GUARD_";
3737
pub const MAX_SHOWN_LINE: usize = 120;
3838
/// How many columns a tab takes when a source line is shown.
3939
pub const TAB_WIDTH: usize = 4;
40+
/// No styling: rendered diagnostics are plain text.
41+
pub const PLAIN_PALETTE: Palette = Palette {
42+
error: "",
43+
message: "",
44+
gutter: "",
45+
note: "",
46+
help: "",
47+
success: "",
48+
keyword: "",
49+
string: "",
50+
number: "",
51+
comment: "",
52+
identifier: "",
53+
reset: "",
54+
};
55+
/// rustc's terminal colours for diagnostics, and SQL highlighting colours for converted output.
56+
pub const ANSI_PALETTE: Palette = Palette {
57+
error: "\x1b[1;31m",
58+
message: "\x1b[1m",
59+
gutter: "\x1b[1;34m",
60+
note: "\x1b[1m",
61+
help: "\x1b[1;36m",
62+
success: "\x1b[1;32m",
63+
keyword: "\x1b[35m",
64+
string: "\x1b[32m",
65+
number: "\x1b[33m",
66+
comment: "\x1b[2m",
67+
identifier: "\x1b[36m",
68+
reset: "\x1b[0m",
69+
};
4070

4171
/// SQL that does not parse.
4272
pub const PARSE_FIDELITY: &str = "SQ0001";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod diagnose;
22
pub mod explain_code;
33
pub mod render_diagnostics;
4+
pub mod render_styled_diagnostics;
45
pub mod unplaced_diagnostic;
Lines changed: 5 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,10 @@
11
//! `render_diagnostics`: diagnostics as rustc-style text, the source line underlined at the span.
22
3-
use std::fmt::Write;
3+
use crate::diagnostics::constants::PLAIN_PALETTE;
4+
use crate::diagnostics::main::render_styled_diagnostics::render_styled_diagnostics;
5+
use crate::diagnostics::models::Diagnostic;
46

5-
use crate::diagnostics::_helpers::line_index::LineIndex;
6-
use crate::diagnostics::constants::{MAX_SHOWN_LINE, TAB_WIDTH};
7-
use crate::diagnostics::models::{Diagnostic, DiagnosticSpan};
8-
9-
/// Every diagnostic of `source`, read from `path`, as text separated by blank lines.
7+
/// Every diagnostic of `source`, read from `path`, as plain text separated by blank lines.
108
pub fn render_diagnostics(diagnostics: &[Diagnostic], source: &str, path: &str) -> String {
11-
let lines = LineIndex::new(source);
12-
diagnostics
13-
.iter()
14-
.map(|diagnostic| render_one(diagnostic, &lines, path))
15-
.collect::<Vec<String>>()
16-
.join("\n")
17-
}
18-
19-
fn render_one(diagnostic: &Diagnostic, lines: &LineIndex<'_>, path: &str) -> String {
20-
let mut text = format!(
21-
"{}[{}]: {}\n",
22-
diagnostic.severity.label(),
23-
diagnostic.code,
24-
diagnostic.message
25-
);
26-
let gutter = " ".repeat(
27-
diagnostic
28-
.span
29-
.map_or(1, |span| span.line.to_string().len()),
30-
);
31-
match diagnostic.span {
32-
Some(span) => {
33-
let _ = writeln!(text, "{gutter}--> {path}:{}:{}", span.line, span.column);
34-
let (shown, underline) = excerpt(lines.text(span.line), span);
35-
let _ = writeln!(text, "{gutter} |");
36-
let _ = writeln!(text, "{} | {shown}", span.line);
37-
let _ = writeln!(text, "{gutter} | {underline} {}", diagnostic.label);
38-
if !diagnostic.notes.is_empty() || diagnostic.help.is_some() {
39-
let _ = writeln!(text, "{gutter} |");
40-
}
41-
}
42-
None => {
43-
let _ = writeln!(text, "{gutter}--> {path}");
44-
let _ = writeln!(text, "{gutter} |");
45-
let _ = writeln!(text, "{gutter} = note: {}", diagnostic.label);
46-
}
47-
}
48-
for note in &diagnostic.notes {
49-
let _ = writeln!(text, "{gutter} = note: {note}");
50-
}
51-
if let Some(help) = &diagnostic.help {
52-
let _ = writeln!(text, "{gutter} = help: {help}");
53-
}
54-
text
55-
}
56-
57-
/// The line as shown, windowed when long and with tabs expanded, and its underline.
58-
fn excerpt(line: &str, span: DiagnosticSpan) -> (String, String) {
59-
let characters: Vec<char> = line.chars().collect();
60-
let first = span.column.saturating_sub(1).min(characters.len());
61-
let last = match span.end_line == span.line {
62-
true => span
63-
.end_column
64-
.saturating_sub(1)
65-
.clamp(first, characters.len()),
66-
false => characters.len(),
67-
}
68-
.max(first + 1);
69-
let window_start = match characters.len() > MAX_SHOWN_LINE {
70-
true => first.saturating_sub(MAX_SHOWN_LINE / 2),
71-
false => 0,
72-
};
73-
let window_end = (window_start + MAX_SHOWN_LINE).min(characters.len());
74-
let mut shown = String::new();
75-
let mut underline = String::new();
76-
if window_start > 0 {
77-
shown.push('…');
78-
underline.push(' ');
79-
}
80-
for (index, character) in characters[window_start..window_end].iter().enumerate() {
81-
let offset = window_start + index;
82-
let width = match character {
83-
'\t' => TAB_WIDTH,
84-
_ => 1,
85-
};
86-
match character {
87-
'\t' => shown.push_str(&" ".repeat(TAB_WIDTH)),
88-
other => shown.push(*other),
89-
}
90-
let mark = match (first..last).contains(&offset) {
91-
true => '^',
92-
false => ' ',
93-
};
94-
underline.push_str(&mark.to_string().repeat(width));
95-
}
96-
if window_end < characters.len() {
97-
shown.push('…');
98-
}
99-
let marked = last.min(window_end.max(first + 1));
100-
for _ in window_end.max(first)..marked {
101-
underline.push('^');
102-
}
103-
(shown, underline.trim_end().to_owned())
9+
render_styled_diagnostics(diagnostics, source, path, &PLAIN_PALETTE)
10410
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! `render_styled_diagnostics`: diagnostics as rustc-style text in a palette, spans underlined.
2+
3+
use std::fmt::Write;
4+
5+
use crate::diagnostics::_helpers::line_index::LineIndex;
6+
use crate::diagnostics::constants::{MAX_SHOWN_LINE, TAB_WIDTH};
7+
use crate::diagnostics::models::{Diagnostic, DiagnosticSpan, Palette};
8+
9+
/// Every diagnostic of `source` at `path`, styled by `style`; the plain palette is the plain text.
10+
pub fn render_styled_diagnostics(
11+
diagnostics: &[Diagnostic],
12+
source: &str,
13+
path: &str,
14+
style: &Palette,
15+
) -> String {
16+
let lines = LineIndex::new(source);
17+
diagnostics
18+
.iter()
19+
.map(|diagnostic| render_one(diagnostic, &lines, path, style))
20+
.collect::<Vec<String>>()
21+
.join("\n")
22+
}
23+
24+
fn render_one(
25+
diagnostic: &Diagnostic,
26+
lines: &LineIndex<'_>,
27+
path: &str,
28+
style: &Palette,
29+
) -> String {
30+
let Palette {
31+
message,
32+
gutter: blue,
33+
note,
34+
help,
35+
reset,
36+
..
37+
} = *style;
38+
let severity = style.severity(diagnostic.severity);
39+
let mut text = format!(
40+
"{severity}{}[{}]{reset}{message}: {}{reset}\n",
41+
diagnostic.severity.label(),
42+
diagnostic.code,
43+
diagnostic.message
44+
);
45+
let gutter = " ".repeat(
46+
diagnostic
47+
.span
48+
.map_or(1, |span| span.line.to_string().len()),
49+
);
50+
match diagnostic.span {
51+
Some(span) => {
52+
let _ = writeln!(
53+
text,
54+
"{gutter}{blue}-->{reset} {path}:{}:{}",
55+
span.line, span.column
56+
);
57+
let (shown, underline) = excerpt(lines.text(span.line), span);
58+
let _ = writeln!(text, "{gutter} {blue}|{reset}");
59+
let _ = writeln!(text, "{blue}{} |{reset} {shown}", span.line);
60+
let _ = writeln!(
61+
text,
62+
"{gutter} {blue}|{reset} {severity}{underline} {}{reset}",
63+
diagnostic.label
64+
);
65+
if !diagnostic.notes.is_empty() || diagnostic.help.is_some() {
66+
let _ = writeln!(text, "{gutter} {blue}|{reset}");
67+
}
68+
}
69+
None => {
70+
let _ = writeln!(text, "{gutter}{blue}-->{reset} {path}");
71+
let _ = writeln!(text, "{gutter} {blue}|{reset}");
72+
let _ = writeln!(
73+
text,
74+
"{gutter} {blue}={reset} {note}note{reset}: {}",
75+
diagnostic.label
76+
);
77+
}
78+
}
79+
for line in &diagnostic.notes {
80+
let _ = writeln!(text, "{gutter} {blue}={reset} {note}note{reset}: {line}");
81+
}
82+
if let Some(advice) = &diagnostic.help {
83+
let _ = writeln!(text, "{gutter} {blue}={reset} {help}help{reset}: {advice}");
84+
}
85+
text
86+
}
87+
88+
/// The line as shown, windowed when long and with tabs expanded, and its underline.
89+
fn excerpt(line: &str, span: DiagnosticSpan) -> (String, String) {
90+
let characters: Vec<char> = line.chars().collect();
91+
let first = span.column.saturating_sub(1).min(characters.len());
92+
let last = match span.end_line == span.line {
93+
true => span
94+
.end_column
95+
.saturating_sub(1)
96+
.clamp(first, characters.len()),
97+
false => characters.len(),
98+
}
99+
.max(first + 1);
100+
let window_start = match characters.len() > MAX_SHOWN_LINE {
101+
true => first.saturating_sub(MAX_SHOWN_LINE / 2),
102+
false => 0,
103+
};
104+
let window_end = (window_start + MAX_SHOWN_LINE).min(characters.len());
105+
let mut shown = String::new();
106+
let mut underline = String::new();
107+
if window_start > 0 {
108+
shown.push('…');
109+
underline.push(' ');
110+
}
111+
for (index, character) in characters[window_start..window_end].iter().enumerate() {
112+
let offset = window_start + index;
113+
let width = match character {
114+
'\t' => TAB_WIDTH,
115+
_ => 1,
116+
};
117+
match character {
118+
'\t' => shown.push_str(&" ".repeat(TAB_WIDTH)),
119+
other => shown.push(*other),
120+
}
121+
let mark = match (first..last).contains(&offset) {
122+
true => '^',
123+
false => ' ',
124+
};
125+
underline.push_str(&mark.to_string().repeat(width));
126+
}
127+
if window_end < characters.len() {
128+
shown.push('…');
129+
}
130+
let marked = last.min(window_end.max(first + 1));
131+
for _ in window_end.max(first)..marked {
132+
underline.push('^');
133+
}
134+
(shown, underline.trim_end().to_owned())
135+
}

‎crates/sqlquack-core/src/diagnostics/models.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,44 @@ impl Severity {
1919
}
2020
}
2121

22+
/// The escape sequences that style diagnostics and SQL; empty strings render plain text.
23+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24+
pub struct Palette {
25+
/// An error's label, code, underline and summary.
26+
pub error: &'static str,
27+
/// The headline message.
28+
pub message: &'static str,
29+
/// The `-->` arrow, the gutter and its line numbers.
30+
pub gutter: &'static str,
31+
/// The `note` label.
32+
pub note: &'static str,
33+
/// The `help` label.
34+
pub help: &'static str,
35+
/// A summary that reports success.
36+
pub success: &'static str,
37+
/// A SQL keyword or type name.
38+
pub keyword: &'static str,
39+
/// A SQL string literal.
40+
pub string: &'static str,
41+
/// A SQL number.
42+
pub number: &'static str,
43+
/// A SQL comment.
44+
pub comment: &'static str,
45+
/// A SQL identifier, quoted or not, including function names.
46+
pub identifier: &'static str,
47+
/// Ends any of the styles above.
48+
pub reset: &'static str,
49+
}
50+
51+
impl Palette {
52+
/// The style of a diagnostic of `severity`.
53+
pub fn severity(&self, severity: Severity) -> &'static str {
54+
match severity {
55+
Severity::Error => self.error,
56+
}
57+
}
58+
}
59+
2260
/// One registered diagnostic code; codes are never reused or renumbered.
2361
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
2462
pub struct CodeEntry {

‎crates/sqlquack-python/src/bindings/_helpers/convert_json.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
use pyo3::exceptions::PyValueError;
44
use pyo3::{PyResult, pyfunction};
55
use sqlquack::cli::main::convert_report::convert_report;
6-
use sqlquack::cli::models::{ConvertArguments, OutputArguments, OutputFormat, PolicyArguments};
6+
use sqlquack::cli::models::{
7+
ColorArguments, ConvertArguments, OutputArguments, OutputFormat, PolicyArguments,
8+
};
79
use sqlquack_core::diagnostics::main::render_diagnostics::render_diagnostics;
810
use sqlquack_core::diagnostics::models::Diagnostic;
911

@@ -25,6 +27,7 @@ pub(crate) fn convert_json(sql: &str, request: &str, path: &str) -> PyResult<Str
2527
format: OutputFormat::Json,
2628
json: false,
2729
},
30+
colors: ColorArguments::default(),
2831
conversion: PolicyArguments {
2932
policy: request.policy,
3033
builtin_policy: request.builtin_policy,

‎crates/sqlquack/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ path = "src/bin/sqlquack.rs"
2121
bundled = ["sqlquack-duckdb/bundled"]
2222

2323
[dependencies]
24+
anstream.workspace = true
2425
clap.workspace = true
2526
rayon.workspace = true
2627
serde.workspace = true

0 commit comments

Comments
 (0)