Patchwork D12167: [WIP] rhg: Add support for colored output

login
register
mail settings
Submitter phabricator
Date Feb. 10, 2022, 7:20 p.m.
Message ID <differential-rev-PHID-DREV-dkkq43kgq4coeudv5xnu-req@mercurial-scm.org>
Download mbox | patch
Permalink /patch/50503/
State New
Headers show

Comments

phabricator - Feb. 10, 2022, 7:20 p.m.
SimonSapin created this revision.
Herald added a reviewer: hg-reviewers.
Herald added a subscriber: mercurial-patches.

REVISION SUMMARY
  The same "label" system is used as in Python code

REPOSITORY
  rHG Mercurial

BRANCH
  default

REVISION DETAIL
  https://phab.mercurial-scm.org/D12167

AFFECTED FILES
  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/ui.rs

CHANGE DETAILS




To: SimonSapin, #hg-reviewers
Cc: mercurial-patches, mercurial-devel

Patch

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<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,25 @@ 
         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 {
+            let mut stdout = self.stdout.lock();
+            (|| {
+                colors.write_start(label, &mut stdout)?;
+                stdout.write_all(bytes)?;
+                colors.write_end(label, &mut stdout)?;
+                stdout.flush()
+            })()
+            .or_else(handle_stdout_error)
+        } else {
+            self.write_stdout(bytes)
+        }
+    }
+
     /// Return whether plain mode is active.
     ///
     /// Plain mode means that all configuration variables which affect
@@ -154,3 +187,279 @@ 
     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<Option<Self>, 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 })
+            }
+        })
+    }
+
+    fn write_start(
+        &self,
+        label: &str,
+        stream: &mut impl Write,
+    ) -> io::Result<()> {
+        if let Some(effects) = self.styles.get(label.as_bytes()) {
+            let mut iter = effects.iter();
+            if let Some(first) = iter.next() {
+                write_bytes!(stream, b"\0o33[{}", first)?;
+                for next in iter {
+                    write_bytes!(stream, b";{}", next)?;
+                }
+                write_bytes!(stream, b"m")?;
+            }
+        }
+        Ok(())
+    }
+
+    fn write_end(
+        &self,
+        label: &str,
+        stream: &mut impl Write,
+    ) -> io::Result<()> {
+        if let Some(effects) = self.styles.get(label.as_bytes()) {
+            if !effects.is_empty() {
+                write_bytes!(stream, b"\0o33[0m")?;
+            }
+        }
+        Ok(())
+    }
+}
+
+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
+            || (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<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)
+    })
+}
+
+type Effect = u32;
+
+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],
+}
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/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<usize>,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum ConfigOrigin {
     /// From a configuration file
     File(PathBuf),
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.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/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",