Skip to content

Commit ae48505

Browse files
kvlongeampagent
andauthored
perf(format): stop re-parsing and re-tokenizing work the formatter already has (#8)
Statement splitting in sqlclean format now parses the file and checks the statement count without collecting every statement's facts. The layout passes now take the printer's own token ranges instead of re-tokenizing the text it just printed. The final token check and the idempotence re-print still re-tokenize and compare, so every safety check stays. Amp-Thread-ID: https://ampcode.com/threads/T-01a18b8a-340f-717d-9ca7-6dcf564a065a Co-authored-by: Amp <amp@ampcode.com>
1 parent a37459b commit ae48505

10 files changed

Lines changed: 143 additions & 51 deletions

File tree

‎crates/sqlclean-cli/src/formatting/main/format_source.rs‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Format one SQL file: each statement in canonical layout, separated by one blank line.
22
3-
use sqlclean::sql_facts::main::file_facts::file_facts;
3+
use sqlclean::sql_facts::main::statement_ends::statement_ends;
44
use sqlclean::sql_lint::constants::LINT_API_VERSION;
55
use sqlclean::sql_lint::main::format_requests::format_requests;
66
use sqlclean::sql_lint::models::{FormatRequest, FormatResponse};
@@ -14,13 +14,8 @@ pub fn format_source(
1414
line_width: usize,
1515
indent: usize,
1616
) -> Result<String, String> {
17-
let facts = file_facts(text, dialect)?;
1817
let characters: Vec<char> = text.chars().collect();
19-
let mut boundaries: Vec<usize> = facts
20-
.statements
21-
.iter()
22-
.map(|statement| statement.terminated_end)
23-
.collect();
18+
let mut boundaries: Vec<usize> = statement_ends(text, dialect)?;
2419
if let Some(last) = boundaries.last_mut() {
2520
*last = characters.len();
2621
}

‎crates/sqlclean/src/sql_facts/_helpers/collection.rs‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,13 @@ use crate::sql_lint::main::parse_sql::parse_sql;
1515
use crate::sql_lint::models::ParsedSql;
1616

1717
pub(crate) fn file_facts(sql: &str, dialect: &str) -> Result<FileFacts, String> {
18-
let ParsedSql {
19-
tokens, statements, ..
20-
} = parse_sql(sql, dialect)?;
18+
let (
19+
ParsedSql {
20+
tokens, statements, ..
21+
},
22+
ranges,
23+
) = parsed_statements(sql, dialect)?;
2124
let characters: Vec<char> = sql.chars().collect();
22-
let ranges: Vec<StatementTokens> = statement_token_ranges(&tokens);
23-
if ranges.len() != statements.len() {
24-
return Err(format!(
25-
"found {} statements but {} statement ranges",
26-
statements.len(),
27-
ranges.len()
28-
));
29-
}
3025
let facts: Vec<StatementFacts> = statements
3126
.iter()
3227
.zip(ranges)
@@ -46,6 +41,28 @@ pub(crate) fn file_facts(sql: &str, dialect: &str) -> Result<FileFacts, String>
4641
Ok(FileFacts { statements: facts })
4742
}
4843

44+
pub(crate) fn statement_ends(sql: &str, dialect: &str) -> Result<Vec<usize>, String> {
45+
let (_, ranges) = parsed_statements(sql, dialect)?;
46+
Ok(ranges.iter().map(|range| range.terminated_end).collect())
47+
}
48+
49+
/// Parse `sql` and split its tokens into one range per parsed statement.
50+
fn parsed_statements(
51+
sql: &str,
52+
dialect: &str,
53+
) -> Result<(ParsedSql, Vec<StatementTokens>), String> {
54+
let parsed: ParsedSql = parse_sql(sql, dialect)?;
55+
let ranges: Vec<StatementTokens> = statement_token_ranges(&parsed.tokens);
56+
if ranges.len() != parsed.statements.len() {
57+
return Err(format!(
58+
"found {} statements but {} statement ranges",
59+
parsed.statements.len(),
60+
ranges.len()
61+
));
62+
}
63+
Ok((parsed, ranges))
64+
}
65+
4966
/// One parsed statement, its tokens and the end offset of its terminator.
5067
struct StatementSource<'a> {
5168
index: usize,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pub mod file_facts;
2+
pub mod statement_ends;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//! Where each statement of one SQL file ends, without collecting its facts.
2+
3+
/// Parse `sql` under `dialect` and return the end of each statement, after its terminator.
4+
pub fn statement_ends(sql: &str, dialect: &str) -> Result<Vec<usize>, String> {
5+
crate::sql_facts::_helpers::collection::statement_ends(sql, dialect)
6+
}

‎crates/sqlclean/src/sql_facts/tests/test_file_facts.rs‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use crate::sql_facts::main::file_facts::file_facts;
2+
use crate::sql_facts::main::statement_ends::statement_ends;
23
use crate::sql_facts::models::{FileFacts, StatementFacts};
34
use crate::sql_facts::tests::helpers::text;
4-
use crate::sql_facts::tests::test_types::{ParseOutcomeTestCase, StatementFactsTestCase};
5+
use crate::sql_facts::tests::test_types::{
6+
ParseOutcomeTestCase, StatementEndsTestCase, StatementFactsTestCase,
7+
};
58

69
const TWO_STATEMENTS: &str = "WITH recent AS (SELECT o.order_id, o.* FROM shop.orders AS o WHERE o.total > 10)\n\
710
SELECT r.order_id, UPPER(c.name) AS name, 'é' AS mark\n\
@@ -229,3 +232,50 @@ fn given_sql_text_when_collecting_facts_then_only_unparseable_sql_is_an_error()
229232
);
230233
}
231234
}
235+
236+
#[test]
237+
fn given_sql_text_when_finding_statement_ends_then_each_ends_after_its_terminator() {
238+
let test_cases = [
239+
StatementEndsTestCase {
240+
description: "a comment after a terminator starts the next statement's text",
241+
sql: "SELECT 'é;' AS a; -- next; note\nSELECT 2 /* end; */",
242+
expected_statements: Some(&["SELECT 'é;' AS a;", " -- next; note\nSELECT 2"]),
243+
},
244+
StatementEndsTestCase {
245+
description: "empty statements between semicolons are skipped",
246+
sql: ";; SELECT 1;;SELECT 2;",
247+
expected_statements: Some(&[";; SELECT 1;", ";SELECT 2;"]),
248+
},
249+
StatementEndsTestCase {
250+
description: "a file of only comments has no statements",
251+
sql: "-- only a note; nothing else\n/* still; nothing */\n",
252+
expected_statements: Some(&[]),
253+
},
254+
StatementEndsTestCase {
255+
description: "an unparseable statement is an error, not a split",
256+
sql: "SELECT 1; SELECT FROM WHERE (",
257+
expected_statements: None,
258+
},
259+
];
260+
for test_case in &test_cases {
261+
let characters: Vec<char> = test_case.sql.chars().collect();
262+
let statements: Option<Vec<String>> = statement_ends(test_case.sql, "duckdb")
263+
.map(|ends| {
264+
let starts = std::iter::once(0).chain(ends.iter().copied());
265+
starts
266+
.zip(ends.iter().copied())
267+
.map(|(start, end)| characters[start..end].iter().collect())
268+
.collect()
269+
})
270+
.ok();
271+
let texts: Option<Vec<&str>> = statements
272+
.as_ref()
273+
.map(|statements| statements.iter().map(String::as_str).collect());
274+
assert_eq!(
275+
texts.as_deref(),
276+
test_case.expected_statements,
277+
"{}",
278+
test_case.description
279+
);
280+
}
281+
}

‎crates/sqlclean/src/sql_facts/tests/test_types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ pub(crate) struct ParseOutcomeTestCase {
1919
pub(crate) sql: &'static str,
2020
pub(crate) expected_statements: Option<usize>,
2121
}
22+
23+
pub(crate) struct StatementEndsTestCase {
24+
pub(crate) description: &'static str,
25+
pub(crate) sql: &'static str,
26+
pub(crate) expected_statements: Option<&'static [&'static str]>,
27+
}

‎crates/sqlclean/src/sql_lint/_helpers/formatter.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,11 @@ impl Layout<'_> {
187187
authored: &AuthoredSql<'_>,
188188
oracle: &OracleLayout,
189189
) -> Result<(String, BTreeMap<usize, KeywordRecase>, TokenTexts), String> {
190-
let (printed, recases, texts) = print_authored_tokens(authored, oracle, self.dialect)?;
190+
let print = print_authored_tokens(authored, oracle, self.dialect)?;
191191
let no_widths: HashMap<String, usize> = HashMap::new();
192192
let token_widths = self.wrap.map_or(&no_widths, |wrap| wrap.token_widths);
193-
let Some(mut sql) = PrintedSql::parse(&printed, self.dialect, token_widths)? else {
194-
return Ok((printed, recases, texts));
193+
let Some(mut sql) = PrintedSql::new(&print.sql, &print.tokens, token_widths) else {
194+
return Ok((print.sql, print.recases, print.texts));
195195
};
196196
sql.set_indent(self.indent);
197197
sql.attach_commas_before_comments();
@@ -203,7 +203,7 @@ impl Layout<'_> {
203203
wrap_lines(&mut sql, wrap.line_width);
204204
}
205205
sql.lead_operators_of_multiline_operands();
206-
Ok((sql.render(), recases, texts))
206+
Ok((sql.render(), print.recases, print.texts))
207207
}
208208
}
209209

‎crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs‎

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
//! Printed SQL as tokens and gaps, with clause layout rules and line-width wrapping.
22
33
use std::collections::HashMap;
4+
use std::ops::Range;
45

5-
use polyglot_sql::Dialect;
66
use polyglot_sql::tokens::TokenType;
77

88
use crate::sql_lint::constants::OPEN_PARENTHESIS;
@@ -87,43 +87,36 @@ struct Line {
8787
}
8888

8989
impl<'a> PrintedSql<'a> {
90-
pub(crate) fn parse(
90+
/// Split printed `sql` at its token byte ranges; `None` when it has no tokens.
91+
pub(crate) fn new(
9192
sql: &str,
92-
dialect: &Dialect,
93+
printed_tokens: &[(Range<usize>, TokenType)],
9394
token_widths: &'a HashMap<String, usize>,
94-
) -> Result<Option<Self>, String> {
95-
let tokens = dialect
96-
.tokenize(sql)
97-
.map_err(|error| format!("native formatter tokenization failed: {error}"))?;
98-
if tokens.is_empty() {
99-
return Ok(None);
100-
}
101-
let characters: Vec<char> = sql.chars().collect();
102-
let mut texts: Vec<String> = Vec::with_capacity(tokens.len());
103-
let mut gaps: Vec<String> = Vec::with_capacity(tokens.len() + 1);
95+
) -> Option<Self> {
96+
if printed_tokens.is_empty() {
97+
return None;
98+
}
99+
let mut texts: Vec<String> = Vec::with_capacity(printed_tokens.len());
100+
let mut gaps: Vec<String> = Vec::with_capacity(printed_tokens.len() + 1);
104101
let mut previous_end = 0_usize;
105-
for token in &tokens {
106-
gaps.push(characters[previous_end..token.span.start].iter().collect());
107-
texts.push(
108-
characters[token.span.start..token.span.end]
109-
.iter()
110-
.collect(),
111-
);
112-
previous_end = token.span.end;
113-
}
114-
gaps.push(characters[previous_end..].iter().collect());
102+
for (range, _) in printed_tokens {
103+
gaps.push(sql[previous_end..range.start].to_owned());
104+
texts.push(sql[range.clone()].to_owned());
105+
previous_end = range.end;
106+
}
107+
gaps.push(sql[previous_end..].to_owned());
115108
let (depths, partners) = bracket_structure(&texts);
116109
let locked = vec![false; texts.len() + 1];
117-
Ok(Some(Self {
118-
kinds: tokens.iter().map(|token| token.token_type).collect(),
110+
Some(Self {
111+
kinds: printed_tokens.iter().map(|(_, kind)| *kind).collect(),
119112
tokens: texts,
120113
gaps,
121114
depths,
122115
partners,
123116
locked,
124117
token_widths,
125118
indent: PARSED_INDENT,
126-
}))
119+
})
127120
}
128121

129122
fn len(&self) -> usize {

‎crates/sqlclean/src/sql_lint/_helpers/token_layout.rs‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Print authored SQL tokens with whitespace taken from a parse-tree layout oracle.
22
33
use std::collections::{BTreeMap, BTreeSet};
4+
use std::ops::Range;
45

56
use polyglot_sql::Dialect;
67
use polyglot_sql::tokens::{Token, TokenType};
@@ -67,6 +68,15 @@ impl Lexeme {
6768
}
6869
}
6970

71+
/// Authored tokens printed against an oracle, with what the token check needs about them.
72+
pub(crate) struct AuthoredPrint {
73+
pub(crate) sql: String,
74+
/// The byte range and type of each printed token; comments and whitespace lie between them.
75+
pub(crate) tokens: Vec<(Range<usize>, TokenType)>,
76+
pub(crate) recases: BTreeMap<usize, KeywordRecase>,
77+
pub(crate) texts: TokenTexts,
78+
}
79+
7080
/// The source text and case-fold flag of every token, read once for the token check.
7181
pub(crate) struct TokenTexts {
7282
texts: Vec<String>,
@@ -227,7 +237,7 @@ pub(crate) fn print_authored_tokens(
227237
source: &AuthoredSql<'_>,
228238
oracle: &OracleLayout,
229239
dialect: &Dialect,
230-
) -> Result<(String, BTreeMap<usize, KeywordRecase>, TokenTexts), String> {
240+
) -> Result<AuthoredPrint, String> {
231241
let AuthoredSql {
232242
sql,
233243
tokens,
@@ -241,6 +251,7 @@ pub(crate) fn print_authored_tokens(
241251
let mut output = String::with_capacity(sql.len() + sql.len() / 4);
242252
let string_breaks = string_breaks(sql, tokens);
243253
let mut recases: BTreeMap<usize, KeywordRecase> = BTreeMap::new();
254+
let mut printed_tokens: Vec<(Range<usize>, TokenType)> = Vec::with_capacity(tokens.len());
244255
for (index, lexeme) in authored.iter().enumerate() {
245256
let forced_break = string_breaks.contains(&index) && !separators[index].contains('\n');
246257
let forced: String;
@@ -281,7 +292,9 @@ pub(crate) fn print_authored_tokens(
281292
},
282293
);
283294
}
295+
let start = output.len();
284296
output.push_str(&printed_text(lexeme, guide));
297+
printed_tokens.push((start..output.len(), tokens[index].token_type));
285298
let trailing = &attached[index].trailing;
286299
for (position, comment) in trailing.iter().enumerate() {
287300
output.push(' ');
@@ -312,7 +325,12 @@ pub(crate) fn print_authored_tokens(
312325
texts.texts.push(lexeme.raw);
313326
texts.foldable.push(lexeme.foldable);
314327
}
315-
Ok((output, recases, texts))
328+
Ok(AuthoredPrint {
329+
sql: output,
330+
tokens: printed_tokens,
331+
recases,
332+
texts,
333+
})
316334
}
317335

318336
/// Refuse output that changes anything but layout, fold-set case, and recorded keyword recases.

‎crates/sqlclean/src/sql_lint/tests/test_comment_formatter.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ fn given_trailing_comments_after_the_last_token_when_formatting_then_no_newline_
7474
expected_sql: "SELECT 1 AS order_id\nFROM orders /* latest orders */",
7575
expected_changed: false,
7676
},
77+
test_types::FormatTestCase {
78+
description: "multibyte comments and literals keep every later token in place",
79+
sql: "select 'café' as naïve, -- prix en €\n ü.x from t as ü /* 日本 */ where ü.y = 'ß'",
80+
expected_sql: "SELECT\n 'café' AS naïve, -- prix en €\n ü.x\nFROM t AS ü /* 日本 */\nWHERE ü.y = 'ß'",
81+
expected_changed: true,
82+
},
7783
];
7884
for test_case in &test_cases {
7985
let response = format_json(

0 commit comments

Comments
 (0)