nreplops_tool/
cli.rs

1// cli.rs
2// Copyright 2022 Matti Hänninen
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License. You may obtain a copy of
6// the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13// License for the specific language governing permissions and limitations under
14// the License.
15
16use std::{
17  env, ffi,
18  io::{self, IsTerminal},
19  path,
20  rc::Rc,
21  time,
22};
23
24use clap::Parser;
25
26use crate::{
27  conn_expr::{ConnectionExpr, ConnectionExprSource},
28  error::Error,
29  version::{Version, VersionRange},
30};
31
32pub use self::tristate::Tristate;
33
34#[derive(Debug)]
35pub struct Args {
36  pub version_range: Option<VersionRange>,
37  pub conn_expr_src: ConnectionExprSource,
38  pub stdin_from: Option<IoArg>,
39  pub stdout_to: Option<IoArg>,
40  pub stderr_to: Option<IoArg>,
41  pub results_to: Option<IoArg>,
42  pub source_args: Vec<SourceArg>,
43  pub template_args: Vec<TemplateArg>,
44  pub pretty: Tristate,
45  pub color: Tristate,
46}
47
48impl Args {
49  pub fn from_command_line() -> Result<Self, Error> {
50    let mut args_os = env::args_os().collect::<Vec<ffi::OsString>>();
51
52    // Inject an "any" version range after the shebang mode flag (`-!`) in
53    // certain cases.  This helps Clap to not confuse positional arguments with
54    // the optional version assertion.
55    if let Some(pos) =
56      args_os.iter().position(|arg| *arg == ffi::OsStr::new("-!"))
57    {
58      if args_os
59        .get(pos + 1)
60        .and_then(|value| value.to_str())
61        .map(|value| parse_version_range(value).is_err())
62        .unwrap_or(true)
63      {
64        args_os.insert(pos + 1, "..".into());
65      }
66    }
67
68    Self::try_from(Cli::parse_from(args_os))
69  }
70}
71
72#[derive(Debug, PartialEq)]
73pub enum IoArg {
74  Pipe,
75  File(path::PathBuf),
76}
77
78#[derive(Debug, PartialEq)]
79pub enum SourceArg {
80  Pipe,
81  Expr(String),
82  File(path::PathBuf),
83}
84
85impl SourceArg {
86  fn is_pipe(&self) -> bool {
87    matches!(*self, SourceArg::Pipe)
88  }
89}
90
91impl From<IoArg> for SourceArg {
92  fn from(io: IoArg) -> Self {
93    match io {
94      IoArg::Pipe => SourceArg::Pipe,
95      IoArg::File(p) => SourceArg::File(p),
96    }
97  }
98}
99
100#[derive(Debug)]
101pub struct TemplateArg {
102  pub pos: Option<usize>,
103  pub name: Option<Rc<str>>,
104  pub value: Rc<str>,
105}
106
107impl TryFrom<Cli> for Args {
108  type Error = Error;
109
110  fn try_from(cli: Cli) -> Result<Self, Self::Error> {
111    let (shebang_mode, assert_version) = match cli.shebang_guard {
112      Some(version) => (true, version),
113      None => (false, None),
114    };
115
116    let mut pos_arg_it = cli.pos_args.iter();
117
118    // XXX(soija) I don't like the how expressions and files are mutually
119    //            exclusive. Figure out a way to lift this restriction SO that
120    //            the relative order of expressions and files is honored.  That
121    //            is, `-e a -f b -e c` should be sourced in order `a`, `b`,
122    //            and `c`.
123
124    let source_args = if shebang_mode {
125      //
126      // When the shebang guard is given the first positional argument is always
127      // interpreted as the (sole) source.
128      //
129      let pos_arg = pos_arg_it.next().ok_or(Error::NoInput)?;
130      let src_arg = IoArg::parse_from_path(pos_arg)
131        .map(SourceArg::from)
132        .map_err(|_| Error::BadSourceFile)?;
133      vec![src_arg]
134    } else if !cli.exprs.is_empty() {
135      cli
136        .exprs
137        .iter()
138        .map(|e| SourceArg::Expr(e.clone()))
139        .collect()
140    } else if !cli.files.is_empty() {
141      cli
142        .files
143        .iter()
144        .map(|f| {
145          IoArg::parse_from_path_or_pipe(f)
146            .map(SourceArg::from)
147            .map_err(|_| Error::BadSourceFile)
148        })
149        .collect::<Result<_, _>>()?
150    } else if !io::stdin().is_terminal() {
151      vec![SourceArg::Pipe]
152    } else if let Some(f) = pos_arg_it.next() {
153      vec![IoArg::parse_from_path_or_pipe(f)
154        .map(SourceArg::from)
155        .map_err(|_| Error::BadSourceFile)?]
156    } else if cli.wait_port_file.is_some() {
157      vec![]
158    } else {
159      return Err(Error::NoInput);
160    };
161
162    let stdin_reserved =
163      match source_args.iter().filter(|s| s.is_pipe()).count() {
164        0 => false,
165        1 => true,
166        _ => return Err(Error::StdInConflict),
167      };
168
169    let stdin_from = cli
170      .stdin
171      .as_ref()
172      .map(IoArg::try_from)
173      .transpose()
174      .map_err(|_| Error::BadStdIn)?;
175    //
176    // XXX(soija) Is this correct? `--stdin` determines what gets sent to the
177    //            server as stdin and `stdin_reserved` means that the source is
178    //            read from the local stdin.  So, I think, it should be okay to
179    //
180    //                cat program.clj | nr --stdin input.txt
181    //
182    //            which should be more or less equivalent to
183    //
184    //                nr --file program.clj --stdin input.txt
185    //
186    //            Right?
187    //
188    if stdin_from.is_some() && stdin_reserved {
189      return Err(Error::StdInConflict);
190    }
191
192    let args = cli
193      .args
194      .iter()
195      .map(|arg| TemplateArg::parse(None, arg))
196      .chain(
197        pos_arg_it
198          .enumerate()
199          .map(|(i, arg)| TemplateArg::parse(Some(i), arg)),
200      )
201      .collect::<Result<_, _>>()?;
202
203    let conn_expr_src = if let Some(ref h) = cli.port {
204      h.into()
205    } else {
206      ConnectionExprSource::PortFile {
207        path: cli.port_file.clone(),
208        wait_for: cli.wait_port_file.map(time::Duration::from_secs),
209      }
210    };
211
212    fn tristate(on: bool, off: bool) -> Tristate {
213      use Tristate::*;
214      match (on, off) {
215        (true, false) => On,
216        (false, true) => Off,
217        _ => Auto,
218      }
219    }
220
221    Ok(Self {
222      version_range: assert_version,
223      conn_expr_src,
224      stdin_from,
225      stdout_to: if cli.no_stdout {
226        None
227      } else {
228        Some(
229          cli
230            .stdout
231            .as_ref()
232            .map(IoArg::from_path)
233            .unwrap_or(IoArg::Pipe),
234        )
235      },
236      stderr_to: if cli.no_stderr {
237        None
238      } else {
239        Some(
240          cli
241            .stderr
242            .as_ref()
243            .map(IoArg::from_path)
244            .unwrap_or(IoArg::Pipe),
245        )
246      },
247      results_to: if cli.no_results {
248        None
249      } else {
250        Some(
251          cli
252            .results
253            .as_ref()
254            .map(IoArg::from_path)
255            .unwrap_or(IoArg::Pipe),
256        )
257      },
258      source_args,
259      template_args: args,
260      pretty: tristate(cli.pretty, cli.no_pretty),
261      color: tristate(cli.color, cli.no_color),
262    })
263  }
264}
265
266impl TemplateArg {
267  fn parse(
268    pos: Option<usize>,
269    s: impl AsRef<ffi::OsStr>,
270  ) -> Result<Self, Error> {
271    if let Some(s) = s.as_ref().to_str() {
272      if let Some((name, value)) = s.split_once('=') {
273        Ok(Self {
274          pos,
275          name: Some(name.to_string().into()),
276          value: value.to_string().into(),
277        })
278      } else if pos.is_some() {
279        Ok(Self {
280          pos,
281          name: None,
282          value: s.to_string().into(),
283        })
284      } else {
285        // non-positional arg must have a name
286        Err(Error::UnnamedNonPositionalTemplateArgument)
287      }
288    } else {
289      // arg must be UTF-8 string
290      Err(Error::NonUtf8TemplateArgument)
291    }
292  }
293}
294
295impl IoArg {
296  fn from_path(path: impl AsRef<path::Path>) -> Self {
297    IoArg::File(path.as_ref().to_owned())
298  }
299
300  fn parse_from_path(s: impl AsRef<ffi::OsStr>) -> Result<Self, IoParseError> {
301    let s = s.as_ref();
302    if let Ok(p) = path::PathBuf::from(s).canonicalize() {
303      Ok(IoArg::File(p))
304    } else {
305      Err(IoParseError(s.to_string_lossy().into()))
306    }
307  }
308
309  fn parse_from_path_or_pipe(
310    s: impl AsRef<ffi::OsStr>,
311  ) -> Result<Self, IoParseError> {
312    let s = s.as_ref();
313    if s == "-" {
314      Ok(IoArg::Pipe)
315    } else if let Ok(p) = path::PathBuf::from(s).canonicalize() {
316      Ok(IoArg::File(p))
317    } else {
318      Err(IoParseError(s.to_string_lossy().into()))
319    }
320  }
321}
322
323impl TryFrom<&ffi::OsString> for IoArg {
324  type Error = IoParseError;
325
326  fn try_from(s: &ffi::OsString) -> Result<Self, Self::Error> {
327    if s == "-" {
328      Ok(IoArg::Pipe)
329    } else if let Ok(p) = path::PathBuf::from(s).canonicalize() {
330      Ok(IoArg::File(p))
331    } else {
332      Err(IoParseError(s.to_string_lossy().into()))
333    }
334  }
335}
336
337#[derive(Debug, thiserror::Error)]
338#[error("bad file agument: {0}")]
339pub struct IoParseError(String);
340
341mod tristate {
342
343  use std::{fmt, str};
344
345  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
346  pub enum Tristate {
347    On,
348    Off,
349    Auto,
350  }
351
352  impl Tristate {
353    pub fn to_bool(&self, default: bool) -> bool {
354      use Tristate::*;
355      match self {
356        On => true,
357        Off => false,
358        Auto => default,
359      }
360    }
361  }
362
363  impl fmt::Display for Tristate {
364    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
365      use Tristate::*;
366      write!(
367        f,
368        "{}",
369        match self {
370          On => "on",
371          Off => "off",
372          Auto => "auto",
373        }
374      )
375    }
376  }
377
378  impl str::FromStr for Tristate {
379    type Err = ParseTristateSwitchError;
380
381    fn from_str(s: &str) -> Result<Self, Self::Err> {
382      use Tristate::*;
383      match s {
384        "on" => Ok(On),
385        "off" => Ok(Off),
386        "auto" => Ok(Auto),
387        _ => Err(ParseTristateSwitchError),
388      }
389    }
390  }
391
392  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
393  pub struct ParseTristateSwitchError;
394
395  #[cfg(test)]
396  mod test {
397    use super::*;
398    use Tristate::*;
399
400    #[test]
401    fn parse_tristate_switch() {
402      assert_eq!("on".parse::<Tristate>(), Ok(On));
403      assert_eq!("off".parse::<Tristate>(), Ok(Off));
404      assert_eq!("auto".parse::<Tristate>(), Ok(Auto));
405
406      assert_eq!("".parse::<Tristate>(), Err(ParseTristateSwitchError));
407      assert_eq!(" on".parse::<Tristate>(), Err(ParseTristateSwitchError));
408      assert_eq!(
409        "whatever".parse::<Tristate>(),
410        Err(ParseTristateSwitchError)
411      );
412    }
413  }
414}
415
416//
417// Clap cli
418//
419
420#[derive(Debug, clap::Parser)]
421#[command(
422  about = "Non-interactive nREPL client for scripts and command-line",
423  version,
424  max_term_width = 80
425)]
426struct Cli {
427  /// Connect to server on [HOST:]PORT
428  #[arg(
429    long,
430    short,
431    visible_alias = "host",
432    value_name = "[[[USER@]TUNNEL[:PORT]:]HOST:]PORT"
433  )]
434  port: Option<ConnectionExpr>,
435
436  /// Read server port from FILE
437  #[arg(long, value_name = "FILE")]
438  port_file: Option<path::PathBuf>,
439
440  /// Evaluate within NAMESPACE
441  #[arg(long, visible_alias = "namespace", value_name = "NAMESPACE")]
442  ns: Option<String>,
443
444  /// Evaluate EXPRESSION
445  #[arg(
446    long = "expr",
447    short,
448    value_name = "EXPRESSION",
449    conflicts_with = "files"
450  )]
451  exprs: Vec<String>,
452
453  /// Evaluate FILE
454  #[arg(
455    long = "file",
456    short = 'f',
457    value_name = "FILE",
458    conflicts_with = "exprs"
459  )]
460  files: Vec<ffi::OsString>,
461
462  /// Send FILE to server's stdin
463  #[arg(long, visible_aliases = &["in", "input"], value_name = "FILE")]
464  stdin: Option<ffi::OsString>,
465
466  /// Write server's stdout to FILE
467  #[arg(long, visible_aliases = &["out", "output"], value_name = "FILE")]
468  stdout: Option<path::PathBuf>,
469
470  /// Discard server's stdout
471  #[arg(
472    long,
473    visible_aliases = &["no-out", "no-output"],
474    conflicts_with = "stdout",
475  )]
476  no_stdout: bool,
477
478  /// Write server's stderr to FILE
479  #[arg(long, visible_alias = "err", value_name = "FILE")]
480  stderr: Option<path::PathBuf>,
481
482  /// Discard server's stderr
483  #[arg(
484    long,
485    visible_aliases = &["no-err"],
486    conflicts_with = "stderr",
487  )]
488  no_stderr: bool,
489
490  /// Write evaluation results to FILE
491  #[arg(long, visible_aliases = &["res", "values"], value_name = "FILE")]
492  results: Option<path::PathBuf>,
493
494  /// Discard evaluation results
495  #[arg(
496    long,
497    visible_aliases = &["no-res", "no-values"],
498    conflicts_with = "results",
499  )]
500  no_results: bool,
501
502  /// Set template argument NAME to VALUE
503  #[arg(long = "arg", short = 'a', value_name = "NAME=VALUE")]
504  args: Vec<String>,
505
506  /// Positional arguments
507  #[arg(value_name = "ARG")]
508  pos_args: Vec<ffi::OsString>,
509
510  /// Run in shebang (#!) mode
511  #[arg(
512    short = '!',
513    value_name = "VERSION_EXPR",
514    value_parser = parse_version_range,
515    conflicts_with_all = &["exprs", "files"],
516  )]
517  shebang_guard: Option<Option<VersionRange>>,
518
519  /// Wait .nrepl-port file to appear for SECONDS
520  #[arg(long = "wait-port-file", value_name = "SECONDS")]
521  wait_port_file: Option<u64>,
522
523  /// Set timeout for program execution
524  #[arg(
525    long = "timeout",
526    value_name = "SECONDS",
527    value_parser = not_implemented::<u32>,
528  )]
529  _timeout: Option<u32>,
530
531  /// Enforce result value pretty-printing
532  #[arg(long, conflicts_with = "no_pretty")]
533  pretty: bool,
534
535  /// Enforce unformatted result values
536  #[arg(long, conflicts_with = "pretty")]
537  no_pretty: bool,
538
539  /// Enforce colored output
540  #[arg(long, conflicts_with = "no_color")]
541  color: bool,
542
543  /// Enforce plain output
544  #[arg(long, conflicts_with = "color")]
545  no_color: bool,
546}
547
548fn parse_version_range(s: &str) -> Result<VersionRange, &'static str> {
549  if let Ok(start) = s.parse::<Version>() {
550    let end = start.next_breaking();
551    Ok(VersionRange {
552      start: Some(start),
553      end: Some(end),
554      inclusive: false,
555    })
556  } else if let Ok(range) = s.parse::<VersionRange>() {
557    Ok(range)
558  } else {
559    Err("bad version or version range")
560  }
561}
562
563fn not_implemented<T>(_: &str) -> Result<T, &'static str> {
564  Err("this feature has not been implemented yet, sorry")
565}