use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use globset::{Glob, GlobMatcher};
use shuck_config::{
ConfigArguments, FormatSettingsPatch, LintConfig, ShuckConfig, apply_config_overrides,
load_project_config, resolve_project_root_for_file,
};
use shuck_formatter::{ShellDialect as FormatDialect, ShellFormatOptions};
use shuck_linter::{
CompiledPerFileIgnoreList, LinterRuleOptions, LinterSettings, PerFileIgnore, RuleSelector,
RuleSet, ShellDialect as LinterShellDialect,
};
use crate::session::{
ClientOptions, CompletionFeatureOptions, RenameFeatureOptions, WorkspaceSymbolFeatureOptions,
};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct ClientSettings {
fix_all: bool,
unsafe_fixes: bool,
show_syntax_errors: bool,
completion: CompletionFeatureOptions,
rename: RenameFeatureOptions,
}
#[derive(Clone, Debug)]
pub struct ShuckSettings {
linter: LinterSettings,
formatter: ShellFormatOptions,
fixable_rules: RuleSet,
project_root: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SettingsResolveContext {
config_root: PathBuf,
project_root: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub(crate) struct ResolvedProjectSettings {
linter: ResolvedLinterSettings,
formatter: ShellFormatOptions,
fixable_rules: RuleSet,
project_root: Option<PathBuf>,
}
#[derive(Clone, Debug)]
struct ResolvedLinterSettings {
base: LinterSettings,
per_file_shell: CompiledPerFileShellList,
}
pub struct GlobalClientSettings {
options: ClientOptions,
#[allow(dead_code)]
client: crate::Client,
}
impl ClientSettings {
pub(crate) fn fix_all(&self) -> bool {
self.fix_all
}
pub(crate) fn unsafe_fixes(&self) -> bool {
self.unsafe_fixes
}
pub(crate) fn show_syntax_errors(&self) -> bool {
self.show_syntax_errors
}
pub(crate) fn completion(&self) -> CompletionFeatureOptions {
self.completion
}
pub(crate) fn rename(&self) -> RenameFeatureOptions {
self.rename
}
pub(crate) fn from_options(options: &ClientOptions) -> Self {
Self::from_layered_options(&[options])
}
pub(crate) fn from_layered_options(option_layers: &[&ClientOptions]) -> Self {
let mut fix_all = None;
let mut unsafe_fixes = None;
let mut show_syntax_errors = None;
let mut completion = CompletionFeatureOptions::default();
let mut rename = RenameFeatureOptions::default();
for options in option_layers {
if options.fix_all.is_some() {
fix_all = options.fix_all;
}
if options.unsafe_fixes.is_some() {
unsafe_fixes = options.unsafe_fixes;
}
if options.show_syntax_errors.is_some() {
show_syntax_errors = options.show_syntax_errors;
}
completion = options.server.completion_layered_over(completion);
rename = options.server.rename_layered_over(rename);
}
Self {
fix_all: fix_all.unwrap_or(true),
unsafe_fixes: unsafe_fixes.unwrap_or(false),
show_syntax_errors: show_syntax_errors.unwrap_or(false),
completion,
rename,
}
}
}
impl ShuckSettings {
pub(crate) fn resolve(
file_path: Option<&Path>,
workspace_roots: &[PathBuf],
option_layers: &[&ClientOptions],
) -> Self {
let context = file_path
.map(|file_path| SettingsResolveContext::for_file(file_path, workspace_roots))
.unwrap_or_else(|| SettingsResolveContext::without_file(workspace_roots));
ResolvedProjectSettings::resolve(&context, option_layers).for_file(file_path)
}
pub(crate) fn linter(&self) -> &LinterSettings {
&self.linter
}
pub(crate) fn formatter(&self) -> &ShellFormatOptions {
&self.formatter
}
pub(crate) fn fixable_rules(&self) -> RuleSet {
self.fixable_rules
}
pub(crate) fn project_root(&self) -> Option<&Path> {
self.project_root.as_deref()
}
}
impl Default for ShuckSettings {
fn default() -> Self {
Self {
linter: LinterSettings::default(),
formatter: ShellFormatOptions::default(),
fixable_rules: RuleSet::all(),
project_root: None,
}
}
}
impl SettingsResolveContext {
pub(crate) fn for_file(file_path: &Path, workspace_roots: &[PathBuf]) -> Self {
let fallback_root = containing_workspace_root(file_path, workspace_roots)
.or_else(|| file_path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."));
let project_root = resolve_project_root_for_file(file_path, &fallback_root, true)
.unwrap_or(fallback_root.clone());
Self {
config_root: project_root.clone(),
project_root: Some(project_root),
}
}
pub(crate) fn without_file(workspace_roots: &[PathBuf]) -> Self {
Self {
config_root: workspace_roots
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from(".")),
project_root: None,
}
}
pub(crate) fn config_root(&self) -> &Path {
&self.config_root
}
}
impl ResolvedProjectSettings {
pub(crate) fn resolve(
context: &SettingsResolveContext,
option_layers: &[&ClientOptions],
) -> Self {
let project_config = context
.project_root
.as_ref()
.map(|project_root| {
load_project_config(project_root, &ConfigArguments::default()).unwrap_or_else(
|error| {
tracing::warn!(
"Failed to load shuck config for {}: {error}",
project_root.display()
);
ShuckConfig::default()
},
)
})
.unwrap_or_default();
Self {
linter: resolved_linter_settings_for_layers(
context.config_root(),
&project_config,
option_layers,
),
formatter: formatter_settings_for_layers(&project_config, option_layers),
fixable_rules: fixable_rules_for_layers(&project_config, option_layers),
project_root: context.project_root.clone(),
}
}
pub(crate) fn for_file(&self, file_path: Option<&Path>) -> ShuckSettings {
ShuckSettings {
linter: self.linter.for_file(file_path),
formatter: self.formatter.clone(),
fixable_rules: self.fixable_rules,
project_root: self.project_root.clone(),
}
}
}
impl ResolvedLinterSettings {
fn for_file(&self, file_path: Option<&Path>) -> LinterSettings {
let mut linter = self.base.clone();
linter.shell = file_path
.and_then(|file_path| self.per_file_shell.shell_for_path(file_path))
.unwrap_or(LinterShellDialect::Unknown);
linter
}
}
impl GlobalClientSettings {
pub(super) fn new(options: ClientOptions, client: crate::Client) -> Self {
Self { options, client }
}
pub(super) fn to_settings_arc(&self) -> Arc<ClientSettings> {
Arc::new(ClientSettings::from_options(&self.options))
}
pub(crate) fn options(&self) -> &ClientOptions {
&self.options
}
pub(crate) fn workspace_symbol_options(&self) -> WorkspaceSymbolFeatureOptions {
self.options.server.workspace_symbols
}
pub(crate) fn update_options(&mut self, options: ClientOptions) {
self.options = options;
}
}
fn containing_workspace_root(path: &Path, workspace_roots: &[PathBuf]) -> Option<PathBuf> {
workspace_roots
.iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
}
#[derive(Debug, Clone, Default)]
struct RuleSelectionLayer {
select: Option<Vec<RuleSelector>>,
ignore: Vec<RuleSelector>,
extend_select: Vec<RuleSelector>,
per_file_ignores: Option<Vec<PerFileIgnoreSpec>>,
extend_per_file_ignores: Vec<PerFileIgnoreSpec>,
per_file_shell: Option<Vec<PerFileShell>>,
extend_per_file_shell: Vec<PerFileShell>,
fixable: Option<Vec<RuleSelector>>,
unfixable: Vec<RuleSelector>,
extend_fixable: Vec<RuleSelector>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PerFileIgnoreSpec {
pattern: String,
selectors: Vec<RuleSelector>,
}
impl PerFileIgnoreSpec {
fn into_ignore(self) -> PerFileIgnore {
PerFileIgnore::new(
self.pattern,
selectors_to_rule_set_from_parsed(&self.selectors),
)
}
}
fn lint_layers<'a>(
project_config: &'a ShuckConfig,
option_layers: &'a [&'a ClientOptions],
) -> impl Iterator<Item = RuleSelectionLayer> + 'a {
std::iter::once(parse_lint_config_layer(&project_config.lint)).chain(option_layers.iter().map(
|options| {
options
.lint
.as_ref()
.map(parse_lint_config_layer)
.unwrap_or_default()
},
))
}
fn resolved_linter_settings_for_layers(
project_root: &Path,
project_config: &ShuckConfig,
option_layers: &[&ClientOptions],
) -> ResolvedLinterSettings {
let mut rules = LinterSettings::default_rules();
let mut per_file_ignores = Vec::new();
let mut per_file_shell = Vec::new();
let mut rule_options = LinterRuleOptions::default();
for layer in lint_layers(project_config, option_layers) {
rules = apply_rule_selector_layer(
rules,
layer.select.as_deref(),
&layer.extend_select,
&layer.ignore,
);
per_file_ignores = apply_per_file_ignore_layer(
per_file_ignores,
layer.per_file_ignores,
layer.extend_per_file_ignores,
);
per_file_shell = apply_per_file_shell_layer(
per_file_shell,
layer.per_file_shell,
layer.extend_per_file_shell,
);
}
for lint in std::iter::once(&project_config.lint).chain(
option_layers
.iter()
.filter_map(|options| options.lint.as_ref()),
) {
apply_linter_rule_options_for_lint_config(&mut rule_options, lint);
}
let compiled_per_file_ignores =
CompiledPerFileIgnoreList::resolve(project_root.to_path_buf(), per_file_ignores)
.unwrap_or_default();
let compiled_per_file_shell =
CompiledPerFileShellList::resolve(project_root.to_path_buf(), per_file_shell)
.unwrap_or_else(|error| {
tracing::warn!("Failed to compile per-file shell settings: {error}");
CompiledPerFileShellList::default()
});
ResolvedLinterSettings {
base: LinterSettings {
rules,
shell: LinterShellDialect::Unknown,
per_file_ignores: Arc::new(compiled_per_file_ignores),
rule_options,
..LinterSettings::default()
},
per_file_shell: compiled_per_file_shell,
}
}
fn formatter_settings_for_layers(
project_config: &ShuckConfig,
option_layers: &[&ClientOptions],
) -> ShellFormatOptions {
let mut config = ShuckConfig {
format: project_config.format.clone(),
..ShuckConfig::default()
};
for options in option_layers {
apply_config_overrides(&mut config, options.to_config_overrides());
}
formatter_settings_for_config(&config)
}
fn formatter_settings_for_config(config: &ShuckConfig) -> ShellFormatOptions {
let patch = config.format.to_patch().unwrap_or(FormatSettingsPatch {
..FormatSettingsPatch::default()
});
let mut options = ShellFormatOptions::default();
if let Some(indent_style) = patch.indent_style {
options = options.with_indent_style(indent_style);
}
if let Some(indent_width) = patch.indent_width {
options = options.with_indent_width(indent_width);
}
if let Some(binary_next_line) = patch.binary_next_line {
options = options.with_binary_next_line(binary_next_line);
}
if let Some(switch_case_indent) = patch.switch_case_indent {
options = options.with_switch_case_indent(switch_case_indent);
}
if let Some(space_redirects) = patch.space_redirects {
options = options.with_space_redirects(space_redirects);
}
if let Some(keep_padding) = patch.keep_padding {
options = options.with_keep_padding(keep_padding);
}
if let Some(function_next_line) = patch.function_next_line {
options = options.with_function_next_line(function_next_line);
}
if let Some(never_split) = patch.never_split {
options = options.with_never_split(never_split);
}
if let Some(simplify) = patch.simplify {
options = options.with_simplify(simplify);
}
if let Some(minify) = patch.minify {
options = options.with_minify(minify);
}
if let Some(dialect) = patch.dialect {
options = options.with_dialect(match dialect {
FormatDialect::Auto => FormatDialect::Auto,
FormatDialect::Bash => FormatDialect::Bash,
FormatDialect::Posix => FormatDialect::Posix,
FormatDialect::Mksh => FormatDialect::Mksh,
FormatDialect::Zsh => FormatDialect::Zsh,
});
}
options
}
fn fixable_rules_for_layers(
project_config: &ShuckConfig,
option_layers: &[&ClientOptions],
) -> RuleSet {
let mut fixable_rules = RuleSet::all();
for layer in lint_layers(project_config, option_layers) {
fixable_rules = apply_rule_selector_layer(
fixable_rules,
layer.fixable.as_deref(),
&layer.extend_fixable,
&layer.unfixable,
);
}
fixable_rules
}
fn selectors_to_rule_set_from_parsed(selectors: &[RuleSelector]) -> RuleSet {
selectors.iter().fold(RuleSet::EMPTY, |rules, selector| {
rules.union(&selector.into_rule_set())
})
}
fn parse_lint_config_layer(lint: &LintConfig) -> RuleSelectionLayer {
RuleSelectionLayer {
select: parse_selector_list(lint.select.as_deref(), "lint.select"),
ignore: parse_selector_list(lint.ignore.as_deref(), "lint.ignore").unwrap_or_default(),
extend_select: parse_selector_list(lint.extend_select.as_deref(), "lint.extend-select")
.unwrap_or_default(),
per_file_ignores: lint
.per_file_ignores
.as_ref()
.map(|entries| parse_per_file_ignore_specs(entries, "lint.per-file-ignores")),
extend_per_file_ignores: lint
.extend_per_file_ignores
.as_ref()
.map(|entries| parse_per_file_ignore_specs(entries, "lint.extend-per-file-ignores"))
.unwrap_or_default(),
per_file_shell: lint
.per_file_shell
.as_ref()
.map(|entries| parse_per_file_shell_map(entries, "lint.per-file-shell")),
extend_per_file_shell: lint
.extend_per_file_shell
.as_ref()
.map(|entries| parse_per_file_shell_map(entries, "lint.extend-per-file-shell"))
.unwrap_or_default(),
fixable: parse_selector_list(lint.fixable.as_deref(), "lint.fixable"),
unfixable: parse_selector_list(lint.unfixable.as_deref(), "lint.unfixable")
.unwrap_or_default(),
extend_fixable: parse_selector_list(lint.extend_fixable.as_deref(), "lint.extend-fixable")
.unwrap_or_default(),
}
}
fn parse_per_file_ignore_specs(
entries: &BTreeMap<String, Vec<String>>,
scope: &str,
) -> Vec<PerFileIgnoreSpec> {
entries
.iter()
.filter_map(|(pattern, selectors)| match parse_selectors(selectors) {
Ok(parsed) => Some(PerFileIgnoreSpec {
pattern: pattern.clone(),
selectors: parsed,
}),
Err(error) => {
tracing::warn!("Ignoring invalid {scope} entry for {pattern:?}: {error}");
None
}
})
.collect()
}
fn parse_selectors(selectors: &[String]) -> anyhow::Result<Vec<RuleSelector>> {
selectors
.iter()
.map(|selector| RuleSelector::from_str(selector).map_err(anyhow::Error::new))
.collect()
}
fn parse_selector_list(selectors: Option<&[String]>, scope: &str) -> Option<Vec<RuleSelector>> {
let selectors = selectors?;
match parse_selectors(selectors) {
Ok(parsed) => Some(parsed),
Err(error) => {
tracing::warn!("Ignoring invalid {scope} selectors: {error}");
None
}
}
}
fn apply_rule_selector_layer(
current: RuleSet,
select: Option<&[RuleSelector]>,
extend_select: &[RuleSelector],
ignore: &[RuleSelector],
) -> RuleSet {
let mut specificities = select
.into_iter()
.flatten()
.chain(extend_select.iter())
.chain(ignore.iter())
.map(selector_specificity)
.collect::<Vec<_>>();
specificities.sort_unstable();
specificities.dedup();
let mut updates = std::collections::HashMap::<shuck_linter::Rule, bool>::new();
for specificity in specificities {
for selector in select
.into_iter()
.flatten()
.chain(extend_select.iter())
.filter(|selector| selector_specificity(selector) == specificity)
{
for rule in selector.into_rule_set().iter() {
updates.insert(rule, true);
}
}
for selector in ignore
.iter()
.filter(|selector| selector_specificity(selector) == specificity)
{
for rule in selector.into_rule_set().iter() {
updates.insert(rule, false);
}
}
}
if select.is_some() {
updates
.into_iter()
.filter_map(|(rule, enabled)| enabled.then_some(rule))
.collect()
} else {
let mut rules = current;
for (rule, enabled) in updates {
rules.set(rule, enabled);
}
rules
}
}
fn selector_specificity(selector: &RuleSelector) -> usize {
match selector {
RuleSelector::All => 0,
RuleSelector::Category(_) => 1,
RuleSelector::Named(_) => 1,
RuleSelector::Prefix(prefix) => 2 + prefix.len(),
RuleSelector::Rule(_) => usize::MAX,
}
}
fn apply_per_file_ignore_layer(
current: Vec<PerFileIgnore>,
per_file_ignores: Option<Vec<PerFileIgnoreSpec>>,
extend_per_file_ignores: Vec<PerFileIgnoreSpec>,
) -> Vec<PerFileIgnore> {
let mut per_file_ignores = per_file_ignores
.map(|per_file_ignores| {
per_file_ignores
.into_iter()
.map(PerFileIgnoreSpec::into_ignore)
.collect()
})
.unwrap_or(current);
per_file_ignores.extend(
extend_per_file_ignores
.into_iter()
.map(PerFileIgnoreSpec::into_ignore),
);
per_file_ignores
}
fn linter_rule_options_for_lint_config(lint: &LintConfig) -> LinterRuleOptions {
let mut rule_options = LinterRuleOptions::default();
apply_linter_rule_options_for_lint_config(&mut rule_options, lint);
rule_options
}
fn apply_linter_rule_options_for_lint_config(
rule_options: &mut LinterRuleOptions,
lint: &LintConfig,
) {
if let Some(value) = lint
.rule_options
.as_ref()
.and_then(|options| options.c001.as_ref())
.and_then(|c001| c001.treat_indirect_expansion_targets_as_used)
{
rule_options.c001.treat_indirect_expansion_targets_as_used = value;
}
if let Some(value) = lint
.rule_options
.as_ref()
.and_then(|options| options.c063.as_ref())
.and_then(|c063| c063.report_unreached_nested_definitions)
{
rule_options.c063.report_unreached_nested_definitions = value;
}
if let Some(value) = lint
.rule_options
.as_ref()
.and_then(|options| options.s085.as_ref())
.and_then(|s085| s085.non_trivial_line_threshold)
{
rule_options.s085.non_trivial_line_threshold = value;
}
if let Some(value) = lint
.rule_options
.as_ref()
.and_then(|options| options.s085.as_ref())
.and_then(|s085| s085.non_trivial_function_count)
{
rule_options.s085.non_trivial_function_count = value;
}
if let Some(value) = lint
.rule_options
.as_ref()
.and_then(|options| options.s085.as_ref())
.and_then(|s085| s085.main_name.as_ref())
{
rule_options.s085.main_name.clone_from(value);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PerFileShell {
pattern: String,
shell: LinterShellDialect,
}
#[derive(Debug, Clone, Default)]
struct CompiledPerFileShellList {
project_root: PathBuf,
entries: Vec<CompiledPerFileShell>,
}
#[derive(Debug, Clone)]
struct CompiledPerFileShell {
basename_matcher: GlobMatcher,
relative_matcher: GlobMatcher,
absolute_matcher: GlobMatcher,
negated: bool,
shell: LinterShellDialect,
}
impl CompiledPerFileShellList {
fn resolve(project_root: PathBuf, per_file_shell: Vec<PerFileShell>) -> anyhow::Result<Self> {
let entries = per_file_shell
.into_iter()
.map(|per_file_shell| {
let mut pattern = per_file_shell.pattern;
let negated = pattern.starts_with('!');
if negated {
pattern.drain(..1);
}
let basename_matcher = Glob::new(&pattern)
.map_err(|err| anyhow::anyhow!("invalid glob {pattern:?}: {err}"))?
.compile_matcher();
let relative_matcher = Glob::new(&pattern)
.map_err(|err| anyhow::anyhow!("invalid glob {pattern:?}: {err}"))?
.compile_matcher();
let absolute_matcher = Glob::new(&pattern)
.map_err(|err| anyhow::anyhow!("invalid glob {pattern:?}: {err}"))?
.compile_matcher();
Ok(CompiledPerFileShell {
basename_matcher,
relative_matcher,
absolute_matcher,
negated,
shell: per_file_shell.shell,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Self {
project_root,
entries,
})
}
fn shell_for_path(&self, path: &Path) -> Option<LinterShellDialect> {
let relative_path = path.strip_prefix(&self.project_root).unwrap_or(path);
let file_name = relative_path.file_name().or_else(|| path.file_name())?;
self.entries.iter().fold(None, |shell, entry| {
let matches = entry.basename_matcher.is_match(file_name)
|| entry.relative_matcher.is_match(relative_path)
|| matches_absolute_path(&entry.absolute_matcher, path);
let applies = if entry.negated { !matches } else { matches };
if applies { Some(entry.shell) } else { shell }
})
}
}
fn parse_per_file_shell_map(entries: &BTreeMap<String, String>, scope: &str) -> Vec<PerFileShell> {
entries
.iter()
.filter_map(|(pattern, shell_name)| {
let shell = LinterShellDialect::from_name(shell_name);
if shell == LinterShellDialect::Unknown {
tracing::warn!("Ignoring invalid {scope} entry for {pattern:?}: {shell_name:?}");
return None;
}
Some(PerFileShell {
pattern: pattern.clone(),
shell,
})
})
.collect()
}
fn apply_per_file_shell_layer(
current: Vec<PerFileShell>,
per_file_shell: Option<Vec<PerFileShell>>,
extend_per_file_shell: Vec<PerFileShell>,
) -> Vec<PerFileShell> {
let mut per_file_shell = per_file_shell.unwrap_or(current);
per_file_shell.extend(extend_per_file_shell);
per_file_shell
}
fn matches_absolute_path(matcher: &GlobMatcher, path: &Path) -> bool {
matcher.is_match(path)
|| matcher.is_match(normalize_path(path))
|| slash_normalized_match_path(path)
.as_deref()
.is_some_and(|normalized| matcher.is_match(normalized))
|| normalized_absolute_match_path(path)
.as_ref()
.is_some_and(|normalized| {
matcher.is_match(normalized)
|| slash_normalized_match_path(normalized)
.as_deref()
.is_some_and(|slash_normalized| matcher.is_match(slash_normalized))
})
}
fn normalize_path(path: &Path) -> PathBuf {
path.components().collect()
}
fn normalized_absolute_match_path(path: &Path) -> Option<PathBuf> {
let path = path.to_string_lossy();
if let Some(stripped) = path.strip_prefix(r"\\?\UNC\") {
return Some(PathBuf::from(format!(r"\\{stripped}")));
}
path.strip_prefix(r"\\?\").map(PathBuf::from)
}
fn slash_normalized_match_path(path: &Path) -> Option<PathBuf> {
let path = path.to_string_lossy();
path.contains('\\')
.then(|| PathBuf::from(path.replace('\\', "/")))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn client_settings_default_to_safe_editor_behavior() {
let settings = ClientSettings::from_options(&ClientOptions::default());
assert!(settings.fix_all());
assert!(!settings.unsafe_fixes());
assert!(!settings.show_syntax_errors());
}
#[test]
fn shuck_settings_load_rule_selection_from_config() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nselect = ['C001']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert_eq!(settings.project_root(), Some(tempdir.path()));
assert!(
settings
.linter()
.rules
.contains(shuck_linter::Rule::UnusedAssignment)
);
assert_eq!(settings.linter().rules.len(), 1);
}
#[test]
fn shuck_settings_merge_extended_per_file_ignores() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nper-file-ignores = { '*.sh' = ['C001'] }\nextend-per-file-ignores = { '*.sh' = ['C006'] }\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
let ignored = settings.linter().per_file_ignores.ignored_rules(&file_path);
assert!(ignored.contains(shuck_linter::Rule::UnusedAssignment));
assert!(ignored.contains(shuck_linter::Rule::UndefinedVariable));
}
#[test]
fn shuck_settings_apply_per_file_shell_override() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nper-file-shell = { '*.sh' = 'zsh' }\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "echo ${(%):-%x}\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert_eq!(settings.linter().shell, LinterShellDialect::Zsh);
}
#[test]
fn shuck_settings_apply_s085_rule_options() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint.rule-options.s085]\nnon-trivial-line-threshold = 20\nnon-trivial-function-count = 3\nmain-name = 'run'\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "run() { :; }\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert_eq!(
settings
.linter()
.rule_options
.s085
.non_trivial_line_threshold,
20
);
assert_eq!(
settings
.linter()
.rule_options
.s085
.non_trivial_function_count,
3
);
assert_eq!(settings.linter().rule_options.s085.main_name, "run");
}
#[test]
fn shuck_settings_honor_unfixable_rule_config() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nunfixable = ['C001']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert!(
!settings
.fixable_rules()
.contains(shuck_linter::Rule::UnusedAssignment)
);
}
#[test]
fn shuck_settings_apply_client_overrides_without_a_file_path() {
let options = ClientOptions {
lint: Some(shuck_config::LintConfig {
select: Some(vec!["C001".to_owned()]),
..shuck_config::LintConfig::default()
}),
format: Some(shuck_config::FormatConfig {
indent_style: Some("space".to_owned()),
indent_width: Some(2),
..shuck_config::FormatConfig::default()
}),
..ClientOptions::default()
};
let settings = ShuckSettings::resolve(None, &[], &[&options]);
assert!(
settings
.linter()
.rules
.contains(shuck_linter::Rule::UnusedAssignment)
);
assert_eq!(settings.linter().rules.len(), 1);
assert_eq!(
settings.formatter().indent_style(),
shuck_formatter::IndentStyle::Space
);
assert_eq!(settings.formatter().indent_width(), 2);
}
#[test]
fn invalid_selectors_leave_default_rules_enabled() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nselect = ['C001', 'oops']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert_eq!(settings.linter().rules, LinterSettings::default_rules());
}
#[test]
fn invalid_ignore_selectors_do_not_clear_valid_rule_selection() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nselect = ['C001']\nignore = ['oops']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert!(
settings
.linter()
.rules
.contains(shuck_linter::Rule::UnusedAssignment)
);
assert_eq!(settings.linter().rules.len(), 1);
}
#[test]
fn invalid_fixable_selectors_leave_fixable_rules_enabled() {
let options = ClientOptions::default();
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nfixable = ['oops']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&options],
);
assert_eq!(settings.fixable_rules(), RuleSet::all());
}
#[test]
fn later_select_replaces_earlier_extend_select() {
let client_options = ClientOptions {
lint: Some(shuck_config::LintConfig {
select: Some(vec!["C001".to_owned()]),
..shuck_config::LintConfig::default()
}),
..ClientOptions::default()
};
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nextend-select = ['C006']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
assert!(
settings
.linter()
.rules
.contains(shuck_linter::Rule::UnusedAssignment)
);
assert!(
!settings
.linter()
.rules
.contains(shuck_linter::Rule::UndefinedVariable)
);
assert_eq!(settings.linter().rules.len(), 1);
}
#[test]
fn later_fixable_replaces_earlier_extend_fixable() {
let client_options = ClientOptions {
lint: Some(shuck_config::LintConfig {
fixable: Some(vec!["C001".to_owned()]),
..shuck_config::LintConfig::default()
}),
..ClientOptions::default()
};
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nextend-fixable = ['C006']\n",
)
.expect("config should be written");
let file_path = tempdir.path().join("script.sh");
std::fs::write(&file_path, "foo=1\n").expect("source should be written");
let settings = ShuckSettings::resolve(
Some(&file_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
assert!(
settings
.fixable_rules()
.contains(shuck_linter::Rule::UnusedAssignment)
);
assert!(
!settings
.fixable_rules()
.contains(shuck_linter::Rule::UndefinedVariable)
);
}
#[test]
fn later_per_file_shell_replaces_earlier_extend_per_file_shell() {
let client_options = ClientOptions {
lint: Some(shuck_config::LintConfig {
per_file_shell: Some(BTreeMap::from([(
"portable.sh".to_owned(),
"sh".to_owned(),
)])),
..shuck_config::LintConfig::default()
}),
..ClientOptions::default()
};
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nextend-per-file-shell = { '*.sh' = 'bash' }\n",
)
.expect("config should be written");
let portable_path = tempdir.path().join("portable.sh");
let other_path = tempdir.path().join("other.sh");
std::fs::write(&portable_path, "source helper.sh\n").expect("source should be written");
std::fs::write(&other_path, "source helper.sh\n").expect("source should be written");
let portable_settings = ShuckSettings::resolve(
Some(&portable_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
let other_settings = ShuckSettings::resolve(
Some(&other_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
assert_eq!(portable_settings.linter().shell, LinterShellDialect::Sh);
assert_eq!(other_settings.linter().shell, LinterShellDialect::Unknown);
}
#[test]
fn later_per_file_ignores_replace_earlier_extend_per_file_ignores() {
let client_options = ClientOptions {
lint: Some(shuck_config::LintConfig {
per_file_ignores: Some(BTreeMap::from([(
"portable.sh".to_owned(),
vec!["C006".to_owned()],
)])),
..shuck_config::LintConfig::default()
}),
..ClientOptions::default()
};
let tempdir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
tempdir.path().join(".shuck.toml"),
"[lint]\nextend-per-file-ignores = { '*.sh' = ['C001'] }\n",
)
.expect("config should be written");
let portable_path = tempdir.path().join("portable.sh");
let other_path = tempdir.path().join("other.sh");
std::fs::write(&portable_path, "foo=1\n").expect("source should be written");
std::fs::write(&other_path, "foo=1\n").expect("source should be written");
let portable_settings = ShuckSettings::resolve(
Some(&portable_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
let other_settings = ShuckSettings::resolve(
Some(&other_path),
&[tempdir.path().to_path_buf()],
&[&client_options],
);
let portable_ignored = portable_settings
.linter()
.per_file_ignores
.ignored_rules(&portable_path);
let other_ignored = other_settings
.linter()
.per_file_ignores
.ignored_rules(&other_path);
assert!(portable_ignored.contains(shuck_linter::Rule::UndefinedVariable));
assert!(!portable_ignored.contains(shuck_linter::Rule::UnusedAssignment));
assert!(other_ignored.is_empty());
}
}