Skip to main content

tomlsmith_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    ffi::OsString,
5    io,
6    path::{Path, PathBuf},
7};
8
9use clap::{Parser, Subcommand, ValueEnum};
10use tomlsmith::{
11    Diagnostic, DiagnosticCode, Document, FormatOptions, FormatOutcome, LineEnding, Severity,
12};
13
14#[derive(Debug, Parser)]
15#[command(name = "tomlsmith", version, about = "A unified TOML toolchain")]
16struct Cli {
17    /// TOML language version used for parsing and validation.
18    #[arg(long, value_enum, default_value_t = TomlVersionArg::V1_1, global = true)]
19    toml_version: TomlVersionArg,
20
21    #[command(subcommand)]
22    command: Command,
23}
24
25#[derive(Clone, Copy, Debug, Default, ValueEnum)]
26enum TomlVersionArg {
27    #[value(name = "1.0")]
28    V1_0,
29    #[default]
30    #[value(name = "1.1")]
31    V1_1,
32}
33
34impl From<TomlVersionArg> for tomlsmith::TomlVersion {
35    fn from(version: TomlVersionArg) -> Self {
36        match version {
37            TomlVersionArg::V1_0 => Self::V1_0,
38            TomlVersionArg::V1_1 => Self::V1_1,
39        }
40    }
41}
42
43#[derive(Clone, Copy, Debug, Default, ValueEnum)]
44enum LineEndingArg {
45    #[default]
46    Preserve,
47    Lf,
48    Crlf,
49}
50
51impl From<LineEndingArg> for LineEnding {
52    fn from(line_ending: LineEndingArg) -> Self {
53        match line_ending {
54            LineEndingArg::Preserve => Self::Preserve,
55            LineEndingArg::Lf => Self::Lf,
56            LineEndingArg::Crlf => Self::CrLf,
57        }
58    }
59}
60
61#[derive(Debug, Subcommand)]
62enum Command {
63    /// Check a TOML document and report diagnostics.
64    Check {
65        /// Input file, or `-` for standard input.
66        #[arg(default_value = "-")]
67        input: PathBuf,
68    },
69
70    /// Format a TOML document.
71    ///
72    /// Symbolic links are followed and preserved. On Unix, files with multiple hard links are
73    /// refused because an atomic replacement cannot preserve their shared inode identity.
74    Fmt {
75        /// Exit with status 1 instead of writing when formatting is needed.
76        #[arg(long)]
77        check: bool,
78
79        /// Number of spaces per indentation level.
80        #[arg(long, value_parser = clap::value_parser!(u8).range(1..))]
81        indent_width: Option<u8>,
82
83        /// Line width that triggers wrapping inside arrays.
84        #[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
85        line_width: Option<u16>,
86
87        /// Line-ending policy for the formatted output.
88        #[arg(long, value_enum, default_value_t = LineEndingArg::Preserve)]
89        line_ending: LineEndingArg,
90
91        /// Input file, or `-` for standard input.
92        #[arg(default_value = "-")]
93        input: PathBuf,
94    },
95
96    /// Parse a TOML document and emit diagnostics as JSON.
97    Parse {
98        /// Input file, or `-` for standard input.
99        #[arg(default_value = "-")]
100        input: PathBuf,
101    },
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum ExitStatus {
106    Success,
107    ContentFailure,
108    OperationalFailure,
109}
110
111impl ExitStatus {
112    #[must_use]
113    pub const fn code(self) -> u8 {
114        match self {
115            Self::Success => 0,
116            Self::ContentFailure => 1,
117            Self::OperationalFailure => 2,
118        }
119    }
120}
121
122struct InvalidUtf8Input {
123    source_name: String,
124    start: u32,
125    end: u32,
126}
127
128enum SourceRead {
129    Text { source_name: String, source: String },
130    InvalidUtf8(InvalidUtf8Input),
131}
132
133pub fn run<I, S>(
134    arguments: I,
135    stdin: &mut dyn io::Read,
136    stdout: &mut dyn io::Write,
137    stderr: &mut dyn io::Write,
138) -> ExitStatus
139where
140    I: IntoIterator<Item = S>,
141    S: Into<OsString> + Clone,
142{
143    let cli = match Cli::try_parse_from(arguments) {
144        Ok(cli) => cli,
145        Err(error) => {
146            let status = if error.use_stderr() {
147                let _ = write!(stderr, "{error}");
148                ExitStatus::OperationalFailure
149            } else {
150                let _ = write!(stdout, "{error}");
151                ExitStatus::Success
152            };
153            return status;
154        }
155    };
156
157    match execute(cli, stdin, stdout, stderr) {
158        Ok(status) => status,
159        Err(error) => {
160            let _ = writeln!(stderr, "tomlsmith: {error}");
161            ExitStatus::OperationalFailure
162        }
163    }
164}
165
166fn execute(
167    cli: Cli,
168    stdin: &mut dyn io::Read,
169    stdout: &mut dyn io::Write,
170    stderr: &mut dyn io::Write,
171) -> io::Result<ExitStatus> {
172    let version = cli.toml_version.into();
173    match cli.command {
174        Command::Check { input } => {
175            let (source_name, source) = match read_source(&input, stdin)? {
176                SourceRead::Text {
177                    source_name,
178                    source,
179                } => (source_name, source),
180                SourceRead::InvalidUtf8(diagnostic) => {
181                    render_invalid_utf8(stderr, &diagnostic)?;
182                    return Ok(ExitStatus::ContentFailure);
183                }
184            };
185            let document = Document::parse_as(source, version);
186            render_diagnostics(stderr, &source_name, document.diagnostics())?;
187
188            Ok(if has_errors(document.diagnostics()) {
189                ExitStatus::ContentFailure
190            } else {
191                ExitStatus::Success
192            })
193        }
194        Command::Fmt {
195            check,
196            indent_width,
197            line_width,
198            line_ending,
199            input,
200        } => {
201            let (source_name, source) = match read_source(&input, stdin)? {
202                SourceRead::Text {
203                    source_name,
204                    source,
205                } => (source_name, source),
206                SourceRead::InvalidUtf8(diagnostic) => {
207                    render_invalid_utf8(stderr, &diagnostic)?;
208                    return Ok(ExitStatus::ContentFailure);
209                }
210            };
211            let mut options = FormatOptions {
212                target_version: version,
213                line_ending: line_ending.into(),
214                ..FormatOptions::default()
215            };
216            if let Some(indent_width) = indent_width {
217                options.indent_width = indent_width;
218            }
219            if let Some(line_width) = line_width {
220                options.line_width = line_width;
221            }
222            let (document, outcome) = Document::parse_and_format_with(source, version, &options);
223            render_format_outcome(
224                &document,
225                outcome,
226                check,
227                &input,
228                &source_name,
229                stdout,
230                stderr,
231            )
232        }
233        Command::Parse { input } => {
234            let source = match read_source(&input, stdin)? {
235                SourceRead::Text { source, .. } => source,
236                SourceRead::InvalidUtf8(diagnostic) => {
237                    let output = serde_json::json!({
238                        "tomlVersion": version_label(version),
239                        "valid": false,
240                        "diagnostics": [invalid_utf8_json(&diagnostic)],
241                    });
242                    serde_json::to_writer(&mut *stdout, &output).map_err(io::Error::other)?;
243                    writeln!(stdout)?;
244                    return Ok(ExitStatus::ContentFailure);
245                }
246            };
247            let document = Document::parse_as(source, version);
248            let diagnostics = document
249                .diagnostics()
250                .iter()
251                .map(diagnostic_json)
252                .collect::<Vec<_>>();
253            let output = serde_json::json!({
254                "tomlVersion": version_label(version),
255                "valid": !has_errors(document.diagnostics()),
256                "diagnostics": diagnostics,
257            });
258            serde_json::to_writer(&mut *stdout, &output).map_err(io::Error::other)?;
259            writeln!(stdout)?;
260
261            Ok(if has_errors(document.diagnostics()) {
262                ExitStatus::ContentFailure
263            } else {
264                ExitStatus::Success
265            })
266        }
267    }
268}
269
270const fn version_label(version: tomlsmith::TomlVersion) -> &'static str {
271    match version {
272        tomlsmith::TomlVersion::V1_0 => "1.0",
273        tomlsmith::TomlVersion::V1_1 => "1.1",
274    }
275}
276
277fn has_errors(diagnostics: &[Diagnostic]) -> bool {
278    diagnostics
279        .iter()
280        .any(|diagnostic| diagnostic.severity() == Severity::Error)
281}
282
283fn diagnostic_json(diagnostic: &Diagnostic) -> serde_json::Value {
284    serde_json::json!({
285        "code": diagnostic.code().as_str(),
286        "severity": match diagnostic.severity() {
287            Severity::Error => "error",
288            Severity::Warning => "warning",
289        },
290        "message": diagnostic.message(),
291        "range": {
292            "start": diagnostic.range().start(),
293            "end": diagnostic.range().end(),
294        },
295    })
296}
297
298fn render_format_outcome(
299    document: &Document,
300    outcome: FormatOutcome,
301    check: bool,
302    input: &Path,
303    source_name: &str,
304    stdout: &mut dyn io::Write,
305    stderr: &mut dyn io::Write,
306) -> io::Result<ExitStatus> {
307    match outcome {
308        FormatOutcome::Unchanged => {
309            if !check && input == Path::new("-") {
310                stdout.write_all(document.text().as_bytes())?;
311            }
312            Ok(ExitStatus::Success)
313        }
314        FormatOutcome::Changed { text, .. } => {
315            if check {
316                writeln!(stderr, "would reformat {source_name}")?;
317                Ok(ExitStatus::ContentFailure)
318            } else {
319                if input == Path::new("-") {
320                    stdout.write_all(text.as_bytes())?;
321                } else {
322                    write_file_atomically(input, text.as_bytes())?;
323                }
324                Ok(ExitStatus::Success)
325            }
326        }
327        FormatOutcome::Refused { diagnostics } => {
328            render_diagnostics(stderr, source_name, &diagnostics)?;
329            Ok(ExitStatus::ContentFailure)
330        }
331    }
332}
333
334/// Replaces the file reached through `input` using a same-directory temporary file, preserving a
335/// symbolic link at the user-facing path. `tempfile::persist` provides replacement semantics on
336/// Windows as well as rename-based atomic replacement on Unix.
337fn write_file_atomically(input: &Path, contents: &[u8]) -> io::Result<()> {
338    let destination = match std::fs::symlink_metadata(input) {
339        Ok(metadata) if metadata.file_type().is_symlink() => std::fs::canonicalize(input)?,
340        Ok(_) => input.to_owned(),
341        Err(error) if error.kind() == io::ErrorKind::NotFound => input.to_owned(),
342        Err(error) => return Err(error),
343    };
344    let directory = destination
345        .parent()
346        .filter(|parent| !parent.as_os_str().is_empty())
347        .unwrap_or_else(|| Path::new("."));
348    let metadata = std::fs::metadata(&destination)?;
349    refuse_multiply_linked_file(&destination, &metadata)?;
350
351    let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
352    temporary
353        .as_file()
354        .set_permissions(metadata.permissions())?;
355    io::Write::write_all(&mut temporary, contents)?;
356    io::Write::flush(&mut temporary)?;
357    temporary.as_file().sync_all()?;
358    temporary
359        .persist(&destination)
360        .map(|_| ())
361        .map_err(|error| error.error)
362}
363
364#[cfg(unix)]
365fn refuse_multiply_linked_file(path: &Path, metadata: &std::fs::Metadata) -> io::Result<()> {
366    use std::os::unix::fs::MetadataExt;
367
368    let links = metadata.nlink();
369    if links > 1 {
370        return Err(io::Error::other(format!(
371            "refusing to atomically replace {} because it has multiple hard links ({links}); format stdin and write the result explicitly instead",
372            path.display(),
373        )));
374    }
375    Ok(())
376}
377
378#[cfg(not(unix))]
379fn refuse_multiply_linked_file(_path: &Path, _metadata: &std::fs::Metadata) -> io::Result<()> {
380    Ok(())
381}
382
383fn read_source(input: &Path, stdin: &mut dyn io::Read) -> io::Result<SourceRead> {
384    let (source_name, bytes) = if input == Path::new("-") {
385        // Pre-size the buffer so `read_to_end` on a pipe does not spend the
386        // cold-start budget growing a fresh Vec while the writer refills it.
387        let mut bytes = Vec::with_capacity(256 * 1024);
388        stdin.read_to_end(&mut bytes)?;
389        ("stdin".to_owned(), bytes)
390    } else {
391        (input.display().to_string(), std::fs::read(input)?)
392    };
393    match String::from_utf8(bytes) {
394        Ok(source) => Ok(SourceRead::Text {
395            source_name,
396            source,
397        }),
398        Err(error) => {
399            let utf8_error = error.utf8_error();
400            let start = utf8_error.valid_up_to();
401            let end = start.saturating_add(utf8_error.error_len().unwrap_or(1));
402            Ok(SourceRead::InvalidUtf8(InvalidUtf8Input {
403                source_name,
404                start: u32::try_from(start).unwrap_or(u32::MAX),
405                end: u32::try_from(end).unwrap_or(u32::MAX),
406            }))
407        }
408    }
409}
410
411fn invalid_utf8_json(diagnostic: &InvalidUtf8Input) -> serde_json::Value {
412    serde_json::json!({
413        "code": DiagnosticCode::INVALID_UTF8.as_str(),
414        "severity": "error",
415        "message": "TOML input must be valid UTF-8",
416        "range": {
417            "start": diagnostic.start,
418            "end": diagnostic.end,
419        },
420    })
421}
422
423fn render_invalid_utf8(
424    output: &mut dyn io::Write,
425    diagnostic: &InvalidUtf8Input,
426) -> io::Result<()> {
427    writeln!(
428        output,
429        "{}:{}..{}: error[{}]: TOML input must be valid UTF-8",
430        diagnostic.source_name,
431        diagnostic.start,
432        diagnostic.end,
433        DiagnosticCode::INVALID_UTF8,
434    )
435}
436
437fn render_diagnostics(
438    output: &mut dyn io::Write,
439    source_name: &str,
440    diagnostics: &[Diagnostic],
441) -> io::Result<()> {
442    for diagnostic in diagnostics {
443        let severity = match diagnostic.severity() {
444            Severity::Error => "error",
445            Severity::Warning => "warning",
446        };
447        writeln!(
448            output,
449            "{}:{}..{}: {}[{}]: {}",
450            source_name,
451            diagnostic.range().start(),
452            diagnostic.range().end(),
453            severity,
454            diagnostic.code(),
455            diagnostic.message(),
456        )?;
457    }
458    Ok(())
459}