wdl-lint 0.26.0

Lint rules for Workflow Description Language (WDL) documents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! A lint rule for the `runtime` section keys.
//!
//! Note that this lint rule will only emit diagnostics for WDL documents that
//! have a major version of 1 but a minor version of less than 2, as the
//! `runtime` section was deprecated in WDL v1.2.

use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::OnceLock;

use wdl_analysis::Diagnostics;
use wdl_analysis::Document;
use wdl_analysis::Example;
use wdl_analysis::LabeledSnippet;
use wdl_analysis::VisitReason;
use wdl_analysis::Visitor;
use wdl_ast::AstNode;
use wdl_ast::AstToken;
use wdl_ast::Diagnostic;
use wdl_ast::Ident;
use wdl_ast::Span;
use wdl_ast::SupportedVersion;
use wdl_ast::SyntaxKind;
use wdl_ast::v1::RuntimeItem;
use wdl_ast::v1::RuntimeSection;
use wdl_ast::v1::TASK_HINT_INPUTS;
use wdl_ast::v1::TASK_HINT_LOCALIZATION_OPTIONAL_ALIAS;
use wdl_ast::v1::TASK_HINT_MAX_CPU_ALIAS;
use wdl_ast::v1::TASK_HINT_MAX_MEMORY_ALIAS;
use wdl_ast::v1::TASK_HINT_OUTPUTS;
use wdl_ast::v1::TASK_HINT_SHORT_TASK_ALIAS;
use wdl_ast::v1::TASK_REQUIREMENT_CONTAINER;
use wdl_ast::v1::TASK_REQUIREMENT_CONTAINER_ALIAS;
use wdl_ast::v1::TASK_REQUIREMENT_CPU;
use wdl_ast::v1::TASK_REQUIREMENT_DISKS;
use wdl_ast::v1::TASK_REQUIREMENT_GPU;
use wdl_ast::v1::TASK_REQUIREMENT_MAX_RETRIES_ALIAS;
use wdl_ast::v1::TASK_REQUIREMENT_MEMORY;
use wdl_ast::v1::TASK_REQUIREMENT_RETURN_CODES_ALIAS;
use wdl_ast::version::V1;

use crate::Config;
use crate::Rule;
use crate::Tag;
use crate::TagSet;
use crate::util::serialize_oxford_comma;

/// The identifier for the runtime section rule.
const ID: &str = "ExpectedRuntimeKeys";

/// A kind of runtime key.
///
/// These are intended to be assigned at a per-version level of granularity.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum KeyKind {
    /// A key that is deprecated in favor of another key.
    Deprecated(
        /// The equivalent key that should be used instead.
        &'static str,
    ),
    /// A runtime key that is recommended to be included.
    Recommended,
    /// A runtime key that has a reserved meaning in the specification but which
    /// execution engines are _not_ required to support. These are also called
    /// "hints" in WDL parlance.
    ReservedHint,
    /// A runtime key that has a reserved meaning in the specification and which
    /// execution engines are required to support (but don't necessarily have to
    /// be present in WDL documents).
    ReservedMandatory,
}

impl KeyKind {
    /// Returns whether a key is recommended to be included.
    pub fn is_recommended(&self) -> bool {
        *self == KeyKind::Recommended
    }
}

/// The mapping between `runtime` keys and their kind for WDL v1.0.
///
/// Link: https://github.com/openwdl/wdl/blob/main/versions/1.0/SPEC.md#runtime-section
fn keys_v1_0() -> &'static HashMap<&'static str, KeyKind> {
    /// Keys and their kind for WDL v1.0.
    static KEYS_V1_0: OnceLock<HashMap<&'static str, KeyKind>> = OnceLock::new();

    KEYS_V1_0.get_or_init(|| {
        let mut keys = HashMap::new();
        keys.insert(TASK_REQUIREMENT_CONTAINER_ALIAS, KeyKind::Recommended);
        keys.insert(TASK_REQUIREMENT_MEMORY, KeyKind::Recommended);
        keys
    })
}

/// The mapping between `runtime` keys and their kind for WDL v1.1.
///
/// Link: https://github.com/openwdl/wdl/blob/wdl-1.1/SPEC.md#runtime-section
fn keys_v1_1() -> &'static HashMap<&'static str, KeyKind> {
    /// Keys and their kind for WDL v1.1.
    static KEYS_V1_1: OnceLock<HashMap<&'static str, KeyKind>> = OnceLock::new();

    KEYS_V1_1.get_or_init(|| {
        let mut keys = HashMap::new();
        keys.insert(TASK_REQUIREMENT_CONTAINER, KeyKind::Recommended);
        keys.insert(
            TASK_REQUIREMENT_CONTAINER_ALIAS,
            KeyKind::Deprecated(TASK_REQUIREMENT_CONTAINER),
        );
        keys.insert(TASK_REQUIREMENT_CPU, KeyKind::ReservedMandatory);
        keys.insert(TASK_REQUIREMENT_MEMORY, KeyKind::ReservedMandatory);
        keys.insert(TASK_REQUIREMENT_GPU, KeyKind::ReservedMandatory);
        keys.insert(TASK_REQUIREMENT_DISKS, KeyKind::ReservedMandatory);
        keys.insert(
            TASK_REQUIREMENT_MAX_RETRIES_ALIAS,
            KeyKind::ReservedMandatory,
        );
        keys.insert(
            TASK_REQUIREMENT_RETURN_CODES_ALIAS,
            KeyKind::ReservedMandatory,
        );
        keys.insert(TASK_HINT_MAX_CPU_ALIAS, KeyKind::ReservedHint);
        keys.insert(TASK_HINT_MAX_MEMORY_ALIAS, KeyKind::ReservedHint);
        keys.insert(TASK_HINT_SHORT_TASK_ALIAS, KeyKind::ReservedHint);
        keys.insert(TASK_HINT_LOCALIZATION_OPTIONAL_ALIAS, KeyKind::ReservedHint);
        keys.insert(TASK_HINT_INPUTS, KeyKind::ReservedHint);
        keys.insert(TASK_HINT_OUTPUTS, KeyKind::ReservedHint);
        keys
    })
}

/// Creates a "deprecated runtime key" diagnostic.
fn deprecated_runtime_key(key: &Ident, replacement: &str) -> Diagnostic {
    Diagnostic::note(format!(
        "the `{key}` runtime key has been deprecated in favor of `{replacement}`",
        key = key.text()
    ))
    .with_rule(ID)
    .with_highlight(key.span())
    .with_fix(format!(
        "replace the `{key}` key with `{replacement}`",
        key = key.text()
    ))
}

/// Creates a "non-reserved runtime key" diagnostic for a specific `key`
fn report_non_reserved_runtime_key(key: &str, span: Span, specification: &str) -> Diagnostic {
    Diagnostic::warning(format!(
        "the runtime key `{key}` is not reserved in {specification}; arbitrary runtime keys are \
         deprecated"
    ))
    .with_rule(ID)
    .with_highlight(span)
    .with_fix(format!("remove the `{key}` key"))
}

/// Creates a "missing recommended runtime key" diagnostic.
fn report_missing_recommended_keys(
    mut keys: Vec<&str>,
    runtime_span: Span,
    specification: &str,
) -> Diagnostic {
    assert!(!keys.is_empty());
    keys.sort();

    let (message, fix) = if keys.len() == 1 {
        // SAFETY: we just checked to make sure there is exactly one element in
        // `keys`, so this will always unwrap.
        let key = keys.first().unwrap();

        (
            format!("the following runtime key is recommended by {specification}: `{key}`"),
            format!("include an entry for the `{key}` key in the `runtime` section"),
        )
    } else {
        // SAFETY: we know that this has more than one element because we
        // asserted the input `Vec` not be empty above. As such, this will
        // always produce a result.
        let keys = serialize_oxford_comma(
            &keys
                .iter()
                .map(|key| format!("`{key}`"))
                .collect::<Vec<_>>(),
        )
        .unwrap();

        (
            format!("the following runtime keys are recommended by {specification}: {keys}"),
            format!("include entries for the {keys} keys in the `runtime` section"),
        )
    };

    Diagnostic::note(message)
        .with_rule(ID)
        .with_highlight(runtime_span)
        .with_fix(fix)
}

/// Detects the use of deprecated, unknown, or missing runtime keys.
#[derive(Debug, Clone)]
pub struct ExpectedRuntimeKeysRule {
    /// The detected version of the current document.
    version: Option<SupportedVersion>,
    /// Whether or not we've already processed a `runtime` section within the
    /// current task.
    runtime_processed_for_task: bool,
    /// All keys encountered in the current runtime section.
    encountered_keys: Vec<String>,
    /// Allowed keys from the config.
    allowed_runtime_keys: HashSet<String>,
}

impl ExpectedRuntimeKeysRule {
    /// Create a new instance of `ExpectedRuntimeKeysRule`
    pub fn new(config: &Config) -> Self {
        Self {
            version: None,
            runtime_processed_for_task: false,
            encountered_keys: Vec::new(),
            allowed_runtime_keys: HashSet::from_iter(config.allowed_runtime_keys.iter().cloned()),
        }
    }
}

impl Rule for ExpectedRuntimeKeysRule {
    fn id(&self) -> &'static str {
        ID
    }

    fn description(&self) -> &'static str {
        "Ensures that `runtime` sections have the appropriate keys."
    }

    fn explanation(&self) -> &'static str {
        "The behavior of this rule is different depending on the WDL version:

For WDL v1.0 documents, the `docker` and `memory` keys are recommended, but the inclusion of any \
         number of other keys is permitted.

For WDL v1.1 documents:

- A list of mandatory, reserved keywords will be recommended for inclusion if they are not \
         present. Here, 'mandatory' refers to the requirement that all execution engines support \
         this key—not that the key must be present in the `runtime` section.
- Optional, reserved \"hint\" keys are also permitted but not flagged when they are missing (as \
         their support in execution engines is not guaranteed).
- The WDL v1.1 specification deprecates the inclusion of non-reserved keys in a  `runtime` \
         section. As such, any non-reserved keys will be flagged for removal.

For WDL v1.2 documents and later, this rule does not evaluate because `runtime` sections were \
         deprecated in this version."
    }

    fn examples(&self) -> &'static [Example] {
        &[
            Example {
                negative: LabeledSnippet {
                    label: Some("The following is missing a mandatory key"),
                    snippet: r#"version 1.1

task missing_required_keys {
    runtime {
    # Missing `container` key
    }
}
"#,
                },
                revised: None,
            },
            Example {
                negative: LabeledSnippet {
                    label: Some("The following has an unexpected key"),
                    snippet: r#"version 1.1

task unexpected_runtime_key {
    runtime {
        container: "ubuntu"
        foo: "bar"
    }
}
"#,
                },
                revised: None,
            },
        ]
    }

    fn tags(&self) -> crate::TagSet {
        TagSet::new(&[Tag::Completeness, Tag::Deprecated])
    }

    fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
        Some(&[
            SyntaxKind::VersionStatementNode,
            SyntaxKind::RuntimeSectionNode,
            SyntaxKind::RuntimeItemNode,
        ])
    }

    fn related_rules(&self) -> &'static [&'static str] {
        &["DeprecatedObject", "DeprecatedPlaceholder"]
    }
}

/// A utility method to parse the recommended keys from a static set of runtime
/// keys from either WDL v1.0 or WDL v1.1.
fn recommended_keys<'a, 'k>(
    keys: &'a HashMap<&'k str, KeyKind>,
) -> impl Iterator<Item = (&'k str, &'a KeyKind)> {
    keys.iter()
        .filter(|(_, kind)| kind.is_recommended())
        .map(|(key, kind)| (*key, kind))
}

impl Visitor for ExpectedRuntimeKeysRule {
    fn reset(&mut self) {
        self.version = None;
        self.encountered_keys.clear();
    }

    fn document(
        &mut self,
        _: &mut Diagnostics,
        reason: VisitReason,
        _: &Document,
        version: SupportedVersion,
    ) {
        if reason == VisitReason::Exit {
            return;
        }

        self.version = Some(version);
    }

    fn task_definition(
        &mut self,
        _: &mut Diagnostics,
        reason: VisitReason,
        _: &wdl_ast::v1::TaskDefinition,
    ) {
        if reason == VisitReason::Exit {
            self.runtime_processed_for_task = false;
        }
    }

    fn runtime_section(
        &mut self,
        diagnostics: &mut Diagnostics,
        reason: VisitReason,
        section: &RuntimeSection,
    ) {
        // NOTE: if we've already processed a `runtime` section for this task
        // and we hit this again, that means there are multiple `runtime`
        // sections in the task. In that case, validation should report that
        // this cannot occur, and the runtime section should be ignored.
        if self.runtime_processed_for_task {
            return;
        }

        match reason {
            VisitReason::Enter => {}
            VisitReason::Exit => {
                // SAFETY: the version must always be set before we get to this
                // point, as document is the root node of the tree.
                if let SupportedVersion::V1(minor_version) = self.version.unwrap() {
                    let specification = format!("the WDL {minor_version} specification");

                    let recommended_keys = match minor_version {
                        V1::Zero => recommended_keys(keys_v1_0()),
                        V1::One => recommended_keys(keys_v1_1()),
                        _ => return,
                    };

                    let missing_keys = recommended_keys
                        .filter(|(key, _)| !self.encountered_keys.iter().any(|s| s == *key))
                        .map(|(key, _)| key)
                        .collect::<Vec<_>>();

                    if !missing_keys.is_empty() {
                        diagnostics.exceptable_add(
                            report_missing_recommended_keys(
                                missing_keys,
                                // Note that we don't use `section.span()` to avoid highlighting
                                // the entire runtime_section
                                // (instead, we highlight just the key "runtime")
                                section
                                    .inner()
                                    .first_token()
                                    .expect("runtime section should have tokens")
                                    .text_range()
                                    .into(),
                                &specification,
                            ),
                            section.inner(),
                            &self.exceptable_nodes(),
                        );
                    }

                    // Now that we've emitted the necessary diagnostics for this runtime section,
                    // clear our tracking container of encountered keys to prepare for the next
                    // runtime section.
                    self.encountered_keys.clear();
                    self.runtime_processed_for_task = true;
                }
            }
        }
    }

    fn runtime_item(
        &mut self,
        diagnostics: &mut Diagnostics,
        reason: VisitReason,
        item: &RuntimeItem,
    ) {
        // NOTE: if we've already processed a `runtime` section for this task
        // and we hit this again, that means there are multiple `runtime`
        // sections in the task. In that case, validation should report that
        // this cannot occur, and the runtime items should be ignored.
        if self.runtime_processed_for_task || reason == VisitReason::Exit {
            return;
        }

        let key_name = item.name();

        // SAFETY: the version must always be set before we get to this point,
        // as document is the root node of the tree.
        if let SupportedVersion::V1(minor_version) = self.version.unwrap() {
            // The only keys that need to be individually inspected are WDL v1.1
            // keys because,
            //
            // - WDL v1.0 contains no deprecated keys: the only issue that can occur is when
            //   one of the two recommended key is omitted, and that is handled at the end
            //   of the `document()` method.
            // - WDL v1.2 deprecates the `runtime` section, so any WDL document with a
            //   version of 1.2 or later should ignore the keys and report the section as
            //   deprecated (in another rule).
            if minor_version == V1::One {
                match keys_v1_1().get(key_name.text()) {
                    Some(kind) => {
                        // If the key was found in the map, the only potential
                        // problem that can be encountered is if the key is
                        // deprecated.
                        if let KeyKind::Deprecated(replacement) = kind {
                            diagnostics.exceptable_add(
                                deprecated_runtime_key(&key_name, replacement),
                                item.inner(),
                                &self.exceptable_nodes(),
                            );
                        }
                    }
                    None => {
                        let specification = format!("the WDL {minor_version} specification");
                        let key_text = key_name.text();
                        // If the key was _not_ found in the map, that means the
                        // key was not one of the permitted values for WDL v1.1.
                        //
                        // If it's also not in the explicitly allowed configuration,
                        // add a diagnostic for it.
                        if !self.allowed_runtime_keys.contains(key_text) {
                            // Note we don't use `item.span()` here to avoid highlighting the whole
                            // runtime object.
                            let text_for_key_span = item
                                .inner()
                                .first_token()
                                .expect("RuntimeItem must have text in first token")
                                .text_range()
                                .into();
                            diagnostics.exceptable_add(
                                report_non_reserved_runtime_key(
                                    key_text,
                                    text_for_key_span,
                                    &specification,
                                ),
                                item.inner(),
                                &self.exceptable_nodes(),
                            );
                        }
                    }
                }
            }
        }

        self.encountered_keys.push(key_name.text().to_string());
    }
}