use crate::config::Config;
use crate::paths::Paths;
use anyhow::Result;
use clap::Args as ClapArgs;
use furigana::{Furigana, RomajiStyle, TtsOptions};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{Context, Editor, Helper};
use std::time::Instant;
use unicode_width::UnicodeWidthStr;
#[derive(ClapArgs, Debug)]
pub struct Args {
#[arg(long, default_value = "all")]
mode: String,
}
impl Default for Args {
fn default() -> Self {
Self {
mode: "all".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
All,
Ruby,
Hiragana,
Tts,
Kanji,
Romaji,
RomajiKunrei,
Accent,
}
impl Mode {
fn parse(s: &str) -> Option<Self> {
Some(match s {
"all" => Self::All,
"ruby" => Self::Ruby,
"hiragana" | "hira" => Self::Hiragana,
"tts" => Self::Tts,
"kanji" => Self::Kanji,
"romaji" => Self::Romaji,
"romaji-kunrei" | "kunrei" => Self::RomajiKunrei,
"accent" => Self::Accent,
_ => return None,
})
}
fn as_str(self) -> &'static str {
match self {
Self::All => "all",
Self::Ruby => "ruby",
Self::Hiragana => "hiragana",
Self::Tts => "tts",
Self::Kanji => "kanji",
Self::Romaji => "romaji",
Self::RomajiKunrei => "romaji-kunrei",
Self::Accent => "accent",
}
}
}
const META_COMMANDS: &[&str] = &[
"debug", "help", "mode", "pull", "quit", "reload", "size", "tokens",
];
const MODE_NAMES: &[&str] = &[
"all",
"ruby",
"hiragana",
"tts",
"kanji",
"romaji",
"romaji-kunrei",
"accent",
];
const META_COMMAND_ALIASES: &[&str] = &[
"debug", "exit", "h", "help", "mode", "pull", "q", "quit", "r", "reload", "size", "tokens",
];
#[derive(Default)]
struct ReplHelper;
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let head = &line[..pos];
let mode_arg = head
.strip_prefix(":mode ")
.or_else(|| head.strip_prefix("mode "));
if let Some(arg) = mode_arg {
let start = pos - arg.len();
let candidates = MODE_NAMES
.iter()
.filter(|m| m.starts_with(arg))
.map(|m| Pair {
display: (*m).to_string(),
replacement: (*m).to_string(),
})
.collect();
return Ok((start, candidates));
}
if let Some(rest) = head.strip_prefix(':') {
let candidates = META_COMMANDS
.iter()
.filter(|c| c.starts_with(rest))
.map(|c| Pair {
display: format!(":{c}"),
replacement: format!(":{c}"),
})
.collect();
return Ok((0, candidates));
}
if !head.is_empty() && head.chars().all(|c| c.is_ascii_alphabetic()) {
let candidates = META_COMMANDS
.iter()
.filter(|c| c.starts_with(head))
.map(|c| Pair {
display: (*c).to_string(),
replacement: (*c).to_string(),
})
.collect();
return Ok((0, candidates));
}
Ok((pos, vec![]))
}
}
impl Hinter for ReplHelper {
type Hint = String;
}
impl Highlighter for ReplHelper {}
impl Validator for ReplHelper {}
impl Helper for ReplHelper {}
pub fn run(args: Args, paths: &Paths, _cfg: &Config) -> Result<()> {
let Args { mode: initial_mode } = args;
let mut f = super::build_furigana(paths)?;
let mut mode = Mode::parse(&initial_mode).unwrap_or(Mode::All);
let mut debug = false;
let mut editor: Editor<ReplHelper, DefaultHistory> = Editor::new()?;
editor.set_helper(Some(ReplHelper));
let history_path = paths.data_dir.join("repl_history");
let _ = editor.load_history(&history_path);
println!("furigana REPL (dict_size: {})", f.dict_size());
println!(" Tab で補完 / ↑↓ で履歴 / `help` でコマンド / `quit` で終了 (`:` は optional)");
println!();
if f.dict_size() == 0 {
println!("辞書が未配置です。furigana-dict (~226 KB) を取得して使えるようにしますか?");
if let Ok(ans) = editor.readline("[Y/n] > ") {
let ans = ans.trim().to_ascii_lowercase();
if ans.is_empty() || ans == "y" || ans == "yes" {
if let Err(e) = run_pull_and_reload(paths, None, &mut f) {
eprintln!("pull failed: {e}");
}
} else {
println!("(skipped — あとで `:pull` で取得できます)");
}
}
println!();
}
loop {
let prompt = format!("{}> ", mode.as_str());
let line = match editor.readline(&prompt) {
Ok(l) => l,
Err(ReadlineError::Interrupted) => {
continue;
}
Err(ReadlineError::Eof) => {
break;
}
Err(e) => {
eprintln!("read error: {e}");
break;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
let _ = editor.add_history_entry(line);
if let Some((cmd, arg)) = parse_meta(line) {
handle_meta(cmd, arg, &mut mode, &mut debug, &mut f, paths);
println!();
continue;
}
let t0 = Instant::now();
let tokens = f.tokenize(line);
let t_tok = t0.elapsed();
let t1 = Instant::now();
match mode {
Mode::All => {
println!(" ruby: {}", furigana::tokens_to_ruby(&tokens));
println!(" hiragana: {}", furigana::tokens_to_hiragana(&tokens));
}
Mode::Ruby => println!(" {}", furigana::tokens_to_ruby(&tokens)),
Mode::Hiragana => println!(" {}", furigana::tokens_to_hiragana(&tokens)),
Mode::Tts => {
let opts = TtsOptions::default();
let hira = furigana::tokens_to_hiragana(&tokens);
println!(" {}", furigana::tts::normalize_for_tts(&hira, &opts));
}
Mode::Kanji => println!(" {line}"),
Mode::Romaji => {
let hira = furigana::tokens_to_hiragana(&tokens);
println!(
" {}",
furigana::hiragana_to_romaji(&hira, RomajiStyle::Hepburn)
);
}
Mode::RomajiKunrei => {
let hira = furigana::tokens_to_hiragana(&tokens);
println!(
" {}",
furigana::hiragana_to_romaji(&hira, RomajiStyle::Kunrei)
);
}
Mode::Accent => {
let result = f.to_accent(line);
if let Ok(json) = serde_json::to_string_pretty(&result) {
println!(" {json}");
}
}
}
let t_conv = t1.elapsed();
if debug {
println!(
" \x1b[2m[debug] tokenize {:.2}ms / convert {:.2}ms / total {:.2}ms\x1b[0m",
t_tok.as_secs_f64() * 1000.0,
t_conv.as_secs_f64() * 1000.0,
(t_tok + t_conv).as_secs_f64() * 1000.0,
);
}
println!(); }
let _ = editor.save_history(&history_path);
Ok(())
}
fn parse_meta(line: &str) -> Option<(&str, &str)> {
let stripped = line.strip_prefix(':').unwrap_or(line);
let mut parts = stripped.splitn(2, char::is_whitespace);
let cmd = parts.next()?;
let arg = parts.next().unwrap_or("").trim();
if META_COMMAND_ALIASES.contains(&cmd) {
Some((cmd, arg))
} else {
None
}
}
fn handle_meta(
cmd: &str,
arg: &str,
mode: &mut Mode,
debug: &mut bool,
f: &mut Furigana,
paths: &Paths,
) {
match cmd {
"h" | "help" => print_help(),
"q" | "quit" | "exit" => std::process::exit(0),
"size" => println!(" dict_size: {}", f.dict_size()),
"r" | "reload" => match super::build_furigana(paths) {
Ok(new) => {
*f = new;
println!(" reloaded. dict_size: {}", f.dict_size());
}
Err(e) => println!(" reload failed: {e}"),
},
"pull" => {
let version = if arg.is_empty() { None } else { Some(arg) };
if let Err(e) = run_pull_and_reload(paths, version, f) {
println!(" pull failed: {e}");
}
}
"mode" => {
if arg.is_empty() {
println!(" current: {}", mode.as_str());
println!(" available: {}", MODE_NAMES.join(" | "));
} else if let Some(m) = Mode::parse(arg) {
*mode = m;
println!(" mode -> {}", mode.as_str());
} else {
println!(" unknown mode: {arg}");
}
}
"debug" => {
*debug = !*debug;
println!(" debug: {}", if *debug { "on" } else { "off" });
}
"tokens" => {
if arg.is_empty() {
println!(" usage: :tokens <text>");
} else {
dump_tokens(f, arg);
}
}
other => println!(" unknown command: :{other} (try :help)"),
}
}
fn run_pull_and_reload(
paths: &Paths,
version: Option<&str>,
f: &mut Furigana,
) -> anyhow::Result<()> {
super::dict_pull::run(paths, version)?;
let new = super::build_furigana(paths)?;
*f = new;
println!(" reload 完了。dict_size: {}", f.dict_size());
Ok(())
}
fn dump_tokens(f: &Furigana, text: &str) {
let tokens = f.tokenize(text);
if tokens.is_empty() {
println!(" (no tokens)");
return;
}
let surface_w = tokens
.iter()
.map(|t| UnicodeWidthStr::width(t.surface.as_str()))
.max()
.unwrap_or(0)
.max(7);
println!(" {:width$} reading", "surface", width = surface_w);
println!(" {:-<width$} -------", "", width = surface_w);
for t in &tokens {
let pad = surface_w.saturating_sub(UnicodeWidthStr::width(t.surface.as_str()));
let reading = t
.reading
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| "\x1b[2m(none)\x1b[0m".to_string());
println!(" {}{} {}", t.surface, " ".repeat(pad), reading);
}
}
fn print_help() {
println!("Commands (先頭の `:` は optional、`help` でも `:help` でも OK):");
println!(" help このヘルプ");
println!(" mode <m> mode 切替 (all|ruby|hiragana|tts|kanji|romaji|romaji-kunrei) ※Tab で候補補完");
println!(" debug timing 表示の on/off (toggle)");
println!(" tokens <text> 内部 token 配列を dump (なぜこの読み?を調べる用)");
println!(" pull [vX.Y.Z] furigana-dict を取得 + 自動 reload");
println!(" reload data_dir から辞書を再 build");
println!(" size dict_size を表示");
println!(" quit 終了 (Ctrl-D も可)");
println!();
println!("コマンド名と一致しない入力は現在の mode で変換して表示します。");
println!("Tab: コマンド補完 / ↑↓: 履歴 / Ctrl-A,E,W,U: 行編集");
}