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
6use std::path::Path;
7
8use crate::cli::FormatArgs;
9use crate::config::SCHEMA_FILE_PATH;
10use crate::error::{CliError, CliResult};
11use crate::output::{self, success};
12
13/// Run the format command
14pub async fn run(args: FormatArgs) -> CliResult<()> {
15    output::header("Format Schema");
16
17    let cwd = std::env::current_dir()?;
18    let schema_path = args.schema.unwrap_or_else(|| cwd.join(SCHEMA_FILE_PATH));
19
20    if !schema_path.exists() {
21        return Err(CliError::Config(format!(
22            "Schema path not found: {}",
23            schema_path.display()
24        )));
25    }
26
27    output::kv("Schema", &schema_path.display().to_string());
28    output::newline();
29
30    let files: Vec<std::path::PathBuf> = if schema_path.is_dir() {
31        let discovered = prax_schema::loader::discover(&schema_path).map_err(CliError::from)?;
32        if discovered.is_empty() {
33            return Err(CliError::Config(format!(
34                "No .prax files found under {}",
35                schema_path.display()
36            )));
37        }
38        discovered.into_iter().map(|d| d.absolute).collect()
39    } else {
40        vec![schema_path.clone()]
41    };
42
43    let mut any_changed = false;
44    let mut any_needs_format = false;
45    for file in &files {
46        match format_one(file, args.check)? {
47            FormatOutcome::Unchanged => {}
48            FormatOutcome::Reformatted => any_changed = true,
49            FormatOutcome::NeedsFormatting => any_needs_format = true,
50        }
51    }
52
53    output::newline();
54    if args.check {
55        if any_needs_format {
56            output::error("Some schema files are not formatted correctly.");
57            output::info("Run `prax format` to fix formatting.");
58            return Err(CliError::Format(
59                "One or more schema files need formatting".to_string(),
60            ));
61        }
62        success(&format!(
63            "All {} schema file(s) are formatted!",
64            files.len()
65        ));
66    } else if any_changed {
67        success(&format!("Formatted {} schema file(s).", files.len()));
68    } else {
69        success(&format!(
70            "All {} schema file(s) are already formatted!",
71            files.len()
72        ));
73    }
74
75    Ok(())
76}
77
78enum FormatOutcome {
79    Unchanged,
80    Reformatted,
81    NeedsFormatting,
82}
83
84fn format_one(path: &Path, check: bool) -> CliResult<FormatOutcome> {
85    let content = std::fs::read_to_string(path)?;
86    let schema = parse_schema(&content)?;
87    let formatted = format_schema(&schema);
88    let changed = formatted != content;
89
90    if check {
91        return Ok(if changed {
92            output::error(&format!("Needs formatting: {}", path.display()));
93            FormatOutcome::NeedsFormatting
94        } else {
95            FormatOutcome::Unchanged
96        });
97    }
98
99    if changed {
100        std::fs::write(path, &formatted)?;
101        output::list_item(&format!("Formatted {}", path.display()));
102        Ok(FormatOutcome::Reformatted)
103    } else {
104        Ok(FormatOutcome::Unchanged)
105    }
106}
107
108fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
109    // Use validate_schema to ensure field types are properly resolved
110    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
111    prax_schema::validate_schema(content)
112        .map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
113}
114
115/// Format a schema AST into a formatted string
116fn format_schema(schema: &prax_schema::ast::Schema) -> String {
117    let mut output = String::new();
118
119    // Format datasource (if present in schema)
120    // For now, just add a standard datasource section
121    output.push_str("datasource db {\n");
122    output.push_str("    provider = \"postgresql\"\n");
123    output.push_str("    url      = env(\"DATABASE_URL\")\n");
124    output.push_str("}\n");
125    let mut first_section = false;
126
127    // Format generator
128    if !first_section {
129        output.push('\n');
130    }
131    output.push_str("generator client {\n");
132    output.push_str("    provider = \"prax-client-rust\"\n");
133    output.push_str("    output   = \"./src/generated\"\n");
134    output.push_str("}\n");
135    first_section = false;
136
137    // Format enums first (since they're used by models)
138    for enum_def in schema.enums.values() {
139        if !first_section {
140            output.push('\n');
141        }
142        format_enum(&mut output, enum_def);
143        first_section = false;
144    }
145
146    // Format models
147    for model in schema.models.values() {
148        if !first_section {
149            output.push('\n');
150        }
151        format_model(&mut output, model);
152        first_section = false;
153    }
154
155    // Format views
156    for view in schema.views.values() {
157        if !first_section {
158            output.push('\n');
159        }
160        format_view(&mut output, view);
161        first_section = false;
162    }
163
164    // Format composite types
165    for composite in schema.types.values() {
166        if !first_section {
167            output.push('\n');
168        }
169        format_composite(&mut output, composite);
170        first_section = false;
171    }
172
173    output
174}
175
176fn format_enum(output: &mut String, enum_def: &prax_schema::ast::Enum) {
177    // Documentation
178    if let Some(doc) = &enum_def.documentation {
179        for line in doc.text.lines() {
180            output.push_str(&format!("/// {}\n", line));
181        }
182    }
183
184    output.push_str(&format!("enum {} {{\n", enum_def.name()));
185
186    for variant in &enum_def.variants {
187        // Documentation
188        if let Some(doc) = &variant.documentation {
189            for line in doc.text.lines() {
190                output.push_str(&format!("    /// {}\n", line));
191            }
192        }
193
194        output.push_str(&format!("    {}", variant.name()));
195
196        // Format attributes
197        for attr in &variant.attributes {
198            output.push_str(&format!(" {}", format_attribute(attr)));
199        }
200
201        output.push('\n');
202    }
203
204    // Enum-level attributes
205    for attr in &enum_def.attributes {
206        output.push_str(&format!("\n    {}", format_attribute(attr)));
207    }
208
209    output.push_str("}\n");
210}
211
212fn format_model(output: &mut String, model: &prax_schema::ast::Model) {
213    // Documentation
214    if let Some(doc) = &model.documentation {
215        for line in doc.text.lines() {
216            output.push_str(&format!("/// {}\n", line));
217        }
218    }
219
220    output.push_str(&format!("model {} {{\n", model.name()));
221
222    // Calculate alignment for fields
223    let max_name_len = model
224        .fields
225        .values()
226        .map(|f| f.name().len())
227        .max()
228        .unwrap_or(0);
229
230    let max_type_len = model
231        .fields
232        .values()
233        .map(|f| format_field_type(&f.field_type, f.modifier).len())
234        .max()
235        .unwrap_or(0);
236
237    for field in model.fields.values() {
238        // Documentation
239        if let Some(doc) = &field.documentation {
240            for line in doc.text.lines() {
241                output.push_str(&format!("    /// {}\n", line));
242            }
243        }
244
245        let type_str = format_field_type(&field.field_type, field.modifier);
246
247        // Pad name and type for alignment
248        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
249        let padded_type = format!("{:width$}", type_str, width = max_type_len);
250
251        output.push_str(&format!("    {} {}", padded_name, padded_type));
252
253        // Format attributes
254        for attr in &field.attributes {
255            output.push_str(&format!(" {}", format_attribute(attr)));
256        }
257
258        output.push('\n');
259    }
260
261    // Model-level attributes
262    let model_attrs: Vec<_> = model.attributes.iter().collect();
263    if !model_attrs.is_empty() {
264        output.push('\n');
265        for attr in model_attrs {
266            output.push_str(&format!("    {}\n", format_attribute(attr)));
267        }
268    }
269
270    output.push_str("}\n");
271}
272
273fn format_view(output: &mut String, view: &prax_schema::ast::View) {
274    // Documentation
275    if let Some(doc) = &view.documentation {
276        for line in doc.text.lines() {
277            output.push_str(&format!("/// {}\n", line));
278        }
279    }
280
281    output.push_str(&format!("view {} {{\n", view.name()));
282
283    // Calculate alignment for fields
284    let max_name_len = view
285        .fields
286        .values()
287        .map(|f| f.name().len())
288        .max()
289        .unwrap_or(0);
290
291    let max_type_len = view
292        .fields
293        .values()
294        .map(|f| format_field_type(&f.field_type, f.modifier).len())
295        .max()
296        .unwrap_or(0);
297
298    for field in view.fields.values() {
299        let type_str = format_field_type(&field.field_type, field.modifier);
300        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
301        let padded_type = format!("{:width$}", type_str, width = max_type_len);
302
303        output.push_str(&format!("    {} {}", padded_name, padded_type));
304
305        for attr in &field.attributes {
306            output.push_str(&format!(" {}", format_attribute(attr)));
307        }
308
309        output.push('\n');
310    }
311
312    // View-level attributes
313    let view_attrs: Vec<_> = view.attributes.iter().collect();
314    if !view_attrs.is_empty() {
315        output.push('\n');
316        for attr in view_attrs {
317            output.push_str(&format!("    {}\n", format_attribute(attr)));
318        }
319    }
320
321    output.push_str("}\n");
322}
323
324fn format_composite(output: &mut String, composite: &prax_schema::ast::CompositeType) {
325    // Documentation
326    if let Some(doc) = &composite.documentation {
327        for line in doc.text.lines() {
328            output.push_str(&format!("/// {}\n", line));
329        }
330    }
331
332    output.push_str(&format!("type {} {{\n", composite.name()));
333
334    // Calculate alignment for fields
335    let max_name_len = composite
336        .fields
337        .values()
338        .map(|f| f.name().len())
339        .max()
340        .unwrap_or(0);
341
342    let max_type_len = composite
343        .fields
344        .values()
345        .map(|f| format_field_type(&f.field_type, f.modifier).len())
346        .max()
347        .unwrap_or(0);
348
349    for field in composite.fields.values() {
350        let type_str = format_field_type(&field.field_type, field.modifier);
351        let padded_name = format!("{:width$}", field.name(), width = max_name_len);
352        let padded_type = format!("{:width$}", type_str, width = max_type_len);
353
354        output.push_str(&format!("    {} {}", padded_name, padded_type));
355
356        for attr in &field.attributes {
357            output.push_str(&format!(" {}", format_attribute(attr)));
358        }
359
360        output.push('\n');
361    }
362
363    output.push_str("}\n");
364}
365
366fn format_field_type(
367    field_type: &prax_schema::ast::FieldType,
368    modifier: prax_schema::ast::TypeModifier,
369) -> String {
370    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
371
372    let base = match field_type {
373        FieldType::Scalar(scalar) => match scalar {
374            ScalarType::Int => "Int",
375            ScalarType::BigInt => "BigInt",
376            ScalarType::Float => "Float",
377            ScalarType::String => "String",
378            ScalarType::Boolean => "Boolean",
379            ScalarType::DateTime => "DateTime",
380            ScalarType::Date => "Date",
381            ScalarType::Time => "Time",
382            ScalarType::Json => "Json",
383            ScalarType::Bytes => "Bytes",
384            ScalarType::Decimal => "Decimal",
385            ScalarType::Uuid => "Uuid",
386            ScalarType::Cuid => "Cuid",
387            ScalarType::Cuid2 => "Cuid2",
388            ScalarType::NanoId => "NanoId",
389            ScalarType::Ulid => "Ulid",
390            ScalarType::Vector(_) => "Vector",
391            ScalarType::HalfVector(_) => "HalfVector",
392            ScalarType::SparseVector(_) => "SparseVector",
393            ScalarType::Bit(_) => "Bit",
394        }
395        .to_string(),
396        FieldType::Model(name) => name.to_string(),
397        FieldType::Enum(name) => name.to_string(),
398        FieldType::Composite(name) => name.to_string(),
399        FieldType::Unsupported(name) => format!("Unsupported(\"{}\")", name),
400    };
401
402    match modifier {
403        TypeModifier::Optional => format!("{}?", base),
404        TypeModifier::List => format!("{}[]", base),
405        TypeModifier::OptionalList => format!("{}[]?", base),
406        TypeModifier::Required => base,
407    }
408}
409
410fn format_attribute(attr: &prax_schema::ast::Attribute) -> String {
411    // For model-level attributes we check if it's a known model attribute
412    let prefix = if attr.is_model_attribute() { "@@" } else { "@" };
413
414    if attr.args.is_empty() {
415        format!("{}{}", prefix, attr.name())
416    } else {
417        let args: Vec<String> = attr
418            .args
419            .iter()
420            .map(|arg| {
421                if let Some(name) = &arg.name {
422                    format!("{}: {}", name.as_str(), format_attribute_value(&arg.value))
423                } else {
424                    format_attribute_value(&arg.value)
425                }
426            })
427            .collect();
428
429        format!("{}{}({})", prefix, attr.name(), args.join(", "))
430    }
431}
432
433fn format_attribute_value(value: &prax_schema::ast::AttributeValue) -> String {
434    use prax_schema::ast::AttributeValue;
435
436    match value {
437        AttributeValue::String(s) => format!("\"{}\"", s),
438        AttributeValue::Int(i) => i.to_string(),
439        AttributeValue::Float(f) => f.to_string(),
440        AttributeValue::Boolean(b) => b.to_string(),
441        AttributeValue::Ident(id) => id.to_string(),
442        AttributeValue::Function(name, args) => {
443            if args.is_empty() {
444                format!("{}()", name)
445            } else {
446                let arg_strs: Vec<String> = args.iter().map(format_attribute_value).collect();
447                format!("{}({})", name, arg_strs.join(", "))
448            }
449        }
450        AttributeValue::Array(items) => {
451            let item_strs: Vec<String> = items.iter().map(format_attribute_value).collect();
452            format!("[{}]", item_strs.join(", "))
453        }
454        AttributeValue::FieldRef(field) => field.to_string(),
455        AttributeValue::FieldRefList(fields) => {
456            format!(
457                "[{}]",
458                fields
459                    .iter()
460                    .map(|f| f.to_string())
461                    .collect::<Vec<_>>()
462                    .join(", ")
463            )
464        }
465    }
466}