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 #[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 {
65 #[arg(default_value = "-")]
67 input: PathBuf,
68 },
69
70 Fmt {
75 #[arg(long)]
77 check: bool,
78
79 #[arg(long, value_parser = clap::value_parser!(u8).range(1..))]
81 indent_width: Option<u8>,
82
83 #[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
85 line_width: Option<u16>,
86
87 #[arg(long, value_enum, default_value_t = LineEndingArg::Preserve)]
89 line_ending: LineEndingArg,
90
91 #[arg(default_value = "-")]
93 input: PathBuf,
94 },
95
96 Parse {
98 #[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
334fn 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 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}