sql_dialect_fmt_formatter/
lib.rs1pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum KeywordCase {
39 Upper,
41 Lower,
43 Preserve,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum LineEnding {
51 Auto,
53 Lf,
55 Crlf,
57}
58
59#[derive(Clone, Copy, Debug)]
76#[non_exhaustive]
77pub struct FormatOptions {
78 pub line_width: usize,
80 pub indent_width: usize,
82 pub uppercase_keywords: bool,
88 pub keyword_case: KeywordCase,
90 pub line_ending: LineEnding,
92 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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Debug)]
189pub struct FormatResult {
190 pub formatted: String,
193 pub parse_errors: Vec<ParseError>,
195}
196
197pub fn format(source: &str, options: &FormatOptions) -> String {
204 format_with_diagnostics(source, options).formatted
205}
206
207pub 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}