Skip to main content

wdl_lint/rules/
doc_meta_strings.rs

1//! A lint rule for ensuring reserved meta keys have string values.
2
3use wdl_analysis::Diagnostics;
4use wdl_analysis::Example;
5use wdl_analysis::LabeledSnippet;
6use wdl_analysis::VisitReason;
7use wdl_analysis::Visitor;
8use wdl_ast::AstNode;
9use wdl_ast::AstToken;
10use wdl_ast::Diagnostic;
11use wdl_ast::Span;
12use wdl_ast::SyntaxKind;
13use wdl_ast::v1::MetadataSection;
14use wdl_ast::v1::MetadataValue;
15use wdl_ast::v1::ParameterMetadataSection;
16
17use crate::Rule;
18use crate::Tag;
19use crate::TagSet;
20
21/// The identifier for the doc meta string rule.
22const ID: &str = "DocMetaStrings";
23
24/// Reserved keys that must have string values for Sprocket's doc command.
25const RESERVED_KEYS: &[&str] = &[
26    "description",
27    "help",
28    "external_help",
29    "warning",
30    "category",
31    "group",
32];
33
34/// Creates a diagnostic for non-string metadata values.
35fn non_string_value_diagnostic(key: &str, value_type: &str, span: Span) -> Diagnostic {
36    Diagnostic::warning(format!(
37        "metadata key `{}` should have a `String` value, found {}",
38        key, value_type
39    ))
40    .with_rule(ID)
41    .with_label(
42        format!(
43            "`{}` must be a `String` for proper documentation rendering",
44            key
45        ),
46        span,
47    )
48    .with_fix(format!("change the value of `{}` to a `String`", key))
49}
50
51/// Gets a human-readable type name for a metadata value.
52fn get_value_type_name(value: &MetadataValue) -> &'static str {
53    match value {
54        MetadataValue::Null(_) => "null",
55        MetadataValue::Boolean(_) => "boolean",
56        MetadataValue::Integer(_) => "integer",
57        MetadataValue::Float(_) => "float",
58        MetadataValue::String(_) => "string",
59        MetadataValue::Array(_) => "array",
60        MetadataValue::Object(_) => "object",
61    }
62}
63
64/// Checks if a metadata value is a string.
65fn is_string_value(value: &MetadataValue) -> bool {
66    matches!(value, MetadataValue::String(_))
67}
68
69/// Recursively checks metadata object items for reserved keys with non-string
70/// values. This handles both top-level objects and nested objects (like in
71/// "outputs").
72fn check_object_items(
73    obj: &wdl_ast::v1::MetadataObject,
74    diagnostics: &mut Diagnostics,
75    exceptable_nodes: &Option<&'static [SyntaxKind]>,
76) {
77    for item in obj.items() {
78        let name = item.name();
79        let key = name.text();
80        let value = item.value();
81
82        // Check if this key is reserved and has a non-string value
83        if RESERVED_KEYS.contains(&key) && !is_string_value(&value) {
84            let value_type = get_value_type_name(&value);
85            diagnostics.exceptable_add(
86                non_string_value_diagnostic(key, value_type, item.span()),
87                item.inner(),
88                exceptable_nodes,
89            );
90        }
91
92        // Recursively check nested objects
93        if let MetadataValue::Object(ref nested_obj) = value {
94            check_object_items(nested_obj, diagnostics, exceptable_nodes);
95        }
96    }
97}
98
99/// Detects non-string values for reserved meta keys.
100#[derive(Default, Debug, Clone, Copy)]
101pub struct DocMetaStringsRule;
102
103impl Rule for DocMetaStringsRule {
104    fn id(&self) -> &'static str {
105        ID
106    }
107
108    fn description(&self) -> &'static str {
109        "Ensures that reserved meta keys have string values."
110    }
111
112    fn explanation(&self) -> &'static str {
113        "Sprocket's documentation command reserves certain keys in `meta` and `parameter_meta` \
114         sections for documentation generation. These keys (`description`, `help`, \
115         `external_help`, `warning`, `category`, and `group`) must have `String` values. Using \
116         non-`String` values will cause the documentation to be rendered incorrectly or not at \
117         all. This rule ensures all reserved keys have `String` values for proper documentation \
118         generation."
119    }
120
121    fn examples(&self) -> &'static [Example] {
122        &[Example {
123            negative: LabeledSnippet {
124                label: None,
125                snippet: r#"version 1.2
126
127workflow example {
128    meta {
129        description: 123
130    }
131}
132"#,
133            },
134            revised: Some(LabeledSnippet {
135                label: None,
136                snippet: r#"version 1.2
137
138workflow example {
139    meta {
140        description: "123"
141    }
142}
143"#,
144            }),
145        }]
146    }
147
148    fn tags(&self) -> TagSet {
149        TagSet::new(&[Tag::SprocketCompatibility])
150    }
151
152    fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
153        Some(&[
154            SyntaxKind::VersionStatementNode,
155            SyntaxKind::MetadataSectionNode,
156            SyntaxKind::ParameterMetadataSectionNode,
157            SyntaxKind::MetadataObjectItemNode,
158        ])
159    }
160
161    fn related_rules(&self) -> &'static [&'static str] {
162        &[
163            "MetaDescription",
164            "MetaSections",
165            "ParameterMetaMatched",
166            "ParameterDescription",
167            "DescriptionLength",
168        ]
169    }
170}
171
172impl Visitor for DocMetaStringsRule {
173    fn reset(&mut self) {
174        *self = Default::default();
175    }
176
177    fn metadata_section(
178        &mut self,
179        diagnostics: &mut Diagnostics,
180        reason: VisitReason,
181        section: &MetadataSection,
182    ) {
183        if reason == VisitReason::Exit {
184            return;
185        }
186
187        // Check each item in the meta section
188        for item in section.items() {
189            let name = item.name();
190            let key = name.text();
191            let value = item.value();
192
193            // Check if this is a reserved key with a non-string value
194            if RESERVED_KEYS.contains(&key) && !is_string_value(&value) {
195                let value_type = get_value_type_name(&value);
196                diagnostics.exceptable_add(
197                    non_string_value_diagnostic(key, value_type, item.span()),
198                    item.inner(),
199                    &self.exceptable_nodes(),
200                );
201            }
202
203            // Recursively check any nested objects (handles "outputs" and other nested
204            // structures)
205            if let MetadataValue::Object(ref obj) = value {
206                check_object_items(obj, diagnostics, &self.exceptable_nodes());
207            }
208        }
209    }
210
211    fn parameter_metadata_section(
212        &mut self,
213        diagnostics: &mut Diagnostics,
214        reason: VisitReason,
215        section: &ParameterMetadataSection,
216    ) {
217        if reason == VisitReason::Exit {
218            return;
219        }
220
221        // Check each parameter in the parameter_meta section
222        for item in section.items() {
223            let value = item.value();
224
225            match value {
226                // Simple string description - this is valid
227                MetadataValue::String(_) => {}
228
229                // Object with potential reserved keys - recursively check all nested objects
230                MetadataValue::Object(obj) => {
231                    check_object_items(&obj, diagnostics, &self.exceptable_nodes());
232                }
233
234                // Any other type - warn that parameter descriptions should be strings
235                _ => {
236                    let value_type = get_value_type_name(&value);
237                    diagnostics.exceptable_add(
238                        non_string_value_diagnostic("description", value_type, item.span()),
239                        item.inner(),
240                        &self.exceptable_nodes(),
241                    );
242                }
243            }
244        }
245    }
246}