Skip to main content

nautilus_schema/
formatter.rs

1//! Formatter: converts a `Schema` AST back to canonical `.nautilus` source text.
2//!
3//! - 2-space indentation
4//! - Blank lines between top-level blocks
5//! - Column spacing of 1 (padding = max_width - len + 1)
6//! - Field name column always padded (even for fields with no attributes)
7//! - Field type column padded only for fields that have attributes
8//! - Single space between attributes on the same field
9//! - Key-value pairs in datasource/generator blocks are aligned
10//! - Blank lines between field groups are preserved (detected via spans)
11//! - Blank line inserted before model-level `@@` attributes
12//! - Comments are preserved (both inline trailing comments and standalone comment lines)
13
14use crate::ast::{
15    ComputedKind, Declaration, Expr, FieldAttribute, FieldModifier, FieldType, Literal,
16    ModelAttribute, ReferentialAction, Schema, StorageStrategy, TypeDecl,
17};
18
19/// Format a [`Schema`] AST back to canonical `.nautilus` source text.
20///
21/// `source` is the original schema text that was parsed into `schema`; it is
22/// used to detect blank lines between consecutive fields, to preserve inline
23/// trailing comments (`// …`) and standalone comment lines between items.
24///
25/// Running `format_schema` twice on the same input produces identical output
26/// (the operation is idempotent).
27pub fn format_schema(schema: &Schema, source: &str) -> String {
28    let mut parts: Vec<(String, usize)> = Vec::new();
29
30    for decl in &schema.declarations {
31        let block = match decl {
32            Declaration::Datasource(ds) => {
33                let max_key = ds
34                    .fields
35                    .iter()
36                    .map(|f| f.name.value.len())
37                    .max()
38                    .unwrap_or(0);
39                let mut lines = vec![format!("datasource {} {{", ds.name.value)];
40                for (idx, field) in ds.fields.iter().enumerate() {
41                    // Preserve inter-field comments/blank-lines inside datasource blocks.
42                    if idx > 0 {
43                        let prev = &ds.fields[idx - 1];
44                        push_gap_content(source, prev.span.end, field.span.start, &mut lines, "  ");
45                    }
46                    let padding = max_key - field.name.value.len() + 1;
47                    let mut line = format!(
48                        "  {}{}= {}",
49                        field.name.value,
50                        " ".repeat(padding),
51                        format_expr(&field.value)
52                    );
53                    if let Some(c) = trailing_inline_comment(source, field.span.end) {
54                        line.push_str("  ");
55                        line.push_str(&c);
56                    }
57                    lines.push(line);
58                }
59                lines.push("}".to_string());
60                lines.join("\n")
61            }
62
63            Declaration::Generator(gen) => {
64                let max_key = gen
65                    .fields
66                    .iter()
67                    .map(|f| f.name.value.len())
68                    .max()
69                    .unwrap_or(0);
70                let mut lines = vec![format!("generator {} {{", gen.name.value)];
71                for (idx, field) in gen.fields.iter().enumerate() {
72                    if idx > 0 {
73                        let prev = &gen.fields[idx - 1];
74                        push_gap_content(source, prev.span.end, field.span.start, &mut lines, "  ");
75                    }
76                    let padding = max_key - field.name.value.len() + 1;
77                    let mut line = format!(
78                        "  {}{}= {}",
79                        field.name.value,
80                        " ".repeat(padding),
81                        format_expr(&field.value)
82                    );
83                    if let Some(c) = trailing_inline_comment(source, field.span.end) {
84                        line.push_str("  ");
85                        line.push_str(&c);
86                    }
87                    lines.push(line);
88                }
89                lines.push("}".to_string());
90                lines.join("\n")
91            }
92
93            Declaration::Enum(e) => {
94                let mut lines = vec![format!("enum {} {{", e.name.value)];
95                for (idx, variant) in e.variants.iter().enumerate() {
96                    if idx > 0 {
97                        let prev = &e.variants[idx - 1];
98                        push_gap_content(
99                            source,
100                            prev.span.end,
101                            variant.span.start,
102                            &mut lines,
103                            "  ",
104                        );
105                    }
106                    let mut line = format!("  {}", variant.name.value);
107                    if let Some(c) = trailing_inline_comment(source, variant.span.end) {
108                        line.push_str("  ");
109                        line.push_str(&c);
110                    }
111                    lines.push(line);
112                }
113                lines.push("}".to_string());
114                lines.join("\n")
115            }
116
117            Declaration::Model(model) => {
118                let max_name = model
119                    .fields
120                    .iter()
121                    .map(|f| f.name.value.len())
122                    .max()
123                    .unwrap_or(0);
124                let max_type = model
125                    .fields
126                    .iter()
127                    .map(|f| format_field_type_with_modifier(&f.field_type, f.modifier).len())
128                    .max()
129                    .unwrap_or(0);
130
131                let mut lines = vec![format!("model {} {{", model.name.value)];
132
133                for (idx, field) in model.fields.iter().enumerate() {
134                    // Preserve blank lines and comment lines between consecutive fields.
135                    if idx > 0 {
136                        let prev = &model.fields[idx - 1];
137                        push_gap_content(source, prev.span.end, field.span.start, &mut lines, "  ");
138                    }
139
140                    let type_str =
141                        format_field_type_with_modifier(&field.field_type, field.modifier);
142                    let attrs: Vec<String> =
143                        field.attributes.iter().map(format_field_attr).collect();
144
145                    let name_padding = max_name - field.name.value.len() + 1;
146
147                    let mut line = if attrs.is_empty() {
148                        format!(
149                            "  {}{}{}",
150                            field.name.value,
151                            " ".repeat(name_padding),
152                            type_str,
153                        )
154                    } else {
155                        let type_padding = max_type - type_str.len() + 1;
156                        format!(
157                            "  {}{}{}{}{}",
158                            field.name.value,
159                            " ".repeat(name_padding),
160                            type_str,
161                            " ".repeat(type_padding),
162                            attrs.join(" "),
163                        )
164                    };
165
166                    line = line.trim_end().to_string();
167
168                    if let Some(c) = trailing_inline_comment(source, field.span.end) {
169                        line.push_str("  ");
170                        line.push_str(&c);
171                    }
172
173                    lines.push(line);
174                }
175
176                if !model.attributes.is_empty() && !model.fields.is_empty() {
177                    lines.push(String::new());
178                }
179                for attr in &model.attributes {
180                    lines.push(format!("  {}", format_model_attr(attr)));
181                }
182
183                lines.push("}".to_string());
184                lines.join("\n")
185            }
186
187            Declaration::Type(type_decl) => format_type_decl(type_decl, source),
188        };
189
190        parts.push((block, decl.span().end));
191    }
192
193    let mut out = String::new();
194
195    if let Some((_, _)) = parts.first() {
196        if let Some(first_decl) = schema.declarations.first() {
197            let leading = top_level_comments(source, 0, first_decl.span().start);
198            for line in &leading {
199                out.push_str(line);
200                out.push('\n');
201            }
202            if !leading.is_empty() {
203                out.push('\n');
204            }
205        }
206    }
207
208    for (i, (block, span_end)) in parts.iter().enumerate() {
209        if i > 0 {
210            let prev_end = parts[i - 1].1;
211            let curr_start = schema.declarations[i].span().start;
212            let gap_comments = top_level_comments(source, prev_end, curr_start);
213            if gap_comments.is_empty() {
214                out.push_str("\n\n");
215            } else {
216                out.push_str("\n\n");
217                for comment in &gap_comments {
218                    out.push_str(comment);
219                    out.push('\n');
220                }
221                out.push('\n');
222            }
223        }
224        out.push_str(block);
225        let _ = span_end;
226    }
227
228    if let Some(last_decl) = schema.declarations.last() {
229        let trailing = top_level_comments(source, last_decl.span().end, source.len());
230        for comment in &trailing {
231            out.push('\n');
232            out.push_str(comment);
233        }
234    }
235
236    if !out.ends_with('\n') {
237        out.push('\n');
238    }
239    out
240}
241
242/// Extract the trailing inline `//` comment from `source` starting at byte `pos`,
243/// looking only at the rest of the current line.  Returns `None` if no comment
244/// is present on that line.
245fn trailing_inline_comment(source: &str, pos: usize) -> Option<String> {
246    let rest = source.get(pos..)?;
247    let line_end = rest.find('\n').unwrap_or(rest.len());
248    let rest_of_line = rest[..line_end].trim();
249    if rest_of_line.starts_with("//") {
250        Some(rest_of_line.to_string())
251    } else {
252        None
253    }
254}
255
256/// Push gap content (blank lines and indented comment lines) that appears
257/// between two adjacent items in a block.  `indent` is prepended to each
258/// comment line (e.g. `"  "` for 2-space indent).
259///
260/// The function looks at `source[prev_end..curr_start]`, skips the first
261/// partial line (which may already be covered by a trailing inline comment),
262/// then emits blank lines and `//` comment lines as needed.
263fn push_gap_content(
264    source: &str,
265    prev_end: usize,
266    curr_start: usize,
267    lines: &mut Vec<String>,
268    indent: &str,
269) {
270    if prev_end >= curr_start {
271        return;
272    }
273    let gap = match source.get(prev_end..curr_start) {
274        Some(s) => s,
275        None => return,
276    };
277
278    let after_first_nl = match gap.find('\n') {
279        Some(pos) => &gap[pos + 1..],
280        None => return,
281    };
282
283    let mut comment_lines: Vec<String> = Vec::new();
284    let mut blank_before_first = false;
285
286    for raw_line in after_first_nl.lines() {
287        let trimmed = raw_line.trim();
288        if trimmed.starts_with("//") {
289            comment_lines.push(trimmed.to_string());
290        } else if trimmed.is_empty() && comment_lines.is_empty() {
291            blank_before_first = true;
292        }
293    }
294
295    if !comment_lines.is_empty() {
296        if blank_before_first {
297            lines.push(String::new());
298        }
299        for c in &comment_lines {
300            lines.push(format!("{}{}", indent, c));
301        }
302    } else {
303        let nl_count = after_first_nl.chars().filter(|&c| c == '\n').count();
304        if nl_count >= 1 {
305            lines.push(String::new());
306        }
307    }
308}
309
310/// Extract top-level comment lines (and blank-line structure around them) from
311/// a source range, for use between top-level declarations.
312///
313/// Returns a `Vec` of strings where each entry is either a `//` comment line
314/// (without leading indentation) or an empty string representing a blank line.
315/// Leading and trailing blank-only entries are stripped.
316fn top_level_comments(source: &str, from: usize, to: usize) -> Vec<String> {
317    let slice = match source.get(from..to) {
318        Some(s) => s,
319        None => return Vec::new(),
320    };
321
322    let mut result: Vec<String> = Vec::new();
323    for raw_line in slice.lines() {
324        let trimmed = raw_line.trim();
325        if trimmed.starts_with("//") {
326            result.push(trimmed.to_string());
327        } else if trimmed.is_empty() && !result.is_empty() {
328            result.push(String::new());
329        }
330    }
331
332    while result
333        .last()
334        .map(|s: &String| s.is_empty())
335        .unwrap_or(false)
336    {
337        result.pop();
338    }
339
340    result
341}
342
343/// Format a composite type declaration block.
344fn format_type_decl(type_decl: &TypeDecl, source: &str) -> String {
345    let max_name = type_decl
346        .fields
347        .iter()
348        .map(|f| f.name.value.len())
349        .max()
350        .unwrap_or(0);
351    let max_type = type_decl
352        .fields
353        .iter()
354        .map(|f| format_field_type_with_modifier(&f.field_type, f.modifier).len())
355        .max()
356        .unwrap_or(0);
357
358    let mut lines = vec![format!("type {} {{", type_decl.name.value)];
359
360    for (idx, field) in type_decl.fields.iter().enumerate() {
361        if idx > 0 {
362            let prev = &type_decl.fields[idx - 1];
363            push_gap_content(source, prev.span.end, field.span.start, &mut lines, "  ");
364        }
365
366        let type_str = format_field_type_with_modifier(&field.field_type, field.modifier);
367        let attrs: Vec<String> = field.attributes.iter().map(format_field_attr).collect();
368
369        let name_padding = max_name - field.name.value.len() + 1;
370
371        let mut line = if attrs.is_empty() {
372            format!(
373                "  {}{}{}",
374                field.name.value,
375                " ".repeat(name_padding),
376                type_str,
377            )
378        } else {
379            let type_padding = max_type - type_str.len() + 1;
380            format!(
381                "  {}{}{}{}{}",
382                field.name.value,
383                " ".repeat(name_padding),
384                type_str,
385                " ".repeat(type_padding),
386                attrs.join(" "),
387            )
388        };
389
390        line = line.trim_end().to_string();
391
392        if let Some(c) = trailing_inline_comment(source, field.span.end) {
393            line.push_str("  ");
394            line.push_str(&c);
395        }
396
397        lines.push(line);
398    }
399
400    lines.push("}".to_string());
401    lines.join("\n")
402}
403
404fn format_field_type_with_modifier(ft: &FieldType, modifier: FieldModifier) -> String {
405    let base = ft.to_string();
406    match modifier {
407        FieldModifier::None => base,
408        FieldModifier::Optional => format!("{}?", base),
409        FieldModifier::NotNull => format!("{}!", base),
410        FieldModifier::Array => format!("{}[]", base),
411    }
412}
413
414/// Format a field-level attribute (`@id`, `@default(…)`, …).
415fn format_field_attr(attr: &FieldAttribute) -> String {
416    match attr {
417        FieldAttribute::Id => "@id".to_string(),
418        FieldAttribute::Unique => "@unique".to_string(),
419
420        FieldAttribute::Default(expr, _) => format!("@default({})", format_expr(expr)),
421
422        FieldAttribute::Map(name) => format!("@map(\"{}\")", name),
423
424        FieldAttribute::Store { strategy, .. } => match strategy {
425            StorageStrategy::Json => "@store(json)".to_string(),
426            StorageStrategy::Native => "@store(native)".to_string(),
427        },
428
429        FieldAttribute::Relation {
430            name,
431            fields,
432            references,
433            on_delete,
434            on_update,
435            ..
436        } => {
437            let mut args: Vec<String> = Vec::new();
438
439            if let Some(n) = name {
440                args.push(format!("name: \"{}\"", n));
441            }
442            if let Some(flds) = fields {
443                let names: Vec<_> = flds.iter().map(|i| i.value.clone()).collect();
444                args.push(format!("fields: [{}]", names.join(", ")));
445            }
446            if let Some(refs) = references {
447                let names: Vec<_> = refs.iter().map(|i| i.value.clone()).collect();
448                args.push(format!("references: [{}]", names.join(", ")));
449            }
450            if let Some(action) = on_delete {
451                args.push(format!("onDelete: {}", format_referential_action(action)));
452            }
453            if let Some(action) = on_update {
454                args.push(format!("onUpdate: {}", format_referential_action(action)));
455            }
456
457            format!("@relation({})", args.join(", "))
458        }
459
460        FieldAttribute::UpdatedAt { .. } => "@updatedAt".to_string(),
461
462        FieldAttribute::Computed { expr, kind, .. } => {
463            let kind_str = match kind {
464                ComputedKind::Stored => "Stored",
465                ComputedKind::Virtual => "Virtual",
466            };
467            format!("@computed({}, {})", expr, kind_str)
468        }
469
470        FieldAttribute::Check { expr, .. } => format!("@check({})", expr),
471    }
472}
473
474/// Format a model-level attribute (`@@map`, `@@id`, `@@unique`, `@@index`).
475fn format_model_attr(attr: &ModelAttribute) -> String {
476    match attr {
477        ModelAttribute::Map(name) => format!("@@map(\"{}\")", name),
478        ModelAttribute::Id(fields) => {
479            let names: Vec<_> = fields.iter().map(|i| i.value.clone()).collect();
480            format!("@@id([{}])", names.join(", "))
481        }
482        ModelAttribute::Unique(fields) => {
483            let names: Vec<_> = fields.iter().map(|i| i.value.clone()).collect();
484            format!("@@unique([{}])", names.join(", "))
485        }
486        ModelAttribute::Index {
487            fields,
488            index_type,
489            name,
490            map,
491        } => {
492            let names: Vec<_> = fields.iter().map(|i| i.value.clone()).collect();
493            let mut s = format!("@@index([{}])", names.join(", "));
494            if index_type.is_some() || name.is_some() || map.is_some() {
495                s.pop();
496                if let Some(t) = index_type {
497                    s.push_str(&format!(", type: {}", t.value));
498                }
499                if let Some(n) = name {
500                    s.push_str(&format!(", name: \"{}\"", n));
501                }
502                if let Some(m) = map {
503                    s.push_str(&format!(", map: \"{}\"", m));
504                }
505                s.push(')');
506            }
507            s
508        }
509
510        ModelAttribute::Check { expr, .. } => format!("@@check({})", expr),
511    }
512}
513
514/// Format a [`ReferentialAction`] to the identifier form used in the schema language.
515fn format_referential_action(action: &ReferentialAction) -> &'static str {
516    match action {
517        ReferentialAction::Cascade => "Cascade",
518        ReferentialAction::Restrict => "Restrict",
519        ReferentialAction::NoAction => "NoAction",
520        ReferentialAction::SetNull => "SetNull",
521        ReferentialAction::SetDefault => "SetDefault",
522    }
523}
524
525/// Format an [`Expr`] node to its source representation.
526pub(crate) fn format_expr(expr: &Expr) -> String {
527    match expr {
528        Expr::Literal(lit) => format_literal(lit),
529
530        Expr::FunctionCall { name, args, .. } => {
531            if args.is_empty() {
532                format!("{}()", name.value)
533            } else {
534                let formatted: Vec<_> = args.iter().map(format_expr).collect();
535                format!("{}({})", name.value, formatted.join(", "))
536            }
537        }
538
539        Expr::Array { elements, .. } => {
540            let formatted: Vec<_> = elements.iter().map(format_expr).collect();
541            format!("[{}]", formatted.join(", "))
542        }
543
544        Expr::NamedArg { name, value, .. } => {
545            format!("{}: {}", name.value, format_expr(value))
546        }
547
548        Expr::Ident(ident) => ident.value.clone(),
549    }
550}
551
552/// Format a [`Literal`] to its source representation.
553fn format_literal(lit: &Literal) -> String {
554    match lit {
555        Literal::String(s, _) => format!("\"{}\"", s),
556        Literal::Number(n, _) => n.clone(),
557        Literal::Boolean(b, _) => b.to_string(),
558    }
559}