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