use brink_syntax_native::SyntaxKind as N;
use brink_syntax_native::ast::{self, AstNode as _};
use rowan::TextRange;
use crate::hir::FileId;
use crate::suppressions::AllowScope;
use crate::{
ConventionAnnotation, Diagnostic, DiagnosticCode, EffectsAssertion, ElementAnnotation, Name,
Param, Severity, StyleAnnotation, StyleEntry, StyleToken, TypeExpr,
};
use super::SyntaxNode;
const EFFECTS: &str = "effects";
const WAS: &str = "was";
const ALLOW: &str = "allow";
const ELEMENT: &str = "element";
const CONVENTION: &str = "convention";
const STYLE: &str = "style";
fn diag(file: FileId, range: TextRange, code: DiagnosticCode) -> Diagnostic {
Diagnostic {
file,
range,
message: code.title().to_string(),
code,
}
}
fn annotations_before(decl: &SyntaxNode) -> Vec<ast::AnnotationLine> {
let mut collected = Vec::new();
let mut cursor = decl.prev_sibling_or_token();
while let Some(el) = cursor {
match &el {
rowan::NodeOrToken::Token(tok) => {
if !tok.kind().is_trivia() && tok.kind() != N::NEWLINE {
break;
}
}
rowan::NodeOrToken::Node(node) => match node.kind() {
N::ANNOTATION_LINE => {
if let Some(line) = ast::AnnotationLine::cast(node.clone()) {
collected.insert(0, line);
}
}
N::DOC_COMMENT => {}
_ => break,
},
}
cursor = el.prev_sibling_or_token();
}
collected
}
fn attached_declaration(line: &SyntaxNode) -> Option<SyntaxNode> {
let mut cursor = line.next_sibling_or_token();
while let Some(el) = cursor {
match &el {
rowan::NodeOrToken::Token(tok) => {
if !tok.kind().is_trivia() && tok.kind() != N::NEWLINE {
return None;
}
}
rowan::NodeOrToken::Node(node) => match node.kind() {
N::ANNOTATION_LINE | N::DOC_COMMENT => {}
_ => return Some(node.clone()),
},
}
cursor = el.next_sibling_or_token();
}
None
}
fn container_nesting_depth(decl: &SyntaxNode) -> usize {
let mut depth = 0;
let mut cursor = decl.parent();
while let Some(node) = cursor {
if matches!(node.kind(), N::FLOW_DECL | N::FN_DECL) {
depth += 1;
}
cursor = node.parent();
}
depth
}
fn is_consumed_position(name: &str, line: &SyntaxNode) -> bool {
match name {
CONVENTION => attached_declaration(line).is_some_and(|d| {
d.kind() == N::FN_DECL && d.parent().is_some_and(|p| p.kind() == N::SOURCE_FILE)
}),
WAS => line.parent().is_some_and(|p| p.kind() == N::SOURCE_FILE),
EFFECTS | ELEMENT | STYLE => attached_declaration(line).is_some_and(|d| {
let depth = container_nesting_depth(&d);
match d.kind() {
N::FLOW_DECL => depth <= 1,
N::FN_DECL => depth == 0,
_ => false,
}
}),
ALLOW => attached_declaration(line).is_some(),
_ => false,
}
}
pub(super) fn handle_line(file_id: FileId, node: &SyntaxNode, diags: &mut Vec<Diagnostic>) {
let Some(name) = ast::AnnotationLine::cast(node.clone()).and_then(|l| l.name_token()) else {
return;
};
let range = node.text_range();
if !matches!(
name.text(),
EFFECTS | WAS | ALLOW | ELEMENT | CONVENTION | STYLE
) {
diags.push(diag(file_id, range, DiagnosticCode::E111));
} else if !is_consumed_position(name.text(), node) {
diags.push(diag(file_id, range, DiagnosticCode::E112));
}
}
pub(super) fn allow_scopes(
file_id: FileId,
root: &SyntaxNode,
diags: &mut Vec<Diagnostic>,
) -> Vec<AllowScope> {
let mut out = Vec::new();
for node in root.descendants() {
if node.kind() != N::ANNOTATION_LINE {
continue;
}
let Some(line) = ast::AnnotationLine::cast(node.clone()) else {
continue;
};
if line.name_token().is_none_or(|t| t.text() != ALLOW) {
continue;
}
let Some(target) = attached_declaration(&node) else {
continue;
};
if let Some(codes) = parse_allow(file_id, &line, node.text_range(), diags) {
out.push(AllowScope {
range: target.text_range(),
codes,
});
}
}
out
}
fn parse_allow(
file_id: FileId,
line: &ast::AnnotationLine,
range: TextRange,
diags: &mut Vec<Diagnostic>,
) -> Option<Vec<DiagnosticCode>> {
let Some(args) = line.args() else {
diags.push(diag(file_id, range, DiagnosticCode::E155));
return None;
};
let mut codes = Vec::new();
let mut ok = true;
let mut any = false;
for arg in args.args() {
any = true;
let name = match arg.name_token() {
Some(t) if arg.nested_args().is_none() => t,
_ => {
diags.push(diag(file_id, range, DiagnosticCode::E155));
ok = false;
continue;
}
};
let Some(code) = DiagnosticCode::from_str_code(name.text()) else {
diags.push(diag(file_id, name.text_range(), DiagnosticCode::E153));
ok = false;
continue;
};
if code.severity() == Severity::Error {
diags.push(diag(file_id, name.text_range(), DiagnosticCode::E154));
ok = false;
continue;
}
codes.push(code);
}
if !any {
diags.push(diag(file_id, range, DiagnosticCode::E155));
return None;
}
ok.then_some(codes)
}
pub(super) fn effects_assertion(
file_id: FileId,
decl: &SyntaxNode,
diags: &mut Vec<Diagnostic>,
) -> Option<EffectsAssertion> {
let mut chosen: Option<EffectsAssertion> = None;
for line in annotations_before(decl) {
if line.name_token().is_none_or(|t| t.text() != EFFECTS) {
continue;
}
let range = line.syntax().text_range();
let Some(parsed) = parse_effects(file_id, &line, range, diags) else {
continue; };
if chosen.is_some() {
diags.push(diag(file_id, range, DiagnosticCode::E048));
continue;
}
chosen = Some(parsed);
}
chosen
}
fn parse_effects(
file_id: FileId,
line: &ast::AnnotationLine,
range: TextRange,
diags: &mut Vec<Diagnostic>,
) -> Option<EffectsAssertion> {
let mut pure = false;
let mut silent = false;
let mut total = false;
let mut reads = Vec::new();
let mut writes = Vec::new();
let mut calls = Vec::new();
let mut ok = true;
let Some(args) = line.args() else {
diags.push(diag(file_id, range, DiagnosticCode::E100));
return None;
};
for arg in args.args() {
if let Some(nested) = arg.nested_args() {
let target = match arg.name_token().as_ref().map(|t| t.text().to_string()) {
Some(n) if n == "reads" => &mut reads,
Some(n) if n == "writes" => &mut writes,
Some(n) if n == "calls" => &mut calls,
_ => {
ok = false;
continue;
}
};
for inner in nested.args() {
match inner.name_token() {
Some(t) if inner.nested_args().is_none() => target.push(t.text().to_string()),
_ => ok = false,
}
}
} else if let Some(name) = arg.name_token() {
match name.text() {
"pure" => pure = true,
"silent" => silent = true,
"total" => total = true,
_ => ok = false,
}
} else {
ok = false;
}
}
if pure && !(reads.is_empty() && writes.is_empty() && calls.is_empty()) {
ok = false;
}
if !ok {
diags.push(diag(file_id, range, DiagnosticCode::E101));
return None;
}
if !pure && !silent && !total && reads.is_empty() && writes.is_empty() && calls.is_empty() {
diags.push(diag(file_id, range, DiagnosticCode::E100));
return None;
}
Some(EffectsAssertion {
pure,
silent,
total,
reads,
writes,
calls,
range,
})
}
const ELEMENT_ARGS: &str = "args";
const ELEMENT_NAME: &str = "name";
const CONVENTION_CLAIMS: &str = "claims";
const CONVENTION_ORDER: &str = "order";
const CONVENTION_ATTACH: &str = "attach";
const ELEMENT_BLOCK: &str = "block";
const ELEMENT_CONTENT_TYPE: &str = "content";
const ELEMENT_STRING_TYPE: &str = "string";
const STYLE_KEY_LINE: &str = "line";
const STYLE_KEY_DISPATCH: &str = "dispatch";
fn eq_value_text(arg: &ast::AnnotationArg) -> Option<String> {
let value = arg.eq_value()?;
let mut out = String::new();
for el in value.syntax().children_with_tokens() {
if let rowan::NodeOrToken::Token(t) = el {
match t.kind() {
N::STRING_TEXT | N::L_BRACKET | N::R_BRACKET => out.push_str(t.text()),
N::STRING_ESCAPE => out.push_str(super::expr::unescape_string_token(t.text())),
_ => {}
}
}
}
Some(out)
}
pub(super) fn element_annotation(
file_id: FileId,
decl: &SyntaxNode,
params: &[Param],
diags: &mut Vec<Diagnostic>,
) -> Option<ElementAnnotation> {
let mut chosen: Option<ElementAnnotation> = None;
for line in annotations_before(decl) {
if line.name_token().is_none_or(|t| t.text() != ELEMENT) {
continue;
}
let range = line.syntax().text_range();
let Some(parsed) = parse_element(file_id, &line, range, params, diags) else {
continue; };
if chosen.is_some() {
diags.push(diag(file_id, range, DiagnosticCode::E048));
continue;
}
chosen = Some(parsed);
}
chosen
}
fn parse_element(
file_id: FileId,
line: &ast::AnnotationLine,
range: TextRange,
params: &[Param],
diags: &mut Vec<Diagnostic>,
) -> Option<ElementAnnotation> {
let Some(args) = line.args() else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let mut pattern: Option<String> = None;
let mut alias: Option<String> = None;
let mut block = false;
let mut ok = true;
for arg in args.args() {
let Some(key) = arg.name_token() else {
ok = false;
continue;
};
if key.text() == ELEMENT_BLOCK {
if !block && arg.eq_value().is_none() && arg.nested_args().is_none() {
block = true;
} else {
ok = false;
}
continue;
}
let Some(value) = eq_value_text(&arg) else {
ok = false;
continue;
};
match key.text() {
ELEMENT_ARGS if pattern.is_none() => pattern = Some(value),
ELEMENT_NAME if alias.is_none() => alias = Some(value),
_ => ok = false,
}
}
if !ok {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
}
let Some(pattern) = pattern else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let Ok(compiled) = regex::Regex::new(&pattern) else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let captures: Vec<String> = compiled
.capture_names()
.flatten()
.map(ToString::to_string)
.collect();
for cap in &captures {
if !params.iter().any(|p| &p.name.text == cap) {
diags.push(diag(file_id, range, DiagnosticCode::E160));
return None;
}
}
if block && !has_block_content_param(params, &captures) {
diags.push(diag(file_id, range, DiagnosticCode::E166));
return None;
}
Some(ElementAnnotation {
pattern,
captures,
alias,
block,
range,
})
}
pub(super) fn convention_annotation(
file_id: FileId,
decl: &SyntaxNode,
params: &[Param],
return_type: Option<&TypeExpr>,
diags: &mut Vec<Diagnostic>,
) -> Option<ConventionAnnotation> {
let mut chosen: Option<ConventionAnnotation> = None;
for line in annotations_before(decl) {
if line.name_token().is_none_or(|t| t.text() != CONVENTION) {
continue;
}
let range = line.syntax().text_range();
let Some(parsed) = parse_convention(file_id, &line, range, params, return_type, diags)
else {
continue; };
if chosen.is_some() {
diags.push(diag(file_id, range, DiagnosticCode::E048));
continue;
}
chosen = Some(parsed);
}
chosen
}
struct ConventionClauses {
pattern: Option<String>,
order: Option<i64>,
attach: Option<Name>,
block: bool,
}
fn parse_convention_clauses(args: &ast::AnnotationArgs) -> Option<ConventionClauses> {
let mut pattern: Option<String> = None;
let mut order: Option<i64> = None;
let mut attach: Option<Name> = None;
let mut block = false;
let mut ok = true;
for arg in args.args() {
let Some(key) = arg.name_token() else {
ok = false;
continue;
};
if key.text() == ELEMENT_BLOCK {
if !block && arg.eq_value().is_none() && arg.nested_args().is_none() {
block = true;
} else {
ok = false;
}
continue;
}
if key.text() == CONVENTION_ORDER {
match (order.is_none(), arg.eq_int_value().and_then(|v| v.value())) {
(true, Some(n)) => order = Some(n),
_ => ok = false,
}
continue;
}
if key.text() == CONVENTION_ATTACH {
match (attach.is_none(), arg.eq_ident_value()) {
(true, Some(tok)) => {
attach = Some(Name {
text: tok.text().to_string(),
range: tok.text_range(),
});
}
_ => ok = false,
}
continue;
}
let Some(value) = eq_value_text(&arg) else {
ok = false;
continue;
};
match key.text() {
CONVENTION_CLAIMS if pattern.is_none() => pattern = Some(value),
_ => ok = false,
}
}
ok.then_some(ConventionClauses {
pattern,
order,
attach,
block,
})
}
fn parse_convention(
file_id: FileId,
line: &ast::AnnotationLine,
range: TextRange,
params: &[Param],
return_type: Option<&TypeExpr>,
diags: &mut Vec<Diagnostic>,
) -> Option<ConventionAnnotation> {
let Some(args) = line.args() else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let Some(ConventionClauses {
pattern,
order,
attach,
block,
}) = parse_convention_clauses(&args)
else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let Some(pattern) = pattern else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let Some(order) = order else {
diags.push(diag(file_id, range, DiagnosticCode::E178));
return None;
};
let Ok(compiled) = regex::Regex::new(&pattern) else {
diags.push(diag(file_id, range, DiagnosticCode::E159));
return None;
};
let captures: Vec<String> = compiled
.capture_names()
.flatten()
.map(ToString::to_string)
.collect();
for cap in &captures {
if !params.iter().any(|p| &p.name.text == cap) {
diags.push(diag(file_id, range, DiagnosticCode::E160));
return None;
}
}
if block && attach.is_some() {
diags.push(diag(file_id, range, DiagnosticCode::E186));
return None;
}
if block && !has_block_content_param(params, &captures) {
diags.push(diag(file_id, range, DiagnosticCode::E166));
return None;
}
let e167_checked_params = if block {
¶ms[..params.len().saturating_sub(1)]
} else {
params
};
if let Some(p) = e167_checked_params
.iter()
.find(|p| !captures.contains(&p.name.text))
{
diags.push(diag(file_id, p.name.range, DiagnosticCode::E167));
return None;
}
if let Some(p) = params.iter().find(|p| {
captures.contains(&p.name.text)
&& !is_satisfiable_by_a_string_capture(p.annotation.as_ref())
}) {
let range = p.annotation.as_ref().map_or(p.name.range, TypeExpr::range);
diags.push(diag(file_id, range, DiagnosticCode::E171));
return None;
}
if let Some(attach) = &attach {
let matches = matches!(
return_type,
Some(TypeExpr::Named { name, .. }) if name == &attach.text
);
if !matches {
diags.push(diag(file_id, attach.range, DiagnosticCode::E180));
return None;
}
}
Some(ConventionAnnotation {
pattern,
order,
captures,
block,
attach,
range,
})
}
fn has_block_content_param(params: &[Param], captures: &[String]) -> bool {
let Some(last) = params.last() else {
return false;
};
let is_content_typed = matches!(
&last.annotation,
Some(TypeExpr::Named { name, .. }) if name == ELEMENT_CONTENT_TYPE
);
is_content_typed && !captures.contains(&last.name.text)
}
fn is_satisfiable_by_a_string_capture(annotation: Option<&TypeExpr>) -> bool {
match annotation {
None => true,
Some(TypeExpr::Named { name, .. }) => {
name == ELEMENT_STRING_TYPE || name == ELEMENT_CONTENT_TYPE
}
Some(_) => false,
}
}
pub(super) fn style_annotation(
file_id: FileId,
decl: &SyntaxNode,
element: Option<&ElementAnnotation>,
convention: Option<&ConventionAnnotation>,
diags: &mut Vec<Diagnostic>,
) -> Option<StyleAnnotation> {
let mut chosen: Option<StyleAnnotation> = None;
for line in annotations_before(decl) {
if line.name_token().is_none_or(|t| t.text() != STYLE) {
continue;
}
let range = line.syntax().text_range();
let Some(parsed) = parse_style(file_id, &line, range, element, convention, diags) else {
continue;
};
if chosen.is_some() {
diags.push(diag(file_id, range, DiagnosticCode::E048));
continue;
}
chosen = Some(parsed);
}
chosen
}
fn parse_style(
file_id: FileId,
line: &ast::AnnotationLine,
range: TextRange,
element: Option<&ElementAnnotation>,
convention: Option<&ConventionAnnotation>,
diags: &mut Vec<Diagnostic>,
) -> Option<StyleAnnotation> {
if element.is_none() && convention.is_none() {
diags.push(diag(file_id, range, DiagnosticCode::E163));
return None;
}
let captures: Vec<&str> = element
.iter()
.flat_map(|e| e.captures.iter().map(String::as_str))
.chain(
convention
.iter()
.flat_map(|c| c.captures.iter().map(String::as_str)),
)
.collect();
let Some(args) = line.args() else {
diags.push(diag(file_id, range, DiagnosticCode::E161));
return None;
};
let mut entries = Vec::new();
let mut ok = true;
let mut any = false;
for arg in args.args() {
any = true;
let arg_range = arg.syntax().text_range();
let key = arg.name_token();
let value = eq_value_text(&arg);
let (Some(key), Some(value)) = (key, value) else {
diags.push(diag(file_id, arg_range, DiagnosticCode::E161));
ok = false;
continue;
};
let key_text = key.text().to_string();
if key_text != STYLE_KEY_LINE
&& key_text != STYLE_KEY_DISPATCH
&& !captures.contains(&key_text.as_str())
{
diags.push(diag(file_id, arg_range, DiagnosticCode::E162));
ok = false;
continue;
}
entries.push(StyleEntry {
key: key_text,
value: parse_style_token(&value),
range: arg_range,
});
}
if !ok {
return None;
}
if !any {
diags.push(diag(file_id, range, DiagnosticCode::E161));
return None;
}
Some(StyleAnnotation { entries, range })
}
fn parse_style_token(value: &str) -> StyleToken {
match value {
"left" => StyleToken::AlignLeft,
"center" => StyleToken::AlignCenter,
"right" => StyleToken::AlignRight,
"bold" => StyleToken::Bold,
"italic" => StyleToken::Italic,
"dim" => StyleToken::Dim,
"mono" => StyleToken::Mono,
"uppercase" => StyleToken::Uppercase,
"conceal" => StyleToken::Conceal,
_ if is_hex_color(value) => StyleToken::Color(value.to_string()),
_ => StyleToken::Custom(value.to_string()),
}
}
fn is_hex_color(value: &str) -> bool {
let Some(hex) = value.strip_prefix('#') else {
return false;
};
(hex.len() == 3 || hex.len() == 6) && hex.bytes().all(|b| b.is_ascii_hexdigit())
}