mod data_json;
mod error_formatter;
mod formatter;
mod interactive;
mod mcp;
pub(crate) mod server;
mod unit_value_prompt;
use anyhow::Result;
use clap::{Args, CommandFactory, Parser, Subcommand};
use formatter::Formatter;
use indexmap::IndexMap;
use lemma::DateTimeValue;
use lemma::Engine;
use lemma::EvaluateDataValue;
use lemma_cli::install;
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Parser)]
#[command(name = "lemma")]
#[command(about = "A pure, declarative language for business rules.")]
#[command(
long_about = "Lemma is a declarative programming language for business logic, expressed simply and clearly.\nThe CLI lets you evaluate rules from .lemma files, run Lemma as an HTTP server, or integrate with AI tools via MCP."
)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Args)]
pub(crate) struct ServerArgs {
#[arg(long, value_name = "PATH")]
pub(crate) path: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1")]
pub(crate) host: String,
#[arg(short, long, default_value = "8012")]
pub(crate) port: u16,
#[arg(short, long)]
pub(crate) watch: bool,
#[arg(long)]
pub(crate) explain: bool,
#[arg(long, default_value = "10", value_name = "SECONDS")]
pub(crate) eval_timeout: u64,
#[arg(long)]
pub(crate) cors: bool,
}
#[derive(Subcommand)]
enum Commands {
#[command(name = "evaluate", alias = "run", verbatim_doc_comment)]
Evaluate {
args: Vec<String>,
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long, value_name = "RULES")]
rules: Option<String>,
#[arg(short = 'x', long)]
explain: bool,
#[arg(long)]
json: bool,
#[arg(short = 'i', long)]
interactive: bool,
#[arg(long)]
effective: Option<String>,
},
#[command(verbatim_doc_comment)]
Show {
repo: Option<String>,
spec: Option<String>,
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long)]
effective: Option<String>,
#[arg(long)]
json: bool,
},
#[command(verbatim_doc_comment)]
Specs {
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(short = 'a', long)]
all: bool,
},
Install {
repository: Option<String>,
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(short = 'a', long)]
all: bool,
#[arg(short = 'f', long)]
force: bool,
},
Format {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
stdout: bool,
},
#[command(verbatim_doc_comment)]
Server {
#[command(flatten)]
args: ServerArgs,
},
Mcp {
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long)]
write: bool,
#[arg(long, default_value = "10", value_name = "SECONDS")]
request_timeout: u64,
#[arg(long)]
http: bool,
#[arg(long, default_value = "127.0.0.1", requires = "http")]
host: String,
#[arg(long, default_value = "8013", requires = "http")]
port: u16,
#[arg(long, requires = "http")]
cors: bool,
},
Lsp,
}
struct EvaluateArgs {
positionals: Vec<String>,
data: Vec<String>,
stdin_data: bool,
}
fn parse_evaluate_args(arguments: &[String]) -> Result<EvaluateArgs> {
let mut data = Vec::new();
let mut positionals = Vec::new();
let mut stdin_data = false;
for argument in arguments {
if argument.contains('=') {
data.push(argument.clone());
} else if argument == "-" {
if stdin_data {
anyhow::bail!("`-` given more than once");
}
stdin_data = true;
} else {
positionals.push(argument.to_string());
}
}
if positionals.len() > 2 {
anyhow::bail!(
"Too many positional arguments; expected [repo] [spec], [spec], or [repo] with --interactive (use `--path` for workspace path)"
);
}
for pos in &positionals {
if Path::new(pos).exists() {
anyhow::bail!(
"Workspace path must be passed with --path {}, not as a bare positional",
pos
);
}
}
Ok(EvaluateArgs {
positionals,
data,
stdin_data,
})
}
fn workspace_dir(path: Option<&PathBuf>) -> &Path {
path.map(|p| p.as_path()).unwrap_or_else(|| Path::new("."))
}
fn resolve_spec(engine: &Engine, spec: Option<&str>, interactive: bool) -> Result<String> {
if let Some(name) = spec {
return Ok(name.to_string());
}
let workspace = engine
.specs()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: default repository must exist after Engine::new")
.specs;
let unique_names: std::collections::BTreeSet<&str> =
workspace.iter().map(|ls| ls.name.as_str()).collect();
match unique_names.len() {
0 => anyhow::bail!("No specs found in source"),
1 => Ok(unique_names
.into_iter()
.next()
.expect("BUG: len was 1")
.to_string()),
_ if interactive => Ok(String::new()),
_ => {
let names: Vec<&str> = unique_names.into_iter().collect();
anyhow::bail!(
"Workspace contains multiple specs: {}\n\nUsage: lemma evaluate [repo] <spec> [--path PATH] [name=value ...]",
names.join(", ")
);
}
}
}
fn resolve_effective(cli_effective: Option<&String>) -> Result<DateTimeValue> {
lemma::resolve_effective(cli_effective.map(String::as_str))
.map_err(|e| anyhow::anyhow!("{}", e.message()))
}
fn main() {
std::panic::set_hook(Box::new(|info| {
eprintln!("lemma aborted: {info}");
std::process::abort();
}));
let cli = Cli::parse();
let result: Result<()> = (|| match &cli.command {
Commands::Evaluate {
args,
path,
rules,
explain,
interactive,
effective,
json,
} => {
let parsed = parse_evaluate_args(args)?;
let workdir = path.as_deref().unwrap_or_else(|| Path::new("."));
evaluate_command(EvaluateOptions {
source: workdir,
positionals: &parsed.positionals,
rules: rules.as_ref(),
data: &parsed.data,
stdin_data: parsed.stdin_data,
explain: *explain,
interactive: *interactive,
effective: effective.as_ref(),
json: *json,
})
}
Commands::Show {
repo,
spec,
path,
effective,
json,
} => show_command(
workspace_dir(path.as_ref()),
repo.as_deref(),
spec.as_deref(),
effective.as_ref(),
*json,
),
Commands::Specs { path, json, all } => {
specs_command(workspace_dir(path.as_ref()), *json, *all)
}
Commands::Server { args } => server_command(args),
Commands::Lsp => lsp_command(),
Commands::Mcp {
path,
write,
request_timeout,
http,
host,
port,
cors,
} => mcp_command(
workspace_dir(path.as_ref()),
*write,
*request_timeout,
*http,
host,
*port,
*cors,
),
Commands::Install {
repository,
path,
all,
force,
} => {
if repository.is_some() && *all {
anyhow::bail!("Cannot specify both a repository and --all");
}
if repository.is_none() && !*all {
let mut cmd = Cli::command();
cmd.build();
let install_cmd = cmd
.find_subcommand_mut("install")
.expect("BUG: Cli must define install subcommand");
install_cmd.print_help()?;
std::process::exit(1);
}
install_command(workspace_dir(path.as_ref()), repository.as_deref(), *force)
}
Commands::Format {
paths,
check,
stdout,
} => format_command(paths, *check, *stdout),
})();
if let Err(e) = result {
eprintln!("{}", e);
std::process::exit(1);
}
}
struct EvaluateOptions<'a> {
source: &'a Path,
positionals: &'a [String],
rules: Option<&'a String>,
data: &'a [String],
stdin_data: bool,
explain: bool,
interactive: bool,
effective: Option<&'a String>,
json: bool,
}
fn evaluate_command(options: EvaluateOptions<'_>) -> Result<()> {
let now = resolve_effective(options.effective)?;
let mut engine = Engine::new();
load_workspace(&mut engine, options.source)?;
let (repository_qualifier_optional, spec_name_optional) = match options.positionals {
[] => (None, None),
[one] => {
let is_repo = interactive::repository_loaded(&engine, one);
let is_spec = engine
.specs()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: default repository must exist after Engine::new")
.specs
.iter()
.any(|ls| ls.name == *one);
if is_repo && !is_spec {
(Some(one.to_string()), None)
} else if is_spec && !is_repo {
(None, Some(one.to_string()))
} else if is_repo && is_spec {
if options.interactive {
anyhow::bail!(
"'{}' resolves to both a repository and a specification. Please specify both [repo] [spec] to disambiguate.",
one
);
} else {
(None, Some(one.to_string()))
}
} else {
(None, Some(one.to_string()))
}
}
[repo, spec] => (Some(repo.to_string()), Some(spec.to_string())),
_ => unreachable!("Parser ensures <= 2 positionals"),
};
if repository_qualifier_optional.is_some()
&& spec_name_optional.is_none()
&& !options.interactive
{
anyhow::bail!(
"Repository positional requires a specification name (second argument), or use --interactive"
);
}
let resolved_spec_name =
resolve_spec(&engine, spec_name_optional.as_deref(), options.interactive)?;
let provided_inputs = evaluate_data_inputs(options.data, options.stdin_data)?;
let (repository_qualifier_for_run, spec_set_identifier, rule_names, evaluation_inputs) =
if options.interactive {
let (interactive_spec_preset, interactive_rules_preset) =
if resolved_spec_name.is_empty() {
(None, None)
} else {
let preset_identifier = lemma::parse_spec_set_id(&resolved_spec_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
(
Some(preset_identifier),
options
.rules
.map(|rules_fragment| parse_rule_names(rules_fragment.as_str())),
)
};
let interactive_outcome = interactive::evaluate_interactive(
&engine,
interactive_spec_preset,
interactive_rules_preset,
&provided_inputs,
&now,
repository_qualifier_optional.as_deref(),
)?;
let (
chosen_repository_qualifier,
chosen_specification_name,
interactive_rules_selection,
prompted_data,
) = interactive_outcome;
println!();
let mut evaluation_inputs = provided_inputs;
evaluation_inputs.extend(prompted_data);
let interactive_spec_id = lemma::parse_spec_set_id(&chosen_specification_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
(
chosen_repository_qualifier,
interactive_spec_id,
interactive_rules_selection.unwrap_or_default(),
evaluation_inputs,
)
} else {
let non_interactive_spec_id = lemma::parse_spec_set_id(&resolved_spec_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let rule_names: Vec<String> = options
.rules
.map(|rules_fragment| parse_rule_names(rules_fragment.as_str()))
.unwrap_or_default();
(
repository_qualifier_optional,
non_interactive_spec_id,
rule_names,
provided_inputs,
)
};
let rules = if rule_names.is_empty() {
None
} else {
Some(rule_names.as_slice())
};
let response = engine
.evaluate(
repository_qualifier_for_run.as_deref(),
&spec_set_identifier,
Some(&now),
evaluation_inputs,
rules,
options.explain,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let formatter = Formatter;
if options.json {
let json_document = if options.explain {
serde_json::to_string_pretty(&lemma::api::Response::from(&response))
.expect("BUG: failed to serialize response JSON")
} else {
formatter.serialize_response_json(&response, false)
};
println!("{}", json_document);
} else {
print!("{}", formatter.format_response(&response, options.explain));
}
Ok(())
}
fn evaluate_data_inputs(
pairs: &[String],
stdin_data: bool,
) -> Result<IndexMap<String, EvaluateDataValue>> {
let mut entries: Vec<(String, EvaluateDataValue)> = Vec::new();
if stdin_data {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
if text.trim().is_empty() {
anyhow::bail!("stdin is empty");
}
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("invalid JSON on stdin: {e}"))?;
if !value.is_object() {
anyhow::bail!("stdin must contain a JSON object");
}
let parsed =
lemma::parse_evaluate_data_object(Some(value)).map_err(|e| anyhow::anyhow!("{e}"))?;
entries.extend(parsed);
}
entries.extend(pairs.iter().map(|pair| {
let (key, value) = pair
.split_once('=')
.expect("BUG: parse_evaluate_args routes only name=value arguments to data");
(
key.to_string(),
EvaluateDataValue::Literal(value.to_string()),
)
}));
lemma::evaluate_data_from_entries(entries).map_err(|e| anyhow::anyhow!("{e}"))
}
fn resolve_show_target(
engine: &Engine,
repository_qualifier: Option<&str>,
specification_name: Option<&str>,
) -> Result<(Option<String>, String)> {
match (repository_qualifier, specification_name) {
(None, Some(spec)) => Ok((None, spec.to_string())),
(Some(repo), Some(spec)) => Ok((Some(repo.to_string()), spec.to_string())),
(Some(one), None) => {
let is_repository = interactive::repository_loaded(engine, one);
let is_spec = engine
.specs()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: default repository must exist after Engine::new")
.specs
.iter()
.any(|ls| ls.name == *one);
if is_repository && !is_spec {
anyhow::bail!(
"Repository positional requires a specification name (second argument)"
);
}
Ok((None, one.to_string()))
}
(None, None) => {
let chosen = resolve_spec(engine, None, false)?;
Ok((None, chosen))
}
}
}
fn show_command(
source_path: &Path,
repository_qualifier: Option<&str>,
specification_name: Option<&str>,
effective: Option<&String>,
json: bool,
) -> Result<()> {
let now = resolve_effective(effective)?;
let mut engine = Engine::new();
load_workspace(&mut engine, source_path)?;
let (repository_for_show, chosen_specification) =
resolve_show_target(&engine, repository_qualifier, specification_name)?;
let show = engine
.show(
repository_for_show.as_deref(),
&chosen_specification,
Some(&now),
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if json {
let json_document =
serde_json::to_string_pretty(&lemma::api::show_document(&engine, &show))
.expect("BUG: failed to serialize show JSON");
println!("{}", json_document);
} else {
println!("{show}");
}
Ok(())
}
fn specs_command(source_path: &Path, json: bool, all: bool) -> Result<()> {
let mut engine = Engine::new();
load_workspace(&mut engine, source_path)?;
let mut payload = engine.specs();
if !all {
payload.retain(|repository| !repository.from_dependency);
}
if json {
let json_document =
serde_json::to_string_pretty(&payload).expect("BUG: failed to serialize specs JSON");
print!("{}", json_document);
return Ok(());
}
print!("{}", lemma::format_repositories(&payload, all));
Ok(())
}
fn server_command(args: &ServerArgs) -> Result<()> {
use tokio::runtime::Runtime;
let source = workspace_dir(args.path.as_ref());
let rt = Runtime::new()?;
rt.block_on(async {
let mut engine = Engine::new();
load_workspace(&mut engine, source)?;
let workspace = engine
.specs()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: default repository must exist after Engine::new")
.specs;
let unique_specs: std::collections::BTreeSet<&str> =
workspace.iter().map(|ls| ls.name.as_str()).collect();
println!(
"Starting HTTP server with {} spec(s) loaded...",
unique_specs.len()
);
server::http::start_server(engine, args, source.to_path_buf()).await
})?;
Ok(())
}
fn lsp_command() -> Result<()> {
let workspace_files: Arc<dyn lemma_lsp::workspace_files::WorkspaceFiles> =
Arc::new(lemma_cli::workspace::CliWorkspaceFiles);
lemma_lsp::stdio::run_stdio(Some(workspace_files)).map_err(anyhow::Error::from)
}
fn mcp_command(
workdir: &Path,
write: bool,
request_timeout_secs: u64,
http: bool,
host: &str,
port: u16,
cors: bool,
) -> Result<()> {
let mut engine = Engine::new();
load_workspace(&mut engine, workdir)?;
let config = mcp::McpConfig {
write,
request_timeout: std::time::Duration::from_secs(request_timeout_secs),
};
let workspace_specs = engine
.specs()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: default repository must exist after Engine::new")
.specs;
let unique_specs: std::collections::BTreeSet<&str> =
workspace_specs.iter().map(|ls| ls.name.as_str()).collect();
if http {
eprintln!(
"Starting MCP HTTP server with {} spec(s) loaded",
unique_specs.len()
);
use tokio::runtime::Runtime;
let rt = Runtime::new()?;
rt.block_on(mcp::http::start_http_server(
engine,
config,
workdir,
mcp::http::McpHttpBind {
host: host.to_string(),
port,
cors,
},
))?;
} else {
eprintln!(
"Starting MCP server with {} spec(s) loaded",
unique_specs.len()
);
mcp::server::start_server(engine, config, workdir)?;
}
Ok(())
}
fn install_command(source: &Path, spec_name: Option<&str>, force: bool) -> Result<()> {
let registries = lemma::Registries::default();
let transport = install::ReqwestTransport::new();
match spec_name {
Some(id) => install_repo(source, id, ®istries, &transport, force),
None => install_workspace_deps(source, ®istries, &transport, force),
}
}
fn install_repo(
workdir: &Path,
raw_id: &str,
registries: &lemma::Registries,
transport: &install::ReqwestTransport,
force: bool,
) -> Result<()> {
match install::install_registry_dependency(workdir, raw_id, force, registries, transport) {
Ok(install::InstallOutcome::AlreadyUpToDate { .. }) => {
eprintln!("Already up to date: {}.", raw_id);
Ok(())
}
Ok(install::InstallOutcome::Written { relative_path, .. }) => {
eprintln!(" installed: {} -> {}", raw_id, relative_path.display());
Ok(())
}
Err(ref e @ install::InstallError::Plan(ref load_err)) => {
for err in load_err.iter() {
eprintln!("{}", error_formatter::format_error(err, &load_err.sources));
}
Err(anyhow::anyhow!("{e}"))
}
Err(error) => Err(anyhow::anyhow!("{error}")),
}
}
fn install_workspace_deps(
workdir: &Path,
registries: &lemma::Registries,
transport: &install::ReqwestTransport,
force: bool,
) -> Result<()> {
let mut ctx = lemma::Context::new();
let mut sources: HashMap<lemma::SourceType, String> = HashMap::new();
let limits = lemma::ResourceLimits::default();
let discovered = lemma_cli::workspace::discover_lemma_paths(workdir)
.map_err(|error| anyhow::anyhow!("{error}"))?;
for path in &discovered.workspace_paths {
let code = fs::read_to_string(path)?;
let source_type =
lemma::SourceType::Path(lemma_cli::workspace::workspace_path_label(workdir, path));
match lemma::parse(&code, source_type.clone(), &limits) {
Ok(result) => {
for (parsed_repo, specs) in &result.repositories {
let repository_arc = std::sync::Arc::clone(parsed_repo);
for spec in specs {
ctx.insert_spec(std::sync::Arc::clone(&repository_arc), spec.clone())
.map_err(|errors| {
anyhow::anyhow!(
"{}",
errors
.iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
})?;
}
}
sources.insert(source_type, code);
}
Err(e) => {
sources.insert(source_type.clone(), code.clone());
eprintln!("{}", error_formatter::format_error(&e, &sources));
anyhow::bail!("Parse error in {}", path.display());
}
}
}
let local_workspace_sources: std::collections::HashSet<lemma::SourceType> =
sources.keys().cloned().collect();
if let Err(errs) = lemma::Resolve::run(registries, &mut ctx, &mut sources, &limits, transport) {
for e in &errs {
eprintln!("{}", error_formatter::format_error(e, &sources));
}
anyhow::bail!("Registry resolution failed ({} error(s))", errs.len());
}
let mut installed_count = 0u32;
let mut skipped_count = 0u32;
for (attribute, source_text) in &sources {
if local_workspace_sources.contains(attribute) {
continue;
}
let id = match attribute {
lemma::SourceType::Dependency(id) => id.as_str(),
other => {
panic!("BUG: Resolve inserted non-Dependency source: {other}")
}
};
match install::persist_registry_dependency(workdir, id, source_text, force) {
Ok(install::InstallOutcome::AlreadyUpToDate { .. }) => {
skipped_count += 1;
}
Ok(install::InstallOutcome::Written { relative_path, .. }) => {
installed_count += 1;
eprintln!(" installed: {} -> {}", id, relative_path.display());
}
Err(ref e @ install::InstallError::Plan(ref load_err)) => {
for err in load_err.iter() {
eprintln!("{}", error_formatter::format_error(err, &load_err.sources));
}
return Err(anyhow::anyhow!("{e}"));
}
Err(error) => return Err(anyhow::anyhow!("{error}")),
}
}
let plural = if installed_count == 1 {
"repository"
} else {
"repositories"
};
if installed_count == 0 && skipped_count == 0 {
eprintln!("No repositories found.");
} else if installed_count == 0 {
eprintln!("All repositories are up to date. Use --force to overwrite.");
} else if skipped_count > 0 {
eprintln!(
"Installed {} {} ({} already up to date).",
installed_count, plural, skipped_count
);
} else {
eprintln!("Installed {} {}.", installed_count, plural);
}
Ok(())
}
fn load_workspace(engine: &mut Engine, workdir: &std::path::Path) -> Result<()> {
match lemma_cli::workspace::load_workspace(engine, workdir) {
Ok(()) => Ok(()),
Err(lemma_cli::workspace::WorkspaceDiskError::EngineLoad(load_failures)) => {
bail_workspace_load_errors(&load_failures)
}
Err(error) => Err(anyhow::Error::msg(error.to_string())),
}
}
fn bail_workspace_load_errors(load_errors: &lemma::Errors) -> anyhow::Result<()> {
let mut emitted_message_keys = std::collections::HashSet::new();
let unique_errors: Vec<_> = load_errors
.iter()
.filter(|report| emitted_message_keys.insert(report.message().to_string()))
.collect();
for report in &unique_errors {
eprintln!(
"{}",
error_formatter::format_error(report, &load_errors.sources)
);
}
anyhow::bail!("Workspace load failed ({} error(s))", unique_errors.len());
}
fn parse_rule_names(comma_separated_rules: &str) -> Vec<String> {
comma_separated_rules
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn collect_lemma_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>, std::io::Error> {
collect_format_paths(paths, &["lemma"])
}
fn collect_doc_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>, std::io::Error> {
collect_format_paths(paths, &["md", "txt"])
}
fn collect_format_paths(
paths: &[PathBuf],
extensions: &[&str],
) -> Result<Vec<PathBuf>, std::io::Error> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for path in paths {
if path.is_file() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if extensions.contains(&ext) {
let canonical = path.canonicalize()?;
if seen.insert(canonical.clone()) {
result.push(path.clone());
}
}
} else if path.is_dir() {
for entry_result in ignore::WalkBuilder::new(path)
.hidden(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.require_git(false)
.build()
{
let entry = match entry_result {
Ok(e) => e,
Err(e) => {
eprintln!("warning: ignoring directory entry: {}", e);
continue;
}
};
let p = entry.path();
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
if p.is_file() && extensions.contains(&ext) {
if let Ok(canonical) = p.canonicalize() {
if seen.insert(canonical) {
result.push(p.to_path_buf());
}
} else if seen.insert(p.to_path_buf()) {
result.push(p.to_path_buf());
}
}
}
}
}
result.sort();
Ok(result)
}
fn apply_formatted_file(
file_path: &Path,
source: &str,
formatted: &str,
check: bool,
stdout: bool,
any_changed: &mut bool,
parse_errors: &mut u32,
) {
if stdout {
print!("{}", formatted);
return;
}
if source == formatted {
return;
}
*any_changed = true;
if check {
eprintln!("Would reformat: {}", file_path.display());
} else if let Err(e) = fs::write(file_path, formatted) {
eprintln!("Error writing {}: {}", file_path.display(), e);
*parse_errors += 1;
} else {
eprintln!("Formatted: {}", file_path.display());
}
}
fn format_command(paths: &[PathBuf], check: bool, stdout: bool) -> Result<()> {
let lemma_files = collect_lemma_paths(paths)?;
let doc_files = collect_doc_paths(paths)?;
let mut any_changed = false;
let mut parse_errors = 0u32;
for file_path in &lemma_files {
let source = match fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", file_path.display(), e);
parse_errors += 1;
continue;
}
};
let formatted = match lemma::format_source(
&source,
lemma::SourceType::Path(std::sync::Arc::new(file_path.clone())),
) {
Ok(s) => s,
Err(e) => {
let mut m = std::collections::HashMap::new();
m.insert(
lemma::SourceType::Path(std::sync::Arc::new(file_path.clone())),
source.clone(),
);
eprintln!("{}", error_formatter::format_error(&e, &m));
parse_errors += 1;
continue;
}
};
apply_formatted_file(
file_path,
&source,
&formatted,
check,
stdout,
&mut any_changed,
&mut parse_errors,
);
}
for file_path in &doc_files {
let source = match fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", file_path.display(), e);
parse_errors += 1;
continue;
}
};
let formatted = match lemma_cli::doc_fences::format_lemma_fences_in_text(&source) {
Ok(s) => s,
Err(failure) => {
let mut m = std::collections::HashMap::new();
let fence_source = lemma::SourceType::Path(std::sync::Arc::new(file_path.clone()));
m.insert(fence_source, failure.fence_source.clone());
eprintln!(
"{}:{}: ```lemma fence format failed",
file_path.display(),
failure.fence_line
);
eprintln!("{}", error_formatter::format_error(&failure.error, &m));
parse_errors += 1;
continue;
}
};
apply_formatted_file(
file_path,
&source,
&formatted,
check,
stdout,
&mut any_changed,
&mut parse_errors,
);
}
if parse_errors > 0 {
std::process::exit(1);
}
if check && any_changed {
std::process::exit(1);
}
Ok(())
}