zinit_client/
repl.rs

1//! Interactive REPL for Zinit Rhai scripting
2//!
3//! Provides a readline-style interface with:
4//! - Tab completion for zinit_* functions
5//! - Command history
6//! - Syntax highlighting
7//! - Help hints
8
9use crate::client::ZinitHandle;
10use crate::rhai::ZinitEngine;
11use colored::Colorize;
12use rustyline::completion::{Completer, Pair};
13use rustyline::error::ReadlineError;
14use rustyline::highlight::Highlighter;
15use rustyline::hint::{Hinter, HistoryHinter};
16use rustyline::history::DefaultHistory;
17use rustyline::validate::Validator;
18use rustyline::{Config, Context, Editor, Helper};
19use std::borrow::Cow;
20
21/// All available zinit functions with their signatures and descriptions
22const ZINIT_FUNCTIONS: &[(&str, &str, &str)] = &[
23    // Zinit control
24    ("zinit_ping()", "map", "Check if zinit is responding"),
25    ("zinit_shutdown()", "void", "Shutdown zinit server"),
26    // Service management
27    (
28        "new_service(name)",
29        "ServiceBuilder",
30        "Create a new service builder",
31    ),
32    (
33        "register(builder)",
34        "void",
35        "Register service without starting",
36    ),
37    (
38        "start(builder, secs)",
39        "void",
40        "Register, start, and wait for service",
41    ),
42    ("zinit_start(name)", "void", "Start a service"),
43    ("zinit_stop(name)", "void", "Stop a service"),
44    ("zinit_restart(name)", "void", "Restart a service"),
45    (
46        "zinit_delete(name)",
47        "void",
48        "Stop, kill if needed, and remove service",
49    ),
50    ("zinit_kill(name, signal)", "void", "Send signal to service"),
51    // Query functions
52    ("zinit_list()", "array", "List all service names"),
53    ("zinit_status(name)", "map", "Get service status"),
54    ("zinit_stats(name)", "map", "Get CPU/memory stats"),
55    (
56        "zinit_is_running(name)",
57        "bool",
58        "Check if service is running",
59    ),
60    // Batch operations
61    ("zinit_start_all()", "void", "Start all services"),
62    ("zinit_stop_all()", "void", "Stop all services"),
63    // Logging functions
64    ("zinit_logs()", "array", "Get all logs from ring buffer"),
65    (
66        "zinit_logs_filter(service)",
67        "array",
68        "Get logs for a service",
69    ),
70    // Utilities
71    ("print(msg)", "void", "Print message"),
72    ("sleep(secs)", "void", "Sleep for seconds"),
73    ("sleep_ms(ms)", "void", "Sleep for milliseconds"),
74    ("get_env(key)", "string|unit", "Get environment variable"),
75    ("set_env(key, value)", "void", "Set environment variable"),
76    ("file_exists(path)", "bool", "Check if path exists"),
77    ("is_dir(path)", "bool", "Check if path is directory"),
78    ("is_file(path)", "bool", "Check if path is file"),
79    ("check_tcp(addr)", "bool", "Check if TCP port is open"),
80    ("check_http(url)", "bool", "Check if HTTP endpoint is up"),
81    ("kill_port(port)", "int", "Kill process on TCP port"),
82];
83
84/// Service builder methods
85const BUILDER_METHODS: &[(&str, &str)] = &[
86    (".name(name)", "Service name (required)"),
87    (".exec(cmd)", "Command to run (required)"),
88    (".test(cmd)", "Health check command"),
89    (".oneshot(bool)", "Run once, don't restart"),
90    (".shutdown_timeout(secs)", "Seconds before SIGKILL"),
91    (".after(service)", "Start after this service"),
92    (".signal_stop(signal)", "Signal to send on stop"),
93    (".log(mode)", "\"ring\", \"stdout\", or \"none\""),
94    (".env(key, value)", "Set environment variable"),
95    (".dir(path)", "Working directory"),
96    (".test_cmd(cmd)", "Command-based health check"),
97    (".test_tcp(addr)", "TCP port health check"),
98    (".test_http(url)", "HTTP endpoint health check"),
99    (".tcp_kill()", "Kill processes on port before starting"),
100    (".register()", "Register the service"),
101    (".wait(secs)", "Wait for service to be running"),
102];
103
104/// REPL commands
105const REPL_COMMANDS: &[(&str, &str)] = &[
106    ("/help", "Show this help"),
107    ("/functions", "List all zinit functions"),
108    ("/builder", "Show service builder methods"),
109    ("/load <file>", "Load and execute a .rhai script"),
110    ("/tui", "Enter full-screen interactive TUI mode"),
111    ("/clear", "Clear screen"),
112    ("/quit", "Exit REPL (or Ctrl+D)"),
113];
114
115/// Rhai keywords for syntax highlighting
116const RHAI_KEYWORDS: &[&str] = &[
117    "let", "const", "if", "else", "while", "loop", "for", "in", "break", "continue", "return",
118    "throw", "try", "catch", "fn", "private", "import", "export", "as", "true", "false", "null",
119];
120
121/// Helper struct for rustyline
122#[derive(Helper)]
123struct ReplHelper {
124    hinter: HistoryHinter,
125}
126
127impl Completer for ReplHelper {
128    type Candidate = Pair;
129
130    fn complete(
131        &self,
132        line: &str,
133        pos: usize,
134        _ctx: &Context<'_>,
135    ) -> rustyline::Result<(usize, Vec<Pair>)> {
136        let mut completions = Vec::new();
137
138        // Find the word being typed
139        let line_to_cursor = &line[..pos];
140        let word_start = line_to_cursor
141            .rfind(|c: char| !c.is_alphanumeric() && c != '_')
142            .map(|i| i + 1)
143            .unwrap_or(0);
144        let word = &line_to_cursor[word_start..];
145
146        if word.is_empty() {
147            return Ok((pos, completions));
148        }
149
150        // Check for REPL commands
151        if word.starts_with('/') {
152            for (cmd, desc) in REPL_COMMANDS {
153                // Get just the command part (without arguments like <file>)
154                let cmd_name = cmd.split_whitespace().next().unwrap_or(cmd);
155                if cmd_name.starts_with(word) {
156                    completions.push(Pair {
157                        display: format!("{} - {}", cmd, desc),
158                        replacement: cmd_name.to_string(),
159                    });
160                }
161            }
162            return Ok((word_start, completions));
163        }
164
165        // Check for builder methods (after a dot)
166        if word.starts_with('.') || line_to_cursor.ends_with('.') {
167            let method_word = if word.starts_with('.') { word } else { "." };
168            for (method, desc) in BUILDER_METHODS {
169                if method.starts_with(method_word) {
170                    let replacement = method.split('(').next().unwrap_or(method);
171                    completions.push(Pair {
172                        display: format!("{} - {}", method, desc),
173                        replacement: replacement.to_string(),
174                    });
175                }
176            }
177            let start = if word.starts_with('.') {
178                word_start
179            } else {
180                pos
181            };
182            return Ok((start, completions));
183        }
184
185        // Complete zinit functions
186        for (func, ret, desc) in ZINIT_FUNCTIONS {
187            let func_name = func.split('(').next().unwrap_or(func);
188            if func_name.starts_with(word) {
189                completions.push(Pair {
190                    display: format!("{} -> {} - {}", func, ret, desc),
191                    replacement: func_name.to_string(),
192                });
193            }
194        }
195
196        // Complete Rhai keywords
197        for kw in RHAI_KEYWORDS {
198            if kw.starts_with(word) && !completions.iter().any(|p| p.replacement == *kw) {
199                completions.push(Pair {
200                    display: format!("{} (keyword)", kw),
201                    replacement: kw.to_string(),
202                });
203            }
204        }
205
206        Ok((word_start, completions))
207    }
208}
209
210impl Hinter for ReplHelper {
211    type Hint = String;
212
213    fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
214        // First try history hints
215        if let Some(hint) = self.hinter.hint(line, pos, ctx) {
216            return Some(hint);
217        }
218
219        // Then try function signature hints
220        let line_to_cursor = &line[..pos];
221
222        // Find if we're typing a zinit function
223        for (func, ret, desc) in ZINIT_FUNCTIONS {
224            let func_name = func.split('(').next().unwrap_or(func);
225            if line_to_cursor.ends_with(func_name) {
226                let signature = &func[func_name.len()..];
227                return Some(
228                    format!("{} -> {} | {}", signature, ret, desc)
229                        .dimmed()
230                        .to_string(),
231                );
232            }
233        }
234
235        None
236    }
237}
238
239impl Highlighter for ReplHelper {
240    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
241        let mut result = String::with_capacity(line.len() * 2);
242        let mut chars = line.chars().peekable();
243        let mut current_word = String::new();
244
245        while let Some(c) = chars.next() {
246            if c.is_alphanumeric() || c == '_' {
247                current_word.push(c);
248            } else {
249                if !current_word.is_empty() {
250                    result.push_str(&highlight_word(&current_word));
251                    current_word.clear();
252                }
253                // Highlight strings
254                if c == '"' {
255                    result.push_str(&format!("{}", "\"".green()));
256                    let mut string_content = String::new();
257                    while let Some(&next) = chars.peek() {
258                        chars.next();
259                        if next == '"' {
260                            result.push_str(&format!("{}", string_content.green()));
261                            result.push_str(&format!("{}", "\"".green()));
262                            break;
263                        } else if next == '\\' {
264                            string_content.push(next);
265                            if let Some(escaped) = chars.next() {
266                                string_content.push(escaped);
267                            }
268                        } else {
269                            string_content.push(next);
270                        }
271                    }
272                }
273                // Highlight comments
274                else if c == '/' && chars.peek() == Some(&'/') {
275                    result.push_str(&format!(
276                        "{}",
277                        format!("//{}", chars.collect::<String>()).dimmed()
278                    ));
279                    break;
280                }
281                // Highlight operators
282                else if "=+-*/<>!&|".contains(c) {
283                    result.push_str(&format!("{}", c.to_string().yellow()));
284                }
285                // Parentheses and brackets
286                else if "()[]{}".contains(c) {
287                    result.push_str(&format!("{}", c.to_string().cyan()));
288                } else {
289                    result.push(c);
290                }
291            }
292        }
293
294        if !current_word.is_empty() {
295            result.push_str(&highlight_word(&current_word));
296        }
297
298        Cow::Owned(result)
299    }
300
301    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
302        &'s self,
303        prompt: &'p str,
304        _default: bool,
305    ) -> Cow<'b, str> {
306        Cow::Owned(format!("{}", prompt.cyan().bold()))
307    }
308
309    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
310        Cow::Owned(format!("{}", hint.dimmed()))
311    }
312
313    fn highlight_char(
314        &self,
315        _line: &str,
316        _pos: usize,
317        _forced: rustyline::highlight::CmdKind,
318    ) -> bool {
319        true
320    }
321}
322
323fn highlight_word(word: &str) -> String {
324    // Rhai keywords
325    if RHAI_KEYWORDS.contains(&word) {
326        return format!("{}", word.magenta().bold());
327    }
328
329    // Zinit functions
330    if word.starts_with("zinit_") {
331        return format!("{}", word.blue().bold());
332    }
333
334    // Built-in functions
335    if [
336        "print",
337        "sleep",
338        "sleep_ms",
339        "get_env",
340        "set_env",
341        "file_exists",
342        "is_dir",
343        "is_file",
344        "check_tcp",
345        "check_http",
346        "kill_port",
347    ]
348    .contains(&word)
349    {
350        return format!("{}", word.blue());
351    }
352
353    // Numbers
354    if word.chars().all(|c| c.is_numeric() || c == '.') {
355        return format!("{}", word.yellow());
356    }
357
358    // Booleans
359    if word == "true" || word == "false" {
360        return format!("{}", word.yellow().bold());
361    }
362
363    word.to_string()
364}
365
366impl Validator for ReplHelper {}
367
368/// Print the welcome banner
369fn print_banner() {
370    println!(
371        "{}",
372        "╔═══════════════════════════════════════════════════════════╗".cyan()
373    );
374    println!(
375        "{}",
376        "║           Zinit Interactive Shell                         ║".cyan()
377    );
378    println!(
379        "{}",
380        "╠═══════════════════════════════════════════════════════════╣".cyan()
381    );
382    println!(
383        "{}  {}",
384        "║".cyan(),
385        format!(
386            "{:<56} {}",
387            "Tab: completion | Up/Down: history | Ctrl+D: exit", "║"
388        )
389        .cyan()
390    );
391    println!(
392        "{}  {}",
393        "║".cyan(),
394        format!(
395            "{:<56} {}",
396            "Type /help for commands, /functions for API", "║"
397        )
398        .cyan()
399    );
400    println!(
401        "{}",
402        "╚═══════════════════════════════════════════════════════════╝".cyan()
403    );
404    println!();
405}
406
407/// Print help
408fn print_help() {
409    println!("{}", "REPL Commands:".yellow().bold());
410    for (cmd, desc) in REPL_COMMANDS {
411        println!("  {:15} {}", cmd.cyan(), desc);
412    }
413    println!();
414    println!("{}", "Tips:".yellow().bold());
415    println!("  - Press {} to autocomplete function names", "Tab".cyan());
416    println!("  - Use {} arrows for command history", "Up/Down".cyan());
417    println!(
418        "  - Multi-line input: end lines with {} or {}",
419        "\\".cyan(),
420        "{".cyan()
421    );
422    println!("  - Scripts execute when you press Enter on complete statement",);
423}
424
425/// Print all functions
426fn print_functions() {
427    println!("{}", "Zinit Functions:".yellow().bold());
428    println!();
429
430    println!("  {}", "Control:".green().bold());
431    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().take(2) {
432        println!(
433            "    {:40} {} {:15} {}",
434            func.blue(),
435            "->".dimmed(),
436            ret,
437            desc.dimmed()
438        );
439    }
440
441    println!();
442    println!("  {}", "Service Management:".green().bold());
443    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().skip(2).take(7) {
444        println!(
445            "    {:40} {} {:15} {}",
446            func.blue(),
447            "->".dimmed(),
448            ret,
449            desc.dimmed()
450        );
451    }
452
453    println!();
454    println!("  {}", "Query:".green().bold());
455    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().skip(9).take(4) {
456        println!(
457            "    {:40} {} {:15} {}",
458            func.blue(),
459            "->".dimmed(),
460            ret,
461            desc.dimmed()
462        );
463    }
464
465    println!();
466    println!("  {}", "Batch:".green().bold());
467    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().skip(13).take(2) {
468        println!(
469            "    {:40} {} {:15} {}",
470            func.blue(),
471            "->".dimmed(),
472            ret,
473            desc.dimmed()
474        );
475    }
476
477    println!();
478    println!("  {}", "Logging:".green().bold());
479    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().skip(15).take(2) {
480        println!(
481            "    {:40} {} {:15} {}",
482            func.blue(),
483            "->".dimmed(),
484            ret,
485            desc.dimmed()
486        );
487    }
488
489    println!();
490    println!("  {}", "Utilities:".green().bold());
491    for (func, ret, desc) in ZINIT_FUNCTIONS.iter().skip(17) {
492        println!(
493            "    {:40} {} {:15} {}",
494            func.blue(),
495            "->".dimmed(),
496            ret,
497            desc.dimmed()
498        );
499    }
500}
501
502/// Print builder methods
503fn print_builder() {
504    println!("{}", "Service Builder Pattern:".yellow().bold());
505    println!();
506    println!("  let svc = {}()", "zinit_service_new".blue());
507    for (method, desc) in BUILDER_METHODS {
508        println!("      {:30} // {}", method.cyan(), desc.dimmed());
509    }
510    println!();
511    println!("  Example:");
512    println!("    let svc = {}()", "zinit_service_new".blue());
513    println!("        {}", ".name(\"myservice\")".cyan());
514    println!("        {}", ".exec(\"python -m http.server 8080\")".cyan());
515    println!("        {}", ".test_tcp(\"127.0.0.1:8080\")".cyan());
516    println!("        {}", ".register();".cyan());
517    println!("    {}", "svc.wait(30);".cyan());
518}
519
520/// Load and execute a script file
521fn load_script(file_path: &str, engine: &ZinitEngine) -> anyhow::Result<()> {
522    // Expand ~ to home directory
523    let expanded_path = if file_path.starts_with("~/") {
524        dirs::home_dir()
525            .map(|h| h.join(&file_path[2..]))
526            .unwrap_or_else(|| std::path::PathBuf::from(file_path))
527    } else {
528        std::path::PathBuf::from(file_path)
529    };
530
531    if !expanded_path.exists() {
532        anyhow::bail!("File not found: {}", expanded_path.display());
533    }
534
535    println!("{} {}", "Loading:".green(), expanded_path.display());
536
537    match engine.run_file(&expanded_path) {
538        Ok(result) => {
539            if !result.is_unit() {
540                println!("{} {:?}", "=>".green(), result);
541            }
542            Ok(())
543        }
544        Err(e) => {
545            anyhow::bail!("{}", e)
546        }
547    }
548}
549
550/// Run the interactive REPL
551pub fn run_repl(handle: ZinitHandle) -> anyhow::Result<()> {
552    print_banner();
553
554    // Check if server is running
555    match handle.ping() {
556        Ok(resp) => {
557            println!(
558                "{} {} v{}",
559                "Connected to zinit server:".green(),
560                resp.message,
561                resp.version
562            );
563        }
564        Err(e) => {
565            println!(
566                "{}: {}",
567                "Warning: Could not connect to zinit server".yellow(),
568                e
569            );
570            println!("Make sure the server is running with: zinit server start");
571        }
572    }
573    println!();
574
575    // Create the Rhai engine with the handle
576    let engine = ZinitEngine::with_handle(handle.clone());
577
578    let config = Config::builder()
579        .history_ignore_space(true)
580        .completion_type(rustyline::CompletionType::List)
581        .edit_mode(rustyline::EditMode::Emacs)
582        .build();
583
584    let helper = ReplHelper {
585        hinter: HistoryHinter::new(),
586    };
587
588    let mut rl: Editor<ReplHelper, DefaultHistory> = Editor::with_config(config)?;
589    rl.set_helper(Some(helper));
590
591    // Load history
592    let history_path = dirs::home_dir()
593        .map(|h| h.join(".zinit_history"))
594        .unwrap_or_else(|| std::path::PathBuf::from(".zinit_history"));
595    let _ = rl.load_history(&history_path);
596
597    let mut multiline_buffer = String::new();
598
599    loop {
600        let prompt = if multiline_buffer.is_empty() {
601            "zinit> "
602        } else {
603            "   ... "
604        };
605
606        match rl.readline(prompt) {
607            Ok(line) => {
608                let line = line.trim();
609
610                // Handle REPL commands
611                if multiline_buffer.is_empty() {
612                    match line {
613                        "/help" => {
614                            print_help();
615                            continue;
616                        }
617                        "/functions" => {
618                            print_functions();
619                            continue;
620                        }
621                        "/builder" => {
622                            print_builder();
623                            continue;
624                        }
625                        "/clear" => {
626                            print!("\x1B[2J\x1B[1;1H");
627                            print_banner();
628                            continue;
629                        }
630                        "/tui" => {
631                            // Enter TUI mode
632                            match crate::tui::run_tui(handle.clone()) {
633                                Ok(_) => {
634                                    // After TUI exits, reprint banner
635                                    print_banner();
636                                }
637                                Err(e) => {
638                                    println!("{}: {}", "TUI Error".red().bold(), e);
639                                }
640                            }
641                            continue;
642                        }
643                        "/quit" | "/exit" | "/q" => {
644                            println!("{}", "Goodbye!".cyan());
645                            break;
646                        }
647                        "" => continue,
648                        _ if line.starts_with("/load ") => {
649                            let file_path = line.strip_prefix("/load ").unwrap().trim();
650                            match load_script(file_path, &engine) {
651                                Ok(_) => {}
652                                Err(e) => println!("{}: {}", "Error".red().bold(), e),
653                            }
654                            println!();
655                            continue;
656                        }
657                        _ if line.starts_with('/') => {
658                            println!(
659                                "{}: Unknown command '{}'. Type /help for commands.",
660                                "Error".red().bold(),
661                                line
662                            );
663                            continue;
664                        }
665                        _ => {}
666                    }
667                }
668
669                // Add to multiline buffer
670                multiline_buffer.push_str(line);
671                multiline_buffer.push('\n');
672
673                // Check if we should continue multiline input
674                let trimmed = line.trim_end();
675                if trimmed.ends_with('\\') {
676                    // Remove the backslash and continue
677                    multiline_buffer = multiline_buffer
678                        .trim_end()
679                        .strip_suffix('\\')
680                        .unwrap_or(&multiline_buffer)
681                        .to_string();
682                    multiline_buffer.push('\n');
683                    continue;
684                }
685
686                // Check for unclosed braces/brackets
687                let open_braces = multiline_buffer.matches('{').count();
688                let close_braces = multiline_buffer.matches('}').count();
689                let open_brackets = multiline_buffer.matches('[').count();
690                let close_brackets = multiline_buffer.matches(']').count();
691                let open_parens = multiline_buffer.matches('(').count();
692                let close_parens = multiline_buffer.matches(')').count();
693
694                if open_braces > close_braces
695                    || open_brackets > close_brackets
696                    || open_parens > close_parens
697                {
698                    continue;
699                }
700
701                // Execute the script
702                let script = std::mem::take(&mut multiline_buffer);
703                let script = script.trim();
704
705                if script.is_empty() {
706                    continue;
707                }
708
709                // Add to history
710                let _ = rl.add_history_entry(script);
711
712                // Execute with Rhai engine
713                match engine.run(script) {
714                    Ok(result) => {
715                        if !result.is_unit() {
716                            println!("{} {:?}", "=>".green(), result);
717                        }
718                    }
719                    Err(e) => {
720                        println!("{}: {}", "Error".red().bold(), e);
721                    }
722                }
723                println!();
724            }
725            Err(ReadlineError::Interrupted) => {
726                // Ctrl+C - clear current input
727                if !multiline_buffer.is_empty() {
728                    multiline_buffer.clear();
729                    println!("{}", "^C (input cleared)".dimmed());
730                } else {
731                    println!("{}", "^C (use /quit or Ctrl+D to exit)".dimmed());
732                }
733            }
734            Err(ReadlineError::Eof) => {
735                // Ctrl+D
736                println!("{}", "Goodbye!".cyan());
737                break;
738            }
739            Err(err) => {
740                println!("{}: {:?}", "Error".red(), err);
741                break;
742            }
743        }
744    }
745
746    // Save history
747    let _ = rl.save_history(&history_path);
748
749    Ok(())
750}