use std::borrow::Cow;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use nu_ansi_term::{Color, Style};
use reedline::{
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
ColumnarMenu, Completer, EditMode, Emacs, ExampleHighlighter, FileBackedHistory, KeyCode,
KeyModifiers, Keybindings, MenuBuilder, Prompt, PromptEditMode, PromptHistorySearch,
PromptHistorySearchStatus, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
ValidationResult, Validator, Vi,
};
use tclrs::cursor::{self, Context};
use tclrs::{names, Interp, Script};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const HISTORY_LIMIT: usize = 5_000;
#[derive(Default)]
struct Session {
procs: Vec<(String, String)>,
}
impl Session {
fn prelude(&self) -> String {
let mut out = String::new();
for (_, definition) in &self.procs {
out.push_str(definition);
out.push('\n');
}
out
}
fn forget_redefined(&mut self, script: &Script) {
for (name, _) in definitions(script) {
self.procs.retain(|(defined, _)| *defined != name);
}
}
fn absorb(&mut self, script: &Script) {
for (name, definition) in definitions(script) {
self.procs.push((name, definition));
}
}
fn proc_names(&self) -> Vec<String> {
self.procs.iter().map(|(name, _)| name.clone()).collect()
}
}
fn definitions(script: &Script) -> Vec<(String, String)> {
let mut found = Vec::new();
for command in &script.commands {
let [head, name, spec, body] = command.words.as_slice() else {
continue;
};
if head.as_literal() != Some("proc") {
continue;
}
let (Some(name), Some(spec), Some(body)) =
(name.as_literal(), spec.as_literal(), body.as_literal())
else {
continue;
};
found.push((
name.to_string(),
tclrs::list::join(&["proc", name, spec, body]),
));
}
found
}
struct TclCompleter {
commands: Vec<String>,
procs: Arc<Mutex<Vec<String>>>,
variables: Arc<Mutex<Vec<String>>>,
}
impl TclCompleter {
fn suggestions(
&self,
words: impl Iterator<Item = String>,
word: &str,
span: Span,
) -> Vec<Suggestion> {
let mut out: Vec<Suggestion> = words
.filter(|candidate| candidate.starts_with(word))
.map(|value| Suggestion {
value,
description: None,
style: None,
extra: None,
span,
append_whitespace: true,
display_override: None,
match_indices: None,
})
.collect();
out.sort_by(|a, b| a.value.cmp(&b.value));
out.dedup_by(|a, b| a.value == b.value);
out
}
}
impl Completer for TclCompleter {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let (start, word) = cursor::word_at(line, pos);
let span = Span::new(start, pos);
match cursor::context_in_tcl(line, start, word) {
Context::Command => {
let procs = self.procs.lock().map(|g| g.clone()).unwrap_or_default();
let all = self.commands.iter().cloned().chain(procs);
self.suggestions(all, word, span)
}
Context::Subcommand(head) => {
let subs = names::subcommands(head).iter().map(|s| s.to_string());
self.suggestions(subs, word, span)
}
Context::Variable => {
let names = self.variables.lock().map(|g| g.clone()).unwrap_or_default();
let sigiled = names.into_iter().map(|name| format!("${name}"));
self.suggestions(sigiled, word, span)
}
Context::Argument => Vec::new(),
}
}
}
struct TclValidator;
impl Validator for TclValidator {
fn validate(&self, line: &str) -> ValidationResult {
if crate::repl::incomplete(line) {
ValidationResult::Incomplete
} else {
ValidationResult::Complete
}
}
}
struct TclPrompt {
commands: Arc<Mutex<u64>>,
}
fn now() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as libc::time_t)
.unwrap_or(0);
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
if unsafe { !libc::localtime_r(&secs, &mut tm).is_null() } {
return format!("{:02}:{:02}:{:02}", tm.tm_hour, tm.tm_min, tm.tm_sec);
}
let day = (secs as u64) % 86_400;
format!("{:02}:{:02}:{:02}", day / 3600, (day % 3600) / 60, day % 60)
}
fn columns() -> usize {
use std::os::unix::io::AsRawFd;
let mut size: libc::winsize = unsafe { std::mem::zeroed() };
let fd = std::io::stdout().as_raw_fd();
let cols = if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut size) } == 0 && size.ws_col > 0 {
size.ws_col as usize
} else {
80
};
cols.max(40)
}
fn status_bar(count: u64) -> String {
let dim = Style::new().fg(Color::DarkGray);
let time = format!(" {} ", now());
let commands = format!(" command {count} ");
let version = format!(" tclrs {VERSION} ");
let frame = "─()──<>{}─".chars().count();
let used = time.chars().count() + commands.chars().count() + version.chars().count() + frame;
let left = format!(
"{}{}{}{}{}{}",
dim.paint("─("),
Style::new().fg(Color::Cyan).paint(&time),
dim.paint(")"),
dim.paint("──<"),
Style::new().fg(Color::LightYellow).bold().paint(&commands),
dim.paint(">"),
);
let Some(dashes) = columns().checked_sub(used).filter(|d| *d >= 2) else {
return left;
};
format!(
"{}{}{}{}{}",
left,
dim.paint("─".repeat(dashes)),
dim.paint("{"),
Style::new().fg(Color::Magenta).paint(&version),
dim.paint("}─"),
)
}
impl Prompt for TclPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
let count = self.commands.lock().map(|g| *g).unwrap_or(0);
let name = Style::new().fg(Color::Cyan).bold().paint("tclrs");
Cow::Owned(format!("{}\n{}", status_bar(count), name))
}
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> {
Cow::Owned(
Style::new()
.fg(Color::LightCyan)
.bold()
.paint("❯ ")
.to_string(),
)
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
Cow::Owned(Style::new().fg(Color::DarkGray).paint("····❯ ").to_string())
}
fn render_prompt_history_search_indicator(&self, search: PromptHistorySearch) -> Cow<'_, str> {
let failing = match search.status {
PromptHistorySearchStatus::Passing => "",
PromptHistorySearchStatus::Failing => "failing ",
};
Cow::Owned(format!("({failing}reverse-search: {}) ", search.term))
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Mode {
Emacs,
Vi,
}
fn config_dir() -> PathBuf {
let dir = std::env::var_os("HOME")
.map(|home| PathBuf::from(home).join(".tclrs"))
.unwrap_or_else(|| PathBuf::from(".tclrs"));
let _ = std::fs::create_dir_all(&dir);
dir
}
fn resolve_mode() -> Mode {
if let Some(set) = std::env::var_os("TCLRS_REPL_MODE") {
return named_mode(&set.to_string_lossy()).unwrap_or(Mode::Emacs);
}
let Ok(text) = std::fs::read_to_string(config_dir().join("config.toml")) else {
return Mode::Emacs;
};
configured_mode(&text).unwrap_or(Mode::Emacs)
}
fn named_mode(value: &str) -> Option<Mode> {
match value.trim().trim_matches('"').to_ascii_lowercase().as_str() {
"vi" | "vim" => Some(Mode::Vi),
"emacs" => Some(Mode::Emacs),
_ => None,
}
}
fn configured_mode(text: &str) -> Option<Mode> {
let mut in_repl = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
if line.starts_with('[') {
in_repl = line == "[repl]";
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if in_repl && key.trim() == "mode" {
return named_mode(value);
}
}
None
}
fn bind_menu(keys: &mut Keybindings) {
keys.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuNext,
]),
);
keys.add_binding(
KeyModifiers::SHIFT,
KeyCode::BackTab,
ReedlineEvent::MenuPrevious,
);
keys.add_binding(
KeyModifiers::NONE,
KeyCode::BackTab,
ReedlineEvent::MenuPrevious,
);
}
fn edit_mode() -> Box<dyn EditMode> {
match resolve_mode() {
Mode::Emacs => {
let mut keys = default_emacs_keybindings();
bind_menu(&mut keys);
Box::new(Emacs::new(keys))
}
Mode::Vi => {
let mut insert = default_vi_insert_keybindings();
bind_menu(&mut insert);
Box::new(Vi::new(insert, default_vi_normal_keybindings()))
}
}
}
const LOGO: &str = "\
████████╗ ██████╗██╗ ██████╗ ███████╗
╚══██╔══╝██╔════╝██║ ██╔══██╗██╔════╝
██║ ██║ ██║ ██████╔╝███████╗
██║ ╚██████╗███████╗██║ ██║███████║
╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝╚══════╝";
fn greet() {
println!("{}", Style::new().fg(Color::Cyan).paint(LOGO));
println!(
"{}",
Style::new().fg(Color::DarkGray).paint(format!(
" tclrs {VERSION} — Tcl on fusevm. Tab completes, Ctrl-D or `exit` leaves."
))
);
println!();
}
fn exit_request(line: &str) -> Option<ExitCode> {
let mut words = line.split_whitespace();
match (words.next(), words.next(), words.next()) {
(Some("exit" | "quit"), None, _) => Some(ExitCode::SUCCESS),
(Some("exit"), Some(code), None) => code.parse::<u8>().ok().map(ExitCode::from),
_ => None,
}
}
pub fn run(interp: &mut Interp) -> ExitCode {
greet();
let commands = Arc::new(Mutex::new(0u64));
let procs = Arc::new(Mutex::new(Vec::new()));
let variables = Arc::new(Mutex::new(interp.global_names()));
let vocabulary: Vec<String> = names::commands().iter().map(|s| s.to_string()).collect();
let completer = TclCompleter {
commands: vocabulary.clone(),
procs: Arc::clone(&procs),
variables: Arc::clone(&variables),
};
let menu = ColumnarMenu::default()
.with_name("completion_menu")
.with_columns(4)
.with_column_padding(2);
let mut editor = Reedline::create()
.with_completer(Box::new(completer))
.with_menu(ReedlineMenu::EngineCompleter(Box::new(menu)))
.with_validator(Box::new(TclValidator))
.with_highlighter(Box::new(ExampleHighlighter::new(vocabulary)))
.with_edit_mode(edit_mode())
.with_history(history());
let prompt = TclPrompt {
commands: Arc::clone(&commands),
};
let mut session = Session::default();
loop {
let line = match editor.read_line(&prompt) {
Ok(Signal::Success(line)) => line,
Ok(Signal::CtrlC) => continue,
Ok(_) => return ExitCode::SUCCESS,
Err(e) => {
eprintln!("tclrs: {e}");
return ExitCode::FAILURE;
}
};
if line.trim().is_empty() {
continue;
}
if let Some(code) = exit_request(&line) {
return code;
}
if let Ok(mut count) = commands.lock() {
*count += 1;
}
evaluate(interp, &mut session, &line);
if let Ok(mut names) = procs.lock() {
*names = session.proc_names();
}
if let Ok(mut names) = variables.lock() {
*names = interp.global_names();
}
}
}
fn evaluate(interp: &mut Interp, session: &mut Session, line: &str) {
let parsed = tclrs::parse(line).ok();
if let Some(script) = &parsed {
session.forget_redefined(script);
}
let script = format!("{}{line}", session.prelude());
match interp.eval(&script) {
Ok(result) => {
if !result.is_empty() {
println!("{result}");
}
if let Some(parsed) = &parsed {
session.absorb(parsed);
}
}
Err(e) => eprintln!("{}", e.msg),
}
}
fn history() -> Box<dyn reedline::History> {
match FileBackedHistory::with_file(HISTORY_LIMIT, config_dir().join("history")) {
Ok(history) => Box::new(history),
Err(e) => {
eprintln!("tclrs: history unavailable: {e}");
Box::new(FileBackedHistory::new(HISTORY_LIMIT).expect("in-memory history"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_definition_is_recorded_so_it_can_be_replayed() {
let script = tclrs::parse("proc double {x} {expr {$x * 2}}").expect("parses");
let found = definitions(&script);
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "double");
let mut interp = Interp::capturing();
interp
.eval(&format!("{}\nputs [double 21]", found[0].1))
.expect("replayed definition answers");
assert_eq!(interp.take_output(), "42\n");
}
#[test]
fn a_redefinition_replaces_rather_than_joins() {
let mut session = Session::default();
let first = tclrs::parse("proc f {} {return 1}").expect("parses");
session.absorb(&first);
let second = tclrs::parse("proc f {} {return 2}").expect("parses");
session.forget_redefined(&second);
session.absorb(&second);
assert_eq!(session.proc_names(), vec!["f".to_string()]);
let mut interp = Interp::capturing();
interp
.eval(&format!("{}\nputs [f]", session.prelude()))
.expect("one definition survives");
assert_eq!(interp.take_output(), "2\n");
}
#[test]
fn a_command_that_is_not_a_definition_is_not_recorded() {
let script = tclrs::parse("set x 1").expect("parses");
assert!(definitions(&script).is_empty());
assert!(Session::default().prelude().is_empty());
}
#[test]
fn exit_is_answered_by_the_loop_and_nothing_else_is() {
assert!(exit_request("exit").is_some());
assert!(exit_request("quit").is_some());
assert!(exit_request("exit 3").is_some());
assert!(exit_request("exit please now").is_none());
assert!(exit_request("puts exit").is_none());
}
#[test]
fn the_configured_mode_is_read_from_the_repl_table_only() {
assert_eq!(configured_mode("[repl]\nmode = \"vi\"\n"), Some(Mode::Vi));
assert_eq!(configured_mode("[repl]\n# mode = \"vi\"\n"), None);
assert_eq!(configured_mode("[other]\nmode = \"vi\"\n"), None);
assert_eq!(configured_mode(""), None);
assert_eq!(named_mode("VIM"), Some(Mode::Vi));
assert_eq!(named_mode("sometimes"), None);
}
}