Repository: reubeno/brush
Pull request #298 (2024-12-26 06:34:20)

fix: implement date and time in prompts
Fills out implementation and adds tests for the follow escape sequences in prompt strings:

* `\d`
* `\D`
* `\t`
* `\T`
* `\@`

github-actions[bot]: ## Test Results
    2 files      9 suites   1m 14s ⏱️
  564 tests   564 ✅ 0 💤 0 ❌
1 114 runs  1 114 ✅ 0 💤 0 ❌

Results for commit 1582640a.

[test-results]:data:application/gzip;base64,H4sIADT7bGcC/13MyQ6CMBSF4VchXbvorR3AlzG1Q3IjUNNhRXx3S4MILr//JGchHkeXyK1jl46kgrlhqLAl6oxhrlS8ui553YTcdU/FmP/0xFdNdA9e43gKLsYQtxLLvH4CAN/0/Tyl32fz4bL5+GjCNGGuICB6JjnVCsA9LJPae005Z+Yq9eCVoIIC2F6Q9wdbwdCXBgEAAA==

github-actions[bot]: # Performance Benchmark Report
| Benchmark name                         | Baseline (μs) |  Test/PR (μs) |    Delta (μs) | Delta %         |
| -------------------------------------- | ------------- | ------------- | ------------- | --------------- |
| `expand_one_string                   ` | `    3.51 μs` | `    3.51 μs` | `    0.00 μs` | `⚪  Unchanged` |
| `instantiate_shell                   ` | `   62.22 μs` | `   62.30 μs` | `    0.08 μs` | `⚪  Unchanged` |
| `instantiate_shell_with_init_scripts ` | `28564.83 μs` | `28420.24 μs` | ` -144.59 μs` | `⚪  Unchanged` |
| `parse_bash_completion               ` | ` 2867.59 μs` | ` 2824.63 μs` | `  -42.97 μs` | `🟢 -1.50%    ` |
| `parse_sample_script                 ` | `    4.45 μs` | `    4.38 μs` | `   -0.07 μs` | `⚪  Unchanged` |
| `run_echo_builtin_command            ` | `   92.27 μs` | `   92.14 μs` | `   -0.13 μs` | `⚪  Unchanged` |
| `run_one_builtin_command             ` | `  113.40 μs` | `  110.92 μs` | `   -2.48 μs` | `⚪  Unchanged` |
| `run_one_external_command            ` | ` 1757.49 μs` | ` 1751.26 μs` | `   -6.23 μs` | `⚪  Unchanged` |
| `run_one_external_command_directly   ` | `  823.80 μs` | `  823.36 μs` | `   -0.45 μs` | `⚪  Unchanged` |

Benchmarks added:
  - for_loop

# Code Coverage Report: Only Changed Files listed

| Package | Base Coverage | New Coverage | Difference |
| ------- | ------------- | ------------ | ---------- |
| brush-core/src/jobs.rs | 🔴 42.42% | 🔴 37.23% | 🔴 -5.19% |
| brush-core/src/prompt.rs | 🟢 77.92% | 🟢 90.38% | 🟢 12.46% |
| brush-core/src/sys/unix/signal.rs | 🟠 67.92% | 🟠 71.7% | 🟢 3.78% |
| **Overall Coverage** | **🟢 77.12%** | **🟢 77.19%** | **🟢 0.07%** |

_Minimum allowed coverage is_ `70%`_, this run produced_ `77.19%`
<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 7c33719..d16bc71 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -265,6 +265,7 @@ dependencies = [
  "brush-parser",
  "cached",
  "cfg-if",
+ "chrono",
  "clap",
  "command-fds",
  "criterion",
@@ -475,8 +476,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
+ "js-sys",
  "num-traits",
  "serde",
+ "wasm-bindgen",
  "windows-targets 0.52.6",
 ]
 
@@ -1579,9 +1582,9 @@ checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9"
 
 [[package]]
 name = "os_info"
-version = "3.9.0"
+version = "3.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5ca711d8b83edbb00b44d504503cd247c9c0bd8b0fa2694f2a1a3d8165379ce"
+checksum = "eb6651f4be5e39563c4fe5cc8326349eb99a25d805a3493f791d5bfd0269e430"
 dependencies = [
  "log",
  "serde",
diff --git a/brush-core/Cargo.toml b/brush-core/Cargo.toml
index 6b2427c..eca6cb5 100644
--- a/brush-core/Cargo.toml
+++ b/brush-core/Cargo.toml
@@ -22,6 +22,7 @@ async-trait = "0.1.83"
 brush-parser = { version = "^0.2.11", path = "../brush-parser" }
 cached = "0.54.0"
 cfg-if = "1.0.0"
+chrono = "0.4.39"
 clap = { version = "4.5.21", features = ["derive", "wrap_help"] }
 fancy-regex = "0.14.0"
 futures = "0.3.31"
diff --git a/brush-core/benches/shell.rs b/brush-core/benches/shell.rs
index 82e0e8b..bbd8d00 100644
--- a/brush-core/benches/shell.rs
+++ b/brush-core/benches/shell.rs
@@ -76,6 +76,13 @@ mod unix {
         c.bench_function("expand_one_string", |b| {
             b.iter(|| black_box(expand_one_string()));
         });
+        c.bench_function("for_loop", |b| {
+            b.to_async(tokio()).iter(|| {
+                black_box(run_one_command(
+                    "for ((i = 0; i < 100000; i++)); do :; done",
+                ))
+            });
+        });
     }
 }
 
diff --git a/brush-core/src/prompt.rs b/brush-core/src/prompt.rs
index 5337d81..ba5053a 100644
--- a/brush-core/src/prompt.rs
+++ b/brush-core/src/prompt.rs
@@ -52,7 +52,9 @@ pub(crate) fn format_prompt_piece(
             tilde_replaced,
             basename,
         } => format_current_working_directory(shell, tilde_replaced, basename),
-        brush_parser::prompt::PromptPiece::Date(_) => return error::unimp("prompt: date"),
+        brush_parser::prompt::PromptPiece::Date(format) => {
+            format_date(&chrono::Local::now(), &format)
+        }
         brush_parser::prompt::PromptPiece::DollarOrPound => {
             if users::is_root() {
                 "#".to_owned()
@@ -77,9 +79,7 @@ pub(crate) fn format_prompt_piece(
             hn
         }
         brush_parser::prompt::PromptPiece::Newline => "\n".to_owned(),
-        brush_parser::prompt::PromptPiece::NumberOfManagedJobs => {
-            return error::unimp("prompt: number of managed jobs")
-        }
+        brush_parser::prompt::PromptPiece::NumberOfManagedJobs => shell.jobs.jobs.len().to_string(),
         brush_parser::prompt::PromptPiece::ShellBaseName => {
             if let Some(shell_name) = &shell.shell_name {
                 Path::new(shell_name)
@@ -100,7 +100,9 @@ pub(crate) fn format_prompt_piece(
         brush_parser::prompt::PromptPiece::TerminalDeviceBaseName => {
             return error::unimp("prompt: terminal device base name")
         }
-        brush_parser::prompt::PromptPiece::Time(_) => return error::unimp("prompt: time"),
+        brush_parser::prompt::PromptPiece::Time(time_fmt) => {
+            format_time(&chrono::Local::now(), &time_fmt)
+        }
     };
 
     Ok(formatted)
@@ -125,3 +127,101 @@ fn format_current_working_directory(shell: &Shell, tilde_replaced: bool, basenam
 
     working_dir_str
 }
+
+fn format_time<Tz: chrono::TimeZone>(
+    datetime: &chrono::DateTime<Tz>,
+    format: &brush_parser::prompt::PromptTimeFormat,
+) -> String
+where
+    Tz::Offset: std::fmt::Display,
+{
+    let formatted = match format {
+        brush_parser::prompt::PromptTimeFormat::TwelveHourAM => datetime.format("%I:%M %p"),
+        brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS => datetime.format("%I:%M:%S"),
+        brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS => datetime.format("%H:%M:%S"),
+    };
+
+    formatted.to_string()
+}
+
+fn format_date<Tz: chrono::TimeZone>(
+    datetime: &chrono::DateTime<Tz>,
+    format: &brush_parser::prompt::PromptDateFormat,
+) -> String
+where
+    Tz::Offset: std::fmt::Display,
+{
+    match format {
+        brush_parser::prompt::PromptDateFormat::WeekdayMonthDate => {
+            datetime.format("%a %b %d").to_string()
+        }
+        brush_parser::prompt::PromptDateFormat::Custom(fmt) => {
+            let fmt_items = chrono::format::StrftimeItems::new(fmt);
+            datetime.format_with_items(fmt_items).to_string()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_format_time() {
+        // Create a well-known test date/time.
+        let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T13:34:56.789Z").unwrap();
+
+        assert_eq!(
+            format_time(&dt, &brush_parser::prompt::PromptTimeFormat::TwelveHourAM),
+            "01:34 PM"
+        );
+
+        assert_eq!(
+            format_time(
+                &dt,
+                &brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS
+            ),
+            "13:34:56"
+        );
+
+        assert_eq!(
+            format_time(
+                &dt,
+                &brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS
+            ),
+            "01:34:56"
+        );
+    }
+
+    #[test]
+    fn test_format_date() {
+        // Create a well-known test date/time.
+        let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T12:34:56.789Z").unwrap();
+
+        assert_eq!(
+            format_date(
+                &dt,
+                &brush_parser::prompt::PromptDateFormat::WeekdayMonthDate
+            ),
+            "Wed Dec 25"
+        );
+
+        assert_eq!(
+            format_date(
+                &dt,
+                &brush_parser::prompt::PromptDateFormat::Custom(String::from("%Y-%m-%d"))
+            ),
+            "2024-12-25"
+        );
+
+        assert_eq!(
+            format_date(
+                &dt,
+                &brush_parser::prompt::PromptDateFormat::Custom(String::from(
+                    "%Y-%m-%d %H:%M:%S.%f"
+                ))
+            ),
+            "2024-12-25 12:34:56.789000000"
+        );
+    }
+}
diff --git a/brush-parser/src/prompt.rs b/brush-parser/src/prompt.rs
index ba273fe..9190b17 100644
--- a/brush-parser/src/prompt.rs
+++ b/brush-parser/src/prompt.rs
@@ -123,7 +123,7 @@ peg::parser! {
             s:$((!special_sequence() [c])+) { PromptPiece::Literal(s.to_owned()) }
 
         rule date_format() -> String =
-            s:$(!"}" [c]+) { s.to_owned() }
+            s:$([c if c != '}']*) { s.to_owned() }
 
         rule octal_number() -> u32 =
             s:$(['0'..='9']*<3,3>) {? u32::from_str_radix(s, 8).or(Err("invalid octal number")) }


Test patch:
diff --git a/brush-shell/tests/cases/prompt.yaml b/brush-shell/tests/cases/prompt.yaml
index 1bca3d7..7053ff3 100644
--- a/brush-shell/tests/cases/prompt.yaml
+++ b/brush-shell/tests/cases/prompt.yaml
@@ -54,3 +54,39 @@ cases:
 
       prompt='\V'
       [[ "${prompt@P}" == ^\d+\.\d+\.\d+$ ]] && echo "Release is correct"
+
+  - name: "Simple date"
+    stdin: |
+      prompt='\d'
+      [[ "${prompt@P}" == $(date +'%a %b %d') ]] && echo '\d date matches'
+
+  - name: "Date format with plain string"
+    stdin: |
+      prompt='\D{something}'
+      echo "Date format with plain string: '${prompt@P}'"
+
+  - name: "Date format with year"
+    stdin: |
+      prompt='\d{%Y}'
+      echo "Date format with year: '${prompt@P}'"
+
+  - name: "Time format: @"
+    stdin: |
+      prompt='\@'
+      expanded=${prompt@P}
+      roundtripped=$(date --date="${expanded}" +'%I:%M %p')
+      [[ ${expanded} == ${roundtripped} ]] && echo "Time matches"
+
+  - name: "Time format: T"
+    stdin: |
+      prompt='\T'
+      expanded=${prompt@P}
+      roundtripped=$(date --date="${expanded}" +'%I:%M:%S')
+      [[ ${expanded} == ${roundtripped} ]] && echo "Time matches"
+
+  - name: "Time format: t"
+    stdin: |
+      prompt='\t'
+      expanded=${prompt@P}
+      roundtripped=$(date --date="${expanded}" +'%H:%M:%S')
+      [[ ${expanded} == ${roundtripped} ]] && echo "Time matches"