use brink_syntax::{SyntaxKind, SyntaxNode};
use rowan::TextRange;
use super::context::LowerSink;
use crate::{DiagnosticCode, DocBlock, ExternalKind, TypeRef};
#[derive(Debug, Clone, Copy)]
pub struct DocPolicy {
pub allow_params: bool,
pub allow_kind: bool,
}
impl DocPolicy {
pub const EXTERNAL: Self = Self {
allow_params: true,
allow_kind: true,
};
pub const CALLABLE: Self = Self {
allow_params: true,
allow_kind: false,
};
pub const VALUE: Self = Self {
allow_params: false,
allow_kind: false,
};
}
#[derive(Debug, Default)]
pub struct DocIssues {
pub malformed: Vec<TextRange>,
pub inapplicable: Vec<TextRange>,
}
impl DocIssues {
pub fn diagnose(self, sink: &mut impl LowerSink) {
for range in self.malformed {
sink.diagnose(range, DiagnosticCode::E038);
}
for range in self.inapplicable {
sink.diagnose(range, DiagnosticCode::E043);
}
}
}
#[must_use]
pub fn parse_doc_comment(node: &SyntaxNode, policy: DocPolicy) -> (Option<DocBlock>, DocIssues) {
let lines = collect_doc_lines(node);
if lines.is_empty() {
return (None, DocIssues::default());
}
parse_lines(&lines, policy)
}
fn collect_doc_lines(node: &SyntaxNode) -> Vec<(String, TextRange)> {
let Some(first) = node.first_token() else {
return Vec::new();
};
let mut out: Vec<(String, TextRange)> = Vec::new();
let mut newlines = 0u32;
let mut tok = first.prev_token();
while let Some(t) = tok {
match t.kind() {
SyntaxKind::WHITESPACE => {}
SyntaxKind::NEWLINE => {
newlines += 1;
if newlines >= 2 {
break; }
}
SyntaxKind::LINE_COMMENT => {
if let Some(body) = t.text().strip_prefix("///") {
newlines = 0;
out.push((body.trim_start().to_string(), t.text_range()));
} else {
break; }
}
_ => break, }
tok = t.prev_token();
}
out.reverse(); out
}
fn parse_lines(lines: &[(String, TextRange)], policy: DocPolicy) -> (Option<DocBlock>, DocIssues) {
let mut doc = DocBlock::default();
let mut free: Vec<String> = Vec::new();
let mut issues = DocIssues::default();
for (line, range) in lines {
let line = line.trim();
if let Some(rest) = line.strip_prefix('@') {
let mut it = rest.splitn(2, char::is_whitespace);
let tag = it.next().unwrap_or("");
let arg = it.next().unwrap_or("").trim();
match tag {
"param" if !policy.allow_params => issues.inapplicable.push(*range),
"param" => match parse_param(arg) {
Some(entry) => doc.params.push(entry),
None => issues.malformed.push(*range),
},
"returns" | "return" if !policy.allow_params => {
issues.inapplicable.push(*range);
}
"returns" | "return" => match parse_braced_type(arg) {
Some(ty) => doc.returns = Some(ty),
None => issues.malformed.push(*range),
},
"kind" if !policy.allow_kind => issues.inapplicable.push(*range),
"kind" => match ExternalKind::from_tag(arg) {
Some(kind) => doc.kind = Some(kind),
None => issues.malformed.push(*range),
},
_ => {}
}
} else if !line.is_empty() {
free.push(line.to_string());
}
}
if !free.is_empty() {
doc.doc = Some(free.join("\n"));
}
let has_content =
doc.doc.is_some() || !doc.params.is_empty() || doc.returns.is_some() || doc.kind.is_some();
(has_content.then_some(doc), issues)
}
fn parse_param(arg: &str) -> Option<(String, TypeRef)> {
let mut it = arg.splitn(2, char::is_whitespace);
let name = it.next().unwrap_or("").trim();
if name.is_empty() {
return None;
}
let ty = parse_braced_type(it.next().unwrap_or("").trim())?;
Some((name.to_string(), ty))
}
fn parse_braced_type(s: &str) -> Option<TypeRef> {
let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?.trim();
if inner.is_empty() {
return None;
}
Some(TypeRef(inner.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use brink_syntax::parse;
fn doc_of_kind(
src: &str,
kind: SyntaxKind,
policy: DocPolicy,
) -> (Option<DocBlock>, DocIssues) {
let parsed = parse(src);
let node = parsed
.syntax()
.descendants()
.find(|n| n.kind() == kind)
.expect("source should contain the requested declaration");
parse_doc_comment(&node, policy)
}
fn doc_of(src: &str) -> (Option<DocBlock>, DocIssues) {
doc_of_kind(src, SyntaxKind::EXTERNAL_DECL, DocPolicy::EXTERNAL)
}
#[test]
fn parses_full_doc_block() {
let src = "\
/// Whether the player holds an item.
/// @param item {item_id}
/// @returns {bool}
/// @kind query
EXTERNAL holds(item)
";
let (doc, issues) = doc_of(src);
let doc = doc.expect("doc present");
assert!(
issues.malformed.is_empty(),
"no malformed tags: {:?}",
issues.malformed
);
assert_eq!(
doc.doc.as_deref(),
Some("Whether the player holds an item.")
);
assert_eq!(
doc.params,
vec![("item".to_string(), TypeRef("item_id".to_string()))]
);
assert_eq!(doc.returns, Some(TypeRef("bool".to_string())));
assert_eq!(doc.kind, Some(ExternalKind::Query));
}
#[test]
fn no_doc_when_no_comments() {
let (doc, issues) = doc_of("EXTERNAL plain(x)\n");
assert!(doc.is_none());
assert!(issues.malformed.is_empty());
}
#[test]
fn blank_line_breaks_the_block() {
let src = "\
/// orphaned doc
EXTERNAL holds(item)
";
let (doc, _) = doc_of(src);
assert!(
doc.is_none(),
"doc separated by a blank line is not attached"
);
}
#[test]
fn plain_comment_breaks_the_block() {
let src = "\
/// kept
// not a doc line
EXTERNAL holds(item)
";
let (doc, _) = doc_of(src);
assert!(doc.is_none(), "a plain // comment terminates the doc block");
}
#[test]
fn malformed_param_is_reported() {
let src = "\
/// @param item
/// @returns {bool}
EXTERNAL holds(item)
";
let (doc, issues) = doc_of(src);
let doc = doc.expect("doc present");
assert_eq!(issues.malformed.len(), 1, "the bad @param is reported");
assert!(doc.params.is_empty(), "malformed param not recorded");
assert_eq!(doc.returns, Some(TypeRef("bool".to_string())));
}
#[test]
fn unknown_tags_and_widget_are_ignored() {
let src = "\
/// @widget color_picker
/// @nonsense whatever
/// @param c {color}
EXTERNAL tint(c)
";
let (doc, issues) = doc_of(src);
let doc = doc.expect("doc present");
assert!(
issues.malformed.is_empty(),
"unknown/widget tags ignored, not malformed"
);
assert_eq!(
doc.params,
vec![("c".to_string(), TypeRef("color".to_string()))]
);
}
#[test]
fn knot_doc_with_signature_tags() {
let src = "\
/// Damage roll for an attack.
/// @param weapon {item_id}
/// @returns {int}
== function damage(weapon) ==
~ return 1
";
let (doc, issues) = doc_of_kind(src, SyntaxKind::KNOT_DEF, DocPolicy::CALLABLE);
let doc = doc.expect("doc present");
assert!(issues.malformed.is_empty());
assert!(issues.inapplicable.is_empty());
assert_eq!(doc.doc.as_deref(), Some("Damage roll for an attack."));
assert_eq!(
doc.params,
vec![("weapon".to_string(), TypeRef("item_id".to_string()))]
);
assert_eq!(doc.returns, Some(TypeRef("int".to_string())));
}
#[test]
fn kind_tag_on_knot_is_inapplicable() {
let src = "\
/// A knot.
/// @kind query
== hub ==
text
";
let (doc, issues) = doc_of_kind(src, SyntaxKind::KNOT_DEF, DocPolicy::CALLABLE);
let doc = doc.expect("doc present");
assert_eq!(issues.inapplicable.len(), 1, "@kind reported inapplicable");
assert!(doc.kind.is_none(), "inapplicable tag is dropped");
assert_eq!(doc.doc.as_deref(), Some("A knot."));
}
#[test]
fn nested_stitch_doc_is_reachable() {
let src = "\
== hub ==
intro text
/// The market square.
= market
stall text
";
let (doc, issues) = doc_of_kind(src, SyntaxKind::STITCH_DEF, DocPolicy::CALLABLE);
let doc = doc.expect("doc present");
assert!(issues.malformed.is_empty());
assert_eq!(doc.doc.as_deref(), Some("The market square."));
}
#[test]
fn var_doc_free_text_and_inapplicable_param() {
let src = "\
/// Player health.
/// @param x {int}
VAR health = 100
";
let (doc, issues) = doc_of_kind(src, SyntaxKind::VAR_DECL, DocPolicy::VALUE);
let doc = doc.expect("doc present");
assert_eq!(issues.inapplicable.len(), 1, "@param on VAR inapplicable");
assert!(doc.params.is_empty(), "inapplicable tag is dropped");
assert_eq!(doc.doc.as_deref(), Some("Player health."));
}
#[test]
fn const_and_list_docs_are_reachable() {
let src = "\
/// Movement speed.
CONST SPEED = 0.5
";
let (doc, _) = doc_of_kind(src, SyntaxKind::CONST_DECL, DocPolicy::VALUE);
assert_eq!(
doc.expect("doc present").doc.as_deref(),
Some("Movement speed.")
);
let src = "\
/// Mood states.
LIST mood = happy, sad
";
let (doc, _) = doc_of_kind(src, SyntaxKind::LIST_DECL, DocPolicy::VALUE);
assert_eq!(
doc.expect("doc present").doc.as_deref(),
Some("Mood states.")
);
}
}