Skip to main content

sql_dialect_fmt_formatter/
lib.rs

1//! **sql-dialect-fmt-formatter** — the formatting half of the toolchain.
2//!
3//! Two layers:
4//! * [`doc`] — a grammar-agnostic pretty-printing engine (a `Doc` IR + width-aware printer) in the
5//!   Wadler → Prettier → biome/ruff lineage. Depends on nothing SQL-specific.
6//! * `sql` — the Snowflake SQL rules that lower the parser's lossless CST into `Doc`s.
7//!
8//! [`format()`] ties them together: parse → lower → print. Like the parser, it never panics; input
9//! it cannot model structurally is preserved verbatim, so formatting is always content-preserving.
10//!
11//! ```
12//! use sql_dialect_fmt_formatter::{format, FormatOptions};
13//! let out = format("select a,b from t", &FormatOptions::default());
14//! assert_eq!(out, "SELECT a, b\nFROM t;\n");
15//! ```
16//!
17//! ## Public API stability
18//!
19//! [`FormatOptions`] is the configuration entry point and is `#[non_exhaustive]`: build it from
20//! [`FormatOptions::default`] and refine it with the `with_*` methods so adding future knobs stays
21//! backwards compatible.
22
23pub mod doc;
24mod sql;
25
26#[doc(inline)]
27pub use doc::{print, Doc, PrintOptions};
28use sql_dialect_fmt_parser::ParseError;
29pub use sql_dialect_fmt_syntax::Dialect;
30
31use sql::Ctx;
32
33/// How SQL keywords should be cased in formatted output.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35#[non_exhaustive]
36pub enum KeywordCase {
37    /// Upper-case recognized keywords (`SELECT`, `FROM`).
38    Upper,
39    /// Lower-case recognized keywords (`select`, `from`).
40    Lower,
41    /// Preserve the source spelling of recognized keywords.
42    Preserve,
43}
44
45/// Output line-ending policy.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum LineEnding {
49    /// Use the first line ending found in the input, falling back to LF when there is none.
50    Auto,
51    /// Always write LF (`\n`).
52    Lf,
53    /// Always write CRLF (`\r\n`).
54    Crlf,
55}
56
57/// Options controlling formatting. Opinionated and intentionally small.
58///
59/// This type is `#[non_exhaustive]`: future releases may add knobs without it being a breaking
60/// change. Consequently, callers in other crates cannot build it with a struct literal. Start from
61/// [`FormatOptions::default`] and adjust it through the `with_*` builder methods instead:
62///
63/// ```
64/// use sql_dialect_fmt_formatter::FormatOptions;
65/// let options = FormatOptions::default()
66///     .with_line_width(80)
67///     .with_indent_width(2)
68///     .with_keyword_case(sql_dialect_fmt_formatter::KeywordCase::Preserve);
69/// assert_eq!(options.line_width, 80);
70/// assert_eq!(options.indent_width, 2);
71/// assert_eq!(options.keyword_case, sql_dialect_fmt_formatter::KeywordCase::Preserve);
72/// ```
73#[derive(Clone, Copy, Debug)]
74#[non_exhaustive]
75pub struct FormatOptions {
76    /// Target line width the printer keeps within where it can.
77    pub line_width: usize,
78    /// Spaces per indentation level.
79    pub indent_width: usize,
80    /// Upper-case SQL keywords.
81    ///
82    /// Kept for 1.x source compatibility. New code should use [`FormatOptions::keyword_case`] or
83    /// [`FormatOptions::with_keyword_case`]. When this is set to `false` and `keyword_case` is still
84    /// [`KeywordCase::Upper`], the formatter treats it as [`KeywordCase::Preserve`].
85    pub uppercase_keywords: bool,
86    /// Keyword casing policy.
87    pub keyword_case: KeywordCase,
88    /// Output line-ending policy.
89    pub line_ending: LineEnding,
90    /// The SQL dialect to parse and format. Defaults to [`Dialect::Snowflake`].
91    pub dialect: Dialect,
92}
93
94impl Default for FormatOptions {
95    fn default() -> Self {
96        FormatOptions {
97            line_width: 100,
98            indent_width: 4,
99            uppercase_keywords: true,
100            keyword_case: KeywordCase::Upper,
101            line_ending: LineEnding::Lf,
102            dialect: Dialect::Snowflake,
103        }
104    }
105}
106
107impl FormatOptions {
108    /// Set the target line width (the column the printer tries to keep lines within), returning the
109    /// updated options so calls can be chained.
110    #[must_use]
111    pub fn with_line_width(mut self, line_width: usize) -> Self {
112        self.line_width = line_width;
113        self
114    }
115
116    /// Set the number of spaces per indentation level, returning the updated options so calls can be
117    /// chained.
118    #[must_use]
119    pub fn with_indent_width(mut self, indent_width: usize) -> Self {
120        self.indent_width = indent_width;
121        self
122    }
123
124    /// Choose whether SQL keywords are upper-cased, returning the updated options so calls can be
125    /// chained.
126    #[must_use]
127    pub fn with_uppercase_keywords(mut self, uppercase_keywords: bool) -> Self {
128        self.uppercase_keywords = uppercase_keywords;
129        self.keyword_case = if uppercase_keywords {
130            KeywordCase::Upper
131        } else {
132            KeywordCase::Preserve
133        };
134        self
135    }
136
137    /// Choose keyword casing, returning the updated options so calls can be chained.
138    #[must_use]
139    pub fn with_keyword_case(mut self, keyword_case: KeywordCase) -> Self {
140        self.keyword_case = keyword_case;
141        self.uppercase_keywords = matches!(keyword_case, KeywordCase::Upper);
142        self
143    }
144
145    /// Choose output line endings, returning the updated options so calls can be chained.
146    #[must_use]
147    pub fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
148        self.line_ending = line_ending;
149        self
150    }
151
152    /// Set the SQL dialect to parse and format, returning the updated options so calls can be
153    /// chained.
154    #[must_use]
155    pub fn with_dialect(mut self, dialect: Dialect) -> Self {
156        self.dialect = dialect;
157        self
158    }
159
160    fn print_options(&self) -> PrintOptions {
161        PrintOptions {
162            line_width: self.line_width,
163            indent_width: self.indent_width,
164        }
165    }
166
167    fn ctx(&self) -> Ctx {
168        Ctx {
169            keyword_case: self.effective_keyword_case(),
170            line_width: self.line_width,
171            indent_width: self.indent_width,
172            dialect: self.dialect,
173        }
174    }
175
176    fn effective_keyword_case(&self) -> KeywordCase {
177        if !self.uppercase_keywords && self.keyword_case == KeywordCase::Upper {
178            KeywordCase::Preserve
179        } else {
180            self.keyword_case
181        }
182    }
183}
184
185/// Output plus parse diagnostics from a single formatter pass.
186#[derive(Clone, Debug)]
187pub struct FormatResult {
188    /// The formatted SQL, or the original source when lexing/parsing fails or a protected token
189    /// shape requires verbatim preservation.
190    pub formatted: String,
191    /// Parser diagnostics collected from the same parse used for formatting.
192    pub parse_errors: Vec<ParseError>,
193}
194
195/// Format Snowflake SQL source text. Never panics; never drops content.
196///
197/// Phase 3 scope: we only reflow input the lexer and parser fully accept. If lexing or parsing
198/// reports any error (i.e. the grammar does not yet model some construct, or a token is
199/// unterminated), the source is returned **unchanged** — trivially lossless and idempotent — rather
200/// than risking a mangled reflow of a fragmented tree.
201pub fn format(source: &str, options: &FormatOptions) -> String {
202    format_with_diagnostics(source, options).formatted
203}
204
205/// Format SQL and return parse diagnostics from the same lex/parse pass.
206pub fn format_with_diagnostics(source: &str, options: &FormatOptions) -> FormatResult {
207    if let Some(result) = format_directive_regions(source, options) {
208        return result;
209    }
210    format_plain_with_diagnostics(source, options, 0)
211}
212
213fn format_plain_with_diagnostics(
214    source: &str,
215    options: &FormatOptions,
216    base_offset: usize,
217) -> FormatResult {
218    let ctx = options.ctx();
219    let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(source, ctx.dialect);
220    let has_lex_errors = !lexed.errors.is_empty();
221    let has_multiline_trailing_space = lexed.tokens.iter().any(|token| {
222        !token.kind.is_trivia() && multiline_token_has_line_trailing_space(token.text)
223    });
224    let parse = sql_dialect_fmt_parser::parse_lexed(source, ctx.dialect, lexed);
225    let mut parse_errors = parse.errors().to_vec();
226    if base_offset > 0 {
227        adjust_parse_errors(&mut parse_errors, base_offset);
228    }
229    if has_lex_errors || has_multiline_trailing_space || !parse_errors.is_empty() {
230        return FormatResult {
231            formatted: source.to_string(),
232            parse_errors,
233        };
234    }
235    let root = parse.syntax();
236    let doc = sql::lower_source(&root, ctx);
237    let printed = print(&doc, &options.print_options());
238    FormatResult {
239        formatted: apply_line_ending(&printed, source, options.line_ending),
240        parse_errors,
241    }
242}
243
244fn format_directive_regions(source: &str, options: &FormatOptions) -> Option<FormatResult> {
245    let regions = directive_regions(source)?;
246    let mut formatted = String::new();
247    let mut parse_errors = Vec::new();
248    for region in regions {
249        let text = &source[region.start..region.end];
250        if region.enabled {
251            let result = format_plain_with_diagnostics(text, options, region.start);
252            formatted.push_str(&result.formatted);
253            parse_errors.extend(result.parse_errors);
254        } else {
255            formatted.push_str(text);
256        }
257    }
258    let index = sql_dialect_fmt_text::LineIndex::new(source);
259    for error in &mut parse_errors {
260        error.line_column = Some(index.line_column(error.offset));
261    }
262    let formatted = apply_line_ending(&formatted, source, options.line_ending);
263    Some(FormatResult {
264        formatted,
265        parse_errors,
266    })
267}
268
269#[derive(Clone, Copy)]
270struct FormatRegion {
271    start: usize,
272    end: usize,
273    enabled: bool,
274}
275
276fn directive_regions(source: &str) -> Option<Vec<FormatRegion>> {
277    let mut regions = Vec::new();
278    let mut disabled = false;
279    let mut region_start = 0usize;
280    let mut line_start = 0usize;
281    let mut saw_directive = false;
282
283    for line in source.split_inclusive('\n') {
284        let line_end = line_start + line.len();
285        match format_directive(line) {
286            Some(FormatDirective::Off) if !disabled => {
287                saw_directive = true;
288                if region_start < line_start {
289                    regions.push(FormatRegion {
290                        start: region_start,
291                        end: line_start,
292                        enabled: true,
293                    });
294                }
295                disabled = true;
296                region_start = line_start;
297            }
298            Some(FormatDirective::On) if disabled => {
299                saw_directive = true;
300                regions.push(FormatRegion {
301                    start: region_start,
302                    end: line_end,
303                    enabled: false,
304                });
305                disabled = false;
306                region_start = line_end;
307            }
308            _ => {}
309        }
310        line_start = line_end;
311    }
312
313    if line_start < source.len() {
314        let line = &source[line_start..];
315        match format_directive(line) {
316            Some(FormatDirective::Off) if !disabled => {
317                saw_directive = true;
318                if region_start < line_start {
319                    regions.push(FormatRegion {
320                        start: region_start,
321                        end: line_start,
322                        enabled: true,
323                    });
324                }
325                disabled = true;
326                region_start = line_start;
327            }
328            Some(FormatDirective::On) if disabled => {
329                saw_directive = true;
330                regions.push(FormatRegion {
331                    start: region_start,
332                    end: source.len(),
333                    enabled: false,
334                });
335                disabled = false;
336                region_start = source.len();
337            }
338            _ => {}
339        }
340    }
341
342    if region_start < source.len() {
343        regions.push(FormatRegion {
344            start: region_start,
345            end: source.len(),
346            enabled: !disabled,
347        });
348    }
349    saw_directive.then_some(regions)
350}
351
352#[derive(Clone, Copy)]
353enum FormatDirective {
354    Off,
355    On,
356}
357
358fn format_directive(line: &str) -> Option<FormatDirective> {
359    let trimmed = line.trim_start();
360    let body = trimmed.strip_prefix("--")?.trim_start();
361    for prefix in ["sql-dialect-fmt:", "snowfmt:", "fmt:"] {
362        if let Some(rest) = body.strip_prefix(prefix) {
363            let word = rest.split_whitespace().next()?;
364            if word.eq_ignore_ascii_case("off") {
365                return Some(FormatDirective::Off);
366            }
367            if word.eq_ignore_ascii_case("on") {
368                return Some(FormatDirective::On);
369            }
370        }
371    }
372    None
373}
374
375fn adjust_parse_errors(errors: &mut [ParseError], base_offset: usize) {
376    for error in errors {
377        error.offset += base_offset;
378    }
379}
380
381fn apply_line_ending(text: &str, source: &str, line_ending: LineEnding) -> String {
382    let target = match line_ending {
383        LineEnding::Lf => "\n",
384        LineEnding::Crlf => "\r\n",
385        LineEnding::Auto => first_line_ending(source).unwrap_or("\n"),
386    };
387    if target == "\n" {
388        text.replace("\r\n", "\n")
389    } else {
390        text.replace("\r\n", "\n").replace('\n', "\r\n")
391    }
392}
393
394fn first_line_ending(source: &str) -> Option<&'static str> {
395    let bytes = source.as_bytes();
396    let mut i = 0;
397    while i < bytes.len() {
398        match bytes[i] {
399            b'\n' => return Some("\n"),
400            b'\r' if bytes.get(i + 1) == Some(&b'\n') => return Some("\r\n"),
401            _ => i += 1,
402        }
403    }
404    None
405}
406
407pub(crate) fn multiline_token_has_line_trailing_space(text: &str) -> bool {
408    text.lines()
409        .any(|line| line.ends_with(' ') || line.ends_with('\t'))
410}