mod analyzer;
mod assertions;
mod cancellation;
mod error;
mod format;
mod language_server;
mod parser;
mod path_util;
mod scoring;
mod spelled_path;
mod validator;
mod wiki;
mod wiki_tree;
use crate::{
analyzer::analyze,
cancellation::CancellationFlag,
error::{Error, format_errors},
format::{CodePath, CodeStr},
path_util::relative_path,
wiki::WIKI_EXTENSION,
};
use clap::{ArgAction, Args, Parser, Subcommand as ClapSubcommand};
use similar::TextDiff;
use std::{
env, fs,
path::{Path, PathBuf},
process::exit,
rc::Rc,
};
#[derive(Parser)]
#[command(
about = concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"More information can be found at: ",
env!("CARGO_PKG_HOMEPAGE"),
),
version,
disable_version_flag = true
)]
struct Cli {
#[arg(short, long, help = "Print version", action = ArgAction::Version)]
_version: Option<bool>,
#[command(subcommand)]
command: Option<Subcommand>,
}
#[derive(ClapSubcommand)]
enum Subcommand {
#[command(about = "Check a wiki")]
Check(WikiArgs),
#[command(about = "Fix a wiki (default)")]
Fix(WikiArgs),
#[command(about = "Start the language server (editors use this)")]
LanguageServer,
}
#[derive(Args, Default)]
struct WikiArgs {
#[arg(long, value_name = "PATH", help = "Specify the path to the wiki")]
path: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
if let Err(errors) = entry().await {
eprintln!("{}", format_errors(&errors));
exit(1);
}
}
async fn entry() -> Result<(), Vec<Error>> {
let cli = Cli::parse();
let (should_fix, WikiArgs { path }) =
match cli.command.unwrap_or(Subcommand::Fix(WikiArgs::default())) {
Subcommand::Check(wiki_args) => (false, wiki_args),
Subcommand::Fix(wiki_args) => (true, wiki_args),
Subcommand::LanguageServer => {
language_server::run().await;
return Ok(());
}
};
let current_directory = env::current_dir().map_err(|error| {
vec![Error::new(
"Unable to determine the current directory.",
None,
None,
Some(Rc::new(error)),
None,
)]
})?;
let wiki_path = relative_path(
¤t_directory,
&path
.map_or_else(|| find_wiki(¤t_directory), Ok)
.map_err(|error| vec![error])?,
)
.to_owned();
let wiki_bytes = fs::read(&wiki_path).map_err(|error| {
vec![Error::new(
"Unable to read the wiki.",
Some(&wiki_path),
None,
Some(Rc::new(error)),
None,
)]
})?;
let wiki_contents = String::from_utf8(wiki_bytes).map_err(|error| {
vec![Error::new(
"The wiki isn't valid UTF-8.",
Some(&wiki_path),
None,
Some(Rc::new(error)),
None,
)]
})?;
let rendered_wiki = analyze(
Some(&wiki_path),
&wiki_contents,
&CancellationFlag::default(),
)
.assume_completed()?
.to_string();
if wiki_contents == rendered_wiki {
println!("Wiki {} looks good.", wiki_path.code_path());
} else if should_fix {
fs::write(&wiki_path, rendered_wiki).map_err(|error| {
vec![Error::new(
"Unable to write the wiki.",
Some(&wiki_path),
None,
Some(Rc::new(error)),
None,
)]
})?;
println!("Fixed {}.", wiki_path.code_path());
} else {
return Err(vec![Error::new(
&format!(
"The wiki isn't formatted correctly. {} can fix it.\n\n{}",
"mull fix".code_str(),
TextDiff::from_lines(&wiki_contents, &rendered_wiki)
.unified_diff()
.header("wiki", "rendered"),
),
Some(&wiki_path),
None,
None,
None,
)]);
}
Ok(())
}
fn find_wiki(current_directory: &Path) -> Result<PathBuf, Error> {
for directory in current_directory.ancestors() {
let entries = fs::read_dir(directory).map_err(|error| {
Error::new(
&format!("Unable to read {}.", directory.code_path()),
None,
None,
Some(Rc::new(error)),
None,
)
})?;
let mut wikis = Vec::<PathBuf>::new();
for entry in entries {
let entry = entry.map_err(|error| {
Error::new(
&format!("Unable to read an entry in {}.", directory.code_path()),
None,
None,
Some(Rc::new(error)),
None,
)
})?;
let path = entry.path();
let has_wiki_extension = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(WIKI_EXTENSION));
if has_wiki_extension {
let metadata = fs::metadata(&path).map_err(|error| {
Error::new(
&format!("Unable to inspect {}.", path.code_path()),
None,
None,
Some(Rc::new(error)),
None,
)
})?;
if metadata.is_file() {
wikis.push(path);
}
}
}
wikis.sort();
if wikis.len() > 1 {
let file_names = wikis
.iter()
.map(|path| {
Path::new(
path.file_name()
.expect("A directory entry's path should end with its name."),
)
.code_path()
.to_string()
})
.collect::<Vec<String>>()
.join(", ");
return Err(Error::new(
&format!(
"Found multiple wikis in {}: {file_names}",
directory.code_path(),
),
None,
None,
None,
None,
));
}
if let Some(wiki) = wikis.into_iter().next() {
return Ok(wiki);
}
}
Err(Error::new(
&format!(
"No wiki found in {} or its ancestors.",
current_directory.code_path(),
),
None,
None,
None,
None,
))
}
#[cfg(test)]
mod tests {
use super::{Cli, Subcommand, WikiArgs};
use clap::{CommandFactory, Parser};
use std::path::PathBuf;
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
#[test]
fn parse_subcommands() {
assert!(matches!(
Cli::try_parse_from(["mull", "check"]).unwrap().command,
Some(Subcommand::Check(_)),
));
assert!(matches!(
Cli::try_parse_from(["mull", "fix"]).unwrap().command,
Some(Subcommand::Fix(_)),
));
assert!(matches!(
Cli::try_parse_from(["mull", "language-server"])
.unwrap()
.command,
Some(Subcommand::LanguageServer),
));
}
#[test]
fn parse_path() {
for subcommand in ["check", "fix"] {
let Some(Subcommand::Check(WikiArgs { path }) | Subcommand::Fix(WikiArgs { path })) =
Cli::try_parse_from(["mull", subcommand, "--path", "notes.mull"])
.unwrap()
.command
else {
panic!("The {subcommand} subcommand should be parsed.");
};
assert_eq!(path, Some(PathBuf::from("notes.mull")));
}
}
}