Skip to main content

wdl_analysis/
config.rs

1//! Configuration for this crate.
2
3use std::sync::Arc;
4
5use schemars::JsonSchema;
6use toml_spanner::Context;
7use toml_spanner::Failed;
8use toml_spanner::FromToml;
9use toml_spanner::Item;
10use toml_spanner::Toml;
11use toml_spanner::helper::parse_string;
12use tracing::warn;
13use wdl_ast::Severity;
14use wdl_ast::SupportedVersion;
15use wdl_ast::SyntaxNode;
16
17use crate::ExceptDirectiveValidRule;
18use crate::Exceptable as _;
19use crate::FormatConfig;
20use crate::KnownRulesRule;
21use crate::MeaninglessLintDirective;
22use crate::MisleadingDeclarationOrderRule;
23use crate::Rule;
24use crate::UnnecessaryFunctionCall;
25use crate::UnusedCallRule;
26use crate::UnusedDeclarationRule;
27use crate::UnusedImportRule;
28use crate::UnusedInputRule;
29use crate::UsingFallbackVersion;
30use crate::rules;
31
32/// Configuration for `wdl-analysis`.
33///
34/// This type is a wrapper around an `Arc`, and so can be cheaply cloned and
35/// sent between threads.
36#[derive(Clone, PartialEq, Eq)]
37pub struct Config {
38    /// The actual fields, `Arc`ed up for easy cloning.
39    inner: Arc<ConfigInner>,
40}
41
42impl<'de> FromToml<'de> for Config {
43    fn from_toml(ctx: &mut Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
44        Ok(Self {
45            inner: ConfigInner::from_toml(ctx, item)?.into(),
46        })
47    }
48}
49
50// Custom `Debug` impl for the `Config` wrapper type that simplifies away the
51// arc and the private inner struct
52impl std::fmt::Debug for Config {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("Config")
55            .field("diagnostics", &self.inner.diagnostics)
56            .field("fallback_version", &self.inner.fallback_version)
57            .finish()
58    }
59}
60
61impl Default for Config {
62    fn default() -> Self {
63        Self {
64            inner: Arc::new(ConfigInner {
65                diagnostics: Default::default(),
66                fallback_version: None,
67                format: FormatConfig::default(),
68                ignore_filename: None,
69                all_rules: Default::default(),
70                feature_flags: FeatureFlags::default(),
71            }),
72        }
73    }
74}
75
76impl Config {
77    /// Get this configuration's [`DiagnosticsConfig`].
78    pub fn diagnostics_config(&self) -> &DiagnosticsConfig {
79        &self.inner.diagnostics
80    }
81
82    /// Get this configuration's fallback version; see
83    /// [`Config::with_fallback_version()`].
84    pub fn fallback_version(&self) -> Option<SupportedVersion> {
85        self.inner.fallback_version
86    }
87
88    /// Get this configuration's [`FormatConfig`]; see
89    /// [`Config::with_format_config()`].
90    pub fn format(&self) -> &FormatConfig {
91        &self.inner.format
92    }
93
94    /// Get this configuration's ignore filename.
95    pub fn ignore_filename(&self) -> Option<&str> {
96        self.inner.ignore_filename.as_deref()
97    }
98
99    /// Gets the list of all known rule identifiers.
100    pub fn all_rules(&self) -> &[String] {
101        &self.inner.all_rules
102    }
103
104    /// Gets the feature flags.
105    pub fn feature_flags(&self) -> &FeatureFlags {
106        &self.inner.feature_flags
107    }
108
109    /// Return a new configuration with the previous [`DiagnosticsConfig`]
110    /// replaced by the argument.
111    pub fn with_diagnostics_config(&self, diagnostics: DiagnosticsConfig) -> Self {
112        let mut inner = (*self.inner).clone();
113        inner.diagnostics = diagnostics;
114        Self {
115            inner: Arc::new(inner),
116        }
117    }
118
119    /// Return a new configuration with the previous version fallback option
120    /// replaced by the argument.
121    ///
122    /// This option controls what happens when analyzing a WDL document with a
123    /// syntactically valid but unrecognized version in the version
124    /// statement. The default value is `None`, with no fallback behavior.
125    ///
126    /// Configured with `Some(fallback_version)`, analysis will proceed as
127    /// normal if the version statement contains a recognized version. If
128    /// the version is unrecognized, analysis will continue as if the
129    /// version statement contained `fallback_version`, though the concrete
130    /// syntax of the version statement will remain unchanged.
131    ///
132    /// <div class="warning">
133    ///
134    /// # Warnings
135    ///
136    /// This option is intended only for situations where unexpected behavior
137    /// due to unsupported syntax is acceptable, such as when providing
138    /// best-effort editor hints via `wdl-lsp`. The semantics of executing a
139    /// WDL workflow with an unrecognized version is undefined and not
140    /// recommended.
141    ///
142    /// Once this option has been configured for an `Analyzer`, it should not be
143    /// changed. A document that was initially parsed and analyzed with one
144    /// fallback option may cause errors if subsequent operations are
145    /// performed with a different fallback option.
146    ///
147    /// </div>
148    pub fn with_fallback_version(&self, fallback_version: Option<SupportedVersion>) -> Self {
149        let mut inner = (*self.inner).clone();
150        inner.fallback_version = fallback_version;
151        Self {
152            inner: Arc::new(inner),
153        }
154    }
155
156    /// Return a new configuration with the previous [`FormatConfig`]
157    /// replaced by the argument.
158    pub fn with_format_config(&self, format: FormatConfig) -> Self {
159        let mut inner = (*self.inner).clone();
160        inner.format = format;
161        Self {
162            inner: Arc::new(inner),
163        }
164    }
165
166    /// Return a new configuration with the previous ignore filename replaced by
167    /// the argument.
168    ///
169    /// Specifying `None` for `filename` disables ignore behavior. This is also
170    /// the default.
171    ///
172    /// `Some(filename)` will use `filename` as the ignorefile basename to
173    /// search for. Child directories _and_ parent directories are searched
174    /// for a file with the same basename as `filename` and if a match is
175    /// found it will attempt to be parsed as an ignorefile with a syntax
176    /// similar to `.gitignore` files.
177    pub fn with_ignore_filename(&self, filename: Option<String>) -> Self {
178        let mut inner = (*self.inner).clone();
179        inner.ignore_filename = filename;
180        Self {
181            inner: Arc::new(inner),
182        }
183    }
184
185    /// Returns a new configuration with the list of all known rule identifiers
186    /// replaced by the argument.
187    ///
188    /// This is used internally to populate the `#@ except:` snippet.
189    pub fn with_all_rules(&self, rules: Vec<String>) -> Self {
190        let mut inner = (*self.inner).clone();
191        inner.all_rules = rules;
192        Self {
193            inner: Arc::new(inner),
194        }
195    }
196
197    /// Return a new configuration with the previous [`FeatureFlags`]
198    /// replaced by the argument.
199    pub fn with_feature_flags(&self, feature_flags: FeatureFlags) -> Self {
200        let mut inner = (*self.inner).clone();
201        inner.feature_flags = feature_flags;
202        Self {
203            inner: Arc::new(inner),
204        }
205    }
206}
207
208/// The actual configuration fields inside the [`Config`] wrapper.
209#[derive(Clone, Debug, PartialEq, Eq, Toml)]
210struct ConfigInner {
211    /// See [`DiagnosticsConfig`].
212    #[toml(default, style = Header)]
213    diagnostics: DiagnosticsConfig,
214    /// See [`Config::with_fallback_version()`]
215    #[toml(FromToml with = parse_string)]
216    fallback_version: Option<SupportedVersion>,
217    /// See [`Config::with_format_config()`]
218    #[toml(default, style = Header)]
219    format: FormatConfig,
220    /// See [`Config::with_ignore_filename()`]
221    ignore_filename: Option<String>,
222    /// A list of all known rule identifiers.
223    #[toml(default)]
224    all_rules: Vec<String>,
225    /// The set of feature flags that can be enabled or disabled.
226    #[toml(default)]
227    feature_flags: FeatureFlags,
228}
229
230/// Default value for the WDL v1.3 feature flag.
231fn default_wdl_1_3() -> bool {
232    true
233}
234
235/// A set of feature flags that can be enabled.
236#[derive(Clone, Copy, Debug, PartialEq, Eq, Toml, JsonSchema)]
237pub struct FeatureFlags {
238    /// Formerly enabled experimental WDL 1.3 features.
239    ///
240    /// This flag is now a no-op as WDL 1.3 is fully supported. Setting this to
241    /// `false` will emit a warning.
242    #[toml(default = true)]
243    #[schemars(default = "default_wdl_1_3")]
244    wdl_1_3: bool,
245    /// Enables experimental WDL 1.4 features.
246    ///
247    /// Defaults to `false`. While `false`, `wdl-analysis` reports an error for
248    /// any document declaring `version 1.4`.
249    #[toml(default)]
250    #[schemars(default)]
251    wdl_1_4: bool,
252}
253
254impl Default for FeatureFlags {
255    fn default() -> Self {
256        Self {
257            wdl_1_3: true,
258            wdl_1_4: false,
259        }
260    }
261}
262
263impl FeatureFlags {
264    /// Returns whether WDL 1.3 is enabled.
265    ///
266    /// WDL 1.3 is now fully supported and defaults to `true`. Setting this to
267    /// `false` will emit a deprecation warning.
268    pub fn wdl_1_3(&self) -> bool {
269        self.wdl_1_3
270    }
271
272    /// Returns a new `FeatureFlags` with WDL 1.3 features enabled.
273    #[deprecated(note = "WDL 1.3 is now enabled by default; this method is a no-op")]
274    pub fn with_wdl_1_3(self) -> Self {
275        self
276    }
277
278    /// Returns whether WDL 1.4 is enabled.
279    pub fn wdl_1_4(&self) -> bool {
280        self.wdl_1_4
281    }
282
283    /// Returns a new `FeatureFlags` with WDL 1.4 features enabled.
284    pub fn with_wdl_1_4(mut self) -> Self {
285        self.wdl_1_4 = true;
286        self
287    }
288}
289
290/// Configuration for analysis diagnostics.
291///
292/// Only the analysis diagnostics that aren't inherently treated as errors are
293/// represented here.
294///
295/// These diagnostics default to a warning severity.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Toml)]
297pub struct DiagnosticsConfig {
298    /// The severity for the unused import diagnostic.
299    ///
300    /// A value of `None` disables the diagnostic.
301    #[toml(FromToml with = parse_string)]
302    pub unused_import: Option<Severity>,
303    /// The severity for the unused input diagnostic.
304    ///
305    /// A value of `None` disables the diagnostic.
306    #[toml(FromToml with = parse_string)]
307    pub unused_input: Option<Severity>,
308    /// The severity for the unused declaration diagnostic.
309    ///
310    /// A value of `None` disables the diagnostic.
311    #[toml(FromToml with = parse_string)]
312    pub unused_declaration: Option<Severity>,
313    /// The severity for the unused call diagnostic.
314    ///
315    /// A value of `None` disables the diagnostic.
316    #[toml(FromToml with = parse_string)]
317    pub unused_call: Option<Severity>,
318    /// The severity for the unnecessary function call diagnostic.
319    ///
320    /// A value of `None` disables the diagnostic.
321    #[toml(FromToml with = parse_string)]
322    pub unnecessary_function_call: Option<Severity>,
323    /// The severity for the using fallback version diagnostic.
324    ///
325    /// A value of `None` disables the diagnostic. If there is no version
326    /// configured with [`Config::with_fallback_version()`], this diagnostic
327    /// will not be emitted.
328    #[toml(FromToml with = parse_string)]
329    pub using_fallback_version: Option<Severity>,
330    /// The severity for the misleading declaration order diagnostic.
331    ///
332    /// A value of `None` disables the diagnostic.
333    #[toml(FromToml with = parse_string)]
334    pub misleading_declaration_order: Option<Severity>,
335    /// The severity for the meaningless lint directive diagnostic.
336    ///
337    /// A value of `None` disables the diagnostic.
338    #[toml(FromToml with = parse_string)]
339    pub meaningless_lint_directive: Option<Severity>,
340    /// The severity for the known rules diagnostic.
341    ///
342    /// A value of `None` disables the diagnostic.
343    #[toml(FromToml with = parse_string)]
344    pub known_rules: Option<Severity>,
345    /// The severity for the except directive valid diagnostic.
346    ///
347    /// A value of `None` disables the diagnostic.
348    #[toml(FromToml with = parse_string)]
349    pub except_directive_valid: Option<Severity>,
350}
351
352impl Default for DiagnosticsConfig {
353    fn default() -> Self {
354        Self::new(rules())
355    }
356}
357
358impl DiagnosticsConfig {
359    /// Creates a new diagnostics configuration from a rule set.
360    pub fn new<T: AsRef<dyn Rule>>(rules: impl IntoIterator<Item = T>) -> Self {
361        let mut unused_import = None;
362        let mut unused_input = None;
363        let mut unused_declaration = None;
364        let mut unused_call = None;
365        let mut unnecessary_function_call = None;
366        let mut using_fallback_version = None;
367        let mut misleading_declaration_order = None;
368        let mut meaningless_lint_directive = None;
369        let mut known_rules = None;
370        let mut except_directive_valid = None;
371
372        for rule in rules {
373            let rule = rule.as_ref();
374            match rule.id() {
375                UnusedImportRule::ID => unused_import = Some(rule.severity()),
376                UnusedInputRule::ID => unused_input = Some(rule.severity()),
377                UnusedDeclarationRule::ID => unused_declaration = Some(rule.severity()),
378                UnusedCallRule::ID => unused_call = Some(rule.severity()),
379                UnnecessaryFunctionCall::ID => unnecessary_function_call = Some(rule.severity()),
380                UsingFallbackVersion::ID => using_fallback_version = Some(rule.severity()),
381                MisleadingDeclarationOrderRule::ID => {
382                    misleading_declaration_order = Some(rule.severity())
383                }
384                MeaninglessLintDirective::ID => meaningless_lint_directive = Some(rule.severity()),
385                KnownRulesRule::ID => known_rules = Some(rule.severity()),
386                ExceptDirectiveValidRule::ID => except_directive_valid = Some(rule.severity()),
387                unrecognized => {
388                    warn!(unrecognized, "unrecognized rule");
389                    if cfg!(test) {
390                        panic!("unrecognized rule: {unrecognized}");
391                    }
392                }
393            }
394        }
395
396        Self {
397            unused_import,
398            unused_input,
399            unused_declaration,
400            unused_call,
401            unnecessary_function_call,
402            using_fallback_version,
403            misleading_declaration_order,
404            meaningless_lint_directive,
405            known_rules,
406            except_directive_valid,
407        }
408    }
409
410    /// Returns a modified set of diagnostics that accounts for any `#@ except`
411    /// comments that precede the given syntax node.
412    pub fn excepted_for_node(mut self, node: &SyntaxNode) -> Self {
413        let exceptions = node.rule_exceptions();
414
415        for exception in exceptions {
416            match &*exception.name {
417                UnusedImportRule::ID => self.unused_import = None,
418                UnusedInputRule::ID => self.unused_input = None,
419                UnusedDeclarationRule::ID => self.unused_declaration = None,
420                UnusedCallRule::ID => self.unused_call = None,
421                UnnecessaryFunctionCall::ID => self.unnecessary_function_call = None,
422                UsingFallbackVersion::ID => self.using_fallback_version = None,
423                MisleadingDeclarationOrderRule::ID => self.misleading_declaration_order = None,
424                MeaninglessLintDirective::ID => self.meaningless_lint_directive = None,
425                KnownRulesRule::ID => self.known_rules = None,
426                ExceptDirectiveValidRule::ID => self.except_directive_valid = None,
427                _ => {}
428            }
429        }
430
431        self
432    }
433
434    /// Excepts all of the diagnostics.
435    pub fn except_all() -> Self {
436        Self {
437            unused_import: None,
438            unused_input: None,
439            unused_declaration: None,
440            unused_call: None,
441            unnecessary_function_call: None,
442            using_fallback_version: None,
443            misleading_declaration_order: None,
444            meaningless_lint_directive: None,
445            known_rules: None,
446            except_directive_valid: None,
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test_log::test]
456    fn custom_format_config_round_trip() {
457        let custom_format_config = FormatConfig::default().trailing_commas(false);
458        let analysis_config = Config::default().with_format_config(custom_format_config);
459        assert_eq!(analysis_config.format(), &custom_format_config);
460    }
461
462    #[test_log::test]
463    fn no_format_config_is_default() {
464        let default_format_config = FormatConfig::default();
465        let analysis_config = Config::default();
466        assert_eq!(analysis_config.format(), &default_format_config);
467    }
468}