Skip to main content

prax_cli/commands/
format.rs

1//! `prax format` command - Format Prax schema file(s).
2//!
3//! Bypasses `prax_schema::load` deliberately: formatting is per-file and
4//! syntactic, so cross-file merge/validation would only get in the way.
5//!
6//! Note: plain `//` line comments are not preserved — the schema parser
7//! discards them as trivia, so only `///` documentation comments survive
8//! a round-trip through the formatter.
9
10use std::path::Path;
11
12use crate::cli::FormatArgs;
13use crate::config::SCHEMA_FILE_PATH;
14use crate::error::{CliError, CliResult};
15use crate::output::{self, success};
16
17/// Run the format command
18pub async fn run(args: FormatArgs) -> CliResult<()> {
19    output::header("Format Schema");
20
21    let cwd = std::env::current_dir()?;
22    let schema_path = args.schema.unwrap_or_else(|| cwd.join(SCHEMA_FILE_PATH));
23
24    if !schema_path.exists() {
25        return Err(CliError::Config(format!(
26            "Schema path not found: {}",
27            schema_path.display()
28        )));
29    }
30
31    output::kv("Schema", &schema_path.display().to_string());
32    output::newline();
33
34    let files: Vec<std::path::PathBuf> = if schema_path.is_dir() {
35        let discovered = prax_schema::loader::discover(&schema_path).map_err(CliError::from)?;
36        if discovered.is_empty() {
37            return Err(CliError::Config(format!(
38                "No .prax files found under {}",
39                schema_path.display()
40            )));
41        }
42        discovered.into_iter().map(|d| d.absolute).collect()
43    } else {
44        vec![schema_path.clone()]
45    };
46
47    let mut any_changed = false;
48    let mut any_needs_format = false;
49    for file in &files {
50        match format_one(file, args.check)? {
51            FormatOutcome::Unchanged => {}
52            FormatOutcome::Reformatted => any_changed = true,
53            FormatOutcome::NeedsFormatting => any_needs_format = true,
54        }
55    }
56
57    output::newline();
58    if args.check {
59        if any_needs_format {
60            output::error("Some schema files are not formatted correctly.");
61            output::info("Run `prax format` to fix formatting.");
62            return Err(CliError::Format(
63                "One or more schema files need formatting".to_string(),
64            ));
65        }
66        success(&format!(
67            "All {} schema file(s) are formatted!",
68            files.len()
69        ));
70    } else if any_changed {
71        success(&format!("Formatted {} schema file(s).", files.len()));
72    } else {
73        success(&format!(
74            "All {} schema file(s) are already formatted!",
75            files.len()
76        ));
77    }
78
79    Ok(())
80}
81
82enum FormatOutcome {
83    Unchanged,
84    Reformatted,
85    NeedsFormatting,
86}
87
88fn format_one(path: &Path, check: bool) -> CliResult<FormatOutcome> {
89    let content = std::fs::read_to_string(path)?;
90    let schema = parse_schema(&content)?;
91    let formatted = format_schema(&schema);
92    let changed = formatted != content;
93
94    if check {
95        return Ok(if changed {
96            output::error(&format!("Needs formatting: {}", path.display()));
97            FormatOutcome::NeedsFormatting
98        } else {
99            FormatOutcome::Unchanged
100        });
101    }
102
103    if changed {
104        std::fs::write(path, &formatted)?;
105        output::list_item(&format!("Formatted {}", path.display()));
106        Ok(FormatOutcome::Reformatted)
107    } else {
108        Ok(FormatOutcome::Unchanged)
109    }
110}
111
112fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
113    // Formatting is per-file and syntactic: parse only, without
114    // validation, so files whose relations resolve only after a
115    // multi-file merge still format cleanly. (Validation would also
116    // resolve Model->Enum/Composite field types, but the formatter
117    // renders FieldType::Model/Enum/Composite identically as the bare
118    // name, so the output is unaffected.)
119    prax_schema::parse_schema(content).map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
120}
121
122/// Format a schema AST into a formatted string
123fn format_schema(schema: &prax_schema::ast::Schema) -> String {
124    let mut output = String::new();
125    let mut wrote_section = false;
126
127    // Serialize the datasource declared in the schema, preserving the
128    // actual provider/url instead of injecting a hardcoded default.
129    if let Some(datasource) = &schema.datasource {
130        format_datasource(&mut output, datasource);
131        wrote_section = true;
132    }
133
134    // Serialize the generator blocks declared in the schema.
135    for generator in schema.generators.values() {
136        if wrote_section {
137            output.push('\n');
138        }
139        format_generator(&mut output, generator);
140        wrote_section = true;
141    }
142
143    // Format enums first (since they're used by models)
144    for enum_def in schema.enums.values() {
145        if wrote_section {
146            output.push('\n');
147        }
148        format_enum(&mut output, enum_def);
149        wrote_section = true;
150    }
151
152    // Format models
153    for model in schema.models.values() {
154        if wrote_section {
155            output.push('\n');
156        }
157        format_model(&mut output, model);
158        wrote_section = true;
159    }
160
161    // Format views
162    for view in schema.views.values() {
163        if wrote_section {
164            output.push('\n');
165        }
166        format_view(&mut output, view);
167        wrote_section = true;
168    }
169
170    // Format composite types
171    for composite in schema.types.values() {
172        if wrote_section {
173            output.push('\n');
174        }
175        format_composite(&mut output, composite);
176        wrote_section = true;
177    }
178
179    output
180}
181
182fn format_datasource(output: &mut String, datasource: &prax_schema::ast::Datasource) {
183    output.push_str(&format!("datasource {} {{\n", datasource.name));
184    output.push_str(&format!(
185        "    provider = \"{}\"\n",
186        datasource.provider.as_str()
187    ));
188
189    if let Some(url_env) = &datasource.url_env {
190        output.push_str(&format!("    url      = env(\"{}\")\n", url_env));
191    } else if let Some(url) = &datasource.url {
192        output.push_str(&format!("    url      = \"{}\"\n", url));
193    }
194
195    if !datasource.extensions.is_empty() {
196        let extensions: Vec<String> = datasource
197            .extensions
198            .iter()
199            .map(|ext| {
200                let mut args = Vec::new();
201                if let Some(schema) = &ext.schema {
202                    args.push(format!("schema: \"{}\"", schema));
203                }
204                if let Some(version) = &ext.version {
205                    args.push(format!("version: \"{}\"", version));
206                }
207                if args.is_empty() {
208                    ext.name().to_string()
209                } else {
210                    format!("{}({})", ext.name(), args.join(", "))
211                }
212            })
213            .collect();
214        output.push_str(&format!("    extensions = [{}]\n", extensions.join(", ")));
215    }
216
217    for (key, value) in &datasource.properties {
218        // env("VAR") values are stored verbatim; re-emit them unquoted
219        // so the formatted output stays parseable.
220        if value.starts_with("env(") {
221            output.push_str(&format!("    {} = {}\n", key, value));
222        } else {
223            output.push_str(&format!("    {} = \"{}\"\n", key, value));
224        }
225    }
226
227    output.push_str("}\n");
228}
229
230fn format_generator(output: &mut String, generator: &prax_schema::ast::Generator) {
231    use prax_schema::ast::{GeneratorToggle, GeneratorValue};
232
233    output.push_str(&format!("generator {} {{\n", generator.name()));
234
235    if let Some(provider) = &generator.provider {
236        output.push_str(&format!("    provider = \"{}\"\n", provider));
237    }
238
239    if let Some(out) = &generator.output {
240        output.push_str(&format!("    output   = \"{}\"\n", out));
241    }
242
243    match &generator.generate {
244        GeneratorToggle::Always => {}
245        GeneratorToggle::Never | GeneratorToggle::Literal(false) => {
246            output.push_str("    generate = false\n");
247        }
248        GeneratorToggle::Literal(true) => {
249            output.push_str("    generate = true\n");
250        }
251        GeneratorToggle::Env(var) => {
252            output.push_str(&format!("    generate = env(\"{}\")\n", var));
253        }
254    }
255
256    for (key, value) in &generator.properties {
257        let formatted = match value {
258            GeneratorValue::String(s) => format!("\"{}\"", s),
259            GeneratorValue::Bool(b) => b.to_string(),
260            GeneratorValue::Env(var) => format!("env(\"{}\")", var),
261            GeneratorValue::Ident(s) => s.to_string(),
262        };
263        output.push_str(&format!("    {} = {}\n", key, formatted));
264    }
265
266    output.push_str("}\n");
267}
268
269fn format_enum(output: &mut String, enum_def: &prax_schema::ast::Enum) {
270    // Documentation
271    if let Some(doc) = &enum_def.documentation {
272        for line in doc.text.lines() {
273            output.push_str(&format!("/// {}\n", line));
274        }
275    }
276
277    output.push_str(&format!("enum {} {{\n", enum_def.name()));
278
279    for variant in &enum_def.variants {
280        // Documentation
281        if let Some(doc) = &variant.documentation {
282            for line in doc.text.lines() {
283                output.push_str(&format!("    /// {}\n", line));
284            }
285        }
286
287        output.push_str(&format!("    {}", variant.name()));
288
289        // Format attributes
290        for attr in &variant.attributes {
291            output.push_str(&format!(" {}", format_attribute(attr, AttrLevel::Field)));
292        }
293
294        output.push('\n');
295    }
296
297    // Enum-level attributes
298    for attr in &enum_def.attributes {
299        output.push_str(&format!(
300            "\n    {}",
301            format_attribute(attr, AttrLevel::Block)
302        ));
303    }
304
305    output.push_str("}\n");
306}
307
308fn format_model(output: &mut String, model: &prax_schema::ast::Model) {
309    // Documentation
310    if let Some(doc) = &model.documentation {
311        for line in doc.text.lines() {
312            output.push_str(&format!("/// {}\n", line));
313        }
314    }
315
316    output.push_str(&format!("model {} {{\n", model.name()));
317
318    // Calculate alignment for fields
319    let max_name_len = model
320        .fields
321        .values()
322        .map(|f| f.name().len())
323        .max()
324        .unwrap_or(0);
325
326    let max_type_len = model
327        .fields
328        .values()
329        .map(|f| format_field_type(&f.field_type, f.modifier).len())
330        .max()
331        .unwrap_or(0);
332
333    for field in model.fields.values() {
334        // Documentation
335        if let Some(doc) = &field.documentation {
336            for line in doc.text.lines() {
337                output.push_str(&format!("    /// {}\n", line));
338            }
339        }
340
341        let type_str = format_field_type(&field.field_type, field.modifier);
342
343        // Pad name and type for alignment
344        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
345        let padded_type = format!("{:width$}", type_str, width = max_type_len);
346
347        output.push_str(&format!("    {} {}", padded_name, padded_type));
348
349        // Format attributes
350        for attr in &field.attributes {
351            output.push_str(&format!(" {}", format_attribute(attr, AttrLevel::Field)));
352        }
353
354        output.push('\n');
355    }
356
357    // Model-level attributes
358    let model_attrs: Vec<_> = model.attributes.iter().collect();
359    if !model_attrs.is_empty() {
360        output.push('\n');
361        for attr in model_attrs {
362            output.push_str(&format!(
363                "    {}\n",
364                format_attribute(attr, AttrLevel::Block)
365            ));
366        }
367    }
368
369    output.push_str("}\n");
370}
371
372fn format_view(output: &mut String, view: &prax_schema::ast::View) {
373    // Documentation
374    if let Some(doc) = &view.documentation {
375        for line in doc.text.lines() {
376            output.push_str(&format!("/// {}\n", line));
377        }
378    }
379
380    output.push_str(&format!("view {} {{\n", view.name()));
381
382    // Calculate alignment for fields
383    let max_name_len = view
384        .fields
385        .values()
386        .map(|f| f.name().len())
387        .max()
388        .unwrap_or(0);
389
390    let max_type_len = view
391        .fields
392        .values()
393        .map(|f| format_field_type(&f.field_type, f.modifier).len())
394        .max()
395        .unwrap_or(0);
396
397    for field in view.fields.values() {
398        let type_str = format_field_type(&field.field_type, field.modifier);
399        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
400        let padded_type = format!("{:width$}", type_str, width = max_type_len);
401
402        output.push_str(&format!("    {} {}", padded_name, padded_type));
403
404        for attr in &field.attributes {
405            output.push_str(&format!(" {}", format_attribute(attr, AttrLevel::Field)));
406        }
407
408        output.push('\n');
409    }
410
411    // View-level attributes
412    let view_attrs: Vec<_> = view.attributes.iter().collect();
413    if !view_attrs.is_empty() {
414        output.push('\n');
415        for attr in view_attrs {
416            output.push_str(&format!(
417                "    {}\n",
418                format_attribute(attr, AttrLevel::Block)
419            ));
420        }
421    }
422
423    output.push_str("}\n");
424}
425
426fn format_composite(output: &mut String, composite: &prax_schema::ast::CompositeType) {
427    // Documentation
428    if let Some(doc) = &composite.documentation {
429        for line in doc.text.lines() {
430            output.push_str(&format!("/// {}\n", line));
431        }
432    }
433
434    output.push_str(&format!("type {} {{\n", composite.name()));
435
436    // Calculate alignment for fields
437    let max_name_len = composite
438        .fields
439        .values()
440        .map(|f| f.name().len())
441        .max()
442        .unwrap_or(0);
443
444    let max_type_len = composite
445        .fields
446        .values()
447        .map(|f| format_field_type(&f.field_type, f.modifier).len())
448        .max()
449        .unwrap_or(0);
450
451    for field in composite.fields.values() {
452        let type_str = format_field_type(&field.field_type, field.modifier);
453        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
454        let padded_type = format!("{:width$}", type_str, width = max_type_len);
455
456        output.push_str(&format!("    {} {}", padded_name, padded_type));
457
458        for attr in &field.attributes {
459            output.push_str(&format!(" {}", format_attribute(attr, AttrLevel::Field)));
460        }
461
462        output.push('\n');
463    }
464
465    output.push_str("}\n");
466}
467
468fn format_field_type(
469    field_type: &prax_schema::ast::FieldType,
470    modifier: prax_schema::ast::TypeModifier,
471) -> String {
472    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
473
474    let base = match field_type {
475        FieldType::Scalar(scalar) => match scalar {
476            ScalarType::Int => "Int",
477            ScalarType::BigInt => "BigInt",
478            ScalarType::Float => "Float",
479            ScalarType::String => "String",
480            ScalarType::Boolean => "Boolean",
481            ScalarType::DateTime => "DateTime",
482            ScalarType::Date => "Date",
483            ScalarType::Time => "Time",
484            ScalarType::Json => "Json",
485            ScalarType::Bytes => "Bytes",
486            ScalarType::Decimal => "Decimal",
487            ScalarType::Uuid => "Uuid",
488            ScalarType::Cuid => "Cuid",
489            ScalarType::Cuid2 => "Cuid2",
490            ScalarType::NanoId => "NanoId",
491            ScalarType::Ulid => "Ulid",
492            ScalarType::Vector(_) => "Vector",
493            ScalarType::HalfVector(_) => "HalfVector",
494            ScalarType::SparseVector(_) => "SparseVector",
495            ScalarType::Bit(_) => "Bit",
496        }
497        .to_string(),
498        FieldType::Model(name) => name.to_string(),
499        FieldType::Enum(name) => name.to_string(),
500        FieldType::Composite(name) => name.to_string(),
501        FieldType::Unsupported(name) => format!("Unsupported(\"{}\")", name),
502    };
503
504    match modifier {
505        TypeModifier::Optional => format!("{}?", base),
506        TypeModifier::List => format!("{}[]", base),
507        TypeModifier::OptionalList => format!("{}[]?", base),
508        TypeModifier::Required => base,
509    }
510}
511
512/// Attribute position: selects the `@` vs `@@` prefix.
513///
514/// Several attributes (`id`, `unique`, `map`, `index`) are legal in both
515/// positions, so the prefix must come from position, never from the name.
516#[derive(Clone, Copy)]
517enum AttrLevel {
518    Field,
519    Block,
520}
521
522fn format_attribute(attr: &prax_schema::ast::Attribute, level: AttrLevel) -> String {
523    // The prefix comes from the attribute's position (block-level `@@`
524    // vs field-level `@`), never from its name: name-based guessing
525    // corrupts field-level `@id` into `@@id`.
526    let prefix = match level {
527        AttrLevel::Block => "@@",
528        AttrLevel::Field => "@",
529    };
530
531    if attr.args.is_empty() {
532        format!("{}{}", prefix, attr.name())
533    } else {
534        let args: Vec<String> = attr
535            .args
536            .iter()
537            .map(|arg| {
538                if let Some(name) = &arg.name {
539                    format!("{}: {}", name.as_str(), format_attribute_value(&arg.value))
540                } else {
541                    format_attribute_value(&arg.value)
542                }
543            })
544            .collect();
545
546        format!("{}{}({})", prefix, attr.name(), args.join(", "))
547    }
548}
549
550fn format_attribute_value(value: &prax_schema::ast::AttributeValue) -> String {
551    use prax_schema::ast::AttributeValue;
552
553    match value {
554        AttributeValue::String(s) => format!("\"{}\"", s),
555        AttributeValue::Int(i) => i.to_string(),
556        AttributeValue::Float(f) => f.to_string(),
557        AttributeValue::Boolean(b) => b.to_string(),
558        AttributeValue::Ident(id) => id.to_string(),
559        AttributeValue::Function(name, args) => {
560            if args.is_empty() {
561                format!("{}()", name)
562            } else {
563                let arg_strs: Vec<String> = args.iter().map(format_attribute_value).collect();
564                format!("{}({})", name, arg_strs.join(", "))
565            }
566        }
567        AttributeValue::Array(items) => {
568            let item_strs: Vec<String> = items.iter().map(format_attribute_value).collect();
569            format!("[{}]", item_strs.join(", "))
570        }
571        AttributeValue::FieldRef(field) => field.to_string(),
572        AttributeValue::FieldRefList(fields) => {
573            format!(
574                "[{}]",
575                fields
576                    .iter()
577                    .map(|f| f.to_string())
578                    .collect::<Vec<_>>()
579                    .join(", ")
580            )
581        }
582    }
583}