diff --git a/rust/Cargo.lock b/rust/Cargo.lock --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -876,6 +876,7 @@ name = "rhg" version = "0.1.0" dependencies = [ + "atty", "chrono", "clap", "derive_more", diff --git a/rust/hg-core/src/config.rs b/rust/hg-core/src/config.rs --- a/rust/hg-core/src/config.rs +++ b/rust/hg-core/src/config.rs @@ -13,4 +13,4 @@ mod layer; mod values; pub use config::{Config, ConfigSource, ConfigValueParseError}; -pub use layer::{ConfigError, ConfigParseError}; +pub use layer::{ConfigError, ConfigOrigin, ConfigParseError}; diff --git a/rust/hg-core/src/config/config.rs b/rust/hg-core/src/config/config.rs --- a/rust/hg-core/src/config/config.rs +++ b/rust/hg-core/src/config/config.rs @@ -398,6 +398,16 @@ .map(|(_, value)| value.bytes.as_ref()) } + /// Returns the raw value bytes of the first one found, or `None`. + pub fn get_with_origin( + &self, + section: &[u8], + item: &[u8], + ) -> Option<(&[u8], &ConfigOrigin)> { + self.get_inner(section, item) + .map(|(layer, value)| (value.bytes.as_ref(), &layer.origin)) + } + /// Returns the layer and the value of the first one found, or `None`. fn get_inner( &self, diff --git a/rust/hg-core/src/config/layer.rs b/rust/hg-core/src/config/layer.rs --- a/rust/hg-core/src/config/layer.rs +++ b/rust/hg-core/src/config/layer.rs @@ -295,7 +295,7 @@ pub line: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum ConfigOrigin { /// From a configuration file File(PathBuf), diff --git a/rust/rhg/Cargo.toml b/rust/rhg/Cargo.toml --- a/rust/rhg/Cargo.toml +++ b/rust/rhg/Cargo.toml @@ -8,6 +8,7 @@ edition = "2018" [dependencies] +atty = "0.2" hg-core = { path = "../hg-core"} chrono = "0.4.19" clap = "2.33.1" diff --git a/rust/rhg/src/ui.rs b/rust/rhg/src/ui.rs --- a/rust/rhg/src/ui.rs +++ b/rust/rhg/src/ui.rs @@ -1,16 +1,19 @@ use format_bytes::format_bytes; +use format_bytes::write_bytes; use hg::config::Config; +use hg::config::ConfigOrigin; use hg::errors::HgError; use hg::utils::files::get_bytes_from_os_string; use std::borrow::Cow; +use std::collections::HashMap; use std::env; use std::io; use std::io::{ErrorKind, Write}; -#[derive(Debug)] pub struct Ui { stdout: std::io::Stdout, stderr: std::io::Stderr, + colors: Option, } /// The kind of user interface error @@ -23,20 +26,26 @@ /// The commandline user interface impl Ui { - pub fn new(_config: &Config) -> Result { + pub fn new(config: &Config) -> Result { Ok(Ui { + // If using something else, also adapt `isatty()` below. stdout: std::io::stdout(), + stderr: std::io::stderr(), + colors: ColorConfig::new(config)?, }) } /// Default to no color if color configuration errors. /// /// Useful when we’re already handling another error. - pub fn new_infallible(_config: &Config) -> Self { + pub fn new_infallible(config: &Config) -> Self { Ui { + // If using something else, also adapt `isatty()` below. stdout: std::io::stdout(), + stderr: std::io::stderr(), + colors: ColorConfig::new(config).unwrap_or(None), } } @@ -48,6 +57,11 @@ /// Write bytes to stdout pub fn write_stdout(&self, bytes: &[u8]) -> Result<(), UiError> { + // Hack to silence "unused" warnings + if false { + return self.write_stdout_labelled(bytes, ""); + } + let mut stdout = self.stdout.lock(); stdout.write_all(bytes).or_else(handle_stdout_error)?; @@ -64,6 +78,57 @@ stderr.flush().or_else(handle_stderr_error) } + pub fn write_stdout_labelled( + &self, + bytes: &[u8], + label: &str, + ) -> Result<(), UiError> { + if let Some(colors) = &self.colors { + if let Some(effects) = colors.styles.get(label.as_bytes()) { + if !effects.is_empty() { + return self + .write_stdout_with_effects(bytes, effects) + .or_else(handle_stdout_error); + } + } + } + self.write_stdout(bytes) + } + + fn write_stdout_with_effects( + &self, + bytes: &[u8], + effects: &[Effect], + ) -> io::Result<()> { + let stdout = &mut self.stdout.lock(); + let mut write_line = |line: &[u8], first: bool| { + // `line` does not include the newline delimiter + if !first { + stdout.write_all(b"\n")?; + } + if line.is_empty() { + return Ok(()); + } + /// 0x1B == 27 == 0o33 + const ASCII_ESCAPE: &[u8] = b"\x1b"; + write_bytes!(stdout, b"{}[0", ASCII_ESCAPE)?; + for effect in effects { + write_bytes!(stdout, b";{}", effect)?; + } + write_bytes!(stdout, b"m")?; + stdout.write_all(line)?; + write_bytes!(stdout, b"{}[0m", ASCII_ESCAPE) + }; + let mut lines = bytes.split(|&byte| byte == b'\n'); + if let Some(first) = lines.next() { + write_line(first, true)?; + for line in lines { + write_line(line, false)? + } + } + stdout.flush() + } + /// Return whether plain mode is active. /// /// Plain mode means that all configuration variables which affect @@ -154,3 +219,248 @@ let bytes = s.as_bytes(); Cow::Borrowed(bytes) } + +struct ColorConfig { + styles: EffectsMap, +} + +impl ColorConfig { + // Similar to _modesetup in mercurial/color.py + fn new(config: &Config) -> Result, HgError> { + Ok(match ColorMode::get(config)? { + None => None, + Some(ColorMode::Ansi) => { + let mut styles = default_styles(); + for (key, _value) in config.iter_section(b"color") { + if !key.contains(&b'.') + || key.starts_with(b"color.") + || key.starts_with(b"terminfo.") + { + continue; + } + // `unwrap` shouldn’t panic since we just got this key from + // iteration + styles.insert( + key.to_owned(), + config + .get_list(b"color", key) + .unwrap() + .iter() + // TODO: warn for unknown effect/color names + // (when `effect` returns `None`) + .filter_map(|name| effect(name)) + .collect(), + ); + } + Some(ColorConfig { styles }) + } + }) + } +} + +enum ColorMode { + // TODO: support other modes + Ansi, +} + +impl ColorMode { + // Similar to _modesetup in mercurial/color.py + fn get(config: &Config) -> Result, HgError> { + if plain(Some("color")) { + return Ok(None); + } + let enabled_default = b"auto"; + // `origin` is only used when `!auto`, so its default doesn’t matter + let (enabled, origin) = config + .get_with_origin(b"ui", b"color") + .unwrap_or((enabled_default, &ConfigOrigin::CommandLineColor)); + if enabled == b"debug" { + return Err(HgError::unsupported("debug color mode")); + } + let auto = enabled == b"auto"; + let always; + if !auto { + let enabled_bool = config.get_bool(b"ui", b"color")?; + if !enabled_bool { + return Ok(None); + } + always = enabled == b"always" + || *origin == ConfigOrigin::CommandLineColor + } else { + always = false + }; + let formatted = always + || (env::var_os("TERM").unwrap_or_default() != "dumb" + && formatted(config)?); + + // TODO: support modes other than ANSI and select based on color.mode + // config etc + + if formatted { + Ok(Some(ColorMode::Ansi)) + } else { + Ok(None) + } + } +} + +/// Should formatted output be used? +/// +/// Note: rhg does not have the formatter mechanism yet, +/// but this is also used when deciding whether to use color. +fn formatted(config: &Config) -> Result { + if let Some(formatted) = config.get_option(b"ui", b"formatted")? { + Ok(formatted) + } else { + isatty(config) + } +} + +fn isatty(config: &Config) -> Result { + Ok(if config.get_bool(b"ui", b"nontty")? { + false + } else { + atty::is(atty::Stream::Stdout) + }) +} + +type Effect = u32; + +type EffectsMap = HashMap, Vec>; + +macro_rules! effects { + ($( $name: ident: $value: expr ,)+) => { + + #[allow(non_upper_case_globals)] + mod effects { + $( + pub const $name: super::Effect = $value; + )+ + } + + fn effect(name: &[u8]) -> Option { + $( + if name == stringify!($name).as_bytes() { + Some(effects::$name) + } else + )+ + { + None + } + } + }; +} + +effects! { + none: 0, + black: 30, + red: 31, + green: 32, + yellow: 33, + blue: 34, + magenta: 35, + cyan: 36, + white: 37, + bold: 1, + italic: 3, + underline: 4, + inverse: 7, + dim: 2, + black_background: 40, + red_background: 41, + green_background: 42, + yellow_background: 43, + blue_background: 44, + purple_background: 45, + cyan_background: 46, + white_background: 47, +} + +macro_rules! default_styles { + ($( $key: expr => [$($value: expr),*],)+) => { + fn default_styles() -> EffectsMap { + use effects::*; + let mut map = HashMap::new(); + $( + map.insert($key[..].to_owned(), vec![$( $value ),*]); + )+ + map + } + }; +} + +default_styles! { + b"grep.match" => [red, bold], + b"grep.linenumber" => [green], + b"grep.rev" => [blue], + b"grep.sep" => [cyan], + b"grep.filename" => [magenta], + b"grep.user" => [magenta], + b"grep.date" => [magenta], + b"grep.inserted" => [green, bold], + b"grep.deleted" => [red, bold], + b"bookmarks.active" => [green], + b"branches.active" => [none], + b"branches.closed" => [black, bold], + b"branches.current" => [green], + b"branches.inactive" => [none], + b"diff.changed" => [white], + b"diff.deleted" => [red], + b"diff.deleted.changed" => [red, bold, underline], + b"diff.deleted.unchanged" => [red], + b"diff.diffline" => [bold], + b"diff.extended" => [cyan, bold], + b"diff.file_a" => [red, bold], + b"diff.file_b" => [green, bold], + b"diff.hunk" => [magenta], + b"diff.inserted" => [green], + b"diff.inserted.changed" => [green, bold, underline], + b"diff.inserted.unchanged" => [green], + b"diff.tab" => [], + b"diff.trailingwhitespace" => [bold, red_background], + b"changeset.public" => [], + b"changeset.draft" => [], + b"changeset.secret" => [], + b"diffstat.deleted" => [red], + b"diffstat.inserted" => [green], + b"formatvariant.name.mismatchconfig" => [red], + b"formatvariant.name.mismatchdefault" => [yellow], + b"formatvariant.name.uptodate" => [green], + b"formatvariant.repo.mismatchconfig" => [red], + b"formatvariant.repo.mismatchdefault" => [yellow], + b"formatvariant.repo.uptodate" => [green], + b"formatvariant.config.special" => [yellow], + b"formatvariant.config.default" => [green], + b"formatvariant.default" => [], + b"histedit.remaining" => [red, bold], + b"ui.addremove.added" => [green], + b"ui.addremove.removed" => [red], + b"ui.error" => [red], + b"ui.prompt" => [yellow], + b"log.changeset" => [yellow], + b"patchbomb.finalsummary" => [], + b"patchbomb.from" => [magenta], + b"patchbomb.to" => [cyan], + b"patchbomb.subject" => [green], + b"patchbomb.diffstats" => [], + b"rebase.rebased" => [blue], + b"rebase.remaining" => [red, bold], + b"resolve.resolved" => [green, bold], + b"resolve.unresolved" => [red, bold], + b"shelve.age" => [cyan], + b"shelve.newest" => [green, bold], + b"shelve.name" => [blue, bold], + b"status.added" => [green, bold], + b"status.clean" => [none], + b"status.copied" => [none], + b"status.deleted" => [cyan, bold, underline], + b"status.ignored" => [black, bold], + b"status.modified" => [blue, bold], + b"status.removed" => [red, bold], + b"status.unknown" => [magenta, bold, underline], + b"tags.normal" => [green], + b"tags.local" => [black, bold], + b"upgrade-repo.requirement.preserved" => [cyan], + b"upgrade-repo.requirement.added" => [green], + b"upgrade-repo.requirement.removed" => [red], +}