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    // Use validate_schema to ensure field types are properly resolved
114    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
115    prax_schema::validate_schema(content)
116        .map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
117}
118
119/// Format a schema AST into a formatted string
120fn format_schema(schema: &prax_schema::ast::Schema) -> String {
121    let mut output = String::new();
122    let mut wrote_section = false;
123
124    // Serialize the datasource declared in the schema, preserving the
125    // actual provider/url instead of injecting a hardcoded default.
126    if let Some(datasource) = &schema.datasource {
127        format_datasource(&mut output, datasource);
128        wrote_section = true;
129    }
130
131    // Serialize the generator blocks declared in the schema.
132    for generator in schema.generators.values() {
133        if wrote_section {
134            output.push('\n');
135        }
136        format_generator(&mut output, generator);
137        wrote_section = true;
138    }
139
140    // Format enums first (since they're used by models)
141    for enum_def in schema.enums.values() {
142        if wrote_section {
143            output.push('\n');
144        }
145        format_enum(&mut output, enum_def);
146        wrote_section = true;
147    }
148
149    // Format models
150    for model in schema.models.values() {
151        if wrote_section {
152            output.push('\n');
153        }
154        format_model(&mut output, model);
155        wrote_section = true;
156    }
157
158    // Format views
159    for view in schema.views.values() {
160        if wrote_section {
161            output.push('\n');
162        }
163        format_view(&mut output, view);
164        wrote_section = true;
165    }
166
167    // Format composite types
168    for composite in schema.types.values() {
169        if wrote_section {
170            output.push('\n');
171        }
172        format_composite(&mut output, composite);
173        wrote_section = true;
174    }
175
176    output
177}
178
179fn format_datasource(output: &mut String, datasource: &prax_schema::ast::Datasource) {
180    output.push_str(&format!("datasource {} {{\n", datasource.name));
181    output.push_str(&format!(
182        "    provider = \"{}\"\n",
183        datasource.provider.as_str()
184    ));
185
186    if let Some(url_env) = &datasource.url_env {
187        output.push_str(&format!("    url      = env(\"{}\")\n", url_env));
188    } else if let Some(url) = &datasource.url {
189        output.push_str(&format!("    url      = \"{}\"\n", url));
190    }
191
192    if !datasource.extensions.is_empty() {
193        let extensions: Vec<String> = datasource
194            .extensions
195            .iter()
196            .map(|ext| {
197                let mut args = Vec::new();
198                if let Some(schema) = &ext.schema {
199                    args.push(format!("schema: \"{}\"", schema));
200                }
201                if let Some(version) = &ext.version {
202                    args.push(format!("version: \"{}\"", version));
203                }
204                if args.is_empty() {
205                    ext.name().to_string()
206                } else {
207                    format!("{}({})", ext.name(), args.join(", "))
208                }
209            })
210            .collect();
211        output.push_str(&format!("    extensions = [{}]\n", extensions.join(", ")));
212    }
213
214    for (key, value) in &datasource.properties {
215        // env("VAR") values are stored verbatim; re-emit them unquoted
216        // so the formatted output stays parseable.
217        if value.starts_with("env(") {
218            output.push_str(&format!("    {} = {}\n", key, value));
219        } else {
220            output.push_str(&format!("    {} = \"{}\"\n", key, value));
221        }
222    }
223
224    output.push_str("}\n");
225}
226
227fn format_generator(output: &mut String, generator: &prax_schema::ast::Generator) {
228    use prax_schema::ast::{GeneratorToggle, GeneratorValue};
229
230    output.push_str(&format!("generator {} {{\n", generator.name()));
231
232    if let Some(provider) = &generator.provider {
233        output.push_str(&format!("    provider = \"{}\"\n", provider));
234    }
235
236    if let Some(out) = &generator.output {
237        output.push_str(&format!("    output   = \"{}\"\n", out));
238    }
239
240    match &generator.generate {
241        GeneratorToggle::Always => {}
242        GeneratorToggle::Never | GeneratorToggle::Literal(false) => {
243            output.push_str("    generate = false\n");
244        }
245        GeneratorToggle::Literal(true) => {
246            output.push_str("    generate = true\n");
247        }
248        GeneratorToggle::Env(var) => {
249            output.push_str(&format!("    generate = env(\"{}\")\n", var));
250        }
251    }
252
253    for (key, value) in &generator.properties {
254        let formatted = match value {
255            GeneratorValue::String(s) => format!("\"{}\"", s),
256            GeneratorValue::Bool(b) => b.to_string(),
257            GeneratorValue::Env(var) => format!("env(\"{}\")", var),
258            GeneratorValue::Ident(s) => s.to_string(),
259        };
260        output.push_str(&format!("    {} = {}\n", key, formatted));
261    }
262
263    output.push_str("}\n");
264}
265
266fn format_enum(output: &mut String, enum_def: &prax_schema::ast::Enum) {
267    // Documentation
268    if let Some(doc) = &enum_def.documentation {
269        for line in doc.text.lines() {
270            output.push_str(&format!("/// {}\n", line));
271        }
272    }
273
274    output.push_str(&format!("enum {} {{\n", enum_def.name()));
275
276    for variant in &enum_def.variants {
277        // Documentation
278        if let Some(doc) = &variant.documentation {
279            for line in doc.text.lines() {
280                output.push_str(&format!("    /// {}\n", line));
281            }
282        }
283
284        output.push_str(&format!("    {}", variant.name()));
285
286        // Format attributes
287        for attr in &variant.attributes {
288            output.push_str(&format!(" {}", format_attribute(attr)));
289        }
290
291        output.push('\n');
292    }
293
294    // Enum-level attributes
295    for attr in &enum_def.attributes {
296        output.push_str(&format!("\n    {}", format_attribute(attr)));
297    }
298
299    output.push_str("}\n");
300}
301
302fn format_model(output: &mut String, model: &prax_schema::ast::Model) {
303    // Documentation
304    if let Some(doc) = &model.documentation {
305        for line in doc.text.lines() {
306            output.push_str(&format!("/// {}\n", line));
307        }
308    }
309
310    output.push_str(&format!("model {} {{\n", model.name()));
311
312    // Calculate alignment for fields
313    let max_name_len = model
314        .fields
315        .values()
316        .map(|f| f.name().len())
317        .max()
318        .unwrap_or(0);
319
320    let max_type_len = model
321        .fields
322        .values()
323        .map(|f| format_field_type(&f.field_type, f.modifier).len())
324        .max()
325        .unwrap_or(0);
326
327    for field in model.fields.values() {
328        // Documentation
329        if let Some(doc) = &field.documentation {
330            for line in doc.text.lines() {
331                output.push_str(&format!("    /// {}\n", line));
332            }
333        }
334
335        let type_str = format_field_type(&field.field_type, field.modifier);
336
337        // Pad name and type for alignment
338        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
339        let padded_type = format!("{:width$}", type_str, width = max_type_len);
340
341        output.push_str(&format!("    {} {}", padded_name, padded_type));
342
343        // Format attributes
344        for attr in &field.attributes {
345            output.push_str(&format!(" {}", format_attribute(attr)));
346        }
347
348        output.push('\n');
349    }
350
351    // Model-level attributes
352    let model_attrs: Vec<_> = model.attributes.iter().collect();
353    if !model_attrs.is_empty() {
354        output.push('\n');
355        for attr in model_attrs {
356            output.push_str(&format!("    {}\n", format_attribute(attr)));
357        }
358    }
359
360    output.push_str("}\n");
361}
362
363fn format_view(output: &mut String, view: &prax_schema::ast::View) {
364    // Documentation
365    if let Some(doc) = &view.documentation {
366        for line in doc.text.lines() {
367            output.push_str(&format!("/// {}\n", line));
368        }
369    }
370
371    output.push_str(&format!("view {} {{\n", view.name()));
372
373    // Calculate alignment for fields
374    let max_name_len = view
375        .fields
376        .values()
377        .map(|f| f.name().len())
378        .max()
379        .unwrap_or(0);
380
381    let max_type_len = view
382        .fields
383        .values()
384        .map(|f| format_field_type(&f.field_type, f.modifier).len())
385        .max()
386        .unwrap_or(0);
387
388    for field in view.fields.values() {
389        let type_str = format_field_type(&field.field_type, field.modifier);
390        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
391        let padded_type = format!("{:width$}", type_str, width = max_type_len);
392
393        output.push_str(&format!("    {} {}", padded_name, padded_type));
394
395        for attr in &field.attributes {
396            output.push_str(&format!(" {}", format_attribute(attr)));
397        }
398
399        output.push('\n');
400    }
401
402    // View-level attributes
403    let view_attrs: Vec<_> = view.attributes.iter().collect();
404    if !view_attrs.is_empty() {
405        output.push('\n');
406        for attr in view_attrs {
407            output.push_str(&format!("    {}\n", format_attribute(attr)));
408        }
409    }
410
411    output.push_str("}\n");
412}
413
414fn format_composite(output: &mut String, composite: &prax_schema::ast::CompositeType) {
415    // Documentation
416    if let Some(doc) = &composite.documentation {
417        for line in doc.text.lines() {
418            output.push_str(&format!("/// {}\n", line));
419        }
420    }
421
422    output.push_str(&format!("type {} {{\n", composite.name()));
423
424    // Calculate alignment for fields
425    let max_name_len = composite
426        .fields
427        .values()
428        .map(|f| f.name().len())
429        .max()
430        .unwrap_or(0);
431
432    let max_type_len = composite
433        .fields
434        .values()
435        .map(|f| format_field_type(&f.field_type, f.modifier).len())
436        .max()
437        .unwrap_or(0);
438
439    for field in composite.fields.values() {
440        let type_str = format_field_type(&field.field_type, field.modifier);
441        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
442        let padded_type = format!("{:width$}", type_str, width = max_type_len);
443
444        output.push_str(&format!("    {} {}", padded_name, padded_type));
445
446        for attr in &field.attributes {
447            output.push_str(&format!(" {}", format_attribute(attr)));
448        }
449
450        output.push('\n');
451    }
452
453    output.push_str("}\n");
454}
455
456fn format_field_type(
457    field_type: &prax_schema::ast::FieldType,
458    modifier: prax_schema::ast::TypeModifier,
459) -> String {
460    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
461
462    let base = match field_type {
463        FieldType::Scalar(scalar) => match scalar {
464            ScalarType::Int => "Int",
465            ScalarType::BigInt => "BigInt",
466            ScalarType::Float => "Float",
467            ScalarType::String => "String",
468            ScalarType::Boolean => "Boolean",
469            ScalarType::DateTime => "DateTime",
470            ScalarType::Date => "Date",
471            ScalarType::Time => "Time",
472            ScalarType::Json => "Json",
473            ScalarType::Bytes => "Bytes",
474            ScalarType::Decimal => "Decimal",
475            ScalarType::Uuid => "Uuid",
476            ScalarType::Cuid => "Cuid",
477            ScalarType::Cuid2 => "Cuid2",
478            ScalarType::NanoId => "NanoId",
479            ScalarType::Ulid => "Ulid",
480            ScalarType::Vector(_) => "Vector",
481            ScalarType::HalfVector(_) => "HalfVector",
482            ScalarType::SparseVector(_) => "SparseVector",
483            ScalarType::Bit(_) => "Bit",
484        }
485        .to_string(),
486        FieldType::Model(name) => name.to_string(),
487        FieldType::Enum(name) => name.to_string(),
488        FieldType::Composite(name) => name.to_string(),
489        FieldType::Unsupported(name) => format!("Unsupported(\"{}\")", name),
490    };
491
492    match modifier {
493        TypeModifier::Optional => format!("{}?", base),
494        TypeModifier::List => format!("{}[]", base),
495        TypeModifier::OptionalList => format!("{}[]?", base),
496        TypeModifier::Required => base,
497    }
498}
499
500fn format_attribute(attr: &prax_schema::ast::Attribute) -> String {
501    // For model-level attributes we check if it's a known model attribute
502    let prefix = if attr.is_model_attribute() { "@@" } else { "@" };
503
504    if attr.args.is_empty() {
505        format!("{}{}", prefix, attr.name())
506    } else {
507        let args: Vec<String> = attr
508            .args
509            .iter()
510            .map(|arg| {
511                if let Some(name) = &arg.name {
512                    format!("{}: {}", name.as_str(), format_attribute_value(&arg.value))
513                } else {
514                    format_attribute_value(&arg.value)
515                }
516            })
517            .collect();
518
519        format!("{}{}({})", prefix, attr.name(), args.join(", "))
520    }
521}
522
523fn format_attribute_value(value: &prax_schema::ast::AttributeValue) -> String {
524    use prax_schema::ast::AttributeValue;
525
526    match value {
527        AttributeValue::String(s) => format!("\"{}\"", s),
528        AttributeValue::Int(i) => i.to_string(),
529        AttributeValue::Float(f) => f.to_string(),
530        AttributeValue::Boolean(b) => b.to_string(),
531        AttributeValue::Ident(id) => id.to_string(),
532        AttributeValue::Function(name, args) => {
533            if args.is_empty() {
534                format!("{}()", name)
535            } else {
536                let arg_strs: Vec<String> = args.iter().map(format_attribute_value).collect();
537                format!("{}({})", name, arg_strs.join(", "))
538            }
539        }
540        AttributeValue::Array(items) => {
541            let item_strs: Vec<String> = items.iter().map(format_attribute_value).collect();
542            format!("[{}]", item_strs.join(", "))
543        }
544        AttributeValue::FieldRef(field) => field.to_string(),
545        AttributeValue::FieldRefList(fields) => {
546            format!(
547                "[{}]",
548                fields
549                    .iter()
550                    .map(|f| f.to_string())
551                    .collect::<Vec<_>>()
552                    .join(", ")
553            )
554        }
555    }
556}