Skip to content

Commit 27a2719

Browse files
committed
feat(builtins): add clear, fold, expand/unexpand, envsubst commands
- clear: output ANSI escape sequences to clear terminal (Closes #545) - fold: wrap lines at specified width with -w/-s options (Closes #546) - expand/unexpand: convert between tabs and spaces (Closes #548) - envsubst: substitute environment variables in text (Closes #551) Each builtin includes comprehensive unit tests.
1 parent 8b739d9 commit 27a2719

File tree

6 files changed

+876
-0
lines changed

6 files changed

+876
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! clear builtin command - clear terminal screen
2+
3+
use async_trait::async_trait;
4+
5+
use super::{Builtin, Context};
6+
use crate::error::Result;
7+
use crate::interpreter::ExecResult;
8+
9+
/// The clear builtin command.
10+
///
11+
/// Outputs ANSI escape sequences to clear the terminal screen.
12+
/// In virtual/non-interactive mode, outputs the escape codes as-is.
13+
pub struct Clear;
14+
15+
#[async_trait]
16+
impl Builtin for Clear {
17+
async fn execute(&self, _ctx: Context<'_>) -> Result<ExecResult> {
18+
// ESC[2J clears the screen, ESC[H moves cursor to top-left
19+
Ok(ExecResult::ok("\x1b[2J\x1b[H".to_string()))
20+
}
21+
}
22+
23+
#[cfg(test)]
24+
mod tests {
25+
use super::*;
26+
use crate::fs::InMemoryFs;
27+
use std::collections::HashMap;
28+
use std::path::PathBuf;
29+
use std::sync::Arc;
30+
31+
#[tokio::test]
32+
async fn test_clear_outputs_ansi() {
33+
let args: Vec<String> = Vec::new();
34+
let env = HashMap::new();
35+
let mut variables = HashMap::new();
36+
let mut cwd = PathBuf::from("/");
37+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
38+
let ctx = Context {
39+
args: &args,
40+
env: &env,
41+
variables: &mut variables,
42+
cwd: &mut cwd,
43+
fs,
44+
stdin: None,
45+
#[cfg(feature = "http_client")]
46+
http_client: None,
47+
#[cfg(feature = "git")]
48+
git_client: None,
49+
};
50+
let result = Clear.execute(ctx).await.expect("clear failed");
51+
assert_eq!(result.exit_code, 0);
52+
assert!(result.stdout.contains("\x1b[2J"));
53+
assert!(result.stdout.contains("\x1b[H"));
54+
}
55+
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
//! envsubst builtin command - substitute environment variables in text
2+
3+
use async_trait::async_trait;
4+
5+
use super::{Builtin, Context};
6+
use crate::error::Result;
7+
use crate::interpreter::ExecResult;
8+
9+
/// The envsubst builtin command.
10+
///
11+
/// Usage: envsubst [SHELL-FORMAT] < input
12+
///
13+
/// Substitutes `$VAR` and `${VAR}` references with environment variable values.
14+
///
15+
/// Options:
16+
/// -v List variables found in input
17+
/// SHELL-FORMAT Only substitute listed variables (e.g. '$HOST $PORT')
18+
pub struct Envsubst;
19+
20+
#[async_trait]
21+
impl Builtin for Envsubst {
22+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
23+
let mut list_vars = false;
24+
let mut restrict_vars: Option<Vec<String>> = None;
25+
26+
for arg in ctx.args {
27+
match arg.as_str() {
28+
"-v" | "--variables" => list_vars = true,
29+
s if s.starts_with('$') => {
30+
// SHELL-FORMAT: list of vars to substitute
31+
let vars: Vec<String> = s
32+
.split_whitespace()
33+
.map(|v| {
34+
v.trim_start_matches('$')
35+
.trim_matches(|c| c == '{' || c == '}')
36+
})
37+
.filter(|v| !v.is_empty())
38+
.map(|v| v.to_string())
39+
.collect();
40+
restrict_vars = Some(vars);
41+
}
42+
_ => {}
43+
}
44+
}
45+
46+
let input = ctx.stdin.unwrap_or("");
47+
48+
if list_vars {
49+
// List variables found in input
50+
let vars = find_variables(input);
51+
let mut output = String::new();
52+
for var in vars {
53+
output.push_str(&var);
54+
output.push('\n');
55+
}
56+
return Ok(ExecResult::ok(output));
57+
}
58+
59+
let output = substitute(input, ctx.env, ctx.variables, restrict_vars.as_deref());
60+
Ok(ExecResult::ok(output))
61+
}
62+
}
63+
64+
fn find_variables(input: &str) -> Vec<String> {
65+
let mut vars = Vec::new();
66+
let chars: Vec<char> = input.chars().collect();
67+
let mut i = 0;
68+
69+
while i < chars.len() {
70+
if chars[i] == '$' {
71+
i += 1;
72+
if i < chars.len() && chars[i] == '{' {
73+
// ${VAR}
74+
i += 1;
75+
let start = i;
76+
while i < chars.len() && chars[i] != '}' {
77+
i += 1;
78+
}
79+
let name: String = chars[start..i].iter().collect();
80+
if !name.is_empty() && !vars.contains(&name) {
81+
vars.push(name);
82+
}
83+
if i < chars.len() {
84+
i += 1; // skip }
85+
}
86+
} else {
87+
// $VAR
88+
let start = i;
89+
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
90+
i += 1;
91+
}
92+
let name: String = chars[start..i].iter().collect();
93+
if !name.is_empty() && !vars.contains(&name) {
94+
vars.push(name);
95+
}
96+
}
97+
} else {
98+
i += 1;
99+
}
100+
}
101+
102+
vars
103+
}
104+
105+
fn substitute(
106+
input: &str,
107+
env: &std::collections::HashMap<String, String>,
108+
variables: &std::collections::HashMap<String, String>,
109+
restrict: Option<&[String]>,
110+
) -> String {
111+
let mut output = String::new();
112+
let chars: Vec<char> = input.chars().collect();
113+
let mut i = 0;
114+
115+
while i < chars.len() {
116+
if chars[i] == '$' {
117+
i += 1;
118+
if i < chars.len() && chars[i] == '{' {
119+
// ${VAR}
120+
i += 1;
121+
let start = i;
122+
while i < chars.len() && chars[i] != '}' {
123+
i += 1;
124+
}
125+
let name: String = chars[start..i].iter().collect();
126+
if i < chars.len() {
127+
i += 1; // skip }
128+
}
129+
if should_substitute(&name, restrict) {
130+
if let Some(val) = env.get(&name).or_else(|| variables.get(&name)) {
131+
output.push_str(val);
132+
}
133+
// If not found, substitute with empty string
134+
} else {
135+
output.push_str("${");
136+
output.push_str(&name);
137+
output.push('}');
138+
}
139+
} else {
140+
// $VAR
141+
let start = i;
142+
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
143+
i += 1;
144+
}
145+
let name: String = chars[start..i].iter().collect();
146+
if should_substitute(&name, restrict) {
147+
if let Some(val) = env.get(&name).or_else(|| variables.get(&name)) {
148+
output.push_str(val);
149+
}
150+
} else {
151+
output.push('$');
152+
output.push_str(&name);
153+
}
154+
}
155+
} else {
156+
output.push(chars[i]);
157+
i += 1;
158+
}
159+
}
160+
161+
output
162+
}
163+
164+
fn should_substitute(name: &str, restrict: Option<&[String]>) -> bool {
165+
match restrict {
166+
Some(allowed) => allowed.iter().any(|v| v == name),
167+
None => true,
168+
}
169+
}
170+
171+
#[cfg(test)]
172+
mod tests {
173+
use super::*;
174+
use crate::fs::InMemoryFs;
175+
use std::collections::HashMap;
176+
use std::path::PathBuf;
177+
use std::sync::Arc;
178+
179+
async fn run_envsubst(
180+
args: &[&str],
181+
stdin: Option<&str>,
182+
env: HashMap<String, String>,
183+
) -> ExecResult {
184+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
185+
let mut variables = HashMap::new();
186+
let mut cwd = PathBuf::from("/");
187+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
188+
let ctx = Context {
189+
args: &args,
190+
env: &env,
191+
variables: &mut variables,
192+
cwd: &mut cwd,
193+
fs,
194+
stdin,
195+
#[cfg(feature = "http_client")]
196+
http_client: None,
197+
#[cfg(feature = "git")]
198+
git_client: None,
199+
};
200+
Envsubst.execute(ctx).await.expect("envsubst failed")
201+
}
202+
203+
#[tokio::test]
204+
async fn test_basic_substitution() {
205+
let mut env = HashMap::new();
206+
env.insert("HOST".to_string(), "localhost".to_string());
207+
env.insert("PORT".to_string(), "8080".to_string());
208+
let result = run_envsubst(&[], Some("server=$HOST:$PORT"), env).await;
209+
assert_eq!(result.exit_code, 0);
210+
assert_eq!(result.stdout, "server=localhost:8080");
211+
}
212+
213+
#[tokio::test]
214+
async fn test_braced_substitution() {
215+
let mut env = HashMap::new();
216+
env.insert("NAME".to_string(), "world".to_string());
217+
let result = run_envsubst(&[], Some("hello ${NAME}!"), env).await;
218+
assert_eq!(result.exit_code, 0);
219+
assert_eq!(result.stdout, "hello world!");
220+
}
221+
222+
#[tokio::test]
223+
async fn test_missing_var_becomes_empty() {
224+
let env = HashMap::new();
225+
let result = run_envsubst(&[], Some("value=$MISSING"), env).await;
226+
assert_eq!(result.exit_code, 0);
227+
assert_eq!(result.stdout, "value=");
228+
}
229+
230+
#[tokio::test]
231+
async fn test_list_variables() {
232+
let env = HashMap::new();
233+
let result = run_envsubst(&["-v"], Some("$HOST and ${PORT} and $DB"), env).await;
234+
assert_eq!(result.exit_code, 0);
235+
assert!(result.stdout.contains("HOST"));
236+
assert!(result.stdout.contains("PORT"));
237+
assert!(result.stdout.contains("DB"));
238+
}
239+
240+
#[tokio::test]
241+
async fn test_restrict_variables() {
242+
let mut env = HashMap::new();
243+
env.insert("HOST".to_string(), "localhost".to_string());
244+
env.insert("PORT".to_string(), "8080".to_string());
245+
let result = run_envsubst(&["$HOST"], Some("$HOST:$PORT"), env).await;
246+
assert_eq!(result.exit_code, 0);
247+
assert_eq!(result.stdout, "localhost:$PORT");
248+
}
249+
250+
#[tokio::test]
251+
async fn test_no_vars_passthrough() {
252+
let env = HashMap::new();
253+
let result = run_envsubst(&[], Some("no variables here"), env).await;
254+
assert_eq!(result.exit_code, 0);
255+
assert_eq!(result.stdout, "no variables here");
256+
}
257+
}

0 commit comments

Comments
 (0)