A reasonable configuration language
rcl-lang.org
configuration-language
json
1// RCL -- A reasonable configuration language.
2// Copyright 2023 Ruud van Asseldonk
3
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// A copy of the License has been included in the root of the repository.
7
8use std::io::{Stdout, Write};
9use std::path::Path;
10
11use rcl::cli::{
12 self, Cmd, EvalOptions, FormatTarget, GlobalOptions, OutputTarget, StyleOptions, Target,
13};
14use rcl::error::{Error, Result};
15use rcl::loader::{Loader, SandboxMode};
16use rcl::markup::{MarkupMode, MarkupString};
17use rcl::pprint::{self, Doc};
18use rcl::runtime::{self, Value};
19use rcl::source::{DocId, Inputs, Span};
20use rcl::tracer::StderrTracer;
21use rcl::typecheck;
22
23/// The result of [`App::process_format_targets`].
24struct FormatResult {
25 /// Whether we printed to stdout.
26 stdout: bool,
27 /// Whether we updated files in place.
28 in_place: bool,
29 /// Number of files changed, or that would change.
30 n_changed: u32,
31 /// Number of files inspected.
32 n_loaded: u32,
33}
34
35struct App {
36 loader: Loader,
37 opts: GlobalOptions,
38}
39
40impl App {
41 fn initialize_filesystem(&mut self, sandbox_mode: SandboxMode) -> Result<()> {
42 self.loader
43 .initialize_filesystem(sandbox_mode, self.opts.workdir.as_deref())
44 }
45
46 fn print_string(&self, mode: MarkupMode, data: MarkupString, out: &mut dyn Write) {
47 let res = data.write_bytes(mode, out);
48 if res.is_err() {
49 // If we fail to print to stdout/stderr, there is no point in
50 // printing an error, just exit then.
51 std::process::exit(1);
52 }
53 }
54
55 /// Write a string to a file.
56 fn print_to_file_impl(
57 &self,
58 mode: MarkupMode,
59 data: MarkupString,
60 out_path: &Path,
61 ) -> std::io::Result<()> {
62 let f = std::fs::File::create(out_path)?;
63 let mut w = std::io::BufWriter::new(f);
64 data.write_bytes(mode, &mut w)?;
65 // Flush to force errors to materialize. Without this, flush would
66 // happen on drop, which has no opportunity to report errors.
67 w.flush()
68 }
69
70 /// Write a string to a file.
71 fn print_to_file(&self, mode: MarkupMode, data: MarkupString, out_path: &str) -> Result<()> {
72 let out_path = self.loader.resolve_cli_output_path(out_path);
73
74 self.print_to_file_impl(mode, data, out_path.as_ref())
75 .map_err(|err| {
76 // The concat! macro is not exported, we'll make do with a vec here.
77 let parts = vec![
78 "Failed to write to file '".into(),
79 Doc::path(out_path),
80 "': ".into(),
81 err.to_string().into(),
82 ];
83 Error::new(Doc::Concat(parts)).into()
84 })
85 }
86
87 fn get_markup_for_target(&self, target: &OutputTarget, stdout: &Stdout) -> MarkupMode {
88 match target {
89 OutputTarget::Stdout => self
90 .opts
91 .markup
92 .unwrap_or_else(|| MarkupMode::default_for_fd(stdout)),
93 // When the output is a file, we don't want to put ANSI escape codes
94 // in the file; --output is unaffected by --color.
95 OutputTarget::File(..) => MarkupMode::None,
96 }
97 }
98
99 fn print_doc_target(
100 &self,
101 output: OutputTarget,
102 style_opts: &StyleOptions,
103 doc: Doc,
104 ) -> Result<()> {
105 let stdout = std::io::stdout();
106 let markup = self.get_markup_for_target(&output, &stdout);
107 let cfg = pprint::Config {
108 width: Some(style_opts.width),
109 };
110 let result = doc.println(&cfg);
111 match output {
112 OutputTarget::Stdout => {
113 let mut out = stdout.lock();
114 self.print_string(markup, result, &mut out);
115 }
116 OutputTarget::File(fname) => {
117 self.print_to_file(markup, result, &fname)?;
118 }
119 };
120 Ok(())
121 }
122
123 fn print_doc_stderr(&self, doc: Doc) {
124 let stderr = std::io::stderr();
125 let markup = self
126 .opts
127 .markup
128 .unwrap_or_else(|| MarkupMode::default_for_fd(&stderr));
129 let cfg = pprint::Config { width: Some(80) };
130 let result = doc.println(&cfg);
131 let mut out = stderr.lock();
132 self.print_string(markup, result, &mut out);
133 }
134
135 pub fn print_value(
136 &self,
137 eval_opts: &EvalOptions,
138 style_opts: &StyleOptions,
139 output: OutputTarget,
140 value_span: Span,
141 value: &Value,
142 ) -> Result<()> {
143 let out_doc = rcl::cmd_eval::format_value(eval_opts.format, value_span, value)?;
144
145 // Prepend the banner if the user specified one.
146 let out_doc = match eval_opts.banner.as_ref() {
147 Some(banner) => Doc::lines(banner) + Doc::HardBreak + out_doc,
148 None => out_doc,
149 };
150
151 self.print_doc_target(output, style_opts, out_doc)
152 }
153
154 fn print_fatal_error(&self, err: Error) -> ! {
155 let inputs = self.loader.as_inputs();
156 let err_doc = err.report(&inputs);
157 self.print_doc_stderr(err_doc);
158 // Regardless of whether printing to stderr failed or not, the error was
159 // fatal, so we exit with code 1.
160 std::process::exit(1);
161 }
162
163 fn get_tracer(&self) -> StderrTracer {
164 StderrTracer::new(self.opts.markup)
165 }
166
167 /// Load all targets, apply `apply_one` to each, and write each output if applicable.
168 fn process_format_targets<F>(
169 &mut self,
170 output: OutputTarget,
171 style_opts: StyleOptions,
172 targets: FormatTarget,
173 mut apply_one: F,
174 ) -> Result<FormatResult>
175 where
176 F: for<'a> FnMut(&'a Inputs, DocId, &'a mut rcl::cst::Expr) -> Result<Doc<'a>>,
177 {
178 let cfg = pprint::Config {
179 width: Some(style_opts.width),
180 };
181
182 let mut result = FormatResult {
183 stdout: false,
184 in_place: false,
185 n_changed: 0,
186 n_loaded: 0,
187 };
188
189 let fnames = match targets {
190 FormatTarget::Stdout { fname } => {
191 let doc = self.loader.load_cli_target(&fname)?;
192 let mut cst = self.loader.get_cst(doc)?;
193 let inputs = self.loader.as_inputs();
194 let res = apply_one(&inputs, doc, &mut cst)?;
195 self.print_doc_target(output, &style_opts, res)?;
196 result.stdout = true;
197 return Ok(result);
198 }
199 FormatTarget::InPlace { fnames } => {
200 result.in_place = true;
201 fnames
202 }
203 FormatTarget::Check { mut fnames } => {
204 // For in-place formatting we really need files, but for checking,
205 // we can check stdin if the user did not specify any files.
206 if fnames.is_empty() {
207 fnames.push(Target::StdinDefault);
208 }
209 fnames
210 }
211 };
212
213 for target in fnames {
214 result.n_loaded += 1;
215 let doc = self.loader.load_cli_target(&target)?;
216 let mut cst = self.loader.get_cst(doc)?;
217 let inputs = self.loader.as_inputs();
218 let fmt_doc = apply_one(&inputs, doc, &mut cst)?;
219 let res = fmt_doc.println(&cfg);
220 let formatted = res.to_string_no_markup();
221 let did_change = self.loader.get_doc(doc).data != &formatted[..];
222
223 if result.in_place {
224 let fname = match target {
225 Target::File(fname) => fname,
226 Target::Stdin => {
227 let msg =
228 "Rewriting in-place is only possible for named files, not for stdin.";
229 return Error::new(msg).err();
230 }
231 Target::StdinDefault => {
232 unreachable!("In-place default is empty list, not stdin.")
233 }
234 };
235 // We only write to the file if we changed anything. This ensures
236 // that we don't cause rebuilds for build systems that look at mtimes,
237 // that we don't waste space on CoW filesystems, and that we don't
238 // unnecessarily burn through SSDs in general.
239 if did_change {
240 result.n_changed += 1;
241 self.print_to_file(MarkupMode::None, res, &fname)?;
242 }
243 } else {
244 // We are in the --check case, not the --in-place case.
245 if did_change {
246 result.n_changed += 1;
247 println!("Would modify {}", self.loader.get_doc(doc).name);
248 }
249 }
250 }
251
252 Ok(result)
253 }
254
255 fn main(&mut self) -> Result<()> {
256 let (opts, cmd) = cli::parse(std::env::args().collect())?;
257 self.opts = opts;
258
259 match cmd {
260 Cmd::Help { usage } => {
261 println!("{}", usage.concat().trim());
262 std::process::exit(0)
263 }
264
265 Cmd::Build {
266 eval_opts,
267 build_mode,
268 fname,
269 } => {
270 // Evaluation options support a depfile, but this is not implemented
271 // for builds, we'd have to put multiple output filenames in there
272 // and that is not supported right now.
273 if eval_opts.output_depfile.is_some() {
274 return Error::new("Generating depfiles is not supported for 'rcl build'.")
275 .err();
276 }
277
278 self.initialize_filesystem(eval_opts.sandbox)?;
279
280 // TODO: We can make these members, then we can share a lot of code between commands!
281 let mut tracer = self.get_tracer();
282 let mut type_env = typecheck::prelude();
283 let mut value_env = runtime::prelude();
284 let doc = self.loader.load_cli_target(&fname)?;
285
286 // TODO: Would be nice to be able to feed in an expected type.
287 let val = self
288 .loader
289 .evaluate(&mut type_env, &mut value_env, doc, &mut tracer)?;
290
291 let full_span = self.loader.get_span(doc);
292
293 rcl::cmd_build::execute_build(&self.loader, build_mode, doc, full_span, val)
294 }
295
296 Cmd::Evaluate {
297 eval_opts,
298 style_opts,
299 fname,
300 output,
301 } => {
302 self.initialize_filesystem(eval_opts.sandbox)?;
303
304 let mut tracer = self.get_tracer();
305 let mut type_env = typecheck::prelude();
306 let mut value_env = runtime::prelude();
307 let doc = self.loader.load_cli_target(&fname)?;
308 let val = self
309 .loader
310 .evaluate(&mut type_env, &mut value_env, doc, &mut tracer)?;
311
312 if let Some(depfile_path) = eval_opts.output_depfile.as_ref() {
313 self.loader.write_depfile(&output, depfile_path)?;
314 }
315
316 let body_span = self.loader.get_span(doc);
317 self.print_value(&eval_opts, &style_opts, output, body_span, &val)
318 }
319
320 Cmd::Query {
321 eval_opts,
322 style_opts,
323 fname,
324 query: expr,
325 output,
326 } => {
327 self.initialize_filesystem(eval_opts.sandbox)?;
328
329 let input = self.loader.load_cli_target(&fname)?;
330 let query = self.loader.load_string("query", expr);
331
332 // First we evaluate the input document.
333 let mut tracer = self.get_tracer();
334 let mut type_env = typecheck::prelude();
335 let mut value_env = runtime::prelude();
336 let val_input =
337 self.loader
338 .evaluate(&mut type_env, &mut value_env, input, &mut tracer)?;
339
340 // Then we bind that to the variable `input`, and in that context,
341 // we evaluate the query expression. The environments should be
342 // clean at this point, so we can reuse them.
343 type_env.push("input".into(), typecheck::type_any().clone());
344 value_env.push("input".into(), val_input);
345 let val_result =
346 self.loader
347 .evaluate(&mut type_env, &mut value_env, query, &mut tracer)?;
348
349 if let Some(depfile_path) = eval_opts.output_depfile.as_ref() {
350 self.loader.write_depfile(&output, depfile_path)?;
351 }
352
353 let body_span = self.loader.get_span(query);
354 self.print_value(&eval_opts, &style_opts, output, body_span, &val_result)
355 }
356
357 Cmd::Format {
358 style_opts,
359 target,
360 output,
361 } => {
362 // Unrestricted is safe, because `format` does not evaluate documents.
363 self.initialize_filesystem(SandboxMode::Unrestricted)?;
364 let stats = self.process_format_targets(
365 output,
366 style_opts,
367 target,
368 |inputs, _doc, cst| Ok(rcl::fmt_cst::format_expr(inputs, cst)),
369 )?;
370 match (stats.n_changed, stats.n_loaded) {
371 (_, _) if stats.stdout => { /* No stats to print, we printed the doc. */ }
372 (k, n) if stats.in_place => println!("Reformatted {k} of {n} files."),
373 (0, 1) => println!("The file is formatted correctly."),
374 (0, n) => println!("All {n} files are formatted correctly."),
375 _ => {
376 let parts = vec![
377 stats.n_changed.to_string().into(),
378 Doc::str(" of "),
379 stats.n_loaded.to_string().into(),
380 Doc::str(" files would be reformatted."),
381 ];
382 return Error::new(Doc::Concat(parts)).err();
383 }
384 }
385 Ok(())
386 }
387
388 Cmd::Patch {
389 style_opts,
390 target,
391 output,
392 path,
393 replacement,
394 } => {
395 // Unrestricted is safe, because `patch` does not evaluate documents.
396 self.initialize_filesystem(SandboxMode::Unrestricted)?;
397
398 let mut patcher = Some(rcl::patch::Patcher::new(
399 &mut self.loader,
400 &path,
401 replacement,
402 )?);
403
404 let stats = self.process_format_targets(
405 output,
406 style_opts,
407 target,
408 |inputs, doc, input_cst| {
409 let input_doc = &inputs[doc];
410 let patcher = patcher.take().ok_or_else(|| {
411 Error::new("'rcl patch' handles only one target file.")
412 })?;
413 patcher.apply(input_doc.data, input_doc.span, input_cst)?;
414 Ok(rcl::fmt_cst::format_expr(inputs, input_cst))
415 },
416 )?;
417 match (stats.n_changed, stats.n_loaded) {
418 (_, _) if stats.stdout => { /* No stats to print, we printed the doc. */ }
419 (1, 1) if stats.in_place => println!("Patch applied successfully."),
420 (0, 1) => println!("The patch is a no-op."),
421 (1, 1) => return Error::new("File would be patched or reformatted.").err(),
422 _ => unreachable!("Patch patches at most 1 document."),
423 }
424 Ok(())
425 }
426
427 Cmd::Highlight { fname } => {
428 // Unrestricted is safe, because `highlight` does not evaluate documents.
429 self.initialize_filesystem(SandboxMode::Unrestricted)?;
430 let doc = self.loader.load_cli_target(&fname)?;
431 let tokens = self.loader.get_tokens(doc)?;
432 let data = self.loader.get_doc(doc).data;
433 let result = rcl::highlight::highlight(&tokens, data);
434 let out = std::io::stdout();
435 let markup = self.get_markup_for_target(&OutputTarget::Stdout, &out);
436 self.print_string(markup, result, &mut out.lock());
437 Ok(())
438 }
439
440 Cmd::Version => {
441 println!("RCL version {}", env!("CARGO_PKG_VERSION"));
442 Ok(())
443 }
444 }
445 }
446}
447
448fn main() {
449 let mut app = App {
450 opts: GlobalOptions::default(),
451 loader: Loader::new(),
452 };
453
454 if let Err(err) = app.main() {
455 app.print_fatal_error(*err);
456 }
457}