use super::command_norm::{CommandMode, NormalizedCommandItem, NormalizedFlag, PositionalArg};
use super::command_schema::{
CommandKind, CommandModeMask, CommandRegistry, CommandSchema, CommandSchemaValidationError,
CommandSourceKind, EmptyCommandRegistry, FlagArity, FlagArityByMode, FlagSchema,
PositionalSchema, PositionalSlotSchema, PositionalSourcePolicy, PositionalTailSchema,
ReturnBehavior, StaticCommandRegistry, ValueShape,
};
use super::{
DiagnosticFilter, DiagnosticSeverity, IdentTarget, ResolvedCallee, VariableKind, analyze,
analyze_diagnostics_with_registry, analyze_diagnostics_with_registry_filtered,
analyze_with_registry,
};
use mel_ast::{
AssignOp, BinaryOp, Declarator, Expr, InvokeExpr, InvokeSurface, Item, ProcDef, ProcParam,
ShellWord, SourceFile, Stmt, TypeName, UnaryOp, VarDecl,
};
use mel_syntax::{SourceMap, SourceView, text_range};
use std::{cell::RefCell, mem};
thread_local! {
static TEST_SOURCE: RefCell<String> = const { RefCell::new(String::new()) };
}
fn tr(text: &str) -> mel_syntax::TextRange {
TEST_SOURCE.with(|source| {
let mut source = source.borrow_mut();
let start = source.len() as u32;
source.push_str(text);
let end = source.len() as u32;
source.push('\n');
text_range(start, end)
})
}
fn take_test_source() -> String {
TEST_SOURCE.with(|source| {
let mut source = source.borrow_mut();
mem::take(&mut *source)
})
}
fn invoke_expr(invoke: InvokeExpr) -> Expr {
Expr::Invoke(Box::new(invoke))
}
fn boxed_expr(expr: Expr) -> Box<Expr> {
Box::new(expr)
}
fn test_registry(commands: Vec<CommandSchema>) -> StaticCommandRegistry {
StaticCommandRegistry::try_new(commands).expect("valid test registry")
}
fn command_schema(name: &str, kind: CommandKind) -> CommandSchema {
CommandSchema {
name: name.into(),
kind,
source_kind: CommandSourceKind::Command,
mode_mask: CommandModeMask {
create: true,
edit: true,
query: true,
},
return_behavior: ReturnBehavior::Unknown,
flags: Vec::new().into(),
positionals: PositionalSchema::default(),
}
}
fn uniform_arity(arity: FlagArity) -> FlagArityByMode {
FlagArityByMode {
create: arity,
edit: arity,
query: arity,
}
}
fn flag_schema(long_name: &str, short_name: Option<&str>, arity: FlagArity) -> FlagSchema {
FlagSchema {
long_name: long_name.into(),
short_name: short_name.map(Into::into),
mode_mask: CommandModeMask {
create: true,
edit: true,
query: true,
},
arity_by_mode: uniform_arity(arity),
value_shapes: vec![ValueShape::Unknown].into(),
allows_multiple: false,
}
}
const EXPLICIT_STRING_SLOT: PositionalSlotSchema = PositionalSlotSchema {
value_shapes: &[ValueShape::String],
source_policy: PositionalSourcePolicy::ExplicitOnly,
};
const SELECTION_STRING_SLOT: PositionalSlotSchema = PositionalSlotSchema {
value_shapes: &[ValueShape::String],
source_policy: PositionalSourcePolicy::ExplicitOrCurrentSelection,
};
fn analyze_source(source: &SourceFile) -> super::Analysis {
let source_text = take_test_source();
let source_map = SourceMap::identity(source_text.len());
analyze(source, SourceView::new(&source_text, &source_map))
}
fn analyze_source_with_registry(
source: &SourceFile,
registry: &impl CommandRegistry,
) -> super::Analysis {
let source_text = take_test_source();
let source_map = SourceMap::identity(source_text.len());
analyze_with_registry(source, SourceView::new(&source_text, &source_map), registry)
}
fn resolved_variable(analysis: &super::Analysis, index: usize) -> Option<&super::VariableSymbol> {
match analysis.ident_resolutions[index].resolution {
IdentTarget::Unresolved => None,
IdentTarget::Variable(symbol_id) => Some(&analysis.variable_symbols[symbol_id.0]),
}
}
fn warning_messages(analysis: &super::Analysis) -> Vec<&str> {
analysis
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Warning)
.map(|diagnostic| diagnostic.message.as_ref())
.collect()
}
fn assert_single_diagnostic_severity(
analysis: &super::Analysis,
expected: DiagnosticSeverity,
expected_message: &str,
) {
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(analysis.diagnostics[0].severity, expected);
assert_eq!(analysis.diagnostics[0].message.as_ref(), expected_message);
}
#[test]
fn analyze_handles_non_utf8_source_ranges_via_source_view() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: text_range(0, 4),
args: Vec::new(),
},
range: text_range(0, 4),
}),
range: text_range(0, 4),
}))],
};
let source_text = "按钮";
let source_map = SourceMap::from_source_to_display(vec![0, 3, 3, 6, 6]);
let analysis = analyze(&source, SourceView::new(source_text, &source_map));
assert_eq!(analysis.invoke_resolutions.len(), 1);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Unresolved
));
}
#[test]
fn function_local_proc_forward_reference_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(0, 8),
}),
range: text_range(0, 9),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(17, 19),
},
is_global: false,
range: text_range(10, 19),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"local proc \"helper\" is called before its definition"
);
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn proc_body_traversal_respects_visible_local_proc() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(13, 19),
}),
range: text_range(13, 20),
}],
range: text_range(12, 21),
},
is_global: false,
range: text_range(0, 21),
}))],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn ancestor_scope_local_proc_is_visible_in_nested_block() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(12, 14),
},
is_global: false,
range: text_range(0, 14),
})),
Item::Stmt(Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(17, 25),
}),
range: text_range(17, 26),
}],
range: text_range(15, 27),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn block_local_proc_does_not_leak_to_parent_scope() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Block {
statements: vec![Stmt::Proc {
proc_def: Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(17, 19),
},
is_global: false,
range: text_range(8, 19),
}),
range: text_range(8, 19),
}],
range: text_range(0, 20),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(21, 29),
}),
range: text_range(21, 30),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Unresolved
);
}
#[test]
fn shell_like_calls_resolve_to_local_proc_without_diagnostic() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(9, 11),
},
is_global: false,
range: text_range(0, 11),
})),
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$selection"),
array_size: None,
initializer: Some(Expr::String {
text: text_range(19, 29),
range: text_range(19, 29),
}),
range: text_range(12, 29),
}],
range: text_range(12, 30),
},
range: text_range(12, 30),
})),
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(31, 37),
}],
range: text_range(31, 38),
},
range: text_range(31, 38),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(39, 45),
}),
rhs: Box::new(invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("helper"),
words: vec![ShellWord::Variable {
expr: boxed_expr(Expr::Ident {
name_range: tr("$selection"),
range: text_range(50, 60),
}),
range: text_range(50, 60),
}],
captured: true,
},
range: text_range(46, 61),
})),
range: text_range(39, 61),
},
range: text_range(39, 62),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn shell_like_calls_without_proc_or_registry_remain_unresolved() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("unknown"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 7),
}),
range: text_range(0, 8),
}))],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Unresolved
);
}
#[test]
fn shell_like_local_proc_forward_reference_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("helper"),
words: vec![ShellWord::NumericLiteral {
text: text_range(7, 8),
range: text_range(7, 8),
}],
captured: false,
},
range: text_range(0, 8),
}),
range: text_range(0, 9),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(17, 19),
},
is_global: false,
range: text_range(10, 19),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"local proc \"helper\" is called before its definition"
);
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn shell_like_global_proc_resolves_without_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("helper"),
words: vec![ShellWord::QuotedString {
text: text_range(7, 14),
range: text_range(7, 14),
}],
captured: false,
},
range: text_range(0, 14),
}),
range: text_range(0, 15),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(23, 25),
},
is_global: true,
range: text_range(16, 25),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn builtin_command_resolves_with_registry() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("sphere"),
args: Vec::new(),
},
range: text_range(0, 8),
}),
range: text_range(0, 9),
}))],
};
let registry = test_registry(vec![command_schema("sphere", CommandKind::Builtin)]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::BuiltinCommand("sphere".into())
);
}
#[test]
fn plugin_command_resolves_with_registry() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("foo"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 3),
}),
range: text_range(0, 4),
}))],
};
let registry = test_registry(vec![command_schema("foo", CommandKind::Plugin)]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::PluginCommand("foo".into())
);
}
#[test]
fn proc_resolution_takes_precedence_over_registry_command() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("helper"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 6),
}),
range: text_range(0, 7),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: Vec::new(),
range: text_range(15, 17),
},
is_global: true,
range: text_range(8, 17),
})),
],
};
let registry = test_registry(vec![command_schema("helper", CommandKind::Builtin)]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn function_style_proc_call_with_missing_argument_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("setNodeAttributes"),
params: vec![
ProcParam {
ty: TypeName::String,
name_range: tr("$node"),
is_array: false,
range: text_range(32, 44),
},
ProcParam {
ty: TypeName::String,
name_range: tr("$attrs"),
is_array: true,
range: text_range(45, 59),
},
],
body: Stmt::Block {
statements: Vec::new(),
range: text_range(60, 62),
},
is_global: true,
range: text_range(0, 62),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("setNodeAttributes"),
args: vec![Expr::String {
text: text_range(81, 86),
range: text_range(81, 86),
}],
},
range: text_range(63, 87),
}),
range: text_range(63, 88),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"setNodeAttributes\" expects 2 argument(s) but call provides 1"
);
assert_eq!(analysis.diagnostics[0].range, text_range(63, 87));
assert_eq!(analysis.diagnostics[0].labels.len(), 2);
assert_eq!(
analysis.diagnostics[0].labels[1].message.as_ref(),
"proc defined here"
);
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn shell_like_proc_call_with_matching_argument_count_has_no_diagnostic() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: vec![
ProcParam {
ty: TypeName::String,
name_range: tr("$node"),
is_array: false,
range: text_range(7, 19),
},
ProcParam {
ty: TypeName::String,
name_range: tr("$attrs"),
is_array: true,
range: text_range(20, 34),
},
],
body: Stmt::Block {
statements: Vec::new(),
range: text_range(35, 37),
},
is_global: false,
range: text_range(0, 37),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("helper"),
words: vec![
ShellWord::QuotedString {
text: text_range(45, 50),
range: text_range(45, 50),
},
ShellWord::QuotedString {
text: text_range(51, 58),
range: text_range(51, 58),
},
],
captured: false,
},
range: text_range(38, 58),
}),
range: text_range(38, 59),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
assert!(matches!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Proc(_)
));
}
#[test]
fn analyze_without_registry_leaves_builtin_unresolved() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("sphere"),
args: Vec::new(),
},
range: text_range(0, 8),
}),
range: text_range(0, 9),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::Unresolved
);
}
#[test]
fn shell_like_command_reports_unexpected_positional_arguments() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("date"),
words: vec![ShellWord::QuotedString {
text: text_range(5, 10),
range: text_range(5, 10),
}],
captured: false,
},
range: text_range(0, 10),
}),
range: text_range(0, 11),
}))],
};
let mut command = command_schema("date", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
let diagnostic = analysis
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic
.message
.contains("does not accept positional arguments")
})
.expect("should report unexpected positional argument");
assert_eq!(
diagnostic.message.as_ref(),
"command \"date\" does not accept positional arguments"
);
}
#[test]
fn shell_like_command_reports_missing_required_positional_argument() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("rename"),
words: vec![ShellWord::QuotedString {
text: text_range(7, 10),
range: text_range(7, 10),
}],
captured: false,
},
range: text_range(0, 10),
}),
range: text_range(0, 11),
}))],
};
let mut command = command_schema("rename", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[EXPLICIT_STRING_SLOT, EXPLICIT_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
let diagnostic = analysis
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic
.message
.contains("expects 2 positional argument(s)")
})
.expect("should report missing positional argument");
assert_eq!(
diagnostic.message.as_ref(),
"command \"rename\" expects 2 positional argument(s) but call provides 1"
);
}
#[test]
fn shell_like_command_allows_selection_fallback_positional_omission() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("ikHandle"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 8),
}),
range: text_range(0, 9),
}))],
};
let mut command = command_schema("ikHandle", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[SELECTION_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn shell_like_delete_allows_selection_fallback_positional_omission() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("delete"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 6),
}),
range: text_range(0, 7),
}))],
};
let mut command = command_schema("delete", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[SELECTION_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn shell_like_sets_allows_selection_fallback_positional_omission() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("sets"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 4),
}),
range: text_range(0, 5),
}))],
};
let mut command = command_schema("sets", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[SELECTION_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn shell_like_poly_list_component_conversion_allows_selection_fallback_positional_omission() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("polyListComponentConversion"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 27),
}),
range: text_range(0, 28),
}))],
};
let mut command = command_schema("polyListComponentConversion", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[PositionalSlotSchema {
value_shapes: &[ValueShape::Unknown],
source_policy: PositionalSourcePolicy::ExplicitOrCurrentSelection,
}],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn shell_like_command_allows_explicit_positional_for_selection_fallback_slot() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("ikHandle"),
words: vec![ShellWord::QuotedString {
text: text_range(9, 19),
range: text_range(9, 19),
}],
captured: false,
},
range: text_range(0, 19),
}),
range: text_range(0, 20),
}))],
};
let mut command = command_schema("ikHandle", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[SELECTION_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn shell_like_command_allows_selection_fallback_only_as_trailing_suffix() {
let mut command = command_schema("badSelectionShape", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[
EXPLICIT_STRING_SLOT,
SELECTION_STRING_SLOT,
EXPLICIT_STRING_SLOT,
],
tail: PositionalTailSchema::None,
};
let error = StaticCommandRegistry::try_new(vec![command]).expect_err("invalid schema");
assert_eq!(
error,
CommandSchemaValidationError::SelectionAwarePositionalNotTrailingSuffix {
command_name: "badSelectionShape".into(),
slot_index: 2,
}
);
}
#[test]
fn shell_like_command_reports_positional_shape_mismatch_for_known_literals() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("rename"),
words: vec![
ShellWord::NumericLiteral {
text: text_range(7, 8),
range: text_range(7, 8),
},
ShellWord::QuotedString {
text: text_range(9, 12),
range: text_range(9, 12),
},
],
captured: false,
},
range: text_range(0, 12),
}),
range: text_range(0, 13),
}))],
};
let mut command = command_schema("rename", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[EXPLICIT_STRING_SLOT, EXPLICIT_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
let diagnostic = analysis
.diagnostics
.iter()
.find(|diagnostic| diagnostic.message.contains("expects string but got int"))
.expect("should report positional shape mismatch");
assert_eq!(
diagnostic.message.as_ref(),
"positional argument 1 for command \"rename\" expects string but got int"
);
}
#[test]
fn shell_like_command_normalization_tracks_query_mode_and_invalid_flag_usage() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-query"),
range: text_range(12, 18),
},
ShellWord::Flag {
text: tr("-label"),
range: text_range(19, 25),
},
ShellWord::QuotedString {
text: text_range(26, 33),
range: text_range(26, 33),
},
],
captured: false,
},
range: text_range(0, 33),
}),
range: text_range(0, 34),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.mode_mask = CommandModeMask {
create: true,
edit: true,
query: true,
};
command.flags = vec![FlagSchema {
mode_mask: CommandModeMask {
create: false,
edit: true,
query: false,
},
value_shapes: vec![ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
}]
.into();
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert_eq!(
analysis.invoke_resolutions[0].resolution,
ResolvedCallee::BuiltinCommand("frameLayout".into())
);
assert_eq!(analysis.normalized_invokes.len(), 1);
assert_eq!(analysis.normalized_invokes[0].mode, CommandMode::Query);
let diagnostic = analysis
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic.severity == DiagnosticSeverity::Warning
&& diagnostic.message.contains("not available in query mode")
})
.expect("should report invalid query-mode flag usage");
assert_eq!(diagnostic.range, text_range(19, 25));
}
#[test]
fn shell_like_command_unknown_flag_is_warning() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![ShellWord::Flag {
text: tr("-mystery"),
range: text_range(12, 20),
}],
captured: false,
},
range: text_range(0, 20),
}),
range: text_range(0, 21),
}))],
};
let registry = test_registry(vec![command_schema("frameLayout", CommandKind::Builtin)]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.iter().any(|diagnostic| {
diagnostic.severity == DiagnosticSeverity::Warning
&& diagnostic.message.contains("unknown flag")
}));
}
#[test]
fn shell_like_command_normalization_reports_mode_conflict() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-edit"),
range: text_range(12, 17),
},
ShellWord::Flag {
text: tr("-query"),
range: text_range(18, 24),
},
],
captured: false,
},
range: text_range(0, 24),
}),
range: text_range(0, 25),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.mode_mask = CommandModeMask {
create: true,
edit: true,
query: true,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert_eq!(analysis.normalized_invokes[0].mode, CommandMode::Unknown);
assert!(analysis.diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("combine create/edit/query mode flags")
}));
}
#[test]
fn shell_like_command_query_mode_uses_query_specific_flag_arity() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-query"),
range: text_range(12, 18),
},
ShellWord::Flag {
text: tr("-label"),
range: text_range(19, 25),
},
ShellWord::QuotedString {
text: text_range(26, 33),
range: text_range(26, 33),
},
],
captured: false,
},
range: text_range(0, 33),
}),
range: text_range(0, 34),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.flags = vec![FlagSchema {
arity_by_mode: FlagArityByMode {
create: FlagArity::Exact(1),
edit: FlagArity::Exact(1),
query: FlagArity::None,
},
value_shapes: vec![ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
}]
.into();
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
let items = &analysis.normalized_invokes[0].items;
assert!(matches!(
&items[1],
NormalizedCommandItem::Flag(NormalizedFlag {
source_range,
canonical_name: Some(name),
args,
..
}) if *source_range == text_range(19, 25) && name.as_ref() == "label" && args.is_empty()
));
assert!(matches!(
&items[2],
NormalizedCommandItem::Positional(PositionalArg {
word: ShellWord::QuotedString { text, .. },
..
}) if *text == text_range(26, 33)
));
}
#[test]
fn shell_like_command_range_arity_allows_optional_second_arg_to_be_omitted() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-label"),
range: text_range(12, 18),
},
ShellWord::QuotedString {
text: text_range(19, 26),
range: text_range(19, 26),
},
],
captured: false,
},
range: text_range(0, 26),
}),
range: text_range(0, 27),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.flags = vec![FlagSchema {
arity_by_mode: FlagArityByMode {
create: FlagArity::Range { min: 1, max: 2 },
edit: FlagArity::Range { min: 1, max: 2 },
query: FlagArity::None,
},
value_shapes: vec![ValueShape::String, ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
}]
.into();
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
let items = &analysis.normalized_invokes[0].items;
assert!(matches!(
&items[0],
NormalizedCommandItem::Flag(NormalizedFlag {
canonical_name: Some(name),
args,
..
}) if name.as_ref() == "label" && args.len() == 1
));
}
#[test]
fn shell_like_command_range_arity_allows_optional_second_arg_to_be_present() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-label"),
range: text_range(12, 18),
},
ShellWord::QuotedString {
text: text_range(19, 26),
range: text_range(19, 26),
},
ShellWord::QuotedString {
text: text_range(27, 36),
range: text_range(27, 36),
},
],
captured: false,
},
range: text_range(0, 36),
}),
range: text_range(0, 37),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.flags = vec![FlagSchema {
arity_by_mode: FlagArityByMode {
create: FlagArity::Range { min: 1, max: 2 },
edit: FlagArity::Range { min: 1, max: 2 },
query: FlagArity::None,
},
value_shapes: vec![ValueShape::String, ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
}]
.into();
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.is_empty());
let items = &analysis.normalized_invokes[0].items;
assert!(matches!(
&items[0],
NormalizedCommandItem::Flag(NormalizedFlag {
canonical_name: Some(name),
args,
..
}) if name.as_ref() == "label" && args.len() == 2
));
}
#[test]
fn shell_like_command_range_arity_reports_missing_required_argument() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![ShellWord::Flag {
text: tr("-label"),
range: text_range(12, 18),
}],
captured: false,
},
range: text_range(0, 18),
}),
range: text_range(0, 19),
}))],
};
let mut command = command_schema("frameLayout", CommandKind::Builtin);
command.flags = vec![FlagSchema {
arity_by_mode: FlagArityByMode {
create: FlagArity::Range { min: 1, max: 2 },
edit: FlagArity::Range { min: 1, max: 2 },
query: FlagArity::None,
},
value_shapes: vec![ValueShape::String, ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
}]
.into();
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert!(analysis.diagnostics.iter().any(|diagnostic| {
diagnostic.severity == DiagnosticSeverity::Error
&& diagnostic.message.contains("expects 1 to 2 argument(s)")
}));
}
#[test]
fn shell_like_command_without_mode_flag_reports_unavailable_create_mode() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("queryOnly"),
words: vec![ShellWord::BareWord {
text: text_range(10, 15),
range: text_range(10, 15),
}],
captured: false,
},
range: text_range(0, 15),
}),
range: text_range(0, 16),
}))],
};
let mut command = command_schema("queryOnly", CommandKind::Builtin);
command.mode_mask = CommandModeMask {
create: false,
edit: false,
query: true,
};
let registry = test_registry(vec![command]);
let analysis = analyze_source_with_registry(&source, ®istry);
assert_eq!(analysis.normalized_invokes[0].mode, CommandMode::Create);
assert!(analysis.diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("command \"queryOnly\" is not available in create mode")
}));
}
#[test]
fn proc_params_resolve_inside_proc_body() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: vec![ProcParam {
ty: TypeName::String,
name_range: tr("$name"),
is_array: false,
range: text_range(12, 24),
}],
body: Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$name"),
range: text_range(29, 34),
},
range: text_range(29, 35),
}],
range: text_range(26, 36),
},
is_global: false,
range: text_range(0, 36),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.variable_symbols.len(), 1);
assert_eq!(analysis.variable_symbols[0].kind, VariableKind::Parameter);
assert!(resolved_variable(&analysis, 0).is_some());
}
#[test]
fn local_variables_become_visible_after_declaration() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(0, 6),
},
range: text_range(0, 7),
})),
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(18, 19),
}),
range: text_range(12, 19),
}],
range: text_range(8, 20),
},
range: text_range(8, 20),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(21, 27),
},
range: text_range(21, 28),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.variable_symbols.len(), 1);
assert_eq!(analysis.ident_resolutions.len(), 2);
assert!(resolved_variable(&analysis, 0).is_none());
assert!(resolved_variable(&analysis, 1).is_some());
}
#[test]
fn local_variables_shadow_globals_inside_proc_scope() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: true,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(0, 20),
}],
range: text_range(0, 21),
},
range: text_range(0, 21),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(42, 43),
}),
range: text_range(36, 43),
}],
range: text_range(32, 44),
},
range: text_range(32, 44),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(45, 51),
},
range: text_range(45, 52),
},
],
range: text_range(30, 53),
},
is_global: false,
range: text_range(22, 53),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.variable_symbols.len(), 2);
let resolved = resolved_variable(&analysis, 0).expect("local variable should resolve");
assert_eq!(resolved.kind, VariableKind::Local);
assert_eq!(resolved.name_range, analysis.variable_symbols[1].name_range);
}
#[test]
fn block_local_variable_does_not_leak_to_parent_scope() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(13, 14),
}),
range: text_range(7, 14),
}],
range: text_range(3, 15),
},
range: text_range(3, 15),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(16, 22),
},
range: text_range(16, 23),
},
],
range: text_range(0, 24),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(25, 31),
},
range: text_range(25, 32),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(
resolved_variable(&analysis, 0).map(|symbol| symbol.kind),
Some(VariableKind::Local)
);
assert!(resolved_variable(&analysis, 1).is_none());
}
#[test]
fn local_variable_read_before_first_explicit_write_is_warning() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(25, 31),
},
range: text_range(25, 32),
},
],
range: text_range(12, 33),
},
is_global: false,
range: text_range(0, 33),
}))],
};
let analysis = analyze_source(&source);
let warnings = warning_messages(&analysis);
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0],
"local variable \"$value\" is read before its first explicit write; MEL would use a default value here"
);
}
#[test]
fn initialized_local_variable_does_not_warn() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(25, 26),
}),
range: text_range(17, 26),
}],
range: text_range(13, 27),
},
range: text_range(13, 27),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(28, 34),
},
range: text_range(28, 35),
},
],
range: text_range(12, 36),
},
is_global: false,
range: text_range(0, 36),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).is_empty());
}
#[test]
fn proc_param_read_does_not_warn() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: vec![ProcParam {
ty: TypeName::String,
name_range: tr("$name"),
is_array: false,
range: text_range(12, 24),
}],
body: Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$name"),
range: text_range(29, 34),
},
range: text_range(29, 35),
}],
range: text_range(26, 36),
},
is_global: false,
range: text_range(0, 36),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).is_empty());
}
#[test]
fn if_without_else_keeps_maybe_unwritten_state() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::If {
condition: Box::new(Expr::Int {
value: 1,
range: text_range(29, 30),
}),
then_branch: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(36, 42),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(45, 46),
}),
range: text_range(36, 46),
},
range: text_range(36, 47),
}],
range: text_range(32, 49),
}),
else_branch: None,
range: text_range(25, 49),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(50, 56),
},
range: text_range(50, 57),
},
],
range: text_range(12, 58),
},
is_global: false,
range: text_range(0, 58),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).iter().any(|message| {
*message
== "local variable \"$value\" is read before its first explicit write; MEL would use a default value here"
}));
}
#[test]
fn if_else_assigning_both_branches_does_not_warn() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::If {
condition: Box::new(Expr::Int {
value: 1,
range: text_range(29, 30),
}),
then_branch: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(36, 42),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(45, 46),
}),
range: text_range(36, 46),
},
range: text_range(36, 47),
}],
range: text_range(32, 49),
}),
else_branch: Some(Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(57, 63),
}),
rhs: Box::new(Expr::Int {
value: 2,
range: text_range(66, 67),
}),
range: text_range(57, 67),
},
range: text_range(57, 68),
}],
range: text_range(53, 70),
})),
range: text_range(25, 70),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(71, 77),
},
range: text_range(71, 78),
},
],
range: text_range(12, 79),
},
is_global: false,
range: text_range(0, 79),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).is_empty());
}
#[test]
fn while_loop_write_only_does_not_make_post_read_definite() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::While {
condition: Box::new(Expr::Int {
value: 1,
range: text_range(32, 33),
}),
body: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(39, 45),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(48, 49),
}),
range: text_range(39, 49),
},
range: text_range(39, 50),
}],
range: text_range(35, 52),
}),
range: text_range(25, 52),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(53, 59),
},
range: text_range(53, 60),
},
],
range: text_range(12, 61),
},
is_global: false,
range: text_range(0, 61),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).iter().any(|message| {
*message
== "local variable \"$value\" is read before its first explicit write; MEL would use a default value here"
}));
}
#[test]
fn do_while_unconditional_write_allows_post_read() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::DoWhile {
body: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(31, 37),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(40, 41),
}),
range: text_range(31, 41),
},
range: text_range(31, 42),
}],
range: text_range(27, 44),
}),
condition: Box::new(Expr::Int {
value: 0,
range: text_range(51, 52),
}),
range: text_range(25, 53),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$value"),
range: text_range(54, 60),
},
range: text_range(54, 61),
},
],
range: text_range(12, 62),
},
is_global: false,
range: text_range(0, 62),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).is_empty());
}
#[test]
fn arrays_and_matrices_are_treated_as_initialized() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$items"),
array_size: Some(None),
initializer: None,
range: text_range(17, 24),
}],
range: text_range(13, 25),
},
range: text_range(13, 25),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$items"),
range: text_range(26, 32),
},
range: text_range(26, 33),
},
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Matrix,
declarators: vec![Declarator {
name_range: tr("$matrix"),
array_size: None,
initializer: None,
range: text_range(34, 41),
}],
range: text_range(34, 42),
},
range: text_range(34, 42),
},
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$matrix"),
range: text_range(43, 50),
},
range: text_range(43, 51),
},
],
range: text_range(12, 52),
},
is_global: false,
range: text_range(0, 52),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).is_empty());
}
#[test]
fn compound_assignment_before_write_is_warning() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(17, 23),
}],
range: text_range(13, 24),
},
range: text_range(13, 24),
},
Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::AddAssign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(25, 31),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(35, 36),
}),
range: text_range(25, 36),
},
range: text_range(25, 37),
},
],
range: text_range(12, 38),
},
is_global: false,
range: text_range(0, 38),
}))],
};
let analysis = analyze_source(&source);
assert!(warning_messages(&analysis).iter().any(|message| {
*message
== "local variable \"$value\" is read before its first explicit write; MEL would use a default value here"
}));
}
#[test]
fn local_shadowing_warnings_cover_parameter_local_and_global() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: true,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$global"),
array_size: None,
initializer: None,
range: text_range(0, 20),
}],
range: text_range(0, 21),
},
range: text_range(0, 21),
})),
Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: vec![ProcParam {
ty: TypeName::String,
name_range: tr("$param"),
is_array: false,
range: text_range(34, 47),
}],
body: Stmt::Block {
statements: vec![
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$param"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(59, 60),
}),
range: text_range(51, 60),
}],
range: text_range(48, 61),
},
range: text_range(48, 61),
},
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$local"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(72, 73),
}),
range: text_range(64, 73),
}],
range: text_range(62, 74),
},
range: text_range(62, 74),
},
Stmt::Block {
statements: vec![Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$local"),
array_size: None,
initializer: Some(Expr::Int {
value: 2,
range: text_range(87, 88),
}),
range: text_range(79, 88),
}],
range: text_range(75, 89),
},
range: text_range(75, 89),
}],
range: text_range(74, 90),
},
Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$global"),
array_size: None,
initializer: Some(Expr::Int {
value: 3,
range: text_range(102, 103),
}),
range: text_range(93, 103),
}],
range: text_range(91, 104),
},
range: text_range(91, 104),
},
],
range: text_range(47, 105),
},
is_global: false,
range: text_range(22, 105),
})),
],
};
let analysis = analyze_source(&source);
let warnings = warning_messages(&analysis);
assert!(warnings.contains(&"local variable \"$param\" shadows visible parameter variable"));
assert!(warnings.contains(&"local variable \"$local\" shadows visible local variable"));
assert!(warnings.contains(&"local variable \"$global\" shadows visible global variable"));
}
#[test]
fn unresolved_variable_is_reported_as_warning() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$missing"),
range: text_range(0, 8),
},
range: text_range(0, 9),
}))],
};
let analysis = analyze_source(&source);
let warnings = warning_messages(&analysis);
assert!(warnings.contains(&"unresolved variable \"$missing\""));
assert!(resolved_variable(&analysis, 0).is_none());
}
#[test]
fn unresolved_variable_plain_assignment_target_is_not_reported() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$missing"),
range: text_range(13, 21),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(24, 25),
}),
range: text_range(13, 25),
},
range: text_range(13, 26),
}],
range: text_range(12, 27),
},
is_global: false,
range: text_range(0, 27),
}))],
};
let analysis = analyze_source(&source);
let unresolved_count = warning_messages(&analysis)
.into_iter()
.filter(|message| *message == "unresolved variable \"$missing\"")
.count();
assert_eq!(unresolved_count, 0);
assert!(resolved_variable(&analysis, 0).is_none());
}
#[test]
fn unresolved_variable_compound_assignment_target_is_reported() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::AddAssign,
lhs: Box::new(Expr::Ident {
name_range: tr("$missing"),
range: text_range(13, 21),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(25, 26),
}),
range: text_range(13, 26),
},
range: text_range(13, 27),
}],
range: text_range(12, 28),
},
is_global: false,
range: text_range(0, 28),
}))],
};
let analysis = analyze_source(&source);
let unresolved_count = warning_messages(&analysis)
.into_iter()
.filter(|message| *message == "unresolved variable \"$missing\"")
.count();
assert_eq!(unresolved_count, 1);
}
#[test]
fn unresolved_variable_for_in_binding_is_not_reported() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: vec![ProcParam {
ty: TypeName::String,
name_range: tr("$items"),
is_array: true,
range: text_range(7, 21),
}],
body: Stmt::Block {
statements: vec![Stmt::ForIn {
binding: Box::new(Expr::Ident {
name_range: tr("$item"),
range: text_range(24, 29),
}),
iterable: Box::new(Expr::Ident {
name_range: tr("$items"),
range: text_range(33, 39),
}),
body: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: mel_ast::InvokeSurface::Function {
head_range: tr("print"),
args: vec![Expr::Ident {
name_range: tr("$item"),
range: text_range(49, 54),
}],
},
range: text_range(43, 55),
}),
range: text_range(43, 56),
}],
range: text_range(41, 58),
}),
range: text_range(20, 58),
}],
range: text_range(19, 59),
},
is_global: false,
range: text_range(0, 59),
}))],
};
let analysis = analyze_source(&source);
assert!(
!warning_messages(&analysis).contains(&"unresolved variable \"$item\""),
"for-in binding should not emit unresolved-variable warnings"
);
}
#[test]
fn unresolved_variable_for_in_iterable_is_still_reported() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::ForIn {
binding: Box::new(Expr::Ident {
name_range: tr("$item"),
range: text_range(13, 18),
}),
iterable: Box::new(Expr::Ident {
name_range: tr("$missing"),
range: text_range(22, 30),
}),
body: Box::new(Stmt::Block {
statements: vec![Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: mel_ast::InvokeSurface::Function {
head_range: tr("print"),
args: vec![Expr::Ident {
name_range: tr("$item"),
range: text_range(40, 45),
}],
},
range: text_range(34, 46),
}),
range: text_range(34, 47),
}],
range: text_range(32, 49),
}),
range: text_range(9, 49),
}],
range: text_range(8, 50),
},
is_global: false,
range: text_range(0, 50),
}))],
};
let analysis = analyze_source(&source);
let warnings = warning_messages(&analysis);
let missing_count = warnings
.iter()
.filter(|message| **message == "unresolved variable \"$missing\"")
.count();
assert_eq!(missing_count, 1);
assert!(!warnings.contains(&"unresolved variable \"$item\""));
}
#[test]
fn boolean_aliases_do_not_report_unresolved_variable_warnings() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(0, 3),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![
Stmt::Expr {
expr: Expr::Ident {
name_range: tr("true"),
range: text_range(13, 17),
},
range: text_range(13, 18),
},
Stmt::Return {
expr: Some(Expr::Ident {
name_range: tr("false"),
range: text_range(26, 31),
}),
range: text_range(19, 32),
},
],
range: text_range(12, 33),
},
is_global: false,
range: text_range(0, 33),
}))],
};
let analysis = analyze_source(&source);
let warnings = warning_messages(&analysis);
assert!(!warnings.contains(&"unresolved variable \"true\""));
assert!(!warnings.contains(&"unresolved variable \"false\""));
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn void_proc_returning_value_reports_diagnostic() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: None,
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::Int {
value: 1,
range: text_range(16, 17),
}),
range: text_range(9, 18),
}],
range: text_range(8, 19),
},
is_global: false,
range: text_range(0, 19),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"helper\" has no return type but returns a value"
);
}
#[test]
fn typed_proc_without_value_return_reports_diagnostic() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(5, 8),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: None,
range: text_range(16, 23),
}],
range: text_range(15, 24),
},
is_global: false,
range: text_range(0, 24),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"helper\" declares a return type but never returns a value"
);
}
#[test]
fn typed_proc_value_return_in_nested_proc_does_not_satisfy_outer_proc() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(5, 8),
}),
name_range: tr("outer"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Proc {
proc_def: Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(24, 27),
}),
name_range: tr("inner"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::Int {
value: 1,
range: text_range(42, 43),
}),
range: text_range(35, 44),
}],
range: text_range(34, 45),
},
is_global: false,
range: text_range(19, 45),
}),
range: text_range(19, 45),
}],
range: text_range(14, 46),
},
is_global: false,
range: text_range(0, 46),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"outer\" declares a return type but never returns a value"
);
}
#[test]
fn typed_proc_return_type_mismatch_reports_diagnostic() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(5, 8),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::String {
text: text_range(16, 21),
range: text_range(16, 21),
}),
range: text_range(9, 22),
}],
range: text_range(8, 23),
},
is_global: false,
range: text_range(0, 23),
}))],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"helper\" returns String but declares Int"
);
}
#[test]
fn var_initializer_type_mismatch_reports_diagnostic() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 1,
range: text_range(16, 17),
}),
range: text_range(8, 17),
}],
range: text_range(0, 18),
},
range: text_range(0, 18),
}))],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type String but initializer is Int",
);
}
#[test]
fn proc_invoke_return_type_flows_into_initializer_check() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::String,
is_array: false,
range: text_range(5, 11),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::String {
text: text_range(24, 29),
range: text_range(24, 29),
}),
range: text_range(17, 30),
}],
range: text_range(16, 31),
},
is_global: false,
range: text_range(0, 31),
})),
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(44, 52),
})),
range: text_range(36, 52),
}],
range: text_range(32, 53),
},
range: text_range(32, 53),
})),
],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type Int but initializer is String",
);
assert_eq!(analysis.diagnostics[0].range, text_range(44, 52));
}
#[test]
fn proc_return_type_mismatch_is_warning_for_scalar_coercion() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(5, 8),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::String {
text: text_range(21, 26),
range: text_range(21, 26),
}),
range: text_range(14, 27),
}],
range: text_range(13, 28),
},
is_global: false,
range: text_range(0, 28),
}))],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"proc \"helper\" returns String but declares Int",
);
}
#[test]
fn proc_return_type_mismatch_remains_error_for_non_scalar_types() {
let source = SourceFile {
items: vec![Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Vector,
is_array: false,
range: text_range(5, 11),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::String {
text: text_range(24, 29),
range: text_range(24, 29),
}),
range: text_range(17, 30),
}],
range: text_range(16, 31),
},
is_global: false,
range: text_range(0, 31),
}))],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Error,
"proc \"helper\" returns String but declares Vector",
);
}
#[test]
fn comparison_initializer_is_treated_as_int() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Binary {
op: BinaryOp::EqEq,
lhs: Box::new(Expr::String {
text: text_range(17, 23),
range: text_range(17, 23),
}),
rhs: Box::new(Expr::String {
text: text_range(27, 33),
range: text_range(27, 33),
}),
range: text_range(16, 34),
}),
range: text_range(8, 34),
}],
range: text_range(0, 35),
},
range: text_range(0, 35),
}))],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn logical_binary_initializer_is_treated_as_int() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Binary {
op: BinaryOp::AndAnd,
lhs: Box::new(Expr::Int {
value: 1,
range: text_range(16, 17),
}),
rhs: Box::new(Expr::Int {
value: 0,
range: text_range(21, 22),
}),
range: text_range(16, 22),
}),
range: text_range(8, 22),
}],
range: text_range(0, 23),
},
range: text_range(0, 23),
}))],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn logical_not_initializer_is_treated_as_int() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Unary {
op: UnaryOp::Not,
expr: Box::new(Expr::Ident {
name_range: tr("true"),
range: text_range(17, 21),
}),
range: text_range(16, 21),
}),
range: text_range(8, 21),
}],
range: text_range(0, 22),
},
range: text_range(0, 22),
}))],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn comparison_initializer_reports_int_when_assigned_to_string() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Binary {
op: BinaryOp::NotEq,
lhs: Box::new(Expr::String {
text: text_range(19, 25),
range: text_range(19, 25),
}),
rhs: Box::new(Expr::String {
text: text_range(29, 35),
range: text_range(29, 35),
}),
range: text_range(18, 36),
}),
range: text_range(8, 36),
}],
range: text_range(0, 37),
},
range: text_range(0, 37),
}))],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type String but initializer is Int",
);
assert_eq!(analysis.diagnostics[0].range, text_range(18, 36));
}
#[test]
fn boolean_alias_initializer_to_string_is_warning() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Ident {
name_range: tr("true"),
range: text_range(16, 20),
}),
range: text_range(8, 20),
}],
range: text_range(0, 21),
},
range: text_range(0, 21),
}))],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type String but initializer is Int",
);
}
#[test]
fn plain_assignment_type_mismatch_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(8, 14),
}],
range: text_range(0, 15),
},
range: text_range(0, 15),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(16, 22),
}),
rhs: Box::new(Expr::String {
text: text_range(25, 30),
range: text_range(25, 30),
}),
range: text_range(16, 30),
},
range: text_range(16, 31),
})),
],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type Int but assigned expression is String",
);
assert_eq!(analysis.diagnostics[0].range, text_range(25, 30));
}
#[test]
fn boolean_alias_assignment_to_string_is_warning() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::String,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(11, 17),
}],
range: text_range(0, 18),
},
range: text_range(0, 18),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(19, 25),
}),
rhs: Box::new(Expr::Ident {
name_range: tr("off"),
range: text_range(28, 31),
}),
range: text_range(19, 31),
},
range: text_range(19, 32),
})),
],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type String but assigned expression is Int",
);
assert_eq!(analysis.diagnostics[0].range, text_range(28, 31));
}
#[test]
fn plain_assignment_with_matching_type_is_allowed() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: None,
range: text_range(8, 14),
}],
range: text_range(0, 15),
},
range: text_range(0, 15),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(16, 22),
}),
rhs: Box::new(Expr::Int {
value: 1,
range: text_range(25, 26),
}),
range: text_range(16, 26),
},
range: text_range(16, 27),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn compound_assignment_type_mismatch_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(Expr::Int {
value: 0,
range: text_range(16, 17),
}),
range: text_range(8, 17),
}],
range: text_range(0, 18),
},
range: text_range(0, 18),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::AddAssign,
lhs: Box::new(Expr::Ident {
name_range: tr("$value"),
range: text_range(19, 25),
}),
rhs: Box::new(Expr::String {
text: text_range(29, 34),
range: text_range(29, 34),
}),
range: text_range(19, 34),
},
range: text_range(19, 35),
})),
],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$value\" has declared type Int but assigned expression is String",
);
assert_eq!(analysis.diagnostics[0].range, text_range(29, 34));
}
#[test]
fn indexed_assignment_type_mismatch_reports_diagnostic() {
let source = SourceFile {
items: vec![
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$values"),
array_size: Some(None),
initializer: None,
range: text_range(8, 15),
}],
range: text_range(0, 16),
},
range: text_range(0, 16),
})),
Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Assign {
op: AssignOp::Assign,
lhs: Box::new(Expr::Index {
target: Box::new(Expr::Ident {
name_range: tr("$values"),
range: text_range(17, 24),
}),
index: Box::new(Expr::Int {
value: 0,
range: text_range(25, 26),
}),
range: text_range(17, 27),
}),
rhs: Box::new(Expr::String {
text: text_range(30, 35),
range: text_range(30, 35),
}),
range: text_range(17, 35),
},
range: text_range(17, 36),
})),
],
};
let analysis = analyze_source(&source);
assert_single_diagnostic_severity(
&analysis,
DiagnosticSeverity::Warning,
"variable \"$values\" has declared type Int but assigned expression is String",
);
assert_eq!(analysis.diagnostics[0].range, text_range(30, 35));
}
#[test]
fn proc_invoke_return_type_flows_into_return_check() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::String,
is_array: false,
range: text_range(5, 11),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::String {
text: text_range(24, 29),
range: text_range(24, 29),
}),
range: text_range(17, 30),
}],
range: text_range(16, 31),
},
is_global: false,
range: text_range(0, 31),
})),
Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(37, 40),
}),
name_range: tr("outer"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(56, 64),
})),
range: text_range(49, 65),
}],
range: text_range(48, 66),
},
is_global: false,
range: text_range(32, 66),
})),
],
};
let analysis = analyze_source(&source);
assert_eq!(analysis.diagnostics.len(), 1);
assert_eq!(
analysis.diagnostics[0].message.as_ref(),
"proc \"outer\" returns String but declares Int"
);
}
#[test]
fn proc_invoke_return_type_allows_matching_initializer() {
let source = SourceFile {
items: vec![
Item::Proc(Box::new(ProcDef {
return_type: Some(mel_ast::ProcReturnType {
ty: TypeName::Int,
is_array: false,
range: text_range(5, 8),
}),
name_range: tr("helper"),
params: Vec::new(),
body: Stmt::Block {
statements: vec![Stmt::Return {
expr: Some(Expr::Int {
value: 1,
range: text_range(21, 22),
}),
range: text_range(14, 23),
}],
range: text_range(13, 24),
},
is_global: false,
range: text_range(0, 24),
})),
Item::Stmt(Box::new(Stmt::VarDecl {
decl: VarDecl {
is_global: false,
ty: TypeName::Int,
declarators: vec![Declarator {
name_range: tr("$value"),
array_size: None,
initializer: Some(invoke_expr(InvokeExpr {
surface: InvokeSurface::Function {
head_range: tr("helper"),
args: Vec::new(),
},
range: text_range(37, 45),
})),
range: text_range(29, 45),
}],
range: text_range(25, 46),
},
range: text_range(25, 46),
})),
],
};
let analysis = analyze_source(&source);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn diagnostics_only_analysis_matches_full_diagnostics() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("frameLayout"),
words: vec![
ShellWord::Flag {
text: tr("-query"),
range: text_range(12, 18),
},
ShellWord::Flag {
text: tr("-label"),
range: text_range(19, 25),
},
],
captured: false,
},
range: text_range(0, 25),
}),
range: text_range(0, 26),
}))],
};
let registry = test_registry(vec![CommandSchema {
name: "frameLayout".into(),
kind: CommandKind::Builtin,
source_kind: CommandSourceKind::Command,
mode_mask: CommandModeMask {
create: true,
edit: false,
query: true,
},
return_behavior: ReturnBehavior::Unknown,
flags: vec![
FlagSchema {
mode_mask: CommandModeMask {
create: true,
edit: false,
query: true,
},
arity_by_mode: FlagArityByMode {
create: FlagArity::None,
edit: FlagArity::None,
query: FlagArity::None,
},
value_shapes: Vec::new().into(),
..flag_schema("query", Some("q"), FlagArity::None)
},
FlagSchema {
mode_mask: CommandModeMask {
create: true,
edit: false,
query: false,
},
arity_by_mode: FlagArityByMode {
create: FlagArity::Exact(1),
edit: FlagArity::None,
query: FlagArity::None,
},
value_shapes: vec![ValueShape::String].into(),
..flag_schema("label", Some("l"), FlagArity::Exact(1))
},
]
.into(),
positionals: PositionalSchema {
prefix: &[EXPLICIT_STRING_SLOT],
tail: PositionalTailSchema::None,
},
}]);
let source_text = take_test_source();
let source_map = SourceMap::identity(source_text.len());
let source_view = SourceView::new(&source_text, &source_map);
let full = analyze_with_registry(&source, source_view, ®istry);
let diagnostics_only = analyze_diagnostics_with_registry(&source, source_view, ®istry);
assert_eq!(full.normalized_invokes.len(), 1);
assert_eq!(diagnostics_only, full.diagnostics);
}
#[test]
fn diagnostics_error_filter_drops_warning_only_analysis() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: Expr::Ident {
name_range: tr("$missing"),
range: text_range(0, 8),
},
range: text_range(0, 8),
}))],
};
let source_text = take_test_source();
let source_map = SourceMap::identity(source_text.len());
let source_view = SourceView::new(&source_text, &source_map);
let all = analyze_diagnostics_with_registry(&source, source_view, &EmptyCommandRegistry);
let errors_only = analyze_diagnostics_with_registry_filtered(
&source,
source_view,
&EmptyCommandRegistry,
DiagnosticFilter::ErrorsOnly,
);
assert_eq!(all.len(), 1);
assert_eq!(all[0].severity, DiagnosticSeverity::Warning);
assert!(errors_only.is_empty());
}
#[test]
fn diagnostics_error_filter_still_reports_command_schema_errors() {
let source = SourceFile {
items: vec![Item::Stmt(Box::new(Stmt::Expr {
expr: invoke_expr(InvokeExpr {
surface: InvokeSurface::ShellLike {
head_range: tr("addAttr"),
words: Vec::new(),
captured: false,
},
range: text_range(0, 7),
}),
range: text_range(0, 8),
}))],
};
let source_text = take_test_source();
let source_map = SourceMap::identity(source_text.len());
let source_view = SourceView::new(&source_text, &source_map);
let mut command = command_schema("addAttr", CommandKind::Builtin);
command.positionals = PositionalSchema {
prefix: &[EXPLICIT_STRING_SLOT],
tail: PositionalTailSchema::None,
};
let registry = test_registry(vec![command]);
let diagnostics = analyze_diagnostics_with_registry_filtered(
&source,
source_view,
®istry,
DiagnosticFilter::ErrorsOnly,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
assert_eq!(
diagnostics[0].message.as_ref(),
"command \"addAttr\" expects 1 positional argument(s) but call provides 0"
);
}