mod analyzer;
mod assertions;
mod cancellation;
mod error;
mod escaping;
mod file_tree;
mod format;
mod language_server;
mod line_index;
mod lsp_position;
mod parser;
mod path_util;
mod propagation;
mod scoring;
mod scripts;
mod spelled_path;
mod validator;
mod wiki;
use crate::{
analyzer::analyze,
cancellation::CancellationFlag,
error::{Error, format_errors},
format::CodeStr,
line_index::LineIndex,
path_util::relative_path,
wiki::{PageTitle, WIKI_EXTENSION},
};
use clap::{ArgAction, Args, Parser, Subcommand as ClapSubcommand};
use similar::TextDiff;
use std::{
env, fs,
path::{Path, PathBuf},
process::exit,
sync::Arc,
};
#[derive(Parser)]
#[command(
about = concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"More information can be found at: ",
env!("CARGO_PKG_HOMEPAGE"),
),
version,
display_name = "Mull",
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 that a wiki is valid and formatted, with its scripts run")]
Check(FormatArgs),
#[command(about = "Format a wiki, running its scripts (default)")]
Format(FormatArgs),
#[command(about = "Print the content of a page in a wiki")]
Page(PageArgs),
#[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>,
}
#[derive(Args, Default)]
struct FormatArgs {
#[arg(long, help = "Don't run the wiki's scripts")]
no_scripts: bool,
#[arg(
long,
conflicts_with = "no_scripts",
help = "Run all the wiki's scripts, even those whose blocks haven't changed"
)]
rerun_scripts: bool,
#[command(flatten)]
wiki_args: WikiArgs,
}
impl FormatArgs {
fn scripts(&self) -> Scripts {
if self.no_scripts {
Scripts::Skip
} else if self.rerun_scripts {
Scripts::Rerun
} else {
Scripts::Run
}
}
}
#[derive(Args)]
struct PageArgs {
#[arg(help = "The page's title")]
title: String,
#[command(flatten)]
wiki_args: WikiArgs,
}
enum Operation {
Check(Scripts),
Format(Scripts),
Page(String),
}
#[derive(Clone, Copy)]
enum Scripts {
Skip,
Run,
Rerun,
}
#[tokio::main]
async fn main() {
if let Err(errors) = entry().await {
eprintln!("{}", format_errors(&errors));
exit(1);
}
}
#[allow(
clippy::too_many_lines,
reason = "The function dispatches each subcommand once the wiki they share is loaded."
)]
async fn entry() -> Result<(), Vec<Error>> {
let cli = Cli::parse();
let (operation, WikiArgs { path }) = match cli
.command
.unwrap_or(Subcommand::Format(FormatArgs::default()))
{
Subcommand::Check(format_args) => (
Operation::Check(format_args.scripts()),
format_args.wiki_args,
),
Subcommand::Format(format_args) => (
Operation::Format(format_args.scripts()),
format_args.wiki_args,
),
Subcommand::Page(PageArgs { title, wiki_args }) => (Operation::Page(title), 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(Arc::new(error)),
None,
)]
})?;
let wiki_path = relative_path(
¤t_directory,
&path
.or_else(|| env::var_os("MULL_WIKI").map(PathBuf::from))
.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(Arc::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(Arc::new(error)),
None,
)]
})?;
let (scripts_to_run, should_format) = match operation {
Operation::Page(title) => return print_page(&wiki_path, &wiki_contents, &title),
Operation::Check(scripts_to_run) => (scripts_to_run, false),
Operation::Format(scripts_to_run) => (scripts_to_run, true),
};
let (rendered_wiki, mut errors) = match scripts_to_run {
Scripts::Skip => (
analyze(
Some(&wiki_path),
&wiki_contents,
&CancellationFlag::default(),
)
.assume_completed()?
.to_string(),
Vec::new(),
),
Scripts::Run | Scripts::Rerun => {
let (rendered_wiki, failures) = scripts::run(
&wiki_path,
&wiki_contents,
matches!(scripts_to_run, Scripts::Rerun),
)?;
(
rendered_wiki,
failures.into_iter().map(|failure| failure.error).collect(),
)
}
};
if wiki_contents == rendered_wiki {
if errors.is_empty() {
println!("Wiki {} looks good.", wiki_path.code_str());
}
} else if should_format {
fs::write(&wiki_path, rendered_wiki).map_err(|error| {
vec![Error::new(
"Unable to write the wiki.",
Some(&wiki_path),
None,
Some(Arc::new(error)),
None,
)]
})?;
println!("Formatted {}.", wiki_path.code_str());
} else {
errors.push(Error::new(
&format!(
"The wiki isn't formatted correctly. Run {} to format it.\n\n{}",
"mull format".code_str(),
TextDiff::from_lines(&wiki_contents, &rendered_wiki)
.unified_diff()
.header("wiki", "rendered"),
),
Some(&wiki_path),
None,
None,
None,
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
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_str()),
None,
None,
Some(Arc::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_str()),
None,
None,
Some(Arc::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_str()),
None,
None,
Some(Arc::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_str()
.to_string()
})
.collect::<Vec<String>>()
.join(", ");
return Err(Error::new(
&format!(
"Found multiple wikis in {}: {file_names}",
directory.code_str(),
),
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_str(),
),
None,
None,
None,
None,
))
}
fn print_page(wiki_path: &Path, wiki_contents: &str, title: &str) -> Result<(), Vec<Error>> {
let (wiki, errors) = parser::parse(
Some(wiki_path),
wiki_contents,
&LineIndex::new(wiki_contents),
);
if !errors.is_empty() {
return Err(errors);
}
let page = PageTitle::parse(title)
.ok()
.and_then(|title| wiki.pages.get(&title))
.ok_or_else(|| {
vec![Error::new(
&format!("Page {} not found.", title.code_str()),
Some(wiki_path),
None,
None,
None,
)]
})?;
println!("{}", wiki.page_content(page));
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Cli, FormatArgs, PageArgs, 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", "format"]).unwrap().command,
Some(Subcommand::Format(_)),
));
let Some(Subcommand::Page(PageArgs { title, .. })) =
Cli::try_parse_from(["mull", "page", "Home"])
.unwrap()
.command
else {
panic!("The page subcommand should be parsed.");
};
assert_eq!(title, "Home");
assert!(matches!(
Cli::try_parse_from(["mull", "language-server"])
.unwrap()
.command,
Some(Subcommand::LanguageServer),
));
}
#[test]
fn parse_format_args() {
for subcommand in ["check", "format"] {
let Some(
Subcommand::Check(FormatArgs {
no_scripts,
wiki_args: WikiArgs { path },
..
})
| Subcommand::Format(FormatArgs {
no_scripts,
wiki_args: WikiArgs { path },
..
}),
) = Cli::try_parse_from(["mull", subcommand, "--path", "notes.mull", "--no-scripts"])
.unwrap()
.command
else {
panic!("The {subcommand} subcommand should be parsed.");
};
assert_eq!(path, Some(PathBuf::from("notes.mull")));
assert!(no_scripts);
assert!(
Cli::try_parse_from(["mull", subcommand, "--no-scripts", "--rerun-scripts"])
.is_err(),
);
}
}
}