sql_dialect_fmt_formatter/
lib.rs1pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35#[non_exhaustive]
36pub enum KeywordCase {
37 Upper,
39 Lower,
41 Preserve,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum LineEnding {
49 Auto,
51 Lf,
53 Crlf,
55}
56
57#[derive(Clone, Copy, Debug)]
74#[non_exhaustive]
75pub struct FormatOptions {
76 pub line_width: usize,
78 pub indent_width: usize,
80 pub uppercase_keywords: bool,
86 pub keyword_case: KeywordCase,
88 pub line_ending: LineEnding,
90 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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Debug)]
187pub struct FormatResult {
188 pub formatted: String,
191 pub parse_errors: Vec<ParseError>,
193}
194
195pub fn format(source: &str, options: &FormatOptions) -> String {
202 format_with_diagnostics(source, options).formatted
203}
204
205pub 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}