diff --git a/crates/facet-styx/src/lib.rs b/crates/facet-styx/src/lib.rs index fe37dd3..b50c9d8 100644 --- a/crates/facet-styx/src/lib.rs +++ b/crates/facet-styx/src/lib.rs @@ -77,7 +77,8 @@ pub use schema_types::*; pub use schema_validate::{Validator, validate, validate_as}; pub use serializer::{ SerializeOptions, StyxSerializeError, StyxSerializer, peek_to_string, peek_to_string_expr, - peek_to_string_with_options, to_string, to_string_compact, to_string_with_options, + peek_to_string_pretty, peek_to_string_with_options, to_string, to_string_compact, + to_string_pretty, to_string_with_options, }; /// Deserialize a value from a Styx string into an owned type. diff --git a/crates/facet-styx/src/serializer.rs b/crates/facet-styx/src/serializer.rs index 60099ad..18e6516 100644 --- a/crates/facet-styx/src/serializer.rs +++ b/crates/facet-styx/src/serializer.rs @@ -577,6 +577,46 @@ where Ok(String::from_utf8(bytes).expect("Styx output should always be valid UTF-8")) } +/// Serialize a value to a pretty-printed Styx string (respects line length limits). +/// +/// # Example +/// +/// ``` +/// use facet::Facet; +/// use facet_styx::to_string_pretty; +/// +/// #[derive(Facet)] +/// struct Config { +/// server: Server, +/// database: Database +/// } +/// +/// #[derive(Facet)] +/// struct Server { +/// host: String, +/// port: u16, +/// timeout: u32 +/// } +/// +/// let config = Config { +/// server: Server { +/// host: "localhost".to_string(), +/// port: 8080, +/// timeout: 30 +/// }, +/// database: Database { /* ... */ } +/// }; +/// let pretty_styx = to_string_pretty(&config).unwrap(); +/// // Output will have complex structures expanded to multiple lines +/// ``` +pub fn to_string_pretty<'facet, T>(value: &T) -> Result> +where + T: Facet<'facet> + ?Sized, +{ + let options = FormatOptions::default().pretty(80); + to_string_with_options(value, &options) +} + /// Serialize a value to a Styx string with custom options. pub fn to_string_with_options<'facet, T>( value: &T, @@ -591,6 +631,17 @@ where Ok(String::from_utf8(bytes).expect("Styx output should always be valid UTF-8")) } +/// Serialize a value to a pretty-printed Styx string (respects line length limits). +/// +/// This is a convenience wrapper around `peek_to_string_with_options` that enables +/// pretty printing with the default line length of 80 characters. +pub fn peek_to_string_pretty<'input, 'facet>( + peek: Peek<'input, 'facet>, +) -> Result> { + let options = FormatOptions::default().pretty(80); + peek_to_string_with_options(peek, &options) +} + /// Serialize a `Peek` instance to a Styx string. pub fn peek_to_string<'input, 'facet>( peek: Peek<'input, 'facet>, diff --git a/crates/styx-cli/src/main.rs b/crates/styx-cli/src/main.rs index bb47ebf..20e31eb 100644 --- a/crates/styx-cli/src/main.rs +++ b/crates/styx-cli/src/main.rs @@ -63,6 +63,18 @@ struct FileArgs { #[facet(args::named, default)] compact: bool, + /// Force multiline formatting (expand all objects/sequences) + #[facet(args::named, default)] + multiline: bool, + + /// Enable pretty printing (respect line length limits) + #[facet(args::named, default)] + pretty: bool, + + /// Maximum line length for pretty printing (default: 80) + #[facet(args::named, default)] + line_length: Option, + /// Validate against declared schema (no output unless -o specified) #[facet(args::named, default)] validate: bool, @@ -303,6 +315,11 @@ fn print_help() { eprintln!(" --json-out Output as JSON (use '-' for stdout)"); eprintln!(" --in-place Modify input file in place"); eprintln!(" --compact Single-line/compact formatting"); + eprintln!(" --multiline Force multiline formatting (expand all)"); + eprintln!(" --pretty Enable pretty printing (respect line limits)"); + eprintln!( + " --line-length Max line length for pretty printing (default: 80)" + ); eprintln!(" --validate Validate against declared schema"); eprintln!(" --schema Use this schema instead of @schema\n"); eprintln!("SUBCOMMANDS:"); @@ -386,12 +403,36 @@ fn run_file_mode(args: &[String]) -> Result<(), CliError> { serde_json::to_string_pretty(&json).map_err(|e| CliError::Io(io::Error::other(e)))?; write_output(json_path, &output)?; } else { + // Validate mutually exclusive options + if opts.compact && opts.multiline { + return Err(CliError::Usage( + "--compact and --multiline are mutually exclusive".to_string(), + )); + } + if opts.compact && opts.pretty { + return Err(CliError::Usage( + "--compact and --pretty are mutually exclusive".to_string(), + )); + } + if opts.multiline && opts.pretty { + return Err(CliError::Usage( + "--multiline and --pretty are mutually exclusive".to_string(), + )); + } + // Styx output - use CST formatter to preserve comments - let format_opts = if opts.compact { + let mut format_opts = if opts.multiline { + FormatOptions::default().multiline() + } else if opts.compact { FormatOptions::default().inline() } else { FormatOptions::default() }; + + // Apply pretty printing options if enabled + if opts.pretty { + format_opts = format_opts.pretty(opts.line_length.unwrap_or(80)); + } let output = format_source(&source, format_opts); if opts.in_place { diff --git a/crates/styx-format/src/cst_format.rs b/crates/styx-format/src/cst_format.rs index 541307a..280bae3 100644 --- a/crates/styx-format/src/cst_format.rs +++ b/crates/styx-format/src/cst_format.rs @@ -7,7 +7,7 @@ use styx_cst::{ AstNode, Document, Entry, NodeOrToken, Object, Separator, Sequence, SyntaxKind, SyntaxNode, }; -use crate::FormatOptions; +use crate::{FormatOptions, options::ForceStyle}; /// Format a Styx document from its CST. /// @@ -42,6 +42,37 @@ struct CstFormatter { } impl CstFormatter { + /// Estimate the line length if this object were formatted inline + /// format[impl format.line-length] + fn estimate_object_line_length(&self, entries: &[Entry]) -> usize { + let mut length = 2; // "{}" + for (i, entry) in entries.iter().enumerate() { + if i > 0 { + length += 2; // ", " + } + // Add the entry text length + let text_len = entry.syntax().to_string().len(); + length += text_len; + } + // Add indentation + length + (self.indent_level * self.options.indent.len()) + } + + /// Estimate the line length if this sequence were formatted inline + /// format[impl format.line-length] + fn estimate_sequence_line_length(&self, entries: &[Entry]) -> usize { + let mut length = 2; // "()" + for (i, entry) in entries.iter().enumerate() { + if i > 0 { + length += 1; // " " + } + // Add the entry text length + let text_len = entry.syntax().to_string().len(); + length += text_len; + } + // Add indentation + length + (self.indent_level * self.options.indent.len()) + } fn new(options: FormatOptions) -> Self { Self { out: String::new(), @@ -290,11 +321,18 @@ impl CstFormatter { let has_block_child = entries.iter().any(|e| contains_block_object(e.syntax())); // Determine if we need multiline format - let is_multiline = matches!(separator, Separator::Newline | Separator::Mixed) + let is_multiline = matches!(self.options.force_style, ForceStyle::Multiline) + || matches!(separator, Separator::Newline | Separator::Mixed) || has_comments || has_block_child || entries.is_empty(); // Empty with comments needs multiline + // Apply pretty printing: expand if inline version would exceed max_width + let should_expand_for_pretty = self.options.pretty_printing_enabled() + && self.estimate_object_line_length(&entries) > self.options.max_width; + + let is_multiline = is_multiline || should_expand_for_pretty; + if is_multiline { // Multiline format - preserve comments as children of the object self.write_newline(); @@ -392,9 +430,16 @@ impl CstFormatter { let single_tag_with_block = !has_comments && entries.len() == 1 && is_tag_with_block_payload(entries[0].syntax()); - let is_multiline = !should_collapse - && !single_tag_with_block - && (seq.is_multiline() || has_comments || entries.is_empty()); + let is_multiline = matches!(self.options.force_style, ForceStyle::Multiline) + || (!should_collapse + && !single_tag_with_block + && (seq.is_multiline() || has_comments || entries.is_empty())); + + // Apply pretty printing: expand if inline version would exceed max_width + let should_expand_for_pretty = self.options.pretty_printing_enabled() + && self.estimate_sequence_line_length(&entries) > self.options.max_width; + + let is_multiline = is_multiline || should_expand_for_pretty; if single_tag_with_block { // Format the single entry inline with the paren - no newline after ( diff --git a/crates/styx-format/src/options.rs b/crates/styx-format/src/options.rs index 02080da..1a9aa8f 100644 --- a/crates/styx-format/src/options.rs +++ b/crates/styx-format/src/options.rs @@ -35,6 +35,8 @@ pub struct FormatOptions { pub heredoc_line_threshold: usize, pub force_style: ForceStyle, + /// Whether pretty printing is enabled + pub pretty_printing: bool, } impl Default for FormatOptions { @@ -47,6 +49,7 @@ impl Default for FormatOptions { inline_sequence_threshold: 8, heredoc_line_threshold: 2, force_style: ForceStyle::None, + pretty_printing: false, } } } @@ -80,4 +83,16 @@ impl FormatOptions { self.max_width = width; self } + + /// Enable pretty printing with optional line length override. + pub fn pretty(mut self, line_length: usize) -> Self { + self.max_width = line_length; + self.pretty_printing = true; + self + } + + /// Check if pretty printing is enabled. + pub fn pretty_printing_enabled(&self) -> bool { + self.pretty_printing + } } diff --git a/docs/content/reference/spec/_index.md b/docs/content/reference/spec/_index.md index 0689165..e9f8f2d 100644 --- a/docs/content/reference/spec/_index.md +++ b/docs/content/reference/spec/_index.md @@ -11,4 +11,5 @@ The normative Styx specification. - [Scalar Interpretation](scalars) — How scalars become typed values - [Schema](schema) — Type system and validation - [Diagnostics](diagnostics) — Error message standards +- [Formatting](format) — Pretty printing and document formatting rules - [LSP Extensions](lsp-extensions) — Extending the language server with domain-specific intelligence diff --git a/docs/content/reference/spec/format.md b/docs/content/reference/spec/format.md new file mode 100644 index 0000000..d810e5a --- /dev/null +++ b/docs/content/reference/spec/format.md @@ -0,0 +1,146 @@ ++++ +title = "Formatting" +weight = 7 +slug = "format" +insert_anchor_links = "heading" ++++ + +# Formatting Requirements + +This document specifies the formatting behavior for Styx documents. + +## Line-Length Based Pretty Printing + +> format[format.line-length] +> When formatting Styx documents, lines SHOULD not exceed a maximum width. +> When a structure would exceed this width if formatted inline, it SHOULD be +> expanded to multiline format to maintain readability. +> +> The default maximum line width SHOULD be 80 characters, consistent with +> common coding standards. + +> format[format.line-length.default] +> The default maximum line width for pretty printing SHOULD be 80 characters. + +> format[format.line-length.preserve] +> Existing line breaks in the input SHOULD be preserved in the output to +> maintain document structure and intentional formatting. + +## Implementation + +The formatting behavior is controlled through the following mechanisms: + +### CLI Flags + +- `--pretty`: Enable line-length based pretty printing +- `--line-length `: Customize the maximum line length (default: 80) +- `--multiline`: Force all structures to be multiline (aggressive expansion) +- `--compact`: Force all structures to be inline (minimal expansion) + +### Programmatic API + +```rust +use facet_styx::{to_string_pretty, SerializeOptions}; + +// Use default pretty printing (80 character limit) +let pretty_output = to_string_pretty(&value)?; + +// Customize line length +let options = SerializeOptions::default().pretty(100); +let custom_output = to_string_with_options(&value, &options)?; +``` + +## Examples + +### Simple Structure (Fits Within Line Limit) + +**Input:** +```styx +config {name "test", port 8080} +``` + +**Output with `--pretty`:** +```styx +config {name "test", port 8080} +``` + +### Complex Structure (Exceeds Line Limit) + +**Input:** +```styx +server {host "localhost", port 8080, timeout 30, max_connections 100, ssl_enabled true} +``` + +**Output with `--pretty`:** +```styx +server { + host "localhost" + port 8080 + timeout 30 + max_connections 100 + ssl_enabled true +} +``` + +### Nested Structures + +**Input:** +```styx +config {server {host "localhost", port 8080}, database {url "postgres://localhost/mydb"}} +``` + +**Output with `--pretty`:** +```styx +config { + server { + host "localhost" + port 8080 + } + database { + url "postgres://localhost/mydb" + } +} +``` + +## Behavior Details + +### Object Expansion + +An object is expanded to multiline format when: +- Its inline representation would exceed the maximum line width +- It contains doc comments or line comments +- It contains nested block objects +- It's explicitly forced with `--multiline` + +### Sequence Expansion + +A sequence is expanded to multiline format when: +- Its inline representation would exceed the maximum line width +- It contains comments +- It's explicitly forced with `--multiline` + +### Preservation Rules + +- Existing line breaks in the input are preserved +- Comments are always preserved and affect formatting +- Simple structures that fit within line limits remain inline +- The formatter is idempotent (running it multiple times produces the same output) + +## Configuration + +The formatting behavior can be configured through: + +1. **CLI flags** (as shown above) +2. **Programmatic options** via `SerializeOptions` +3. **Environment variables** (future enhancement) +4. **Configuration files** (future enhancement) + +## Relationship to Other Specifications + +The formatting specification builds upon: + +- **Parser specification**: Defines how Styx source is parsed +- **Schema specification**: Defines schema structure and validation +- **Diagnostics specification**: Defines error reporting format + +The formatter operates on the parsed CST (Concrete Syntax Tree) and preserves all semantic information while applying formatting rules. \ No newline at end of file