Repository: eza-community/eza (language: rust)

Issue:
bug: eza doesn’t show characters in filename after last escaped character
Code extracted from `devtools/dev-create-test-filesystem.sh` and slightly edited:

<details><summary>create files</summary>

```
export TEST_ROOT=test_root
mkdir -p "$TEST_ROOT/file-names"
echo -e "\033[1m[ 3/13]\033[0m Creating file names testcases"

echo -ne "$TEST_ROOT/file-names/ascii: hello" | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/emoji: [🆒]"  | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/utf-8: pâté"  | xargs -0 touch

echo -ne "$TEST_ROOT/file-names/bell: [\a]"         | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/backspace: [\b]"    | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/form-feed: [\f]"    | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/new-line: [\n]"     | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/return: [\r]"       | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/tab: [\t]"          | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/vertical-tab: [\v]" | xargs -0 touch

echo -ne "$TEST_ROOT/file-names/escape: [\033]"              | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/ansi: [\033[34mblue\033[0m]" | xargs -0 touch

echo -ne "$TEST_ROOT/file-names/invalid-utf8-1: [\xFF]"             | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/invalid-utf8-2: [\xc3\x28]"         | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/invalid-utf8-3: [\xe2\x82\x28]"     | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/invalid-utf8-4: [\xf0\x28\x8c\x28]" | xargs -0 touch

echo -ne "$TEST_ROOT/file-names/new-line-dir: [\n]"               | xargs -0 mkdir
echo -ne "$TEST_ROOT/file-names/new-line-dir: [\n]/subfile"       | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/new-line-dir: [\n]/another: [\n]" | xargs -0 touch
echo -ne "$TEST_ROOT/file-names/new-line-dir: [\n]/broken"        | xargs -0 touch
```
</details>

```
$ eza -1 test_root/file-names/
ansi: [\u{1b}[34mblue\u{1b}
ascii: hello
backspace: [\u{8}
bell: [\u{7}
emoji: [🆒]
escape: [\u{1b}
form-feed: [\u{c}
invalid-utf8-1: [�]
invalid-utf8-2: [�(]
invalid-utf8-3: [�(]
invalid-utf8-4: [�(�(]
new-line-dir: [\n
new-line: [\n
return: [\r
tab: [\t
utf-8: pâté
vertical-tab: [\u{b}

$ exa -1 test_root/file-names/
ansi: [\u{1b}[34mblue\u{1b}[0m]
ascii: hello
backspace: [\u{8}]
bell: [\u{7}]
emoji: [🆒]
escape: [\u{1b}]
form-feed: [\u{c}]
invalid-utf8-1: [�]
invalid-utf8-2: [�(]
invalid-utf8-3: [�(]
invalid-utf8-4: [�(�(]
new-line-dir: [\n]
new-line: [\n]
return: [\r]
tab: [\t]
utf-8: pâté
vertical-tab: [\u{b}]

$ ls -1 test_root/file-names/
'ansi: ['$'\033''[34mblue'$'\033''[0m]'
'ascii: hello'
'backspace: ['$'\b'']'
'bell: ['$'\a'']'
'emoji: [🆒]'
'escape: ['$'\033'']'
'form-feed: ['$'\f'']'
'invalid-utf8-1: ['$'\377'']'
'invalid-utf8-2: ['$'\303''(]'
'invalid-utf8-3: ['$'\342\202''(]'
'invalid-utf8-4: ['$'\360''('$'\214''(]'
'new-line: ['$'\n'']'
'new-line-dir: ['$'\n'']'/
'return: ['$'\r'']'
'tab: ['$'\t'']'
'utf-8: pâté'
'vertical-tab: ['$'\v'']'
```


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index b07abb91e..39717a671 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -84,6 +84,7 @@ version = "0.11.0"
 dependencies = [
  "ansiterm",
  "datetime",
+ "gethostname",
  "git2",
  "glob",
  "lazy_static",
@@ -100,6 +101,7 @@ dependencies = [
  "terminal_size",
  "timeago",
  "unicode-width",
+ "urlencoding",
  "uzers",
  "zoneinfo_compiled",
 ]
@@ -114,6 +116,16 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "gethostname"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
+dependencies = [
+ "libc",
+ "windows-targets",
+]
+
 [[package]]
 name = "git2"
 version = "0.18.0"
@@ -130,9 +142,9 @@ dependencies = [
 
 [[package]]
 name = "glob"
-version = "0.3.1"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
 
 [[package]]
 name = "hermit-abi"
@@ -539,6 +551,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
 [[package]]
 name = "uzers"
 version = "0.11.2"
diff --git a/Cargo.toml b/Cargo.toml
index 4d54760b2..0096cf043 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,6 +37,7 @@ name = "eza"
 
 [dependencies]
 ansiterm = "0.12.2"
+gethostname = "0.4.3"
 glob = "0.3"
 lazy_static = "1.3"
 libc = "0.2"
@@ -51,6 +52,7 @@ term_grid = "0.1"
 terminal_size = "0.2.6"
 timeago = { version = "0.4.1", default-features = false }
 unicode-width = "0.1"
+urlencoding = "2.1.3"
 zoneinfo_compiled = "0.5.1"
 
 [dependencies.datetime]
diff --git a/src/output/escape.rs b/src/output/escape.rs
index 29cacd962..05d598a30 100644
--- a/src/output/escape.rs
+++ b/src/output/escape.rs
@@ -2,25 +2,31 @@ use ansiterm::{ANSIString, Style};
 
 
 pub fn escape(string: String, bits: &mut Vec<ANSIString<'_>>, good: Style, bad: Style) {
-    if string.chars().all(|c| c >= 0x20 as char && c != 0x7f as char) {
+    // if the string has no control character
+    if string.chars().all(|c| !c.is_control()) {
         bits.push(good.paint(string));
         return;
     }
 
+    // the lengthier string of non control character can’t be bigger than the whole string
+    let mut regular_char_buff = String::with_capacity(string.len());
     for c in string.chars() {
         // The `escape_default` method on `char` is *almost* what we want here, but
         // it still escapes non-ASCII UTF-8 characters, which are still printable.
 
-        if c >= 0x20 as char && c != 0x7f as char {
-            // TODO: This allocates way too much,
-            // hence the `all` check above.
-            let mut s = String::new();
-            s.push(c);
-            bits.push(good.paint(s));
-        }
-        else {
-            let s = c.escape_default().collect::<String>();
-            bits.push(bad.paint(s));
+        if c.is_control() {
+            if !regular_char_buff.is_empty() {
+                bits.push(good.paint(std::mem::take(&mut regular_char_buff)));
+            }
+            regular_char_buff.extend(c.escape_default());
+            // biased towards regular characters, we push control characters immediately
+            bits.push(bad.paint(std::mem::take(&mut regular_char_buff)));
+        } else {
+            regular_char_buff.push(c);
         }
     }
+    // if last character was not a control character, the buffer is not empty!
+    if !regular_char_buff.is_empty() {
+        bits.push(good.paint(std::mem::take(&mut regular_char_buff)));
+    }
 }
diff --git a/src/output/file_name.rs b/src/output/file_name.rs
index c8200670c..9d8333791 100644
--- a/src/output/file_name.rs
+++ b/src/output/file_name.rs
@@ -10,6 +10,8 @@ use crate::output::escape;
 use crate::output::icons::{icon_for_file, iconify_style};
 use crate::output::render::FiletypeColours;
 
+const HYPERLINK_START: &str = "\x1B]8;;";
+const HYPERLINK_END: &str = "\x1B\x5C";
 
 /// Basically a file name factory.
 #[derive(Debug, Copy, Clone)]
@@ -346,59 +348,32 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> {
         let file_style = self.style();
         let mut bits = Vec::new();
 
-        self.escape_color_and_hyperlinks(
+        let mut display_hyperlink = false;
+        if self.options.embed_hyperlinks == EmbedHyperlinks::On {
+            if let Some(abs_path) = self.file.path.canonicalize().unwrap().as_os_str().to_str() {
+                bits.insert(0, ANSIString::from(format!(
+                    "{}file://{}{}{}",
+                    HYPERLINK_START,
+                    gethostname::gethostname().to_str().unwrap_or(""),
+                    urlencoding::encode(abs_path).replace("%2F", "/"),
+                    HYPERLINK_END,
+                )));
+                display_hyperlink = true;
+            }
+        }
+
+        escape(
+            self.file.name.clone(),
             &mut bits,
             file_style,
             self.colours.control_char(),
         );
 
-        bits
-    }
-
-    // An adapted version of escape::escape.
-    // afaik of all the calls to escape::escape, only for escaped_file_name, the call to escape needs to be checked for hyper links
-    // and if that's the case then I think it's best to not try and generalize escape::escape to this case,
-    // as this adaptation would incur some unneeded operations there
-    pub fn escape_color_and_hyperlinks(&self, bits: &mut Vec<ANSIString<'_>>, good: Style, bad: Style) {
-        let string = self.file.name.clone();
-
-        if string.chars().all(|c| c >= 0x20 as char && c != 0x7f as char) {
-            let painted = good.paint(string);
-
-            let adjusted_filename = if let EmbedHyperlinks::On = self.options.embed_hyperlinks {
-                ANSIString::from(format!("\x1B]8;;{}\x1B\x5C{}\x1B]8;;\x1B\x5C", self.file.path.display(), painted))
-            } else {
-                painted
-            };
-            bits.push(adjusted_filename);
-            return;
+        if display_hyperlink {
+            bits.push(ANSIString::from(format!("{HYPERLINK_START}{HYPERLINK_END}")));
         }
 
-        // again adapted from escape::escape
-        // still a slow route, but slightly improved to at least not reallocate buff + have a predetermined buff size
-        //
-        // also note that buff would never need more than len,
-        // even tho 'in total' it will be lenghier than len (as we expand with escape_default),
-        // because we clear it after an irregularity
-        let mut buff = String::with_capacity(string.len());
-        for c in string.chars() {
-            // The `escape_default` method on `char` is *almost* what we want here, but
-            // it still escapes non-ASCII UTF-8 characters, which are still printable.
-
-            if c >= 0x20 as char && c != 0x7f as char {
-                buff.push(c);
-            }
-            else {
-                if ! buff.is_empty() {
-                    bits.push(good.paint(std::mem::take(&mut buff)));
-                }
-                // biased towards regular characters, so we still collect on first sight of bad char
-                for e in c.escape_default() {
-                    buff.push(e);
-                }
-                bits.push(bad.paint(std::mem::take(&mut buff)));
-            }
-        }
+        bits
     }
 
     /// Figures out which colour to paint the filename part of the output,


Test patch: