Skip to content

Commit 4fcdd05

Browse files
authored
feat!: fold header descriptions and accept triple-quoted strings (#1242)
* feat: fold prose header strings and accept triple-quoted strings Descriptions in MODEL, SCENARIO, SCHEMA, FUNCTION, HOOK and MACRO headers, column descriptions, audit description options and Rule exception reasons are stored as folded prose: a line break and the spaces around it are one space, and a blank line is a paragraph break. Strings that are meaningful as written (SQL, regexes, literals, meta values) keep their exact text. Any header string may be written between triple quotes, where quotes need no escaping. A backslash-escaped double quote in a prose string, a quote that ends a string inside its text, and an unclosed or inner triple quote fail with their line and column. MACRO header strings no longer depend on quotes in the macro body. * feat: lay out header descriptions canonically in sqb format A description that fits stays one line, written as "..." or, when it holds a double quote, as """...""". A longer one becomes an indented """ block wrapped at the line width, and a short wrapped one is joined onto one line. FUNCTION, HOOK, SCHEMA and MACRO descriptions are laid out too. Layout never changes the stored text: each rewrite is checked to fold back to the same description. * feat: rewrite escaped description quotes with sqb format --fix sqb format --fix rewrites a description that escapes double quotes with \" as a """...""" string holding real quotes, reported as an applied description-quotes fix. The rewrite is made only when the string's sole backslashes escape quotes and the new string stores exactly that text. When the project cannot compile until the rewrites are saved, Rule fixes are skipped with a warning to rerun. * docs: document header strings, folded descriptions and their layout * test: add escaped description quote cases to the failure corpus * fix: count description length as wrapped to the full line width A """ block indents its lines under the field, so counting its lines would make a description at the line limit fail description-length after formatting. Count the description wrapped from its field to the full width, as before, and update tests for folded descriptions. * test: record goldens for escaped description quote cases * test: use neutral example names and shard the description layout E2E * style: satisfy clippy byte string lint * refactor: satisfy structure checks for header strings and description fixes * fix: read description quotes by one prose rule in every header A double-quoted description takes backslashes literally, so it may end in one; a single-quoted description reads \' and '' as a quote. MODEL and MACRO headers and every header scanner share this rule, and only prose descriptions reject a quote that runs straight on into more text, so other header strings tokenize as before. * fix: never change a SQL macro when laying out its description The canonical description layout and the MACRO header layout now keep a change only when the macro parses to the same definition afterwards. * fix: skip triple-quoted header strings in AUDIT and contract scanners The AUDIT header scanner and the MODEL contract type scanner use the shared header string rules instead of their own quote handling. * feat: rewrite doubled description quotes with sqb format --fix A description that doubles its quotes as "" is rewritten as a triple-quoted string like one that escapes them as \", and the quote error's help names both. * fix: apply description quote fixes only when the whole file then parses A file whose headers, MACRO headers included, still fail once its descriptions are rewritten is left as written, and the fix is reported as refused. * docs: list every folded description and the description quoting rules * fix: measure a description's layout by its own line, not later header fields A one-line header such as FUNCTION (description '...', arguments (...), returns ...) kept its short description as written before; the layout counted the fields after it and turned it into a block. Fields that follow on the line no longer count towards whether a description fits. * test: write reference-error header descriptions in the supported triple-quoted form The two cases put a description with an escaped quote and an apostrophe before a body reference error. Descriptions have no \" escape any more, so they now hold the same text as a triple-quoted string, and still check that the body error is located, typed and names the file.
1 parent 561bd0f commit 4fcdd05

96 files changed

Lines changed: 4092 additions & 434 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ E2E_DUCKDB_CLI_PATHS := \
164164
tests/e2e/src/sqlbuild/cli/commands/main/databricks \
165165
tests/e2e/src/sqlbuild/cli/commands/main/dbt \
166166
tests/e2e/src/sqlbuild/cli/commands/main/debug \
167+
tests/e2e/src/sqlbuild/cli/commands/main/format/test_description_layout.py \
167168
tests/e2e/src/sqlbuild/cli/commands/main/format/test_format_cache.py \
168169
tests/e2e/src/sqlbuild/cli/commands/main/format/test_format_example_projects.py \
169170
tests/e2e/src/sqlbuild/cli/commands/main/format/test_rule_fixes.py \

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/body.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ use crate::sql_macros::_helpers::parameters::{
1010
with_placeholders,
1111
};
1212
use crate::sql_macros::constants::{ARGUMENT_CODE, INVALID_DEFINITION_CODE};
13-
use crate::sql_macros::errors::LexError;
1413
use crate::sql_macros::errors::SqlMacroError;
1514
use crate::sql_macros::models::{
1615
ArgumentShape, BodyExpansion, ParameterKind, RenderedArgument, SqlMacroDefinition,
@@ -335,6 +334,6 @@ fn without_leading_keyword(text: &str) -> String {
335334
Some(first) => text[first.end..].trim().to_owned(),
336335
None => text.trim().to_owned(),
337336
},
338-
Err(LexError::UnclosedQuote(_) | LexError::UnclosedComment(_)) => text.trim().to_owned(),
337+
Err(_) => text.trim().to_owned(),
339338
}
340339
}

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/header.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ use crate::sql_macros::constants::{
1010
use crate::sql_macros::errors::LexError;
1111
use crate::sql_macros::errors::SqlMacroError;
1212
use crate::sql_macros::models::{ParameterKind, SqlMacroLocation, SqlMacroParameter};
13+
use sqlbuild_sqltext::header_strings::constants::{
14+
DOUBLE_QUOTE_INSIDE_HELP, DOUBLE_QUOTE_INSIDE_MESSAGE, SINGLE_QUOTE_INSIDE_HELP,
15+
SINGLE_QUOTE_INSIDE_MESSAGE, TRIPLE_QUOTE_INSIDE_HELP, TRIPLE_QUOTE_INSIDE_MESSAGE,
16+
UNCLOSED_TRIPLE_QUOTE_HELP, UNCLOSED_TRIPLE_QUOTE_MESSAGE,
17+
};
18+
use sqlbuild_sqltext::header_strings::main::prose_string::prose_string;
1319

1420
/// What the header declares and where the body starts.
1521
pub(crate) struct Header {
@@ -42,6 +48,18 @@ impl HeaderParser<'_> {
4248
let scanned: Vec<Token> = tokens(source).map_err(|error| match error {
4349
LexError::UnclosedQuote(at) => self.error(at, "unclosed quote in SQL macro file"),
4450
LexError::UnclosedComment(at) => self.error(at, "unclosed comment in SQL macro file"),
51+
LexError::QuoteInside(at) => self
52+
.error(at, DOUBLE_QUOTE_INSIDE_MESSAGE)
53+
.helped(DOUBLE_QUOTE_INSIDE_HELP),
54+
LexError::SingleQuoteInside(at) => self
55+
.error(at, SINGLE_QUOTE_INSIDE_MESSAGE)
56+
.helped(SINGLE_QUOTE_INSIDE_HELP),
57+
LexError::TripleQuoteInside(at) => self
58+
.error(at, TRIPLE_QUOTE_INSIDE_MESSAGE)
59+
.helped(TRIPLE_QUOTE_INSIDE_HELP),
60+
LexError::UnclosedTripleQuote(at) => self
61+
.error(at, UNCLOSED_TRIPLE_QUOTE_MESSAGE)
62+
.helped(UNCLOSED_TRIPLE_QUOTE_HELP),
4563
})?;
4664
let starts_with_header: bool = scanned
4765
.first()
@@ -89,7 +107,7 @@ impl HeaderParser<'_> {
89107
match key_text.as_str() {
90108
DESCRIPTION_KEY => match value {
91109
[text] if matches!(text.kind, TokenKind::Quoted | TokenKind::Text) => {
92-
header.description = Some(unquoted(text.text(source)));
110+
header.description = Some(self.description(text)?);
93111
Ok(header)
94112
}
95113
_ => Err(self.error(key.start, "description must be one quoted string")),
@@ -139,6 +157,14 @@ impl HeaderParser<'_> {
139157
}
140158
}
141159

160+
/// The folded prose of the description string `text`, which lexing found well quoted.
161+
fn description(&self, text: &Token) -> Result<String, SqlMacroError> {
162+
let raw: Vec<char> = text.text(self.source).chars().collect();
163+
prose_string(&raw, 0)
164+
.map(|prose| prose.value)
165+
.map_err(|_| self.error(text.start, "description must be one quoted string"))
166+
}
167+
142168
fn parameter(&self, tokens: &[Token]) -> Result<SqlMacroParameter, SqlMacroError> {
143169
let source: &str = self.source;
144170
let name: &Token = &tokens[0];

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/lexing.rs‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
//! A comment- and quote-aware SQL token scan, and the `@...` constructs it finds.
22
33
use sqlbuild_render::macro_calls::main::opens_bare_call::opens_bare_call;
4+
use sqlbuild_sqltext::header_strings::errors::{ProseError, TripleQuoteError};
5+
use sqlbuild_sqltext::header_strings::main::closes_inside_text::closes_inside_text;
6+
use sqlbuild_sqltext::header_strings::main::follows_description_key::follows_description_key;
7+
use sqlbuild_sqltext::header_strings::main::opens_triple_quote::opens_triple_quote;
8+
use sqlbuild_sqltext::header_strings::main::prose_end::prose_end;
9+
use sqlbuild_sqltext::header_strings::main::triple_quoted_end::triple_quoted_end;
410

5-
use crate::sql_macros::constants::SQL_KIND_WORD;
11+
use crate::sql_macros::constants::{HEADER_KEYWORD, SQL_KIND_WORD};
612
use crate::sql_macros::errors::LexError;
713

814
/// What one token is.
@@ -63,6 +69,7 @@ fn scanned_tokens(sql: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexE
6369
let bytes: &[u8] = sql.as_bytes();
6470
let mut found: Vec<Token> = Vec::new();
6571
let mut index: usize = 0;
72+
let mut header: HeaderScan = HeaderScan::Before;
6673
while index < bytes.len() {
6774
let byte: u8 = bytes[index];
6875
if byte.is_ascii_whitespace() {
@@ -85,7 +92,16 @@ fn scanned_tokens(sql: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexE
8592
continue;
8693
}
8794
let start: usize = index;
95+
let in_header: bool = matches!(header, HeaderScan::Inside(_));
8896
let kind: TokenKind = match byte {
97+
b'\'' if in_header => {
98+
index = header_string_end(bytes, index)?;
99+
TokenKind::Text
100+
}
101+
b'"' if in_header => {
102+
index = header_string_end(bytes, index)?;
103+
TokenKind::Quoted
104+
}
89105
b'\'' => {
90106
index = quote_end(bytes, index, backslash_escapes)
91107
.ok_or(LexError::UnclosedQuote(start))?;
@@ -130,15 +146,77 @@ fn scanned_tokens(sql: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexE
130146
TokenKind::Operator
131147
}
132148
};
133-
found.push(Token {
149+
let token: Token = Token {
134150
kind,
135151
start,
136152
end: index,
137-
});
153+
};
154+
header = header.after(sql, &token, found.is_empty());
155+
found.push(token);
138156
}
139157
Ok(found)
140158
}
141159

160+
/// Where a scan is relative to the `MACRO (...)` header that may start the text.
161+
#[derive(Clone, Copy)]
162+
enum HeaderScan {
163+
Before,
164+
/// The first token is `MACRO`.
165+
Keyword,
166+
/// Inside the header, at this bracket depth.
167+
Inside(usize),
168+
Outside,
169+
}
170+
171+
impl HeaderScan {
172+
fn after(self, sql: &str, token: &Token, first: bool) -> Self {
173+
let opens: bool =
174+
token.kind == TokenKind::Punct && matches!(token.text(sql), "(" | "[" | "{");
175+
let closes: bool =
176+
token.kind == TokenKind::Punct && matches!(token.text(sql), ")" | "]" | "}");
177+
match self {
178+
Self::Before if first && token.is_word(sql, HEADER_KEYWORD) => Self::Keyword,
179+
Self::Keyword if token.is(sql, "(") => Self::Inside(1),
180+
Self::Inside(depth) if opens => Self::Inside(depth + 1),
181+
Self::Inside(1) if closes => Self::Outside,
182+
Self::Inside(depth) if closes => Self::Inside(depth - 1),
183+
Self::Inside(depth) => Self::Inside(depth),
184+
_ => Self::Outside,
185+
}
186+
}
187+
}
188+
189+
/// The lexing error of a prose string that does not end where it should.
190+
fn prose_lex_error(error: ProseError) -> LexError {
191+
match error {
192+
ProseError::Unterminated(start) => LexError::UnclosedQuote(start),
193+
ProseError::DoubleQuoteInside(position) => LexError::QuoteInside(position),
194+
ProseError::SingleQuoteInside(position) => LexError::SingleQuoteInside(position),
195+
ProseError::TripleQuoteInside(position) => LexError::TripleQuoteInside(position),
196+
ProseError::UnclosedTripleQuote(start) => LexError::UnclosedTripleQuote(start),
197+
}
198+
}
199+
200+
/// The end of the header string at `start`: a description by prose rules, else as a SQL string.
201+
fn header_string_end(bytes: &[u8], start: usize) -> Result<usize, LexError> {
202+
if follows_description_key(bytes, start) {
203+
return prose_end(bytes, start).map_err(prose_lex_error);
204+
}
205+
if opens_triple_quote(bytes, start) {
206+
let (content_end, end) = triple_quoted_end(bytes, start).map_err(|error| match error {
207+
TripleQuoteError::Unclosed => LexError::UnclosedTripleQuote(start),
208+
TripleQuoteError::Inside(position) => LexError::TripleQuoteInside(position),
209+
})?;
210+
if closes_inside_text(bytes, end) {
211+
return Err(LexError::TripleQuoteInside(content_end));
212+
}
213+
return Ok(end);
214+
}
215+
quoted_end(bytes, start)
216+
.or_else(|| quote_end(bytes, start, true))
217+
.ok_or(LexError::UnclosedQuote(start))
218+
}
219+
142220
fn is_operator_byte(byte: u8) -> bool {
143221
matches!(
144222
byte,

‎crates/sqlbuild-analysis/src/sql_macros/errors.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,12 @@ impl<E> From<SqlMacroError> for ExpansionFailure<E> {
8888
pub(crate) enum LexError {
8989
UnclosedQuote(usize),
9090
UnclosedComment(usize),
91+
/// A `"` that ends a `"..."` header string inside its text, at this offset.
92+
QuoteInside(usize),
93+
/// A `'` that ends a `'...'` header description inside its text, at this offset.
94+
SingleQuoteInside(usize),
95+
/// A `"""` that ends a `"""..."""` header string inside its text, at this offset.
96+
TripleQuoteInside(usize),
97+
/// A `"""` header string that opens at this offset and never closes.
98+
UnclosedTripleQuote(usize),
9199
}

‎crates/sqlbuild-analysis/src/sql_macros/tests/test_definitions.rs‎

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ use crate::sql_macros::main::parse_sql_macro::parse_sql_macro;
22
use crate::sql_macros::main::sql_macro_cycle::sql_macro_cycle;
33
use crate::sql_macros::tests::helpers::owned;
44
use crate::sql_macros::tests::test_types::{
5-
CycleTestCase, DefinitionErrorTestCase, DefinitionTestCase,
5+
CycleTestCase, DefinitionErrorTestCase, DefinitionTestCase, MacroDescriptionTestCase,
6+
MacroQuoteErrorTestCase,
67
};
78

9+
const QUOTE_MESSAGE: &str = "a double quote ends this \"...\" string early; use \"\"\"…\"\"\" for text containing double quotes";
10+
811
#[test]
912
fn given_valid_macro_files_when_parsing_then_each_parameter_and_the_body_are_read() {
1013
let test_cases = [
@@ -285,6 +288,125 @@ fn given_invalid_macro_files_when_parsing_then_each_is_rejected() {
285288
}
286289
}
287290

291+
#[test]
292+
fn given_macro_descriptions_when_parsing_then_the_folded_text_is_stored() {
293+
let test_cases = [
294+
MacroDescriptionTestCase {
295+
description: "a triple-quoted description on one line keeps its quotes",
296+
source: "MACRO (description \"\"\"Matches \"Miss\", it's.\"\"\", returns VARCHAR);\n'x'\n",
297+
expected_description: "Matches \"Miss\", it's.",
298+
},
299+
MacroDescriptionTestCase {
300+
description: "a triple-quoted block across lines is folded",
301+
source: "MACRO (\n description \"\"\"\n True for \"Mrs. C. O'Brien\",\n any case.\n \"\"\",\n returns VARCHAR,\n);\n'x'\n",
302+
expected_description: "True for \"Mrs. C. O'Brien\", any case.",
303+
},
304+
MacroDescriptionTestCase {
305+
description: "a backslash escapes a quote in a single-quoted description",
306+
source: "MACRO (\n description 'it\\'s here',\n arguments (v VARCHAR),\n returns VARCHAR,\n);\n\nLOWER(@v)\n",
307+
expected_description: "it's here",
308+
},
309+
MacroDescriptionTestCase {
310+
description: "a doubled quote is one quote in a single-quoted description",
311+
source: "MACRO (\n description 'it''s',\n returns VARCHAR,\n);\n'x'\n",
312+
expected_description: "it's",
313+
},
314+
MacroDescriptionTestCase {
315+
description: "a double-quoted description may end in a backslash",
316+
source: "MACRO (\n description \"ends in C:\\\",\n returns VARCHAR,\n);\n'x'\n",
317+
expected_description: "ends in C:\\",
318+
},
319+
MacroDescriptionTestCase {
320+
description: "a wrapped description stores the same as one line",
321+
source: "MACRO (\n description \"True for a woman's title,\nany case.\",\n returns VARCHAR,\n);\n'it''s'\n",
322+
expected_description: "True for a woman's title, any case.",
323+
},
324+
];
325+
326+
for test_case in test_cases {
327+
let definition = parse_sql_macro("m", "macros/sql/m.sql", test_case.source)
328+
.expect(test_case.description);
329+
330+
assert_eq!(
331+
definition.description.as_deref(),
332+
Some(test_case.expected_description),
333+
"{}",
334+
test_case.description
335+
);
336+
}
337+
}
338+
339+
#[test]
340+
fn given_misquoted_macro_descriptions_when_parsing_then_the_quote_is_located() {
341+
let test_cases = [
342+
MacroQuoteErrorTestCase {
343+
description: "an escaped quote on one line fails at the quote",
344+
source: "MACRO (\n description \"Matches a title (\\\"Miss\\\").\",\n arguments (value VARCHAR),\n returns VARCHAR,\n);\n@value\n",
345+
expected_message: QUOTE_MESSAGE,
346+
expected_location: (2, 34),
347+
},
348+
MacroQuoteErrorTestCase {
349+
description: "apostrophes elsewhere no longer let an escaped quote through",
350+
source: "MACRO (\n description \"True when a name starts with a woman's title. Any case,\nwith or without a dot (\\\"MISS ANNA PARKER\\\", \\\"Mrs. C. O'Brien\\\").\",\n arguments (value VARCHAR),\n returns VARCHAR,\n);\n@value\n",
351+
expected_message: QUOTE_MESSAGE,
352+
expected_location: (3, 25),
353+
},
354+
MacroQuoteErrorTestCase {
355+
description: "a bare quote that ends the string early fails where it is",
356+
source: "MACRO (description \"Matches \"Miss\".\", returns VARCHAR);\n'x'\n",
357+
expected_message: QUOTE_MESSAGE,
358+
expected_location: (1, 29),
359+
},
360+
MacroQuoteErrorTestCase {
361+
description: "doubled double quotes fail at the second quote",
362+
source: "MACRO (description \"say \"\"hi\"\" now\", returns VARCHAR);\n'x'\n",
363+
expected_message: QUOTE_MESSAGE,
364+
expected_location: (1, 25),
365+
},
366+
MacroQuoteErrorTestCase {
367+
description: "a lone quote inside a single-quoted description",
368+
source: "MACRO (description 'it's here', returns VARCHAR);\n'x'\n",
369+
expected_message: "a single quote ends this '...' string early",
370+
expected_location: (1, 23),
371+
},
372+
MacroQuoteErrorTestCase {
373+
description: "an unclosed triple quote reports where it started",
374+
source: "MACRO (\n description \"\"\"never closed,\n returns VARCHAR,\n);\n'x'\n",
375+
expected_message: "this \"\"\" string is never closed",
376+
expected_location: (2, 15),
377+
},
378+
MacroQuoteErrorTestCase {
379+
description: "a triple quote inside a triple-quoted string",
380+
source: "MACRO (description \"\"\"a \"\"\"b\"\"\" c\"\"\", returns VARCHAR);\n'x'\n",
381+
expected_message: "\"\"\" inside a \"\"\"...\"\"\" string ends it early",
382+
expected_location: (1, 25),
383+
},
384+
];
385+
386+
for test_case in test_cases {
387+
let error = parse_sql_macro("m", "macros/sql/m.sql", test_case.source)
388+
.expect_err(test_case.description);
389+
let location = error
390+
.locations
391+
.first()
392+
.map(|location| (location.line, location.column));
393+
394+
assert_eq!(error.code, "P015", "{}", test_case.description);
395+
assert!(
396+
error.message.starts_with(test_case.expected_message),
397+
"{}: {}",
398+
test_case.description,
399+
error.message
400+
);
401+
assert_eq!(
402+
location,
403+
Some(test_case.expected_location),
404+
"{}",
405+
test_case.description
406+
);
407+
}
408+
}
409+
288410
#[test]
289411
fn given_macros_calling_each_other_when_checking_then_the_cycle_is_named() {
290412
let test_cases = [

‎crates/sqlbuild-analysis/src/sql_macros/tests/test_types.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,16 @@ pub(super) struct OutputNamesTestCase {
8383
pub(super) sql: &'static str,
8484
pub(super) expected_names: &'static [(Option<&'static str>, usize, &'static str)],
8585
}
86+
87+
pub(super) struct MacroDescriptionTestCase {
88+
pub(super) description: &'static str,
89+
pub(super) source: &'static str,
90+
pub(super) expected_description: &'static str,
91+
}
92+
93+
pub(super) struct MacroQuoteErrorTestCase {
94+
pub(super) description: &'static str,
95+
pub(super) source: &'static str,
96+
pub(super) expected_message: &'static str,
97+
pub(super) expected_location: (usize, usize),
98+
}

0 commit comments

Comments
 (0)