use directories::ProjectDirs;
use farben::prelude::*;
use farben_build::core::parse;
use rustyline::{
CompletionType, Config, Context, Editor,
completion::{Completer, Pair},
error::ReadlineError,
highlight::Highlighter,
};
use rustyline_derive::{Helper, Hinter, Validator};
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::OnceLock;
static USER_STYLES: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
static USER_PREFIXES: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
fn user_styles() -> &'static Mutex<Vec<(String, String)>> {
USER_STYLES.get_or_init(|| Mutex::new(Vec::new()))
}
fn user_prefixes() -> &'static Mutex<Vec<(String, String)>> {
USER_PREFIXES.get_or_init(|| Mutex::new(Vec::new()))
}
fn config_dir() -> Option<PathBuf> {
ProjectDirs::from("", "", "thuli").map(|d| d.config_dir().to_path_buf())
}
fn load_user_theme_styles() {
let Some(dir) = config_dir() else { return };
let path = dir.join("theme.frb.toml");
let Ok(contents) = std::fs::read_to_string(&path) else {
return;
};
let config = match parse(&contents) {
Ok(c) => c,
Err(e) => {
ceprintln!("[thuli::error][/] [thuli::text.error]failed to parse theme file");
eprintln!("{}: {e}", path.display());
return;
}
};
for (name, markup) in &config.styles {
if !name.starts_with("thuli::") {
ceprintb!("[thuli::error][/] [thuli::text.error]");
eprintln!("style '{name}' cannot be set in theme.frb.toml");
eprintln!(" theme.frb.toml is for thuli's own appearance");
eprintln!(" for project styles, use /load <file>");
ceprint!("");
continue;
}
match farben::Style::parse(markup) {
Ok(style) => insert_style(name, style),
Err(e) => {
ceprintb!("[thuli::error][/] [thuli::text.error]in theme file, style '");
eprint!("{name}': ");
eprintln!("{e}");
ceprint!("");
}
}
}
}
fn load_user_theme_prefixes() {
let Some(dir) = config_dir() else { return };
let path = dir.join("theme.frb.toml");
let Ok(contents) = std::fs::read_to_string(&path) else {
return;
};
let Ok(config) = parse(&contents) else { return };
const ALLOWED_PREFIX_OVERRIDES: &[&str] = &["thuli::error", "thuli::tip"];
for (name, prefix) in &config.prefixes {
if !ALLOWED_PREFIX_OVERRIDES.contains(&name.as_str()) {
ceprintb!("[thuli::error][/] [thuli::text.error]");
eprintln!("prefix '{name}' cannot be set in theme.frb.toml");
eprintln!(" only 'thuli::error' and 'thuli::tip' prefixes may be overridden");
eprintln!(" for other styles, use /prefix at runtime or /load <file>");
ceprint!("");
continue;
}
if let Err(e) = set_prefix(name, prefix) {
ceprintb!("[thuli::error][/] [thuli::text.error]");
eprintln!("in theme file, prefix '{name}': {e}");
ceprint!("");
}
}
}
#[derive(Helper, Hinter, Validator)]
struct ThuliHelper;
const COMMANDS: &[&str] = &[
"help",
"clear",
"copyright",
"license",
"load",
"prefix",
"quit",
"style",
"tags",
"theme",
"show",
];
const BUILTIN_TAGS: &[&str] = &[
"bold",
"dim",
"italic",
"underline",
"blink",
"strikethrough",
"double-underline",
"rapid-blink",
"reverse",
"invisible",
"overline",
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright-black",
"bright-red",
"bright-green",
"bright-yellow",
"bright-blue",
"bright-magenta",
"bright-cyan",
"bright-white",
"fg:",
"bg:",
"rgb(",
"ansi(",
];
const THULI_STYLES: &[&str] = &[
"thuli::brand",
"thuli::frb",
"thuli::error",
"thuli::tip",
"thuli::header",
"thuli::command",
"thuli::prompt",
"thuli::credits",
"thuli::credits.highlight",
"thuli::mit",
"thuli::apache",
"thuli::description",
"thuli::section",
"thuli::help_link",
"thuli::text.error",
"thuli::text.success",
"thuli::text.tip",
];
impl Completer for ThuliHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
if line.starts_with('/') && !line.starts_with("//") {
let after_slash = &line[1..pos];
if !after_slash.contains(char::is_whitespace) {
let prefix = after_slash;
let matches: Vec<Pair> = COMMANDS
.iter()
.filter(|c| c.starts_with(prefix))
.map(|c| Pair {
display: c.to_string(),
replacement: c.to_string(),
})
.collect();
return Ok((1, matches));
}
}
let before_cursor = &line[..pos];
if let Some(bracket_start) = before_cursor.rfind('[') {
let after_bracket = &line[bracket_start + 1..pos];
if after_bracket.contains(']') {
return Ok((pos, vec![]));
}
let last_space = after_bracket.rfind(char::is_whitespace);
let word_start = match last_space {
Some(i) => bracket_start + 1 + i + 1,
None => bracket_start + 1,
};
let prefix = &line[word_start..pos];
let user = user_styles().lock().unwrap();
let candidates = BUILTIN_TAGS
.iter()
.chain(THULI_STYLES.iter())
.map(|s| s.to_string())
.chain(user.iter().map(|(n, _)| n.clone()));
let matches: Vec<Pair> = candidates
.filter(|c| c.starts_with(prefix))
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect();
return Ok((word_start, matches));
}
Ok((pos, vec![]))
}
}
impl Highlighter for ThuliHelper {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
_prompt: &'p str,
_default: bool,
) -> Cow<'b, str> {
init_styles();
Cow::Owned(try_color("[thuli::prompt]>>>[/] ").unwrap_or_else(|_| ">>> ".to_string()))
}
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
if line.is_empty() {
return Cow::Borrowed(line);
}
let is_command = line.starts_with('/') && !line.starts_with("//");
let command_end = if is_command {
line.find(char::is_whitespace).unwrap_or(line.len())
} else {
0
};
let mut out = String::with_capacity(line.len() + 32);
let mut in_bracket = false;
let mut prev_char: Option<char> = None;
if is_command {
out.push_str("\x1b[34;1m");
}
for (i, c) in line.char_indices() {
if is_command && i == command_end {
out.push_str("\x1b[0m");
}
match c {
'[' if prev_char != Some('\\') && !in_bracket => {
out.push_str("\x1b[4m");
out.push(c);
in_bracket = true;
}
']' if in_bracket => {
out.push(c);
out.push_str("\x1b[24m");
in_bracket = false;
}
_ => out.push(c),
}
prev_char = Some(c);
}
if in_bracket {
out.push_str("\x1b[24m");
}
if is_command && command_end == line.len() && !out.ends_with("\x1b[0m") {
out.push_str("\x1b[0m");
}
Cow::Owned(out)
}
fn highlight_char(
&self,
_line: &str,
_pos: usize,
_forced: rustyline::highlight::CmdKind,
) -> bool {
true
}
}
fn init_styles() {
apply_default_styles();
load_user_theme_styles();
apply_default_prefixes();
load_user_theme_prefixes();
}
fn apply_default_styles() {
style!("thuli::brand", "[ansi(93)]");
style!("thuli::frb", "[cyan]");
style!("thuli::error", "[red]");
style!("thuli::tip", "[cyan]");
style!("thuli::header", "[bold]");
style!("thuli::command", "[cyan]");
style!("thuli::prompt", "[bold cyan]");
style!("thuli::credits", "[bright-yellow]");
style!("thuli::credits.highlight", "[yellow]");
style!("thuli::mit", "[yellow]");
style!("thuli::apache", "[blue]");
style!("thuli::description", "[dim]");
style!("thuli::section", "[dim]");
style!("thuli::help_link", "[yellow]");
style!("thuli::text.error", "");
style!("thuli::text.tip", "");
style!("thuli::text.success", "");
}
fn apply_default_prefixes() {
prefix!("thuli::error", "error");
prefix!("thuli::tip", "tip");
}
fn main() -> rustyline::Result<()> {
init_styles();
if let Some(arg) = std::env::args().nth(1) {
return one_shot(arg);
}
repl()
}
fn one_shot(arg: String) -> rustyline::Result<()> {
if arg == "--help" || arg == "-h" {
cprintln!("[bold thuli::brand]thuli[/] is an interactive repl, directly run with 'thuli'");
cprintln!("or pass one argument to directly style and output");
cprintln!();
cprintln!("[thuli::header underline]usage:[/] thuli \\[<text>]");
cprintln!(" \\[<text>] : The text to format, in Farben markup");
std::process::exit(0);
}
if arg == "--version" {
cprintln!(
"[thuli::brand]thuli[/] {}, previewing [thuli::frb]frb0.17",
env!("CARGO_PKG_VERSION")
);
std::process::exit(0);
}
match try_color(&arg) {
Ok(rendered) => {
println!("{rendered}")
}
Err(e) => {
eprintln!("{}", e.display(&arg));
std::process::exit(1);
}
}
Ok(())
}
fn repl() -> rustyline::Result<()> {
let config = Config::builder()
.completion_type(CompletionType::List)
.completion_prompt_limit(20)
.build();
let mut rl: Editor<ThuliHelper, _> = Editor::with_config(config)?;
rl.set_helper(Some(ThuliHelper));
let history_path = config_dir().map(|d| d.join("history.txt"));
if let Some(ref path) = history_path {
let _ = rl.load_history(path);
}
let mut warned_about_ctrl_c = false;
cprintln!(
"[thuli::brand]thuli {}[/] · the official farben repl, previewing [thuli::frb]frb0.17",
env!("CARGO_PKG_VERSION")
);
cprintln!(
"run [thuli::help_link]\\[/help][/], [thuli::help_link]\\[/copyright][/], [thuli::help_link]\\[/license][/] for more information, [thuli::help_link]\\[/quit][/] to exit\n"
);
loop {
match rl.readline("[F] ") {
Ok(line) => {
warned_about_ctrl_c = false;
if !line.trim().is_empty() {
let _ = rl.add_history_entry(line.as_str());
}
if line.starts_with('/') && !line.starts_with("//") {
handle_command(&line);
} else {
let line = match line.strip_prefix('/') {
Some(r) => r.to_string(),
None => line,
};
if line.is_empty() {
continue;
}
match try_color(&line) {
Ok(rendered) => println!(" {rendered}"),
Err(e) => {
print_err(e, &line);
}
}
}
}
Err(ReadlineError::Eof) => {
println!("make great things with farben <3");
break;
}
Err(ReadlineError::Interrupted) => {
if warned_about_ctrl_c {
println!(" Ctrl+C discards the line. To exit, use Ctrl+D.");
}
warned_about_ctrl_c = true;
continue;
}
Err(e) => return Err(e),
}
}
if let Some(ref path) = history_path {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = rl.save_history(path);
}
Ok(())
}
fn handle_command(line: &str) {
let rest = &line[1..];
let mut parts = rest.splitn(2, char::is_whitespace);
let cmd = parts.next().unwrap_or("");
let args = parts.next().unwrap_or("").trim();
match cmd {
"quit" | "q" | "exit" | "x" => {
println!("make great things with farben <3");
std::process::exit(0)
}
"help" | "h" | "?" => print_help(),
"clear" | "cls" => print!("\x1B[2J\x1B[1;1H"),
"copyright" => print_copyright(),
"license" => print_license(),
"tags" => print_tags(),
"theme" => cmd_theme(),
"colors" => cmd_colors(),
"palette" => cmd_palette(),
"style" => cmd_style(args),
"prefix" => cmd_prefix(args),
"show" => cmd_show(args),
"load" => cmd_load(args),
"save" => cmd_save(args),
"" => ceprintln!("[thuli::error][/] [thuli::text.error]empty command. try /help."),
_ => {
ceprintln!("[thuli::error][/] [thuli::text.error]unknown command. try /help.")
}
}
}
fn print_err(e: LexError, args: &str) {
ceprintb!("[thuli::error][/] [thuli::text.error]");
eprintln!("{e}");
ceprint!("");
eprintln!("{}", e.display(args));
ceprintb!("[thuli::tip][/] [thuli::text.tip]");
eprintln!("if you meant to print the brackets directly, escape them '\\['");
ceprint!("");
}
fn print_help() {
cprintln!(
" [thuli::brand]thuli[/] is an interactive repl for experimenting with farben markup"
);
cprintln!(" [thuli::header]commands:");
cprintln!(" [thuli::command]/help[/] show this message");
cprintln!(" [thuli::command]/clear[/] clear the screen");
cprintln!(" [thuli::command]/colors[/] preview named colors");
cprintln!(" [thuli::command]/copyright[/] show copyright mentions");
cprintln!(" [thuli::command]/license[/] show project license");
cprintln!(
" [thuli::command]/load <path>[/] load styles from a .frb.toml file"
);
cprintln!(
" [thuli::command]/palette[/] preview the full ansi 256 palette"
);
cprintln!(" [thuli::command]/prefix <style> <prefix>[/] apply a prefix to a style");
cprintln!(" [thuli::command]/quit[/] exit thuli");
cprintln!(
" [thuli::command]/save <path>[/] save registered styles to a .frb.toml file"
);
cprintln!(" [thuli::command]/style <n> <markup>[/] register a new style");
cprintln!(" [thuli::command]/tags[/] show all tags");
cprintln!(" [thuli::command]/theme[/] info about thuli's app theme");
cprintln!(" to pass '/' to [thuli::brand]thuli[/], write double '//'");
}
fn print_copyright() {
cprintln!("[thuli::credits.highlight]Copyright (c) 2026 RazkarStudio.");
cprintbln!("[thuli::credits]All rights reserved.");
cprintbln!(" razkar on Codeberg");
cprintbln!(" razkar-studio on GitHub");
cprintbln!(" razkar-studio on Crates-io");
cprintln!();
}
fn print_license() {
cprintln!(
"[thuli::brand]thuli[/] is licensed under either of the [thuli::mit]MIT License[/] or [thuli::apache]Apache License, Version 2.0[/], at your option."
);
cprintln!("View the repository for more details");
cprintln!();
}
fn print_tags() {
cprintln!(" [thuli::header]reserved:");
cprintln!(" thuli::brand, thuli::frb, thuli::error, thuli::tip");
cprintln!(" thuli::header, thuli::command, thuli::prompt");
cprintln!(" thuli::credits, thuli::credits.highlight");
cprintln!(" thuli::description, thuli::text.error, thuli::text.tip");
cprintln!(" thuli::text.success, thuli::mit, thuli::apache");
cprintln!(" thuli::section, thuli::help_link");
cprintln!(" [thuli::header]built-in:");
cprintln!(
" [thuli::section]emphasis[/] bold, dim, italic, underline, blink, strikethrough"
);
cprintln!(
" [thuli::section]extended[/] double-underline, rapid-blink, reverse, invisible, overline"
);
cprintln!(
" [thuli::section]colors[/] black, red, green, yellow, blue, magenta, cyan, white"
);
cprintln!(
" [thuli::section]bright[/] bright-black, bright-red, bright-green, bright-yellow, bright-blue, bright-magenta, bright-cyan, bright-white"
);
cprintln!(" [thuli::section]formats[/] rgb(r,g,b), ansi(n), fg:<color>, bg:<color>");
cprintln!(" [thuli::section]resets[/] /, /<tag>");
let styles = user_styles().lock().unwrap();
cprintln!(" [thuli::header]registered:");
if styles.is_empty() {
cprintln!(" [thuli::description](none yet, define with /style)");
} else {
for (name, markup) in styles.iter() {
println!(" {name} = {markup}");
}
}
}
fn parse_two(args: &str) -> (&str, &str) {
let mut parts = args.splitn(2, char::is_whitespace);
(
parts.next().unwrap_or("").trim(),
parts.next().unwrap_or("").trim(),
)
}
use farben::core::{tokenize, tokens_to_markup};
fn cmd_colors() {
cprintln!(" [thuli::header]named colors:");
let names = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
];
for name in names {
let markup = format!("[{name}]{name}[/] [bright-{name}]bright-{name}[/]");
match try_color(&markup) {
Ok(s) => println!(" {s}"),
Err(_) => println!(" {name}"),
}
}
}
fn cmd_palette() {
cprintln!(" [thuli::header]ansi 256 palette:");
cprint!(" ");
for i in 0..16u8 {
let markup = format!("[bg:ansi({i})] {i:3} [/]");
cprint!("{}", try_color(&markup).unwrap_or_default());
if i == 7 {
println!();
cprint!(" ");
}
}
println!();
for row_start in (16..232u16).step_by(36) {
cprint!(" ");
for i in row_start..row_start + 36 {
let markup = format!("[bg:ansi({i})] {i:3} [/]");
cprint!("{}", try_color(&markup).unwrap_or_default());
}
println!();
}
cprint!(" ");
for i in 232..256u16 {
let markup = format!("[bg:ansi({i})] {i:3} [/]");
cprint!("{}", try_color(&markup).unwrap_or_default());
}
println!();
}
fn cmd_show(args: &str) {
if args.is_empty() {
ceprintln!(" [thuli::header]usage[/] /show <markup>");
return;
}
match try_color(args) {
Ok(ansi) => {
let tokens = tokenize(args).unwrap();
println!(" {ansi}");
println!(" \u{2192} tokens: {}", tokens_to_markup(&tokens));
println!(
" \u{2192} ansi: {}",
format!("{:?}", ansi).replace("\\u{1b}", "\\x1b")
);
}
Err(e) => {
print_err(e, args);
}
}
}
fn cmd_style(args: &str) {
let (name, markup) = parse_two(args);
if name.is_empty() || markup.is_empty() {
ceprintln!(" [thuli::header]usage[/] /style <n> <markup>");
return;
}
if name.starts_with("thuli::") {
ceprintln!(
"[thuli::error][/] [thuli::text.error]you cannot modify or add specific styles prefixed with 'thuli::' regardless of status."
);
ceprintln!(
"[thuli::tip][/] [thuli::text.tip]define them in the theme file, run [thuli::help_link]\\[/theme][/] for more information"
);
return;
}
match farben::Style::parse(markup) {
Ok(style) => {
{
let mut styles = user_styles().lock().unwrap();
styles.retain(|(n, _)| n != name);
styles.push((name.to_string(), markup.to_string()));
}
insert_style(name, style);
cprintb!(" \u{2192} [thuli::text.success]");
println!("{name} = {markup}");
cprint!("");
}
Err(e) => {
print_err(e, markup);
}
}
}
fn cmd_prefix(args: &str) {
let (name, prefix) = parse_two(args);
if name.is_empty() || prefix.is_empty() {
ceprintbln!(" [thuli::header]usage[/] /prefix <style> <prefix>");
return;
}
if name.starts_with("thuli::") {
ceprintln!(
"[thuli::error][/] [thuli::text.error]you cannot modify or add specific styles prefixed with 'thuli::' regardless of status."
);
ceprintln!(
"[thuli::tip][/] [thuli::text.tip]define them in the theme file, run [thuli::help_link]\\[/theme][/] for more information"
);
return;
}
match set_prefix(name, prefix) {
Ok(_) => {
let mut prefixes = user_prefixes().lock().unwrap();
prefixes.retain(|(n, _)| n != name);
prefixes.push((name.to_string(), prefix.to_string()));
cprintb!(" \u{2192} [thuli::text.success]");
println!("{name}: {prefix}");
cprint!("");
}
Err(e) => ceprintln!("[thuli::error][/] [thuli::text.error]{e}"),
};
}
fn cmd_theme() {
cprintln!(" [thuli::header]thuli theme");
cprintln!(" thuli's look is fully customizable, here's where to configure it");
match config_dir() {
Some(dir) => {
let path = dir.join("theme.frb.toml");
let exists = path.exists();
cprint!(" config: ");
println!("{}", path.display());
cprint!(" status: ");
if exists {
cprintln!("[thuli::text.success]loaded");
} else {
cprintln!("[thuli::description](no theme file, using defaults)");
cprintln!(
" to customize: create the file above with [thuli::command]\\[styles][/] / [thuli::command]\\[prefixes][/] sections"
);
}
}
None => {
cprintln!(" [thuli::description](could not determine config directory)");
}
}
}
fn cmd_load(args: &str) {
let path = args.trim();
if path.is_empty() {
ceprintln!(" [thuli::header]usage[/] /load <path>");
return;
}
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
ceprintb!("[thuli::error][/] [thuli::text.error]could not read '");
eprintln!("{path}': {e}");
ceprint!("");
return;
}
};
let config = match parse(&contents) {
Ok(c) => c,
Err(e) => {
ceprintbln!("[thuli::error][/] [thuli::text.error]parse error");
eprintln!("{e}");
ceprint!("");
return;
}
};
let mut styles_added = 0;
let mut prefixes_added = 0;
let mut skipped: Vec<String> = Vec::new();
for (name, markup) in &config.styles {
if name.starts_with("thuli::") {
skipped.push(name.clone());
continue;
}
match farben::Style::parse(markup) {
Ok(style) => {
{
let mut list = user_styles().lock().unwrap();
list.retain(|(n, _)| n != name);
list.push((name.clone(), markup.clone()));
}
insert_style(name, style);
styles_added += 1;
}
Err(e) => {
ceprintb!("[thuli::error][/] [thuli::text.error]style '");
eprint!("{name}': ");
eprintln!("{e}");
ceprint!("");
}
}
}
for (name, prefix) in &config.prefixes {
if name.starts_with("thuli::") {
skipped.push(format!("{name} (prefix)"));
continue;
}
if let Err(e) = set_prefix(name, prefix) {
ceprintb!("[thuli::error][/] [thuli::text.error]prefix '");
eprint!("{name}': ");
eprintln!("{e}");
ceprint!(""); } else {
let mut list = user_prefixes().lock().unwrap();
list.retain(|(n, _)| n != name);
list.push((name.clone(), prefix.clone()));
prefixes_added += 1;
}
}
cprintln!(
" \u{2192} [thuli::text.success]loaded {styles_added} styles, {prefixes_added} prefixes from {path}"
);
if !skipped.is_empty() {
cprintln!(
" [thuli::description](skipped {} thuli::* names)",
skipped.len()
);
}
}
fn cmd_save(args: &str) {
let path = args.trim();
if path.is_empty() {
ceprintln!(" [thuli::header]usage[/] /save <path>");
return;
}
let styles = user_styles().lock().unwrap();
let prefixes = user_prefixes().lock().unwrap();
if styles.is_empty() && prefixes.is_empty() {
ceprintln!(
"[thuli::tip][/] [thuli::text.tip]nothing to save, no user styles or prefixes registered"
);
return;
}
let mut out = String::new();
if !styles.is_empty() {
out.push_str("[styles]\n");
for (name, markup) in styles.iter() {
out.push_str(&format!("{name} = \"{markup}\"\n"));
}
}
if !prefixes.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("[prefixes]\n");
for (name, prefix) in prefixes.iter() {
out.push_str(&format!("{name} = \"{prefix}\"\n"));
}
}
match std::fs::write(path, &out) {
Ok(_) => cprintln!(
" \u{2192} [thuli::text.success]saved {} styles, {} prefixes to {path}",
styles.len(),
prefixes.len()
),
Err(e) => {
ceprintb!("[thuli::error][/] [thuli::text.error]could not write '");
eprintln!("{path}': {e}");
ceprint!("");
}
}
}