use sakuramml_v2::IncludeResolver;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
mod midi_dump;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(Failure::Usage(message)) => {
println!("{message}");
ExitCode::SUCCESS
}
Err(Failure::Error(message)) => {
eprintln!("{message}");
ExitCode::FAILURE
}
}
}
enum Failure {
Usage(String),
Error(String),
}
impl From<String> for Failure {
fn from(message: String) -> Self {
Failure::Error(message)
}
}
fn run(args: &[String]) -> Result<(), Failure> {
if matches!(
args.first().map(String::as_str),
Some("--dump-midi" | "--dump" | "-m")
) {
return run_midi_dump(&args[1..]);
}
let options = parse_args(args)?;
let (source, origin) = match &options.source {
Source::Inline(code) => (code.clone(), None),
Source::File(path) => {
let bytes = std::fs::read(path).map_err(|e| {
format!(
"Failed .. Could not load mml file: {} ({e})",
path.display()
)
})?;
let (text, _) = sakuramml_v2::encoding::decode_auto(&bytes);
(text, Some(path.clone()))
}
};
let output_path = options.output.clone().unwrap_or_else(|| match &origin {
Some(path) => path.with_extension("mid"),
None => PathBuf::from("a.mid"),
});
let resolver = FileIncludes::new(origin.as_deref());
let result = sakuramml_v2::compile_with_recovery(&source, &resolver)
.map_err(|e| format!("Failed...{e}"))?;
for message in &result.messages {
println!("[表示] {message}");
}
for warning in &result.warnings {
eprintln!("{warning}");
}
for error in &result.errors {
eprintln!("{error}");
}
std::fs::write(&output_path, &result.smf).map_err(|e| {
format!(
"Failed .. Could not write midi file: {} ({e})",
output_path.display()
)
})?;
if !result.errors.is_empty() {
return Err(format!(
"Failed...{}件のエラーがあります。不完全なMIDIを{}に出力しました",
result.errors.len(),
output_path.display()
)
.into());
}
println!("Success!");
if options.pause {
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
}
Ok(())
}
fn run_midi_dump(args: &[String]) -> Result<(), Failure> {
let input = args.first().ok_or_else(|| {
Failure::Error("--dump-midi にMIDIファイルを指定してください".to_string())
})?;
if args.len() > 2 {
return Err(Failure::Error(
"--dump-midi の出力先は1個だけ指定できます".to_string(),
));
}
let bytes =
std::fs::read(input).map_err(|e| format!("MIDIファイルを読み込めません: {input} ({e})"))?;
let text = midi_dump::dump(&bytes).map_err(|e| format!("MIDIダンプに失敗しました: {e}"))?;
if let Some(output) = args.get(1) {
std::fs::write(output, text)
.map_err(|e| format!("MIDIダンプを書き込めません: {output} ({e})"))?;
} else {
print!("{text}");
}
Ok(())
}
enum Source {
File(PathBuf),
Inline(String),
}
struct Options {
source: Source,
output: Option<PathBuf>,
pause: bool,
}
fn parse_args(args: &[String]) -> Result<Options, Failure> {
let mut inline: Option<String> = None;
let mut positional: Vec<PathBuf> = Vec::new();
let mut pause = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-e" => {
index += 1;
let code = args
.get(index)
.ok_or_else(|| Failure::Error("-e にMMLを指定してください".to_string()))?;
inline = Some(code.clone());
}
"-h" | "--help" => return Err(Failure::Usage(help_text())),
"-v" | "--version" => return Err(Failure::Usage(VERSION.to_string())),
"-pause" => pause = true,
other => positional.push(PathBuf::from(other)),
}
index += 1;
}
match inline {
Some(code) => Ok(Options {
source: Source::Inline(code),
output: positional.first().map(PathBuf::from),
pause,
}),
None => {
let input = positional
.first()
.ok_or_else(|| Failure::Usage(help_text()))?;
Ok(Options {
source: Source::File(input.clone()),
output: positional.get(1).cloned(),
pause,
})
}
}
}
fn help_text() -> String {
format!(
"=== MML Compiler sakuramml v{VERSION} (Rust) ===\n\
[USAGE]\n\
csakura2 mmlfile [midifile] ... Compile file\n\
csakura2 -e code [midifile] ... Evaluate code\n\
csakura2 --dump-midi file.mid [dump.txt] ... Dump normalized events\n\
csakura2 -pause ... Wait for Enter when done\n\
csakura2 -v ... Show version"
)
}
struct FileIncludes {
roots: Vec<PathBuf>,
}
impl FileIncludes {
fn new(source: Option<&Path>) -> Self {
let mut roots = Vec::new();
if let Some(dir) = source.and_then(|p| p.parent()) {
let mut dir = dir.to_path_buf();
for _ in 0..8 {
roots.push(dir.clone());
if !dir.pop() {
break;
}
}
}
if let Ok(cwd) = std::env::current_dir() {
let mut dir = cwd;
for _ in 0..8 {
roots.push(dir.clone());
if !dir.pop() {
break;
}
}
}
roots.push(PathBuf::from("."));
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
roots.push(dir.to_path_buf());
}
}
let with_include: Vec<PathBuf> = roots.iter().map(|r| r.join("Include")).collect();
roots.extend(with_include);
Self { roots }
}
}
impl IncludeResolver for FileIncludes {
fn resolve(&self, name: &str) -> Option<Vec<u8>> {
self.roots
.iter()
.map(|root| root.join(name))
.find_map(|path| std::fs::read(path).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_inline_source_and_output() {
let args = vec!["-e".into(), "cde".into(), "song.mid".into()];
let options = match parse_args(&args) {
Ok(options) => options,
Err(_) => panic!("inline arguments should parse"),
};
assert!(matches!(options.source, Source::Inline(ref code) if code == "cde"));
assert_eq!(options.output, Some(PathBuf::from("song.mid")));
assert!(!options.pause);
}
#[test]
fn parses_file_source_output_and_pause() {
let args = vec!["input.mml".into(), "output.mid".into(), "-pause".into()];
let options = match parse_args(&args) {
Ok(options) => options,
Err(_) => panic!("file arguments should parse"),
};
assert!(matches!(options.source, Source::File(ref path) if path == Path::new("input.mml")));
assert_eq!(options.output, Some(PathBuf::from("output.mid")));
assert!(options.pause);
}
#[test]
fn missing_inline_source_is_an_error() {
let args = vec!["-e".into()];
assert!(matches!(parse_args(&args), Err(Failure::Error(_))));
}
#[test]
fn dump_mode_requires_an_input_file() {
let args = vec!["--dump-midi".into()];
assert!(matches!(run(&args), Err(Failure::Error(_))));
}
#[test]
fn writes_partial_midi_before_reporting_compile_errors() {
let output = std::env::temp_dir().join(format!(
"sakuramml-recovery-{}-{}.mid",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should follow the epoch")
.as_nanos()
));
let args = vec![
"-e".into(),
"c NotACommand d".into(),
output.clone().into_os_string().into_string().unwrap(),
];
assert!(matches!(run(&args), Err(Failure::Error(_))));
let midi = std::fs::read(&output).expect("partial MIDI should be written");
assert_eq!(&midi[..4], b"MThd");
assert!(midi.windows(3).any(|event| event == [0x90, 60, 100]));
assert!(midi.windows(3).any(|event| event == [0x90, 62, 100]));
std::fs::remove_file(output).expect("temporary MIDI should be removable");
}
}