Skip to content

Commit 4274b2a

Browse files
committed
Handle mixed visibility arguments
Ruby allows mixing inline and retroactive visibility arguments in a single call (`private def foo; end, :bar`), where both methods should become private. The previous implementation made a binary call-level decision based on the first argument, routing all args to either the inline or retroactive path and silently dropping arguments that didn't match the chosen path. We now classify and dispatch each argument independently instead of making a single call-level decision. Inline defs and retroactive literal targets can coexist in the same visibility call, and invalid arguments now emit diagnostics at their own locations instead of once for the whole call. This also preserves the existing `attr_*` special case only for the sole-argument form (`private attr_reader(:foo)`). In mixed calls like `private attr_reader(:foo), :bar`, the `attr_reader` call is no longer treated as an inline visibility target.
1 parent 25cab1a commit 4274b2a

2 files changed

Lines changed: 200 additions & 88 deletions

File tree

‎rust/rubydex/src/indexing/ruby_indexer.rs‎

Lines changed: 62 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,17 +1260,7 @@ impl<'a> RubyIndexer<'a> {
12601260
}
12611261
}
12621262

1263-
fn should_apply_inline_visibility(args: &ruby_prism::NodeList) -> bool {
1264-
if args.len() != 1 {
1265-
return false;
1266-
}
1267-
1268-
let arg = args.iter().next().unwrap();
1269-
1270-
if matches!(arg, ruby_prism::Node::DefNode { .. }) {
1271-
return true;
1272-
}
1273-
1263+
fn is_attr_call(arg: &ruby_prism::Node) -> bool {
12741264
arg.as_call_node().is_some_and(|call| {
12751265
let receiver = call.receiver();
12761266
let bare_or_self = receiver.is_none() || receiver.as_ref().is_some_and(|r| r.as_self_node().is_some());
@@ -1282,54 +1272,79 @@ impl<'a> RubyIndexer<'a> {
12821272
})
12831273
}
12841274

1285-
fn handle_retroactive_method_visibility(
1275+
/// Classifies each visibility argument and applies visibility left-to-right:
1276+
/// - `DefNode`: inline visibility (always valid)
1277+
/// - Sole attr_* call: inline visibility (multi-arg attr_* is unsupported — returns array)
1278+
/// - `SymbolNode`/`StringNode`: retroactive `MethodVisibilityDefinition`
1279+
/// - Anything else: per-arg diagnostic
1280+
fn handle_visibility_arguments(
12861281
&mut self,
1287-
node: &ruby_prism::CallNode,
12881282
arguments: &ruby_prism::ArgumentsNode,
12891283
visibility: Visibility,
12901284
call_offset: &Offset,
12911285
call_name: &str,
12921286
) {
12931287
let args = arguments.arguments();
1288+
let arg_count = args.len();
12941289

1295-
let is_literal = |arg: ruby_prism::Node| {
1296-
matches!(
1290+
for arg in &args {
1291+
if matches!(arg, ruby_prism::Node::DefNode { .. }) || (arg_count == 1 && Self::is_attr_call(&arg)) {
1292+
self.visibility_stack
1293+
.push(VisibilityModifier::new(visibility, true, call_offset.clone()));
1294+
self.visit(&arg);
1295+
self.visibility_stack.pop();
1296+
} else if matches!(
12971297
arg,
12981298
ruby_prism::Node::SymbolNode { .. } | ruby_prism::Node::StringNode { .. }
1299-
)
1300-
};
1301-
1302-
if !args.iter().all(&is_literal) {
1303-
let has_any_literal = args.iter().any(is_literal);
1304-
let message = if has_any_literal {
1305-
format!("`{call_name}` called with mixed literal and non-literal arguments")
1299+
) {
1300+
self.create_method_visibility_definition(&arg, visibility);
13061301
} else {
1307-
format!("`{call_name}` called with non-literal arguments")
1308-
};
1309-
1310-
self.local_graph
1311-
.add_diagnostic(Rule::InvalidMethodVisibility, call_offset.clone(), message);
1312-
self.visit_arguments_node(arguments);
1313-
return;
1302+
// Unsupported arg — diagnostic + visit for side effects.
1303+
let arg_offset = Offset::from_prism_location(&arg.location());
1304+
let message = if Self::is_attr_call(&arg) {
1305+
format!("`{call_name}` with `attr_*` is only supported as a single argument")
1306+
} else {
1307+
format!("`{call_name}` called with a non-literal argument")
1308+
};
1309+
self.local_graph
1310+
.add_diagnostic(Rule::InvalidMethodVisibility, arg_offset, message);
1311+
self.visit(&arg);
1312+
}
13141313
}
1314+
}
13151315

1316-
// All symbols/strings — create MethodVisibilityDefinitions
1317-
Self::each_string_or_symbol_arg(node, |name, location| {
1318-
let str_id = self.local_graph.intern_string(format!("{name}()"));
1319-
let arg_offset = Offset::from_prism_location(&location);
1320-
let definition = Definition::MethodVisibility(Box::new(MethodVisibilityDefinition::new(
1321-
str_id,
1322-
visibility,
1323-
self.uri_id,
1324-
arg_offset,
1325-
Box::default(),
1326-
DefinitionFlags::empty(),
1327-
self.current_nesting_definition_id(),
1328-
)));
1316+
fn create_method_visibility_definition(&mut self, arg: &ruby_prism::Node, visibility: Visibility) {
1317+
let (name, location) = match arg {
1318+
ruby_prism::Node::SymbolNode { .. } => {
1319+
let symbol = arg.as_symbol_node().unwrap();
1320+
if let Some(value_loc) = symbol.value_loc() {
1321+
(Self::location_to_string(&value_loc), value_loc)
1322+
} else {
1323+
return;
1324+
}
1325+
}
1326+
ruby_prism::Node::StringNode { .. } => {
1327+
let string = arg.as_string_node().unwrap();
1328+
let name = String::from_utf8_lossy(string.unescaped()).to_string();
1329+
(name, arg.location())
1330+
}
1331+
_ => return,
1332+
};
13291333

1330-
let definition_id = self.local_graph.add_definition(definition);
1331-
self.add_member_to_current_owner(definition_id);
1332-
});
1334+
let str_id = self.local_graph.intern_string(format!("{name}()"));
1335+
let arg_offset = Offset::from_prism_location(&location);
1336+
let definition = Definition::MethodVisibility(Box::new(MethodVisibilityDefinition::new(
1337+
str_id,
1338+
visibility,
1339+
self.uri_id,
1340+
arg_offset,
1341+
Box::default(),
1342+
DefinitionFlags::empty(),
1343+
self.current_nesting_definition_id(),
1344+
)));
1345+
1346+
let definition_id = self.local_graph.add_definition(definition);
1347+
self.add_member_to_current_owner(definition_id);
13331348
}
13341349
}
13351350

@@ -1949,33 +1964,15 @@ impl Visit<'_> for RubyIndexer<'_> {
19491964
let offset = Offset::from_prism_location(&node.location());
19501965

19511966
if let Some(arguments) = node.arguments() {
1952-
let args = arguments.arguments();
1953-
1954-
if Self::should_apply_inline_visibility(&args) {
1955-
// Inline/scoped form: `private def foo(bar); end`
1956-
//
1957-
// Push the new visibility to the stack and then pop it after visiting
1958-
// the arguments so it only affects the method definition.
1959-
self.visibility_stack
1960-
.push(VisibilityModifier::new(visibility, true, offset));
1961-
self.visit_arguments_node(&arguments);
1962-
self.visibility_stack.pop();
1963-
} else if visibility == Visibility::ModuleFunction && !self.current_nesting_is_module() {
1967+
if visibility == Visibility::ModuleFunction && !self.current_nesting_is_module() {
19641968
self.local_graph.add_diagnostic(
19651969
Rule::InvalidMethodVisibility,
19661970
offset,
19671971
"`module_function` can only be used in modules".to_string(),
19681972
);
19691973
self.visit_arguments_node(&arguments);
19701974
} else {
1971-
// Retroactive method visibility: `private :foo`, `module_function :bar`
1972-
self.handle_retroactive_method_visibility(
1973-
node,
1974-
&arguments,
1975-
visibility,
1976-
&offset,
1977-
message.as_str(),
1978-
);
1975+
self.handle_visibility_arguments(&arguments, visibility, &offset, message.as_str());
19791976
}
19801977
} else {
19811978
// Flag mode: `private` with no arguments

‎rust/rubydex/src/indexing/ruby_indexer_tests.rs‎

Lines changed: 138 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3406,18 +3406,15 @@ mod visibility_tests {
34063406

34073407
assert_local_diagnostics_eq!(
34083408
&context,
3409-
vec!["invalid-method-visibility: `private` called with mixed literal and non-literal arguments (4:3-4:27)",]
3409+
vec!["invalid-method-visibility: `private` called with a non-literal argument (4:17-4:27)"]
34103410
);
34113411

3412-
// No MethodVisibilityDefinition created
3413-
for def in context.graph().definitions().values() {
3414-
assert!(
3415-
!matches!(def, Definition::MethodVisibility(_)),
3416-
"should not create MethodVisibility for mixed args"
3417-
);
3418-
}
3412+
// :foo is a literal arg, so visibility is still applied
3413+
assert_definition_at!(&context, "4:12-4:15", MethodVisibility, |def| {
3414+
assert_def_str_eq!(&context, def, "foo()");
3415+
assert_eq!(def.visibility(), &Visibility::Private);
3416+
});
34193417

3420-
// Dynamic args still visited — constant reference indexed
34213418
assert_constant_references_eq!(&context, ["SOME_CONST"]);
34223419
}
34233420

@@ -3460,7 +3457,7 @@ mod visibility_tests {
34603457

34613458
assert_local_diagnostics_eq!(
34623459
&context,
3463-
vec!["invalid-method-visibility: `private` called with non-literal arguments (4:3-4:21)"]
3460+
vec!["invalid-method-visibility: `private` called with a non-literal argument (4:11-4:21)"]
34643461
);
34653462

34663463
// No MethodVisibilityDefinition created
@@ -3484,7 +3481,7 @@ mod visibility_tests {
34843481

34853482
assert_local_diagnostics_eq!(
34863483
&context,
3487-
vec!["invalid-method-visibility: `private` called with non-literal arguments (2:3-2:23)"]
3484+
vec!["invalid-method-visibility: `private` called with a non-literal argument (2:11-2:23)"]
34883485
);
34893486

34903487
// No MethodVisibilityDefinition created
@@ -3508,7 +3505,7 @@ mod visibility_tests {
35083505

35093506
assert_local_diagnostics_eq!(
35103507
&context,
3511-
vec!["invalid-method-visibility: `private` called with non-literal arguments (2:3-2:35)"]
3508+
vec!["invalid-method-visibility: `private` called with a non-literal argument (2:11-2:35)"]
35123509
);
35133510

35143511
// No MethodVisibilityDefinition or AttrReader created from that call
@@ -3548,23 +3545,17 @@ mod visibility_tests {
35483545
",
35493546
);
35503547

3548+
// attr_reader(:foo) returns an array in multi-arg context, invalid for `private`
35513549
assert_local_diagnostics_eq!(
35523550
&context,
3553-
vec!["invalid-method-visibility: `private` called with mixed literal and non-literal arguments (2:3-2:34)"]
3551+
vec![
3552+
"invalid-method-visibility: `private` with `attr_*` is only supported as a single argument (2:11-2:28)"
3553+
]
35543554
);
35553555

3556-
// No MethodVisibilityDefinition created
3557-
for def in context.graph().definitions().values() {
3558-
assert!(
3559-
!matches!(def, Definition::MethodVisibility(_)),
3560-
"should not create MethodVisibility for attr_reader with extra args"
3561-
);
3562-
}
3563-
3564-
// The attr_reader(:foo) call is still visited — AttrReader for foo() is indexed
3556+
// foo reader still defined via side effects, but public
35653557
assert_definition_at!(&context, "2:24-2:27", AttrReader, |def| {
35663558
assert_def_str_eq!(&context, def, "foo()");
3567-
// The attr_reader is public (default) — the visibility stack was NOT pushed
35683559
assert_eq!(def.visibility(), &Visibility::Public);
35693560
});
35703561
}
@@ -3613,6 +3604,130 @@ mod visibility_tests {
36133604
);
36143605
}
36153606
}
3607+
3608+
#[test]
3609+
fn index_inline_visibility_mixed_with_retroactive() {
3610+
let context = index_source(
3611+
"
3612+
class Foo
3613+
def bar; end
3614+
private def foo; end, :bar
3615+
end
3616+
",
3617+
);
3618+
3619+
assert_no_local_diagnostics!(&context);
3620+
3621+
assert_definition_at!(&context, "3:11-3:23", Method, |def| {
3622+
assert_def_str_eq!(&context, def, "foo()");
3623+
assert_eq!(def.visibility(), &Visibility::Private);
3624+
});
3625+
3626+
assert_definition_at!(&context, "3:26-3:29", MethodVisibility, |def| {
3627+
assert_def_str_eq!(&context, def, "bar()");
3628+
assert_eq!(def.visibility(), &Visibility::Private);
3629+
});
3630+
}
3631+
3632+
#[test]
3633+
fn index_inline_visibility_multiple_defs() {
3634+
let context = index_source(
3635+
"
3636+
class Foo
3637+
private def foo; end, def bar; end
3638+
end
3639+
",
3640+
);
3641+
3642+
assert_no_local_diagnostics!(&context);
3643+
3644+
assert_definition_at!(&context, "2:11-2:23", Method, |def| {
3645+
assert_def_str_eq!(&context, def, "foo()");
3646+
assert_eq!(def.visibility(), &Visibility::Private);
3647+
});
3648+
3649+
assert_definition_at!(&context, "2:25-2:37", Method, |def| {
3650+
assert_def_str_eq!(&context, def, "bar()");
3651+
assert_eq!(def.visibility(), &Visibility::Private);
3652+
});
3653+
}
3654+
3655+
#[test]
3656+
fn index_inline_visibility_mixed_with_unsupported() {
3657+
let context = index_source(
3658+
"
3659+
class Foo
3660+
private def foo; end, CONST_A
3661+
private CONST_B, def bar; end
3662+
end
3663+
",
3664+
);
3665+
3666+
assert_local_diagnostics_eq!(
3667+
&context,
3668+
vec![
3669+
"invalid-method-visibility: `private` called with a non-literal argument (2:25-2:32)",
3670+
"invalid-method-visibility: `private` called with a non-literal argument (3:11-3:18)",
3671+
]
3672+
);
3673+
3674+
// Def gets visibility regardless of arg position
3675+
assert_definition_at!(&context, "2:11-2:23", Method, |def| {
3676+
assert_def_str_eq!(&context, def, "foo()");
3677+
assert_eq!(def.visibility(), &Visibility::Private);
3678+
});
3679+
assert_definition_at!(&context, "3:20-3:32", Method, |def| {
3680+
assert_def_str_eq!(&context, def, "bar()");
3681+
assert_eq!(def.visibility(), &Visibility::Private);
3682+
});
3683+
3684+
assert_constant_references_eq!(&context, ["CONST_A", "CONST_B"]);
3685+
}
3686+
3687+
#[test]
3688+
fn index_retroactive_visibility_multiple_unsupported_args() {
3689+
let context = index_source(
3690+
"
3691+
class Foo
3692+
private CONST_A, CONST_B
3693+
end
3694+
",
3695+
);
3696+
3697+
assert_local_diagnostics_eq!(
3698+
&context,
3699+
vec![
3700+
"invalid-method-visibility: `private` called with a non-literal argument (2:11-2:18)",
3701+
"invalid-method-visibility: `private` called with a non-literal argument (2:20-2:27)",
3702+
]
3703+
);
3704+
3705+
assert_constant_references_eq!(&context, ["CONST_A", "CONST_B"]);
3706+
}
3707+
3708+
#[test]
3709+
fn index_module_function_mixed_args_in_class_is_invalid() {
3710+
let context = index_source(
3711+
"
3712+
class Foo
3713+
module_function def foo; end, :bar
3714+
end
3715+
",
3716+
);
3717+
3718+
// module_function in a class is always invalid regardless of args
3719+
assert_local_diagnostics_eq!(
3720+
&context,
3721+
vec!["invalid-method-visibility: `module_function` can only be used in modules (2:3-2:37)"]
3722+
);
3723+
3724+
for def in context.graph().definitions().values() {
3725+
assert!(
3726+
!matches!(def, Definition::MethodVisibility(_)),
3727+
"should not create MethodVisibility for module_function in class"
3728+
);
3729+
}
3730+
}
36163731
}
36173732

36183733
mod attr_accessor_tests {

0 commit comments

Comments
 (0)