val 0.6.0

An arbitrary precision calculator language
Documentation
use super::*;

#[derive(Debug, Parser)]
#[clap(
  about,
  author,
  version,
  help_template = "\
{before-help}{name} {version}

{about}

{usage-heading}: {usage}

{all-args}{after-help}
"
)]
pub(crate) struct Arguments {
  #[clap(
    short,
    long,
    value_parser = clap::value_parser!(NonZeroUsize),
    default_value = "16",
    help = "Decimal digits to display for approximate numbers"
  )]
  digits: NonZeroUsize,
  #[clap(
    short,
    long,
    conflicts_with = "filename",
    help = "Expression to evaluate"
  )]
  expression: Option<String>,
  #[clap(conflicts_with = "expression", help = "File to evaluate")]
  filename: Option<PathBuf>,
  #[clap(
    short,
    long,
    conflicts_with_all = ["expression", "filename"],
    help = "Load files before entering the REPL"
  )]
  load: Option<Vec<PathBuf>>,
  #[clap(
    short,
    long,
    value_parser = clap::value_parser!(NonZeroU32),
    default_value = "1024",
    help = "Binary precision (bits) to use for calculations"
  )]
  precision: NonZeroU32,
  #[clap(
    short,
    long,
    value_parser = clap::value_parser!(RoundingMode),
    default_value = "to-even",
    help = "Rounding mode to use for calculations",
  )]
  rounding_mode: RoundingMode,
  #[clap(
    long,
    value_parser = clap::value_parser!(NonZeroUsize),
    default_value = "128",
    help = "Stack size in MB for evaluations"
  )]
  pub stack_size: NonZeroUsize,
}

impl Arguments {
  fn evaluate_expression(&self, text: &str) -> Result {
    let source = Source::new("<expression>", text);

    let mut evaluator =
      Evaluator::from(Environment::new(Into::<Config>::into(self)));

    match parse(source)
      .and_then(|ast| evaluator.evaluate(&ast).map_err(|error| vec![error]))
    {
      Ok(Evaluation::Exit { code, .. }) => process::exit(code),
      Ok(Evaluation::Value(value)) => {
        if !matches!(value, Value::Null) {
          println!("{}", value.display(Into::<Config>::into(self)));
        }

        Ok(())
      }
      Err(errors) => {
        Self::report(&errors, io::stderr())?;

        process::exit(1);
      }
    }
  }

  fn evaluate_file(&self, filename: &PathBuf) -> Result {
    let content = fs::read_to_string(filename)?;

    let source = Source::new(filename.to_string_lossy(), content);

    let mut evaluator =
      Evaluator::from(Environment::new(Into::<Config>::into(self)));

    match parse(source)
      .and_then(|ast| evaluator.evaluate(&ast).map_err(|error| vec![error]))
    {
      Ok(Evaluation::Exit { code, .. }) => process::exit(code),
      Ok(Evaluation::Value(_)) => Ok(()),
      Err(errors) => {
        Self::report(&errors, io::stderr())?;

        process::exit(1);
      }
    }
  }

  #[cfg(not(target_family = "wasm"))]
  fn read(&self) -> Result {
    let history = dirs::home_dir().unwrap_or_default().join(".val_history");

    let editor_config = Builder::new()
      .color_mode(ColorMode::Enabled)
      .edit_mode(EditMode::Emacs)
      .history_ignore_space(true)
      .completion_type(CompletionType::Circular)
      .max_history_size(1000)?
      .build();

    let mut editor =
      Editor::<Prompt, DefaultHistory>::with_config(editor_config)?;

    editor.set_helper(Some(Prompt::new()));
    editor.load_history(&history).ok();

    let mut evaluator =
      Evaluator::from(Environment::new(Into::<Config>::into(self)));

    if let Some(filenames) = &self.load {
      for filename in filenames {
        let content = fs::read_to_string(filename)?;

        let source = Source::new(filename.to_string_lossy(), content);

        match parse(source)
          .and_then(|ast| evaluator.evaluate(&ast).map_err(|error| vec![error]))
        {
          Ok(Evaluation::Exit { code, .. }) => process::exit(code),
          Ok(Evaluation::Value(_)) => {}
          Err(errors) => {
            Self::report(&errors, io::stderr())?;

            process::exit(1);
          }
        }
      }
    }

    loop {
      let line = editor.readline("> ")?;

      editor.add_history_entry(&line)?;
      editor.save_history(&history)?;

      let source = Source::from(line);

      match parse(source)
        .and_then(|ast| evaluator.evaluate(&ast).map_err(|error| vec![error]))
      {
        Ok(Evaluation::Exit { code, .. }) => process::exit(code),
        Ok(Evaluation::Value(value)) if !matches!(value, Value::Null) => {
          println!("{}", value.display(Into::<Config>::into(self)));
        }
        Ok(Evaluation::Value(_)) => {}
        Err(errors) => Self::report(&errors, io::stderr())?,
      }
    }
  }

  fn report(errors: &[Error], mut writer: impl Write) -> io::Result<()> {
    let mut cache =
      FnCache::new(|source: &Source| Ok::<_, Infallible>(source.clone()));

    for error in errors {
      if let Some(report) = error.report() {
        report.write(&mut cache, &mut writer)?;
      } else {
        writeln!(writer, "error: {error}")?;
      }
    }

    Ok(())
  }

  pub(crate) fn run(self) -> Result {
    match (&self.filename, &self.expression) {
      (Some(filename), _) => self.evaluate_file(filename),
      (_, Some(expression)) => self.evaluate_expression(expression),
      _ => {
        #[cfg(not(target_family = "wasm"))]
        {
          self.read()
        }
        #[cfg(target_family = "wasm")]
        {
          Err(anyhow::anyhow!("Interactive mode not supported in WASM"))
        }
      }
    }
  }
}

impl From<&Arguments> for Config {
  fn from(arguments: &Arguments) -> Self {
    Config {
      digits: arguments.digits,
      precision: arguments.precision.get(),
      rounding_mode: arguments.rounding_mode.into(),
    }
  }
}

#[cfg(test)]
mod tests {
  use {super::*, clap::Parser, std::path::PathBuf};

  #[test]
  fn both_should_fail() {
    assert!(
      Arguments::try_parse_from(vec![
        "program",
        "file.txt",
        "--expression",
        "1 + 2"
      ])
      .is_err()
    );
  }

  #[test]
  fn conflict_error_message() {
    let result = Arguments::try_parse_from(vec![
      "program",
      "file.txt",
      "--expression",
      "1 + 2",
    ]);

    assert!(result.is_err());

    let error = result.unwrap_err().to_string();

    assert!(
      error.contains("cannot be used with"),
      "Error should mention conflicts: {error}"
    );
  }

  #[test]
  fn digits() {
    #[track_caller]
    fn case(argument: &str) {
      let arguments = Arguments::parse_from(vec!["program", argument, "4"]);

      assert_eq!(arguments.digits, NonZeroUsize::new(4).unwrap());
    }

    case("--digits");
    case("-d");
  }

  #[test]
  fn digits_rejects_zero() {
    let result = Arguments::try_parse_from(vec!["program", "--digits", "0"]);

    assert!(result.is_err());
  }

  #[test]
  fn expression_only() {
    let arguments =
      Arguments::parse_from(vec!["program", "--expression", "1 + 2"]);

    assert!(arguments.filename.is_none());
    assert!(arguments.expression.is_some());

    assert_eq!(arguments.expression.unwrap(), "1 + 2");
  }

  #[test]
  fn expression_short_form() {
    let arguments = Arguments::parse_from(vec!["program", "-e", "1 + 2"]);

    assert!(arguments.filename.is_none());
    assert!(arguments.expression.is_some());

    assert_eq!(arguments.expression.unwrap(), "1 + 2");
  }

  #[test]
  fn filename_only() {
    let arguments = Arguments::parse_from(vec!["program", "file.txt"]);

    assert!(arguments.filename.is_some());
    assert!(arguments.expression.is_none());

    assert_eq!(arguments.filename.unwrap(), PathBuf::from("file.txt"));
  }

  #[test]
  fn load_conflicts_with_non_interactive_modes() {
    #[track_caller]
    fn case(arguments: &[&str]) {
      let error = Arguments::try_parse_from(arguments).unwrap_err();

      assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
    }

    case(&["val", "foo", "--load", "bar"]);
    case(&["val", "--load", "foo", "--expression", "1"]);
  }

  #[test]
  fn neither_provided() {
    let arguments = Arguments::parse_from(vec!["program"]);

    assert!(arguments.filename.is_none());
    assert!(arguments.expression.is_none());
  }

  #[test]
  fn nonzero_arguments_reject_zero() {
    for argument in ["--precision", "--stack-size"] {
      let result = Arguments::try_parse_from(vec!["program", argument, "0"]);

      assert!(result.is_err());
    }
  }
}