use rowan::TextRange;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{Diagnostic, DiagnosticCode, Expr, FileId, HirFile, MapLiteral};
type LiteralCheck = fn(&MapLiteral, FileId, &mut Vec<Diagnostic>);
#[must_use]
pub fn check(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
walk_map_literals(files, check_literal)
}
#[must_use]
pub fn check_duplicate_keys(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
walk_map_literals(files, check_duplicates_in_literal)
}
fn walk_map_literals(files: &[(FileId, &HirFile)], check: LiteralCheck) -> Vec<Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
let mut v = MapKeyVisitor {
file,
check,
diagnostics: &mut out,
};
visit::visit_with_decl_initializers(hir, &mut v);
}
out
}
struct MapKeyVisitor<'a> {
file: FileId,
check: LiteralCheck,
diagnostics: &'a mut Vec<Diagnostic>,
}
impl HirVisitor for MapKeyVisitor<'_> {
fn visit_exprs(&self) -> bool {
true
}
fn enter_expr(&mut self, expr: &Expr) {
if let Expr::MapLiteral(m) = expr {
(self.check)(m, self.file, self.diagnostics);
}
}
}
fn check_literal(m: &MapLiteral, file: FileId, out: &mut Vec<Diagnostic>) {
for (key, _value) in &m.entries {
let Some((kind, own_range)) = non_key_domain_kind(key) else {
continue;
};
out.push(Diagnostic {
file,
range: own_range.unwrap_or_else(|| m.ptr.text_range()),
message: format!(
"{}: `{kind}` key literal is outside the int/string/bool key domain",
DiagnosticCode::E106.title(),
),
code: DiagnosticCode::E106,
});
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum StaticKey {
Int(i32),
Bool(bool),
Str(String),
}
impl std::fmt::Display for StaticKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StaticKey::Int(v) => write!(f, "{v}"),
StaticKey::Bool(b) => write!(f, "{b}"),
StaticKey::Str(s) => write!(f, "{s:?}"),
}
}
}
fn static_key(expr: &Expr) -> Option<StaticKey> {
match expr {
Expr::Int(v) => Some(StaticKey::Int(*v)),
Expr::Bool(b) => Some(StaticKey::Bool(*b)),
Expr::String(s) => match s.parts.as_slice() {
[brink_ir::StringPart::Literal(text)] => Some(StaticKey::Str(text.clone())),
_ => None,
},
_ => None,
}
}
fn check_duplicates_in_literal(m: &MapLiteral, file: FileId, out: &mut Vec<Diagnostic>) {
let mut seen: std::collections::BTreeSet<StaticKey> = std::collections::BTreeSet::new();
for (key, _value) in &m.entries {
let Some(k) = static_key(key) else {
continue;
};
if seen.insert(k.clone()) {
continue;
}
out.push(Diagnostic {
file,
range: m.ptr.text_range(),
message: format!(
"{}: duplicate key `{k}` — an earlier entry already supplies it",
DiagnosticCode::E138.title(),
),
code: DiagnosticCode::E138,
});
}
}
fn non_key_domain_kind(expr: &Expr) -> Option<(&'static str, Option<TextRange>)> {
match expr {
Expr::Float(_) => Some(("float", None)),
Expr::ListLiteral(_) => Some(("list", None)),
Expr::DivertTarget(_) => Some(("divert target", None)),
Expr::ArrayLiteral(a) => Some(("array", Some(a.ptr.text_range()))),
Expr::MapLiteral(m) => Some(("map", Some(m.ptr.text_range()))),
Expr::StructLiteral(sl) => Some(("struct", Some(sl.ptr.text_range()))),
Expr::FnLiteral(fl) => Some(("function", Some(fl.ptr.text_range()))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::hir::lower;
fn build(src: &str) -> HirFile {
let parsed = brink_syntax::parse(src);
let (hir, _manifest, _diag) = lower(FileId(0), &parsed.tree());
hir
}
fn check_src(src: &str) -> Vec<Diagnostic> {
let hir = build(src);
check(&[(FileId(0), &hir)])
}
fn build_native(src: &str) -> HirFile {
let parsed = brink_syntax_native::parse(src);
assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
let (hir, _manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &parsed.tree());
hir
}
#[test]
fn clean_int_string_bool_keys_produce_no_diagnostics() {
let diags = check_src("=== main ===\n~ temp m = #{1: \"a\", \"k\": 2, true: 3}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn float_key_is_e106() {
let diags = check_src("=== main ===\n~ temp m = #{3.5: 1}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn array_literal_key_is_e106() {
let diags = check_src("=== main ===\n~ temp m = #{#[1, 2]: 1}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(diags[0].message.contains("array"), "{:?}", diags[0].message);
}
#[test]
fn nested_map_literal_key_is_e106() {
let diags = check_src("=== main ===\n~ temp m = #{#{1: 2}: 1}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(diags[0].message.contains("map"), "{:?}", diags[0].message);
}
#[test]
fn struct_literal_key_is_e106() {
let diags = check_src(
"STRUCT Point = #{x: int}\n\
=== main ===\n~ temp m = #{Point#{x: 1}: 1}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(
diags[0].message.contains("struct"),
"{:?}",
diags[0].message
);
}
#[test]
fn list_literal_key_is_e106() {
let diags = check_src(
"LIST Colors = red, green, blue\n\
=== main ===\n~ temp m = #{(red): 1}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(diags[0].message.contains("list"), "{:?}", diags[0].message);
}
#[test]
fn divert_target_key_is_e106() {
let diags =
check_src("=== main ===\n~ temp m = #{-> other: 1}\n-> DONE\n=== other ===\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(
diags[0].message.contains("divert target"),
"{:?}",
diags[0].message
);
}
#[test]
fn fn_literal_key_is_e106() {
let diags = check_src(
"=== main ===\n~ temp m = #{#fn(score): 1}\n-> DONE\n\
=== score(x) ===\n~ return x\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(
diags[0].message.contains("function"),
"{:?}",
diags[0].message
);
}
#[test]
fn dynamic_variable_key_does_not_fire() {
let diags = check_src("=== main ===\n~ temp k = 1.5\n~ temp m = #{k: 1}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn dynamic_call_key_does_not_fire() {
let diags = check_src(
"=== main ===\n~ temp m = #{score(1): 1}\n-> DONE\n\
=== score(x) ===\n~ return x\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn value_position_non_key_domain_is_not_flagged() {
let diags = check_src("=== main ===\n~ temp m = #{1: 3.5}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn var_decl_initializer_map_literal_is_checked() {
let diags = check_src("VAR m = #{3.5: 1}\n=== main ===\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
}
#[test]
fn a_bad_key_in_a_lambda_statement_of_a_var_initializer_is_reported() {
let hir = build_native("var f = ||: int {\n let m = Map { 3.5: 1 };\n 0\n};\n");
let diags = check(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn a_duplicate_key_in_a_lambda_statement_of_a_var_initializer_is_reported() {
let hir = build_native("var f = ||: int {\n let m = Map { 1: 2, 1: 3 };\n 0\n};\n");
let diags = check_duplicate_keys(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E138);
}
#[test]
fn a_bad_key_in_a_lambda_tail_of_a_var_initializer_is_still_reported() {
let hir = build_native("var f = ||: int {\n let a = 1;\n Map { 3.5: 1 }\n};\n");
let diags = check(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E106);
}
#[test]
fn fires_through_analyze_under_both_gradual_and_strict_type_policy() {
use crate::{AnalysisOptions, Dialect, TypePolicy, analyze_with_options};
let src = "=== main ===\n~ temp m = #{3.5: 1}\n-> DONE\n";
let parsed = brink_syntax::parse(src);
let (hir, manifest, diag) = lower(FileId(0), &parsed.tree());
assert!(diag.is_empty(), "{diag:?}");
for types in [TypePolicy::Gradual, TypePolicy::Strict] {
let opts = AnalysisOptions {
dialect: Dialect::Brink,
types: Some(types),
..Default::default()
};
let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E106),
"types={types:?}: {:?}",
result.diagnostics
);
}
}
fn dup_src(src: &str) -> Vec<Diagnostic> {
let hir = build(src);
check_duplicate_keys(&[(FileId(0), &hir)])
}
#[test]
fn a_repeated_string_key_is_e138() {
let diags = dup_src("=== main ===\n~ temp m = #{\"a\": 1, \"a\": 2}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E138);
}
#[test]
fn repeats_are_caught_in_every_in_domain_key_kind() {
for src in [
"=== main ===\n~ temp m = #{1: \"a\", 1: \"b\"}\n-> DONE\n",
"=== main ===\n~ temp m = #{true: 1, true: 2}\n-> DONE\n",
"=== main ===\n~ temp m = #{\"k\": 1, \"k\": 2}\n-> DONE\n",
] {
let diags = dup_src(src);
assert_eq!(diags.len(), 1, "{src}: {diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E138, "{src}");
}
}
#[test]
fn each_extra_occurrence_reports_once() {
let diags = dup_src("=== main ===\n~ temp m = #{1: \"a\", 1: \"b\", 1: \"c\"}\n-> DONE\n");
assert_eq!(diags.len(), 2, "{diags:?}");
}
#[test]
fn the_message_names_the_duplicated_key() {
let diags = dup_src("=== main ===\n~ temp m = #{1: \"a\", 1: \"b\"}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains('1'),
"message should name the key `1`: {:?}",
diags[0].message
);
let diags = dup_src("=== main ===\n~ temp m = #{\"k\": 1, \"k\": 2}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("\"k\""),
"message should name the key `\"k\"`: {:?}",
diags[0].message
);
}
#[test]
fn each_occurrence_reports_the_same_named_key() {
let diags = dup_src("=== main ===\n~ temp m = #{1: \"a\", 1: \"b\", 1: \"c\"}\n-> DONE\n");
assert_eq!(diags.len(), 2, "{diags:?}");
for d in &diags {
assert!(d.message.contains('1'), "{:?}", d.message);
}
}
#[test]
fn distinct_keys_do_not_fire() {
let diags =
dup_src("=== main ===\n~ temp m = #{1: \"a\", 2: \"b\", true: 3, \"1\": 4}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn keys_of_different_kinds_never_collide() {
let diags = dup_src("=== main ===\n~ temp m = #{1: \"a\", \"1\": \"b\"}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn dynamic_and_interpolated_keys_do_not_fire() {
let diags = dup_src("=== main ===\n~ temp k = 1\n~ temp m = #{k: 1, k: 2}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
let diags = dup_src(
"=== main ===\n~ temp a = \"x\"\n~ temp m = #{\"{a}\": 1, \"{a}\": 2}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn out_of_domain_keys_are_left_to_e106() {
let diags = dup_src("=== main ===\n~ temp m = #{3.5: 1, 3.5: 2}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn a_repeat_inside_a_nested_literal_is_reported() {
let diags = dup_src("=== main ===\n~ temp m = #{1: #{\"a\": 1, \"a\": 2}}\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E138);
}
#[test]
fn a_repeat_in_a_var_initializer_is_reported() {
let diags = dup_src("VAR m = #{\"a\": 1, \"a\": 2}\n=== main ===\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E138);
}
}