#[cfg(feature = "shell-target")]
mod inner {
pub use bashrs::*;
}
#[cfg(feature = "shell-target")]
pub use inner::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Dash,
Fish,
Ash,
BusyBox,
}
impl Shell {
pub fn from_str_loose(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"bash" => Some(Self::Bash),
"zsh" => Some(Self::Zsh),
"dash" => Some(Self::Dash),
"fish" => Some(Self::Fish),
"ash" => Some(Self::Ash),
"busybox" | "busybox-sh" => Some(Self::BusyBox),
_ => None,
}
}
pub fn shebang(&self) -> &'static str {
match self {
Self::Bash => "#!/usr/bin/env bash",
Self::Zsh => "#!/usr/bin/env zsh",
Self::Dash => "#!/usr/bin/env dash",
Self::Fish => "#!/usr/bin/env fish",
Self::Ash => "#!/usr/bin/env ash",
Self::BusyBox => "#!/bin/sh",
}
}
pub fn supports_pipefail(&self) -> bool {
matches!(self, Self::Bash | Self::Zsh)
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bash => write!(f, "bash"),
Self::Zsh => write!(f, "zsh"),
Self::Dash => write!(f, "dash"),
Self::Fish => write!(f, "fish"),
Self::Ash => write!(f, "ash"),
Self::BusyBox => write!(f, "busybox"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShellTarget {
pub shell: Shell,
pub strict: bool,
pub shellcheck: bool,
}
impl Default for ShellTarget {
fn default() -> Self {
Self {
shell: Shell::Bash,
strict: true,
shellcheck: true,
}
}
}
impl ShellTarget {
pub fn for_shell(shell: Shell) -> Self {
Self {
shell,
..Default::default()
}
}
pub fn preamble(&self) -> String {
let mut lines = vec![self.shell.shebang().to_string()];
if self.strict {
if self.shell.supports_pipefail() {
lines.push("set -euo pipefail".to_string());
} else {
lines.push("set -eu".to_string());
}
}
if self.shellcheck {
lines.push(format!("# shellcheck shell={}", self.shell));
}
lines.join("\n")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum IssueSeverity {
Info,
Style,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PurifyIssue {
pub line: usize,
pub column: usize,
pub severity: IssueSeverity,
pub code: String,
pub message: String,
pub fixable: bool,
}
#[derive(Debug, Clone)]
pub struct PurifyResult {
pub issues: Vec<PurifyIssue>,
pub fixed: usize,
}
impl PurifyResult {
pub fn clean() -> Self {
Self {
issues: Vec::new(),
fixed: 0,
}
}
pub fn count_by_severity(&self, severity: IssueSeverity) -> usize {
self.issues
.iter()
.filter(|i| i.severity == severity)
.count()
}
pub fn total_issues(&self) -> usize {
self.issues.len()
}
pub fn fixable_remaining(&self) -> usize {
self.issues.iter().filter(|i| i.fixable).count()
}
pub fn summary(&self) -> String {
format!(
"Purify: {} issues ({} errors, {} warnings), {} auto-fixed",
self.total_issues(),
self.count_by_severity(IssueSeverity::Error),
self.count_by_severity(IssueSeverity::Warning),
self.fixed
)
}
}
#[derive(Debug, Clone)]
pub struct ShellScript {
pub target: ShellTarget,
pub content: String,
pub source: String,
}
impl ShellScript {
pub fn new(target: ShellTarget, content: &str, source: &str) -> Self {
Self {
target,
content: content.to_string(),
source: source.to_string(),
}
}
pub fn to_script(&self) -> String {
let preamble = self.target.preamble();
format!(
"{preamble}\n# Generated from {source} by ruchy --target shell\n\n{content}",
source = self.source,
content = self.content,
)
}
pub fn line_count(&self) -> usize {
self.content.lines().count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuoteStrategy {
Double,
Single,
None,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShellVar {
pub name: String,
pub value: String,
pub quoting: QuoteStrategy,
pub export: bool,
}
impl ShellVar {
pub fn new(name: &str, value: &str) -> Self {
Self {
name: name.to_string(),
value: value.to_string(),
quoting: QuoteStrategy::Double,
export: false,
}
}
pub fn exported(mut self) -> Self {
self.export = true;
self
}
pub fn to_shell(&self) -> String {
let val = match self.quoting {
QuoteStrategy::Double => format!("\"{}\"", self.value),
QuoteStrategy::Single => format!("'{}'", self.value),
QuoteStrategy::None => self.value.clone(),
};
if self.export {
format!("export {}={val}", self.name)
} else {
format!("{}={val}", self.name)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_from_str() {
assert_eq!(Shell::from_str_loose("bash"), Some(Shell::Bash));
assert_eq!(Shell::from_str_loose("ZSH"), Some(Shell::Zsh));
assert_eq!(Shell::from_str_loose("DASH"), Some(Shell::Dash));
assert_eq!(Shell::from_str_loose("fish"), Some(Shell::Fish));
assert_eq!(Shell::from_str_loose("busybox"), Some(Shell::BusyBox));
assert_eq!(Shell::from_str_loose("unknown"), None);
}
#[test]
fn test_shell_shebang() {
assert_eq!(Shell::Bash.shebang(), "#!/usr/bin/env bash");
assert_eq!(Shell::BusyBox.shebang(), "#!/bin/sh");
}
#[test]
fn test_shell_pipefail_support() {
assert!(Shell::Bash.supports_pipefail());
assert!(Shell::Zsh.supports_pipefail());
assert!(!Shell::Dash.supports_pipefail());
assert!(!Shell::Fish.supports_pipefail());
}
#[test]
fn test_shell_target_default() {
let target = ShellTarget::default();
assert_eq!(target.shell, Shell::Bash);
assert!(target.strict);
assert!(target.shellcheck);
}
#[test]
fn test_shell_target_preamble_bash() {
let target = ShellTarget::default();
let preamble = target.preamble();
assert!(preamble.contains("#!/usr/bin/env bash"));
assert!(preamble.contains("set -euo pipefail"));
assert!(preamble.contains("# shellcheck shell=bash"));
}
#[test]
fn test_shell_target_preamble_dash() {
let target = ShellTarget::for_shell(Shell::Dash);
let preamble = target.preamble();
assert!(preamble.contains("#!/usr/bin/env dash"));
assert!(preamble.contains("set -eu"));
assert!(!preamble.contains("pipefail"));
}
#[test]
fn test_purify_result_clean() {
let result = PurifyResult::clean();
assert_eq!(result.total_issues(), 0);
assert_eq!(result.fixed, 0);
assert_eq!(result.fixable_remaining(), 0);
}
#[test]
fn test_purify_result_with_issues() {
let result = PurifyResult {
issues: vec![
PurifyIssue {
line: 5,
column: 1,
severity: IssueSeverity::Error,
code: "SC2086".to_string(),
message: "Double quote to prevent globbing".to_string(),
fixable: true,
},
PurifyIssue {
line: 12,
column: 3,
severity: IssueSeverity::Warning,
code: "SC2034".to_string(),
message: "Variable appears unused".to_string(),
fixable: false,
},
PurifyIssue {
line: 20,
column: 1,
severity: IssueSeverity::Style,
code: "SC2148".to_string(),
message: "Tips depend on target shell".to_string(),
fixable: true,
},
],
fixed: 0,
};
assert_eq!(result.total_issues(), 3);
assert_eq!(result.count_by_severity(IssueSeverity::Error), 1);
assert_eq!(result.count_by_severity(IssueSeverity::Warning), 1);
assert_eq!(result.count_by_severity(IssueSeverity::Style), 1);
assert_eq!(result.fixable_remaining(), 2);
assert!(result.summary().contains("3 issues"));
}
#[test]
fn test_issue_severity_ordering() {
assert!(IssueSeverity::Info < IssueSeverity::Style);
assert!(IssueSeverity::Style < IssueSeverity::Warning);
assert!(IssueSeverity::Warning < IssueSeverity::Error);
}
#[test]
fn test_shell_display() {
assert_eq!(format!("{}", Shell::Bash), "bash");
assert_eq!(format!("{}", Shell::Zsh), "zsh");
assert_eq!(format!("{}", Shell::Fish), "fish");
}
#[test]
fn test_shell_script_new() {
let script = ShellScript::new(ShellTarget::default(), "echo hello", "test.ruchy");
assert_eq!(script.source, "test.ruchy");
assert_eq!(script.line_count(), 1);
}
#[test]
fn test_shell_script_to_script() {
let script = ShellScript::new(
ShellTarget::default(),
"echo hello\necho world",
"test.ruchy",
);
let output = script.to_script();
assert!(output.contains("#!/usr/bin/env bash"));
assert!(output.contains("set -euo pipefail"));
assert!(output.contains("Generated from test.ruchy"));
assert!(output.contains("echo hello"));
}
#[test]
fn test_shell_var_new() {
let var = ShellVar::new("NAME", "world");
assert_eq!(var.to_shell(), "NAME=\"world\"");
}
#[test]
fn test_shell_var_exported() {
let var = ShellVar::new("PATH", "/usr/bin").exported();
assert!(var.export);
assert_eq!(var.to_shell(), "export PATH=\"/usr/bin\"");
}
#[test]
fn test_shell_var_single_quote() {
let var = ShellVar {
name: "PATTERN".to_string(),
value: "*.txt".to_string(),
quoting: QuoteStrategy::Single,
export: false,
};
assert_eq!(var.to_shell(), "PATTERN='*.txt'");
}
#[test]
fn test_quote_strategy_variants() {
assert_ne!(QuoteStrategy::Double, QuoteStrategy::Single);
assert_ne!(QuoteStrategy::Single, QuoteStrategy::None);
}
}