rhg: Add support for colored output
authorSimon Sapin <simon.sapin@octobus.net>
Thu, 10 Feb 2022 12:59:32 +0100
changeset 48733 39c447e03dbc
parent 48732 d4a5c2197208
child 48734 3e2b4bb286e7
rhg: Add support for colored output The same "label" system is used as in Python code Differential Revision: https://phab.mercurial-scm.org/D12167
rust/Cargo.lock
rust/hg-core/src/config.rs
rust/hg-core/src/config/config.rs
rust/hg-core/src/config/layer.rs
rust/rhg/Cargo.toml
rust/rhg/src/color.rs
rust/rhg/src/main.rs
rust/rhg/src/ui.rs
tests/test-status-color.t
--- a/rust/Cargo.lock	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/Cargo.lock	Thu Feb 10 12:59:32 2022 +0100
@@ -876,6 +876,7 @@
 name = "rhg"
 version = "0.1.0"
 dependencies = [
+ "atty",
  "chrono",
  "clap",
  "derive_more",
--- a/rust/hg-core/src/config.rs	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/hg-core/src/config.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -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};
--- a/rust/hg-core/src/config/config.rs	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/hg-core/src/config/config.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -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,
--- a/rust/hg-core/src/config/layer.rs	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/hg-core/src/config/layer.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -295,7 +295,7 @@
     pub line: Option<usize>,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum ConfigOrigin {
     /// From a configuration file
     File(PathBuf),
--- a/rust/rhg/Cargo.toml	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/rhg/Cargo.toml	Thu Feb 10 12:59:32 2022 +0100
@@ -8,6 +8,7 @@
 edition = "2018"
 
 [dependencies]
+atty = "0.2"
 hg-core = { path = "../hg-core"}
 chrono = "0.4.19"
 clap = "2.33.1"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/rhg/src/color.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -0,0 +1,255 @@
+use crate::ui::formatted;
+use crate::ui::plain;
+use format_bytes::write_bytes;
+use hg::config::Config;
+use hg::config::ConfigOrigin;
+use hg::errors::HgError;
+use std::collections::HashMap;
+
+pub type Effect = u32;
+
+pub type EffectsMap = HashMap<Vec<u8>, Vec<Effect>>;
+
+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<Effect> {
+            $(
+                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],
+}
+
+fn parse_effect(config_key: &[u8], effect_name: &[u8]) -> Option<Effect> {
+    let found = effect(effect_name);
+    if found.is_none() {
+        // TODO: have some API for warnings
+        // TODO: handle IO errors during warnings
+        let stderr = std::io::stderr();
+        let _ = write_bytes!(
+            &mut stderr.lock(),
+            b"ignoring unknown color/effect '{}' \
+              (configured in color.{})\n",
+            effect_name,
+            config_key,
+        );
+    }
+    found
+}
+
+fn effects_from_config(config: &Config) -> EffectsMap {
+    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
+        let list = config.get_list(b"color", key).unwrap();
+        let parsed = list
+            .iter()
+            .filter_map(|name| parse_effect(key, name))
+            .collect();
+        styles.insert(key.to_owned(), parsed);
+    }
+    styles
+}
+
+enum ColorMode {
+    // TODO: support other modes
+    Ansi,
+}
+
+impl ColorMode {
+    // Similar to _modesetup in mercurial/color.py
+    fn get(config: &Config) -> Result<Option<Self>, 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
+            || (std::env::var_os("TERM").unwrap_or_default() != "dumb"
+                && formatted(config)?);
+
+        let mode_default = b"auto";
+        let mode = config.get(b"color", b"mode").unwrap_or(mode_default);
+
+        if formatted {
+            match mode {
+                b"ansi" | b"auto" => Ok(Some(ColorMode::Ansi)),
+                // TODO: support other modes
+                _ => Err(HgError::UnsupportedFeature(format!(
+                    "color mode {}",
+                    String::from_utf8_lossy(mode)
+                ))),
+            }
+        } else {
+            Ok(None)
+        }
+    }
+}
+
+pub struct ColorConfig {
+    pub styles: EffectsMap,
+}
+
+impl ColorConfig {
+    // Similar to _modesetup in mercurial/color.py
+    pub fn new(config: &Config) -> Result<Option<Self>, HgError> {
+        Ok(match ColorMode::get(config)? {
+            None => None,
+            Some(ColorMode::Ansi) => Some(ColorConfig {
+                styles: effects_from_config(config),
+            }),
+        })
+    }
+}
--- a/rust/rhg/src/main.rs	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/rhg/src/main.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -17,6 +17,7 @@
 use std::process::Command;
 
 mod blackbox;
+mod color;
 mod error;
 mod ui;
 pub mod utils {
--- a/rust/rhg/src/ui.rs	Thu Feb 10 13:56:43 2022 +0100
+++ b/rust/rhg/src/ui.rs	Thu Feb 10 12:59:32 2022 +0100
@@ -1,4 +1,7 @@
+use crate::color::ColorConfig;
+use crate::color::Effect;
 use format_bytes::format_bytes;
+use format_bytes::write_bytes;
 use hg::config::Config;
 use hg::errors::HgError;
 use hg::utils::files::get_bytes_from_os_string;
@@ -7,10 +10,10 @@
 use std::io;
 use std::io::{ErrorKind, Write};
 
-#[derive(Debug)]
 pub struct Ui {
     stdout: std::io::Stdout,
     stderr: std::io::Stderr,
+    colors: Option<ColorConfig>,
 }
 
 /// The kind of user interface error
@@ -23,20 +26,26 @@
 
 /// The commandline user interface
 impl Ui {
-    pub fn new(_config: &Config) -> Result<Self, HgError> {
+    pub fn new(config: &Config) -> Result<Self, HgError> {
         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,61 @@
         stderr.flush().or_else(handle_stderr_error)
     }
 
+    /// Write bytes to stdout with the given label
+    ///
+    /// Like the optional `label` parameter in `mercurial/ui.py`,
+    /// this label influences the color used for this output.
+    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
@@ -83,7 +152,7 @@
     }
 }
 
-fn plain(opt_feature: Option<&str>) -> bool {
+pub fn plain(opt_feature: Option<&str>) -> bool {
     if let Some(except) = env::var_os("HGPLAINEXCEPT") {
         opt_feature.map_or(true, |feature| {
             get_bytes_from_os_string(except)
@@ -154,3 +223,23 @@
     let bytes = s.as_bytes();
     Cow::Borrowed(bytes)
 }
+
+/// 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.
+pub fn formatted(config: &Config) -> Result<bool, HgError> {
+    if let Some(formatted) = config.get_option(b"ui", b"formatted")? {
+        Ok(formatted)
+    } else {
+        isatty(config)
+    }
+}
+
+fn isatty(config: &Config) -> Result<bool, HgError> {
+    Ok(if config.get_bool(b"ui", b"nontty")? {
+        false
+    } else {
+        atty::is(atty::Stream::Stdout)
+    })
+}
--- a/tests/test-status-color.t	Thu Feb 10 13:56:43 2022 +0100
+++ b/tests/test-status-color.t	Thu Feb 10 12:59:32 2022 +0100
@@ -313,6 +313,7 @@
   ignoring unknown color/effect 'periwinkle' (configured in color.status.modified)
   ignoring unknown color/effect 'periwinkle' (configured in color.status.modified)
   ignoring unknown color/effect 'periwinkle' (configured in color.status.modified)
+  ignoring unknown color/effect 'periwinkle' (configured in color.status.modified) (rhg !)
   M modified
   \x1b[0;32;1mA \x1b[0m\x1b[0;32;1madded\x1b[0m (esc)
   \x1b[0;32;1mA \x1b[0m\x1b[0;32;1mcopied\x1b[0m (esc)