Skip to content

Commit 1deca62

Browse files
committed
feat(parser): add ${...} shell variable exception support
1 parent 6f916d6 commit 1deca62

File tree

2 files changed

+187
-32
lines changed

2 files changed

+187
-32
lines changed

src/pipeline/parser.rs

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -128,47 +128,71 @@ pub fn parse_multi_template(template: &str) -> Result<(Vec<TemplateSection>, boo
128128

129129
while let Some(ch) = chars.next() {
130130
if ch == '{' {
131-
// Found start of template section
131+
// Check if this is a shell variable expansion ${...}
132+
if current_literal.ends_with('$') {
133+
// This is a shell variable expansion, treat as literal text
134+
current_literal.push(ch);
135+
136+
// Find the matching closing brace for the shell variable
137+
let mut brace_count = 1;
138+
for inner_ch in chars.by_ref() {
139+
current_literal.push(inner_ch);
140+
if inner_ch == '{' {
141+
brace_count += 1;
142+
} else if inner_ch == '}' {
143+
brace_count -= 1;
144+
if brace_count == 0 {
145+
break; // Found matching closing brace for shell variable
146+
}
147+
}
148+
}
132149

133-
// Save any accumulated literal text
134-
if !current_literal.is_empty() {
135-
sections.push(TemplateSection::Literal(std::mem::take(
136-
&mut current_literal,
137-
)));
138-
}
150+
if brace_count > 0 {
151+
return Err("Unclosed shell variable brace".to_string());
152+
}
153+
} else {
154+
// Found start of template section
155+
156+
// Save any accumulated literal text
157+
if !current_literal.is_empty() {
158+
sections.push(TemplateSection::Literal(std::mem::take(
159+
&mut current_literal,
160+
)));
161+
}
162+
163+
// Find the matching closing brace
164+
let mut brace_count = 1;
165+
let mut template_content = String::new();
139166

140-
// Find the matching closing brace
141-
let mut brace_count = 1;
142-
let mut template_content = String::new();
143-
144-
for inner_ch in chars.by_ref() {
145-
if inner_ch == '{' {
146-
brace_count += 1;
147-
template_content.push(inner_ch);
148-
} else if inner_ch == '}' {
149-
brace_count -= 1;
150-
if brace_count == 0 {
151-
break; // Found matching closing brace
167+
for inner_ch in chars.by_ref() {
168+
if inner_ch == '{' {
169+
brace_count += 1;
170+
template_content.push(inner_ch);
171+
} else if inner_ch == '}' {
172+
brace_count -= 1;
173+
if brace_count == 0 {
174+
break; // Found matching closing brace
175+
} else {
176+
template_content.push(inner_ch);
177+
}
152178
} else {
153179
template_content.push(inner_ch);
154180
}
155-
} else {
156-
template_content.push(inner_ch);
157181
}
158-
}
159182

160-
if brace_count > 0 {
161-
return Err("Unclosed template brace".to_string());
162-
}
183+
if brace_count > 0 {
184+
return Err("Unclosed template brace".to_string());
185+
}
163186

164-
// Parse the template content
165-
let full_template = format!("{{{template_content}}}");
166-
let (ops, section_debug) = parse_template(&full_template)?;
167-
if section_debug {
168-
debug = true; // If any section has debug enabled, enable for the whole multi-template
169-
}
187+
// Parse the template content
188+
let full_template = format!("{{{template_content}}}");
189+
let (ops, section_debug) = parse_template(&full_template)?;
190+
if section_debug {
191+
debug = true; // If any section has debug enabled, enable for the whole multi-template
192+
}
170193

171-
sections.push(TemplateSection::Template(ops));
194+
sections.push(TemplateSection::Template(ops));
195+
}
172196
} else {
173197
// Regular character, add to current literal
174198
current_literal.push(ch);

tests/multi_template_tests.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,3 +626,134 @@ fn test_structured_template_file_operations() {
626626
"mkdir -p /home/user/projects/new && touch /home/user/projects/new/file.txt.tmp"
627627
);
628628
}
629+
630+
// Tests for shell variable support (${...} patterns)
631+
632+
#[test]
633+
fn test_multi_template_shell_variable_basic() {
634+
// Test basic shell variable pattern ${VAR}
635+
let template = MultiTemplate::parse("${HOME}/projects/{upper}").unwrap();
636+
let result = template.format("readme").unwrap();
637+
assert_eq!(result, "${HOME}/projects/README");
638+
}
639+
640+
#[test]
641+
fn test_multi_template_shell_variable_with_default() {
642+
// Test shell variable with default value ${VAR:-default}
643+
let template = MultiTemplate::parse("${EDITOR:-vim} {upper}.txt").unwrap();
644+
let result = template.format("config").unwrap();
645+
assert_eq!(result, "${EDITOR:-vim} CONFIG.txt");
646+
}
647+
648+
#[test]
649+
fn test_multi_template_shell_variable_specific_case() {
650+
// Test the specific case that was failing: ${EDITOR:-vim} {}
651+
let template = MultiTemplate::parse("${EDITOR:-vim} {}").unwrap();
652+
let result = template.format("file.txt").unwrap();
653+
assert_eq!(result, "${EDITOR:-vim} file.txt");
654+
}
655+
656+
#[test]
657+
fn test_multi_template_multiple_shell_variables() {
658+
// Test multiple shell variables in one template
659+
let template = MultiTemplate::parse("${USER}@${HOST}: {upper}").unwrap();
660+
let result = template.format("hello world").unwrap();
661+
assert_eq!(result, "${USER}@${HOST}: HELLO WORLD");
662+
}
663+
664+
#[test]
665+
fn test_multi_template_shell_variable_complex() {
666+
// Test complex shell variable expressions
667+
let template = MultiTemplate::parse("${PATH:+/usr/bin:}${HOME}/bin {lower}").unwrap();
668+
let result = template.format("SCRIPT").unwrap();
669+
assert_eq!(result, "${PATH:+/usr/bin:}${HOME}/bin script");
670+
}
671+
672+
#[test]
673+
fn test_multi_template_shell_variable_empty() {
674+
// Test empty shell variable ${}
675+
let template = MultiTemplate::parse("${} prefix {upper}").unwrap();
676+
let result = template.format("test").unwrap();
677+
assert_eq!(result, "${} prefix TEST");
678+
}
679+
680+
#[test]
681+
fn test_multi_template_shell_variable_nested_braces() {
682+
// Test shell variables with nested braces
683+
let template = MultiTemplate::parse("${CONFIG_DIR:-${HOME}/.config} {lower}").unwrap();
684+
let result = template.format("APP").unwrap();
685+
assert_eq!(result, "${CONFIG_DIR:-${HOME}/.config} app");
686+
}
687+
688+
#[test]
689+
fn test_multi_template_shell_variable_mixed_with_templates() {
690+
// Test mixing shell variables with multiple template sections
691+
let template = MultiTemplate::parse("cp {upper} ${BACKUP_DIR:-/backup}/{lower}.bak").unwrap();
692+
let result = template.format("important.txt").unwrap();
693+
assert_eq!(
694+
result,
695+
"cp IMPORTANT.TXT ${BACKUP_DIR:-/backup}/important.txt.bak"
696+
);
697+
}
698+
699+
#[test]
700+
fn test_multi_template_shell_variable_at_boundaries() {
701+
// Test shell variables at start/end of template
702+
let template = MultiTemplate::parse("${PREFIX} middle {upper} ${SUFFIX}").unwrap();
703+
let result = template.format("test").unwrap();
704+
assert_eq!(result, "${PREFIX} middle TEST ${SUFFIX}");
705+
}
706+
707+
#[test]
708+
fn test_multi_template_shell_variable_consecutive() {
709+
// Test consecutive shell variables
710+
let template = MultiTemplate::parse("${VAR1}${VAR2} {upper}").unwrap();
711+
let result = template.format("hello").unwrap();
712+
assert_eq!(result, "${VAR1}${VAR2} HELLO");
713+
}
714+
715+
#[test]
716+
fn test_multi_template_shell_variable_special_characters() {
717+
// Test shell variables with special characters
718+
let template = MultiTemplate::parse("${HOME}/some-dir/sub_dir {upper}").unwrap();
719+
let result = template.format("file name").unwrap();
720+
assert_eq!(result, "${HOME}/some-dir/sub_dir FILE NAME");
721+
}
722+
723+
#[test]
724+
fn test_multi_template_shell_variable_real_world_example() {
725+
// Test real-world shell command example
726+
let template = MultiTemplate::parse("${EDITOR:-nano} ${HOME}/.config/{lower}.conf").unwrap();
727+
let result = template.format("MYAPP").unwrap();
728+
assert_eq!(result, "${EDITOR:-nano} ${HOME}/.config/myapp.conf");
729+
}
730+
731+
// Error handling tests for shell variables
732+
733+
#[test]
734+
fn test_multi_template_unclosed_shell_variable_error() {
735+
// Test error when shell variable is not closed
736+
let result = MultiTemplate::parse("${HOME unclosed {upper}");
737+
assert!(result.is_err());
738+
assert!(
739+
result
740+
.unwrap_err()
741+
.contains("Unclosed shell variable brace")
742+
);
743+
}
744+
745+
#[test]
746+
fn test_multi_template_shell_variable_complex_nesting() {
747+
// Test complex nesting of shell variables and templates
748+
let template = MultiTemplate::parse(
749+
"${DIR:-${HOME}/default} contains {split:,:..|filter:\\.txt$|join: and }",
750+
)
751+
.unwrap();
752+
let result = template
753+
.format("file1.txt,doc.pdf,file2.txt,readme.md")
754+
.unwrap();
755+
assert_eq!(
756+
result,
757+
"${DIR:-${HOME}/default} contains file1.txt and file2.txt"
758+
);
759+
}

0 commit comments

Comments
 (0)