use crate::import::ImportGroup;
use crate::lang::CodeLang;
use crate::lang::config::{
BlockSyntaxConfig, EnumAndAnnotationConfig, FunctionSyntaxConfig, GenericSyntaxConfig,
TypeDeclSyntaxConfig, TypePresentationConfig,
};
use crate::spec::modifiers::{DeclarationContext, TypeKind, Visibility};
#[derive(Debug, Clone)]
pub struct Bash {
pub indent: String,
pub extension: String,
}
impl Default for Bash {
fn default() -> Self {
Self {
indent: " ".to_string(),
extension: "bash".to_string(),
}
}
}
impl Bash {
pub fn new() -> Self {
Self::default()
}
pub fn with_indent(mut self, s: &str) -> Self {
self.indent = s.to_string();
self
}
pub fn with_extension(mut self, s: &str) -> Self {
self.extension = s.to_string();
self
}
}
const BASH_RESERVED: &[&str] = &[
"break", "case", "continue", "coproc", "declare", "do", "done", "elif", "else", "esac", "eval",
"exec", "exit", "export", "fi", "for", "function", "if", "in", "local", "readonly", "return",
"select", "shift", "source", "then", "time", "trap", "typeset", "unset", "until", "while",
];
impl CodeLang for Bash {
fn file_extension(&self) -> &str {
&self.extension
}
fn reserved_words(&self) -> &[&str] {
BASH_RESERVED
}
fn render_imports(&self, imports: &ImportGroup) -> String {
if imports.entries.is_empty() {
return String::new();
}
let mut paths: Vec<&str> = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in &imports.entries {
if seen.insert(entry.module.as_str()) {
paths.push(&entry.module);
}
}
paths.sort();
paths
.iter()
.map(|p| format!("source \"{p}\""))
.collect::<Vec<_>>()
.join("\n")
}
fn render_string_literal(&self, s: &str) -> String {
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
.replace('!', "\\!");
format!("\"{escaped}\"")
}
fn render_doc_comment(&self, lines: &[&str]) -> String {
lines
.iter()
.map(|line| {
if line.is_empty() {
"#".to_string()
} else {
format!("# {line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn line_comment_prefix(&self) -> &str {
"#"
}
fn render_visibility(&self, _vis: Visibility, _ctx: DeclarationContext) -> &str {
""
}
fn function_keyword(&self, _ctx: DeclarationContext) -> &str {
"function"
}
fn type_keyword(&self, _kind: TypeKind) -> &str {
""
}
fn methods_inside_type_body(&self, _kind: TypeKind) -> bool {
true
}
fn type_presentation(&self) -> TypePresentationConfig<'_> {
TypePresentationConfig::default()
}
fn generic_syntax(&self) -> GenericSyntaxConfig<'_> {
GenericSyntaxConfig {
constraint_keyword: "",
constraint_separator: "",
..Default::default()
}
}
fn block_syntax(&self) -> BlockSyntaxConfig<'_> {
BlockSyntaxConfig {
indent_unit: &self.indent,
uses_semicolons: false,
field_terminator: "",
..Default::default()
}
}
fn function_syntax(&self) -> FunctionSyntaxConfig<'_> {
FunctionSyntaxConfig {
return_type_separator: "",
..Default::default()
}
}
fn type_decl_syntax(&self) -> TypeDeclSyntaxConfig<'_> {
TypeDeclSyntaxConfig::default()
}
fn enum_and_annotation(&self) -> EnumAndAnnotationConfig<'_> {
EnumAndAnnotationConfig::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_extension() {
let bash = Bash::new();
assert_eq!(bash.file_extension(), "bash");
}
#[test]
fn test_reserved_words() {
let bash = Bash::new();
let reserved = bash.reserved_words();
assert!(reserved.contains(&"if"));
assert!(reserved.contains(&"fi"));
assert!(reserved.contains(&"function"));
assert!(reserved.contains(&"esac"));
assert!(!reserved.contains(&"echo"));
}
#[test]
fn test_escape_reserved() {
let bash = Bash::new();
assert_eq!(bash.escape_reserved("if"), "if_");
assert_eq!(bash.escape_reserved("name"), "name");
assert_eq!(bash.escape_reserved("function"), "function_");
}
#[test]
fn test_string_literal_basic() {
let bash = Bash::new();
assert_eq!(bash.render_string_literal("hello"), "\"hello\"");
}
#[test]
fn test_string_literal_escaping() {
let bash = Bash::new();
assert_eq!(bash.render_string_literal("$HOME"), "\"\\$HOME\"");
assert_eq!(
bash.render_string_literal("say \"hi\""),
"\"say \\\"hi\\\"\""
);
assert_eq!(bash.render_string_literal("`cmd`"), "\"\\`cmd\\`\"");
assert_eq!(bash.render_string_literal("a\\b"), "\"a\\\\b\"");
assert_eq!(bash.render_string_literal("wow!"), "\"wow\\!\"");
}
#[test]
fn test_string_literal_combined() {
let bash = Bash::new();
assert_eq!(
bash.render_string_literal("$USER says \"hi!\""),
"\"\\$USER says \\\"hi\\!\\\"\"",
);
}
#[test]
fn test_render_imports_empty() {
let bash = Bash::new();
let imports = ImportGroup { entries: vec![] };
assert_eq!(bash.render_imports(&imports), "");
}
#[test]
fn test_render_imports_single() {
let bash = Bash::new();
let imports = ImportGroup {
entries: vec![crate::import::ImportEntry {
module: "./lib/utils.sh".into(),
name: "log_info".into(),
alias: None,
is_type_only: false,
is_side_effect: false,
is_wildcard: false,
}],
};
assert_eq!(bash.render_imports(&imports), "source \"./lib/utils.sh\"");
}
#[test]
fn test_render_imports_dedup() {
let bash = Bash::new();
let imports = ImportGroup {
entries: vec![
crate::import::ImportEntry {
module: "./lib/utils.sh".into(),
name: "log_info".into(),
alias: None,
is_type_only: false,
is_side_effect: false,
is_wildcard: false,
},
crate::import::ImportEntry {
module: "./lib/utils.sh".into(),
name: "log_error".into(),
alias: None,
is_type_only: false,
is_side_effect: false,
is_wildcard: false,
},
crate::import::ImportEntry {
module: "./lib/config.sh".into(),
name: "load_config".into(),
alias: None,
is_type_only: false,
is_side_effect: false,
is_wildcard: false,
},
],
};
let output = bash.render_imports(&imports);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "source \"./lib/config.sh\"");
assert_eq!(lines[1], "source \"./lib/utils.sh\"");
}
#[test]
fn test_doc_comment_single() {
let bash = Bash::new();
assert_eq!(bash.render_doc_comment(&["A function."]), "# A function.");
}
#[test]
fn test_doc_comment_multi() {
let bash = Bash::new();
let doc = bash.render_doc_comment(&["First line.", "", "Second paragraph."]);
let lines: Vec<&str> = doc.lines().collect();
assert_eq!(lines[0], "# First line.");
assert_eq!(lines[1], "#");
assert_eq!(lines[2], "# Second paragraph.");
}
#[test]
fn test_no_semicolons() {
let bash = Bash::new();
assert!(!bash.block_syntax().uses_semicolons);
}
#[test]
fn test_comment_prefix() {
let bash = Bash::new();
assert_eq!(bash.line_comment_prefix(), "#");
}
#[test]
fn test_function_keyword() {
let bash = Bash::new();
assert_eq!(
bash.function_keyword(DeclarationContext::TopLevel),
"function"
);
}
#[test]
fn test_block_delimiters() {
let bash = Bash::new();
assert_eq!(bash.block_syntax().block_open, " {");
assert_eq!(bash.block_syntax().block_close, "}");
}
#[test]
fn test_bash_builder_fluent() {
let bash = Bash::new().with_indent(" ").with_extension("sh");
assert_eq!(bash.file_extension(), "sh");
assert_eq!(bash.block_syntax().indent_unit, " ");
}
#[test]
fn test_module_separator() {
let bash = Bash::new();
assert_eq!(bash.module_separator(), None);
}
}