use indexmap::IndexMap;
use wdl_analysis::Diagnostics;
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::Span;
use wdl_ast::SyntaxKind;
use wdl_ast::SyntaxNode;
use wdl_ast::v1::MetadataSection;
use wdl_ast::v1::MetadataValue;
use wdl_ast::v1::OutputSection;
use wdl_ast::v1::TaskDefinition;
use wdl_ast::v1::WorkflowDefinition;
use crate::Rule;
use crate::Tag;
use crate::TagSet;
const ID: &str = "MatchingOutputMeta";
fn nonmatching_output(span: Span, name: &str, item_name: &str, ty: &str) -> Diagnostic {
Diagnostic::warning(format!(
"output `{name}` is missing from `meta.outputs` section in {ty} `{item_name}`"
))
.with_rule(ID)
.with_highlight(span)
.with_fix(format!(
"add a description of output `{name}` to documentation in `meta.outputs`"
))
}
fn missing_outputs_in_meta(span: Span, item_name: &str, ty: &str) -> Diagnostic {
Diagnostic::warning(format!(
"`outputs` key missing in `meta` section for the {ty} `{item_name}`"
))
.with_rule(ID)
.with_highlight(span)
.with_fix("add an `outputs` key to `meta` section describing the outputs")
}
fn extra_output_in_meta(span: Span, name: &str, item_name: &str, ty: &str) -> Diagnostic {
Diagnostic::warning(format!(
"`{name}` appears in `outputs` section of the {ty} `{item_name}` but is not a declared \
`output`"
))
.with_rule(ID)
.with_highlight(span)
.with_fix(format!(
"ensure the output exists or remove the `{name}` key from `meta.outputs`"
))
}
fn out_of_order(span: Span, output_span: Span, item_name: &str, ty: &str) -> Diagnostic {
Diagnostic::note(format!(
"`outputs` section of `meta` for the {ty} `{item_name}` is out of order"
))
.with_rule(ID)
.with_highlight(span)
.with_highlight(output_span)
.with_fix(
"ensure the keys within `meta.outputs` have the same order as they appear in `output`",
)
}
fn non_object_meta_outputs(span: Span, item_name: &str, ty: &str) -> Diagnostic {
Diagnostic::warning(format!(
"{ty} `{item_name}` has a `meta.outputs` key that is not an object containing output \
descriptions"
))
.with_rule(ID)
.with_highlight(span)
.with_fix("ensure `meta.outputs` is an object containing descriptions for each output")
}
#[derive(Default, Debug, Clone)]
pub struct MatchingOutputMetaRule<'a> {
current_meta_span: Option<Span>,
in_meta: bool,
current_meta_outputs_span: Option<Span>,
current_output_span: Option<Span>,
in_output: bool,
meta_outputs_keys: IndexMap<String, Span>,
output_keys: IndexMap<String, Span>,
ty: Option<&'a str>,
name: Option<String>,
prior_objects: Vec<String>,
}
impl Rule for MatchingOutputMetaRule<'_> {
fn id(&self) -> &'static str {
ID
}
fn description(&self) -> &'static str {
"Ensures that each output field is documented in the meta section under `meta.outputs`."
}
fn explanation(&self) -> &'static str {
"The meta section should have an `outputs` key that is an object and contains keys with \
descriptions for each output of the task/workflow. These must match exactly. i.e. for \
each named output of a task or workflow, there should be an entry under `meta.outputs` \
with that same name. Additionally, these entries should be in the same order (that order \
is up to the developer to decide). No extraneous `meta.outputs` entries are allowed."
}
fn examples(&self) -> &'static [Example] {
&[Example {
negative: LabeledSnippet {
label: None,
snippet: r#"version 1.2
task generate_greeting {
meta {
outputs: {
# Missing `greeting`
}
}
input {
String name
}
output {
String greeting = "Hello, ~{name}!"
}
}
"#,
},
revised: Some(LabeledSnippet {
label: None,
snippet: r#"version 1.2
task generate_greeting {
meta {
outputs: {
greeting: "The generated greeting for the provided name",
}
}
input {
String name
}
output {
String greeting = "Hello, ~{name}!"
}
}
"#,
}),
}]
}
fn tags(&self) -> TagSet {
TagSet::new(&[
Tag::Completeness,
Tag::Documentation,
Tag::SprocketCompatibility,
])
}
fn exceptable_nodes(&self) -> Option<&'static [SyntaxKind]> {
Some(&[
SyntaxKind::VersionStatementNode,
SyntaxKind::TaskDefinitionNode,
SyntaxKind::WorkflowDefinitionNode,
])
}
fn related_rules(&self) -> &'static [&'static str] {
&[
"MetaDescription",
"ParameterMetaMatched",
"OutputSection",
"RequirementsSection",
"RuntimeSection",
"DescriptionLength",
]
}
}
fn check_matching(
diagnostics: &mut Diagnostics,
rule: &mut MatchingOutputMetaRule<'_>,
node: &SyntaxNode,
) {
let mut exact_match = true;
for (name, span) in &rule.output_keys {
if !rule.meta_outputs_keys.contains_key(name) {
exact_match = false;
if rule.current_meta_span.is_some() {
diagnostics.exceptable_add(
nonmatching_output(
*span,
name,
rule.name.as_deref().expect("should have a name"),
rule.ty.expect("should have a type"),
),
node,
&rule.exceptable_nodes(),
);
}
}
}
for (name, span) in &rule.meta_outputs_keys {
if !rule.output_keys.contains_key(name) {
exact_match = false;
diagnostics.exceptable_add(
extra_output_in_meta(
*span,
name,
rule.name.as_deref().expect("should have a name"),
rule.ty.expect("should have a type"),
),
node,
&rule.exceptable_nodes(),
);
}
}
if exact_match && !rule.meta_outputs_keys.keys().eq(rule.output_keys.keys()) {
diagnostics.exceptable_add(
out_of_order(
rule.current_meta_outputs_span
.expect("should have a `meta.outputs` span"),
rule.current_output_span
.expect("should have an `output` span"),
rule.name.as_deref().expect("should have a name"),
rule.ty.expect("should have a type"),
),
node,
&rule.exceptable_nodes(),
);
}
}
fn handle_meta_outputs_and_reset(
diagnostics: &mut Diagnostics,
rule: &mut MatchingOutputMetaRule<'_>,
node: &SyntaxNode,
) {
if let Some(current_meta_span) = rule.current_meta_span
&& rule.current_meta_outputs_span.is_none()
&& !rule.output_keys.is_empty()
{
diagnostics.exceptable_add(
missing_outputs_in_meta(
current_meta_span,
rule.name.as_deref().expect("should have a name"),
rule.ty.expect("should have a type"),
),
node,
&rule.exceptable_nodes(),
);
} else {
check_matching(diagnostics, rule, node);
}
rule.name = None;
rule.current_meta_outputs_span = None;
rule.current_meta_span = None;
rule.current_output_span = None;
rule.output_keys.clear();
rule.meta_outputs_keys.clear();
}
impl Visitor for MatchingOutputMetaRule<'_> {
fn reset(&mut self) {
self.current_meta_span = None;
self.in_meta = false;
self.current_meta_outputs_span = None;
self.current_output_span = None;
self.in_output = false;
self.meta_outputs_keys.clear();
self.output_keys.clear();
self.name = None;
self.ty = None;
self.prior_objects.clear();
}
fn workflow_definition(
&mut self,
diagnostics: &mut Diagnostics,
reason: VisitReason,
workflow: &WorkflowDefinition,
) {
match reason {
VisitReason::Enter => {
self.name = Some(workflow.name().text().to_string());
self.ty = Some("workflow");
}
VisitReason::Exit => {
handle_meta_outputs_and_reset(diagnostics, self, workflow.inner());
}
}
}
fn task_definition(
&mut self,
diagnostics: &mut Diagnostics,
reason: VisitReason,
task: &TaskDefinition,
) {
match reason {
VisitReason::Enter => {
self.name = Some(task.name().text().to_string());
self.ty = Some("task");
}
VisitReason::Exit => {
handle_meta_outputs_and_reset(diagnostics, self, task.inner());
}
}
}
fn metadata_section(
&mut self,
_diagnostics: &mut Diagnostics,
reason: VisitReason,
section: &MetadataSection,
) {
match reason {
VisitReason::Enter => {
self.current_meta_span = Some(
section
.inner()
.first_token()
.expect("metadata section should have tokens")
.text_range()
.into(),
);
self.in_meta = true;
}
VisitReason::Exit => {
self.in_meta = false;
}
}
}
fn output_section(
&mut self,
_diagnostics: &mut Diagnostics,
reason: VisitReason,
section: &OutputSection,
) {
match reason {
VisitReason::Enter => {
self.current_output_span = Some(
section
.inner()
.first_token()
.expect("output section should have tokens")
.text_range()
.into(),
);
self.in_output = true;
}
VisitReason::Exit => {
self.in_output = false;
}
}
}
fn bound_decl(
&mut self,
_diagnostics: &mut Diagnostics,
reason: VisitReason,
decl: &wdl_ast::v1::BoundDecl,
) {
if reason == VisitReason::Enter && self.in_output {
self.output_keys
.insert(decl.name().text().to_string(), decl.name().span());
}
}
fn metadata_object_item(
&mut self,
diagnostics: &mut Diagnostics,
reason: VisitReason,
item: &wdl_ast::v1::MetadataObjectItem,
) {
if !self.in_meta {
return;
}
match reason {
VisitReason::Exit => {
if let MetadataValue::Object(_) = item.value() {
self.prior_objects.pop();
}
}
VisitReason::Enter => {
if let Some(_meta_span) = self.current_meta_span {
if item.name().text() == "outputs" {
self.current_meta_outputs_span = Some(item.span());
match item.value() {
MetadataValue::Object(_) => {}
_ => {
diagnostics.exceptable_add(
non_object_meta_outputs(
item.span(),
self.name.as_deref().expect("should have a name"),
self.ty.expect("should have a type"),
),
item.inner(),
&self.exceptable_nodes(),
);
}
}
} else if let Some(meta_outputs_span) = self.current_meta_outputs_span {
let span = item.span();
if span.start() > meta_outputs_span.start()
&& span.end() < meta_outputs_span.end()
&& self
.prior_objects
.last()
.expect("should have seen `meta.outputs`")
== "outputs"
{
self.meta_outputs_keys
.insert(item.name().text().to_string(), item.span());
}
}
}
if let MetadataValue::Object(_) = item.value() {
self.prior_objects.push(item.name().text().to_string());
}
}
}
}
}