mod args;
mod profile_selection;
mod pty;
mod runtime;
mod stream;
mod trace;
use std::ffi::OsString;
use std::io::{self, Write};
use std::process::ExitCode;
use is_terminal::IsTerminal;
use thiserror::Error;
use crate::config::{
MAX_PROFILE_TEST_FILE_BYTES, PrismConfig, load_profile_file, read_bounded_regular_file,
};
use crate::highlight::{Highlighter, RuleMatchError, StreamingHighlighter};
use crate::terminal_text::escape_untrusted;
use args::{Action, Options, parse_args, print_help};
use profile_selection::profile_store;
use pty::run_command;
use runtime::{ReloadWatcher, RuntimeRegistration, request_reload};
use stream::{InputSource, highlight_stream};
use trace::IoTrace;
#[cfg(feature = "completion-generation")]
#[doc(hidden)]
pub use args::completion_command;
#[derive(Debug, Error)]
pub enum CliError {
#[error("{0}")]
Usage(String),
#[error(transparent)]
Config(#[from] crate::config::ConfigError),
#[error(transparent)]
Highlight(#[from] crate::highlight::HighlightError),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("PTY error: {0}")]
Pty(#[from] anyhow::Error),
#[error("terminal mode error: {0}")]
Terminal(#[from] nix::errno::Errno),
}
pub fn run() -> ExitCode {
match run_inner(std::env::args_os().skip(1).collect()) {
Ok(code) => code,
Err(error) => {
let message = error.to_string();
let mut stderr = io::stderr().lock();
if message.is_empty() {
let _ = writeln!(stderr, "prismtty:");
} else {
for line in message.lines() {
let _ = writeln!(stderr, "prismtty: {}", escape_untrusted(line));
}
}
ExitCode::from(1)
}
}
}
fn run_inner(args: Vec<OsString>) -> Result<ExitCode, CliError> {
let (options, action) = parse_args(args)?;
match action {
Action::Help => {
print_help();
Ok(ExitCode::SUCCESS)
}
Action::Version => {
println!("prismtty {}", env!("CARGO_PKG_VERSION"));
Ok(ExitCode::SUCCESS)
}
Action::Reload => {
let count = request_reload()?;
println!("Processes reloaded: {count}");
Ok(ExitCode::SUCCESS)
}
Action::ProfilesList => {
let store = profile_store()?;
for name in store.names() {
println!("{}", escape_untrusted(name));
}
Ok(ExitCode::SUCCESS)
}
Action::ProfilesShow(profile_name) => {
let store = profile_store()?;
let profile = store.profile(&profile_name).ok_or_else(|| {
CliError::Usage(format!(
"unknown profile '{}'",
escape_untrusted(&profile_name)
))
})?;
println!("profile: {}", escape_untrusted(&profile.name));
if profile.inherits.is_empty() {
println!("inherits: none");
} else {
println!(
"inherits: {}",
profile
.inherits
.iter()
.map(|name| escape_untrusted(name))
.collect::<Vec<_>>()
.join(", ")
);
}
if profile.detection.is_empty() {
println!("detection: none");
} else {
println!(
"detection: {}",
profile
.detection
.iter()
.map(|hint| escape_untrusted(hint))
.collect::<Vec<_>>()
.join(", ")
);
}
println!("rules:");
for rule in &profile.rules {
println!(" - {}", escape_untrusted(&rule.description));
}
Ok(ExitCode::SUCCESS)
}
Action::ProfilesValidate(path) => {
let loaded = load_profile_file(&path)?;
let mut store = profile_store()?;
store
.try_insert_user_profile(
loaded.meta.name.clone(),
loaded.meta.inherits.clone(),
loaded.meta.detection.clone(),
loaded.rules.clone(),
)
.map_err(|error| CliError::Usage(error.to_string()))?;
let config = PrismConfig {
rules: PrismConfig::from_profiles(&store, &[loaded.meta.name.as_str()])?.rules,
enabled_profiles: vec![loaded.meta.name.clone()],
};
let _ = Highlighter::from_config(config)?;
println!("profile {} valid", escape_untrusted(&loaded.meta.name));
Ok(ExitCode::SUCCESS)
}
Action::ProfilesTest { profile, fixture } => {
let input =
read_bounded_regular_file(&fixture, MAX_PROFILE_TEST_FILE_BYTES, "fixture")?;
let store = profile_store()?;
let config = PrismConfig::from_profiles(&store, &[profile.as_str()])?;
let highlighter = Highlighter::from_config(config)?;
let mut streaming = StreamingHighlighter::new(highlighter);
let stdout = io::stdout();
let mut stdout = stdout.lock();
for chunk in input.chunks(64 * 1024) {
let rendered = streaming.push(chunk);
if let Some(rule) = streaming.first_rule_error() {
return Err(CliError::Usage(
RuleMatchError {
description: rule.description.to_string(),
message: rule.last.unwrap_or("PCRE2 match error").to_string(),
}
.to_string(),
));
}
stdout.write_all(&rendered)?;
}
let rendered = streaming.finish();
if let Some(rule) = streaming.first_rule_error() {
return Err(CliError::Usage(
RuleMatchError {
description: rule.description.to_string(),
message: rule.last.unwrap_or("PCRE2 match error").to_string(),
}
.to_string(),
));
}
stdout.write_all(&rendered)?;
Ok(ExitCode::SUCCESS)
}
Action::Stdin => run_stdin(options),
Action::Run(command) => run_command(options, command),
}
}
fn run_stdin(options: Options) -> Result<ExitCode, CliError> {
let _registration = RuntimeRegistration::register()?;
let reload_watcher = Some(ReloadWatcher::new());
let trace = IoTrace::open(options.trace_io.as_deref())?;
let stdin = io::stdin();
let mut stdout = io::stdout();
let interactive = stdin_mode_interactive_highlighting(stdout.is_terminal());
highlight_stream(
stdin.lock(),
&mut stdout,
&options,
InputSource {
interactive,
pty_fd: None,
recent_input: None,
},
reload_watcher,
trace,
None,
)?;
Ok(ExitCode::SUCCESS)
}
fn stdin_mode_interactive_highlighting(stdout_is_terminal: bool) -> bool {
stdout_is_terminal
}
#[cfg(test)]
mod tests {
#[test]
fn stdin_mode_uses_interactive_highlighting_when_output_is_terminal() {
assert!(super::stdin_mode_interactive_highlighting(true));
assert!(!super::stdin_mode_interactive_highlighting(false));
}
#[test]
fn pty_module_only_imports_highlight_stream_from_stream_module() {
let source = include_str!("cli/pty.rs");
assert!(
!source.contains("run_stdin"),
"stdin orchestration should stay outside pty.rs"
);
}
}