use wdl_analysis::Diagnostics;
use wdl_analysis::Example;
use wdl_analysis::LabeledSnippet;
use wdl_analysis::Visitor;
use wdl_ast::AstToken;
use wdl_ast::Comment;
use wdl_ast::CommentKind;
use wdl_ast::Diagnostic;
use wdl_ast::Span;
use wdl_ast::SyntaxElement;
use wdl_ast::SyntaxKind;
use wdl_ast::TreeToken;
use crate::Rule;
use crate::Tag;
use crate::TagSet;
const ID: &str = "UnusedDocComments";
fn unused_doc_comment_diagnostic(comment_span: Span, target_span: Option<Span>) -> Diagnostic {
let diagnostic = Diagnostic::note("unused doc comment")
.with_rule(ID)
.with_highlight(comment_span)
.with_fix(
"if this is a non-doc comment, replace the leading `##` with `#`; otherwise move this \
comment so it documents the intended item",
);
if let Some(target_span) = target_span {
diagnostic.with_label(
"documentation will not be generated for this item",
target_span,
)
} else {
diagnostic
}
}
#[derive(Default, Debug, Clone)]
pub struct UnusedDocCommentsRule {
skip_count: u32,
}
const VALID_SYNTAX_KINDS_FOR_DOC_COMMENTS: &[SyntaxKind] = &[
SyntaxKind::VersionStatementNode,
SyntaxKind::WorkflowDefinitionNode,
SyntaxKind::StructDefinitionNode,
SyntaxKind::EnumDefinitionNode,
SyntaxKind::TaskDefinitionNode,
SyntaxKind::EnumChoiceNode,
SyntaxKind::UnboundDeclNode,
SyntaxKind::BoundDeclNode,
];
fn valid_target_for_doc_comment(doc_comment_target: &SyntaxElement) -> bool {
let kind = doc_comment_target.kind();
if kind == SyntaxKind::BoundDeclNode {
let Some(parent) = doc_comment_target.parent() else {
return false;
};
return parent.kind() == SyntaxKind::InputSectionNode
|| parent.kind() == SyntaxKind::OutputSectionNode;
}
VALID_SYNTAX_KINDS_FOR_DOC_COMMENTS.contains(&kind)
}
fn search_siblings_for_doc_comment_target(comment: &Comment) -> Option<SyntaxElement> {
let mut next = comment.inner().next_sibling_or_token();
while let Some(sibling) = next {
next = sibling.next_sibling_or_token();
if !sibling.kind().is_trivia() {
return Some(sibling);
}
}
None
}
fn find_inline_doc_comment_target(comment: &Comment) -> Option<SyntaxElement> {
let mut prev = comment.inner().prev_sibling_or_token();
while let Some(sibling) = prev {
if sibling.kind().is_trivia() {
prev = sibling.prev_sibling_or_token();
continue;
} else {
return Some(sibling);
}
}
None
}
fn get_span_of_first_token_for_syntax_element(element: &SyntaxElement) -> Span {
if let Some(token) = element.as_token() {
token.span()
} else if let Some(node) = element.as_node() {
node.first_token().unwrap().span()
} else {
unreachable!();
}
}
impl UnusedDocCommentsRule {
fn lint_next_doc_comment_block(
&mut self,
diagnostics: &mut Diagnostics,
comment: &Comment,
target_span: Option<Span>,
) {
let mut next = comment.inner().next_sibling_or_token();
let mut span_end = comment.span().end();
while let Some(sibling) = next {
next = sibling.next_sibling_or_token();
if sibling.kind() == SyntaxKind::Whitespace {
continue;
}
if let Some(continued_comment) =
sibling.as_token().and_then(|t| Comment::cast(t.clone()))
&& continued_comment.kind() == CommentKind::Documentation
{
self.skip_count += 1;
span_end = continued_comment.span().end();
continue;
} else {
diagnostics.add(unused_doc_comment_diagnostic(
Span::new(comment.span().start(), span_end - comment.span().start()),
target_span,
));
return;
}
}
diagnostics.add(unused_doc_comment_diagnostic(
Span::new(comment.span().start(), span_end - comment.span().start()),
target_span,
));
}
}
impl Rule for UnusedDocCommentsRule {
fn id(&self) -> &'static str {
ID
}
fn description(&self) -> &'static str {
"Reports doc comments that are attached to WDL items that don't support them."
}
fn explanation(&self) -> &'static str {
"Some Workflow Definition Language items do not support doc comments (`##`). This lint \
reports if a doc comment is attached to an item that isn't supported.
Doc comments are supported on:
- Workflow Definitions
- Task Definitions
- Struct Definitions
- Fields in Struct Definitions
- Fields in Input Sections
- Fields in Output Sections
- Enum Definitions
- Enum Choices"
}
fn examples(&self) -> &'static [Example] {
&[Example {
negative: LabeledSnippet {
label: None,
snippet: r#"version 1.2
workflow example {
# This isn't documenting anything!
## The inputs for the workflow
input {
String name
}
# Neither is this!
## The outputs for the workflow
output {
String greeting = "Hello, ~{name}!"
}
}
"#,
},
revised: Some(LabeledSnippet {
label: Some("Consider removing the comments or moving them to applicable items"),
snippet: r#"version 1.2
workflow example {
input {
## The name to greet
String name
}
output {
## The generated greeting
String greeting = "Hello, ~{name}!"
}
}
"#,
}),
}]
}
fn tags(&self) -> crate::TagSet {
TagSet::new(&[Tag::Documentation])
}
fn exceptable_nodes(&self) -> Option<&'static [wdl_ast::SyntaxKind]> {
Some(&[SyntaxKind::VersionStatementNode])
}
fn related_rules(&self) -> &'static [&'static str] {
&[]
}
}
impl Visitor for UnusedDocCommentsRule {
fn reset(&mut self) {
self.skip_count = 0;
}
fn comment(&mut self, diagnostics: &mut Diagnostics, comment: &Comment) {
if self.skip_count > 0 {
self.skip_count -= 1;
return;
}
if comment.kind() != CommentKind::Documentation {
return;
}
if comment.is_inline_comment()
&& let Some(target) = find_inline_doc_comment_target(comment)
{
diagnostics.add(unused_doc_comment_diagnostic(
comment.span(),
Some(get_span_of_first_token_for_syntax_element(&target)),
));
return;
}
let target = search_siblings_for_doc_comment_target(comment);
if target
.as_ref()
.is_none_or(|t| !valid_target_for_doc_comment(t))
{
self.lint_next_doc_comment_block(
diagnostics,
comment,
target
.as_ref()
.map(get_span_of_first_token_for_syntax_element),
);
}
}
}