Skip to content

Commit a05abc5

Browse files
authored
Resolve retroactive singleton method visibility changes (#781)
Part of #89 and follows #780, which added the `SINGLETON_METHOD_VISIBILITY` bit flag and indexed singleton-flagged `MethodVisibilityDefinition`s. This PR routes singleton-flagged defs through the singleton class and folds the singleton path into the existing `resolve_method_visibilities` pass. Visibility definitions are processed after the main convergence loop, so the target method declaration is guaranteed to exist by the time we attach visibility. For inherited targets like `private_class_method :foo` where `foo` comes from a parent class's singleton, we create a child-owned `MethodDeclaration` on the child's singleton class. This matches what #738 did for instance methods, and what Ruby reports when you ask `Child.singleton_class.instance_method(:foo).owner`: ```ruby class Parent def self.foo; end end class Child < Parent private_class_method :foo end Child.singleton_class.instance_method(:foo).owner # => #<Class:Child> (Ruby copies the method to Child when visibility changes) Parent.singleton_class.instance_method(:foo).owner # => #<Class:Parent> (untouched on the parent) ``` ### Why document-scoped diagnostics for the singleton path When a singleton target doesn't resolve, the diagnostic attaches to the document, not to the singleton declaration. Walking ancestors via `get_or_create_singleton_class(Foo, ...)` can synthesize `Foo::<Foo>` as a side effect, even when it has no real members. For `class Foo; private_class_method :missing; end` where `Foo` has no other singleton methods, that synthetic singleton class only exists to host the diagnostic. Attaching to it would leak the declaration on file delete. Document-scoped clears with the file. Instance-method diagnostics keep their existing declaration scope: their owner can't be synthetic. ### Source-order processing of visibility defs Ruby applies visibility statements in the order they appear in source, so the later one wins: ```ruby class Foo def bar; end private :bar public :bar # Ruby: bar is Public end ``` This was already broken on `main` for instance methods; surfaced while wiring up the singleton path, fixed here for both. `prepare_units` sorted `definitions` and `const_refs` by `(uri_id, offset)` for determinism but left `others` (which holds the visibility defs queued by `handle_remaining_definitions`) in `IdentityHashMap` iteration order, which is bucket-hash order. So `Foo#bar` could end up `Private` because the resolver happened to apply `public` first and `private` second. Same sort applied to `others` so the override-order invariant holds for both instance and singleton paths. ### In this PR - branch on `is_singleton_method_visibility()` inside `resolve_method_visibilities`: resolve through `get_or_create_singleton_class` for the singleton path, render `"class method"` vs `"method"` in the diagnostic, attach document-scoped (singleton) vs declaration-scoped (instance) - add `Graph::find_singleton_method_visibility_declaration` and route through it from the existing `Definition::MethodVisibility` arm in `definition_to_declaration_id` when the flag is set - sort `others` by `(uri_id, offset)` in `prepare_units` so visibility overrides apply in source order
2 parents 5f91f2e + 94a1a2f commit a05abc5

4 files changed

Lines changed: 253 additions & 21 deletions

File tree

rust/rubydex/src/model/graph.rs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::diagnostic::Diagnostic;
66
use crate::indexing::local_graph::LocalGraph;
77
use crate::model::built_in::{OBJECT_ID, add_built_in_data};
88
use crate::model::declaration::{Ancestor, Declaration, Namespace};
9-
use crate::model::definitions::{Definition, Receiver};
9+
use crate::model::definitions::{Definition, MethodVisibilityDefinition, Receiver};
1010
use crate::model::document::Document;
1111
use crate::model::encoding::Encoding;
1212
use crate::model::identity_maps::{IdentityHashMap, IdentityHashSet};
@@ -333,10 +333,15 @@ impl Graph {
333333
.or_else(|| self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref())),
334334
it.target(),
335335
),
336-
Definition::MethodVisibility(it) => (
337-
self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
338-
it.str_id(),
339-
),
336+
Definition::MethodVisibility(it) => {
337+
if it.flags().is_singleton_method_visibility() {
338+
return self.find_singleton_method_visibility_declaration(it);
339+
}
340+
(
341+
self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
342+
it.str_id(),
343+
)
344+
}
340345
Definition::GlobalVariable(it) => (
341346
self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
342347
it.str_id(),
@@ -413,6 +418,27 @@ impl Graph {
413418
None
414419
}
415420

421+
/// Looks up the declaration for a singleton method visibility through the singleton class.
422+
fn find_singleton_method_visibility_declaration(
423+
&self,
424+
definition: &MethodVisibilityDefinition,
425+
) -> Option<&DeclarationId> {
426+
let nesting_name_id = self.find_enclosing_namespace_name_id(definition.lexical_nesting_id().as_ref());
427+
let nesting_declaration_id = match nesting_name_id {
428+
Some(name_id) => self.name_id_to_declaration_id(*name_id),
429+
None => Some(&*OBJECT_ID),
430+
}?;
431+
let singleton_id = self
432+
.declarations
433+
.get(nesting_declaration_id)?
434+
.as_namespace()?
435+
.singleton_class()?;
436+
self.declarations
437+
.get(singleton_id)?
438+
.as_namespace()?
439+
.member(definition.str_id())
440+
}
441+
416442
/// Looks up the declaration for a `SelfReceiver` method/alias through the singleton class.
417443
fn find_self_receiver_declaration(&self, def_id: DefinitionId, member_str_id: StringId) -> Option<&DeclarationId> {
418444
let owner_decl_id = self.definition_id_to_declaration_id(def_id)?;

rust/rubydex/src/resolution.rs

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -604,10 +604,8 @@ impl<'a> Resolver<'a> {
604604
self.graph.add_document_diagnostic(uri_id, diagnostic);
605605
}
606606
}
607-
Definition::MethodVisibility(visibility_def) => {
608-
if !visibility_def.flags().is_singleton_method_visibility() {
609-
method_visibility_ids.push(id);
610-
}
607+
Definition::MethodVisibility(_) => {
608+
method_visibility_ids.push(id);
611609
}
612610
Definition::Class(_)
613611
| Definition::SingletonClass(_)
@@ -622,7 +620,8 @@ impl<'a> Resolver<'a> {
622620
self.resolve_method_visibilities(method_visibility_ids);
623621
}
624622

625-
/// Resolves retroactive method visibility changes (`private :foo`, `protected :foo`, `public :foo`).
623+
/// Resolves retroactive method visibility changes (`private :foo`, `protected :foo`, `public :foo`,
624+
/// `private_class_method :foo`, `public_class_method :foo`).
626625
///
627626
/// Runs as a second pass after all methods/attrs are declared, so `private :bar` works
628627
/// regardless of whether `def bar` appeared before or after it in source.
@@ -638,11 +637,21 @@ impl<'a> Resolver<'a> {
638637
let uri_id = *method_visibility.uri_id();
639638
let offset = method_visibility.offset().clone();
640639
let lexical_nesting_id = *method_visibility.lexical_nesting_id();
640+
let is_singleton = method_visibility.flags().is_singleton_method_visibility();
641641

642-
let Some(owner_id) = self.resolve_lexical_owner(lexical_nesting_id, id) else {
642+
let Some(lexical_owner_id) = self.resolve_lexical_owner(lexical_nesting_id, id) else {
643643
continue;
644644
};
645645

646+
let owner_id = if is_singleton {
647+
let Some(singleton_id) = self.get_or_create_singleton_class(lexical_owner_id, true) else {
648+
continue;
649+
};
650+
singleton_id
651+
} else {
652+
lexical_owner_id
653+
};
654+
646655
let Some(Declaration::Namespace(namespace)) = self.graph.declarations().get(&owner_id) else {
647656
continue;
648657
};
@@ -691,13 +700,20 @@ impl<'a> Resolver<'a> {
691700
Rule::UndefinedMethodVisibilityTarget,
692701
uri_id,
693702
offset,
694-
format!("undefined method `{method_name}` for visibility change in `{owner_name}`"),
703+
format!("undefined method `{owner_name}#{method_name}` for visibility change"),
695704
);
696-
self.graph
697-
.declarations_mut()
698-
.get_mut(&owner_id)
699-
.unwrap()
700-
.add_diagnostic(diagnostic);
705+
if is_singleton {
706+
// Document-scoped: the singleton class may be synthetic (created by this
707+
// visibility resolution) and won't be cleaned up on file delete, so attaching
708+
// the diagnostic to the declaration would leave it orphaned.
709+
self.graph.add_document_diagnostic(uri_id, diagnostic);
710+
} else {
711+
self.graph
712+
.declarations_mut()
713+
.get_mut(&owner_id)
714+
.unwrap()
715+
.add_diagnostic(diagnostic);
716+
}
701717
}
702718
}
703719

@@ -1882,7 +1898,7 @@ impl<'a> Resolver<'a> {
18821898
singleton_methods.push(Unit::Definition(id));
18831899
}
18841900
_ => {
1885-
others.push(id);
1901+
others.push((id, (*definition.uri_id(), definition.offset())));
18861902
}
18871903
}
18881904
}
@@ -1922,14 +1938,15 @@ impl<'a> Resolver<'a> {
19221938
(depths.get(name_a).unwrap(), uri_a, offset_a).cmp(&(depths.get(name_b).unwrap(), uri_b, offset_b))
19231939
});
19241940

1941+
others.sort_unstable_by_key(|(_, key)| *key);
1942+
19251943
// Definitions first, then constant refs, then singleton methods, then ancestors
19261944
self.unit_queue.extend(definitions.into_iter().map(|(id, _)| id));
19271945
self.unit_queue.extend(const_refs.into_iter().map(|(id, _)| id));
19281946
self.unit_queue.extend(singleton_methods);
19291947
self.unit_queue.extend(ancestors.into_iter().map(Unit::Ancestors));
19301948

1931-
others.shrink_to_fit();
1932-
others
1949+
others.into_iter().map(|(id, _)| id).collect()
19331950
}
19341951

19351952
/// Returns the singleton parent ID for an attached object ID. A singleton class' parent depends on what the attached

rust/rubydex/src/resolution_tests.rs

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4926,6 +4926,25 @@ mod visibility_resolution_tests {
49264926
};
49274927
}
49284928

4929+
#[test]
4930+
fn retroactive_visibility_override_applies_in_source_order() {
4931+
let mut context = GraphTest::new();
4932+
context.index_uri(
4933+
"file:///foo.rb",
4934+
r"
4935+
class Foo
4936+
def bar; end
4937+
private :bar
4938+
public :bar
4939+
end
4940+
",
4941+
);
4942+
context.resolve();
4943+
4944+
assert_no_diagnostics!(&context);
4945+
assert_visibility_eq!(context, "Foo#bar()", Visibility::Public);
4946+
}
4947+
49294948
#[test]
49304949
fn retroactive_visibility_on_direct_method() {
49314950
let mut context = GraphTest::new();
@@ -5066,7 +5085,7 @@ mod visibility_resolution_tests {
50665085
assert_diagnostics_eq!(
50675086
context,
50685087
&[
5069-
"undefined-method-visibility-target: undefined method `nonexistent()` for visibility change in `Foo` (2:12-2:23)"
5088+
"undefined-method-visibility-target: undefined method `Foo#nonexistent()` for visibility change (2:12-2:23)"
50705089
]
50715090
);
50725091
}
@@ -5306,4 +5325,139 @@ mod visibility_resolution_tests {
53065325

53075326
assert_visibility_eq!(context, "Foo::BAR", Visibility::Private);
53085327
}
5328+
5329+
#[test]
5330+
fn retroactive_singleton_method_visibility_on_direct_member() {
5331+
let mut context = GraphTest::new();
5332+
context.index_uri(
5333+
"file:///foo.rb",
5334+
r"
5335+
class Foo
5336+
def self.bar; end
5337+
def self.baz; end
5338+
5339+
private_class_method :bar
5340+
private_class_method :baz
5341+
public_class_method :baz
5342+
end
5343+
",
5344+
);
5345+
context.resolve();
5346+
5347+
assert_no_diagnostics!(&context);
5348+
assert_visibility_eq!(context, "Foo::<Foo>#bar()", Visibility::Private);
5349+
assert_visibility_eq!(context, "Foo::<Foo>#baz()", Visibility::Public);
5350+
}
5351+
5352+
#[test]
5353+
fn retroactive_singleton_method_visibility_on_inherited_method() {
5354+
let mut context = GraphTest::new();
5355+
context.index_uri(
5356+
"file:///foo.rb",
5357+
r"
5358+
class Parent
5359+
def self.foo; end
5360+
end
5361+
5362+
class Child < Parent
5363+
private_class_method :foo
5364+
end
5365+
",
5366+
);
5367+
context.resolve();
5368+
5369+
assert_no_diagnostics!(&context);
5370+
assert_declaration_exists!(context, "Child::<Child>#foo()");
5371+
assert_visibility_eq!(context, "Child::<Child>#foo()", Visibility::Private);
5372+
assert_visibility_eq!(context, "Parent::<Parent>#foo()", Visibility::Public);
5373+
}
5374+
5375+
#[test]
5376+
fn retroactive_singleton_method_visibility_on_undefined_method_emits_diagnostic() {
5377+
let mut context = GraphTest::new();
5378+
context.index_uri(
5379+
"file:///foo.rb",
5380+
r"
5381+
class Foo
5382+
private_class_method :nonexistent
5383+
end
5384+
",
5385+
);
5386+
context.resolve();
5387+
5388+
assert_diagnostics_eq!(
5389+
context,
5390+
&[
5391+
"undefined-method-visibility-target: undefined method `Foo::<Foo>#nonexistent()` for visibility change (2:25-2:36)"
5392+
]
5393+
);
5394+
}
5395+
5396+
#[test]
5397+
fn retroactive_singleton_method_visibility_undefined_target_diagnostic_clears_when_file_deleted() {
5398+
let mut context = GraphTest::new();
5399+
context.index_uri(
5400+
"file:///foo.rb",
5401+
r"
5402+
class Foo
5403+
end
5404+
",
5405+
);
5406+
context.index_uri(
5407+
"file:///bad.rb",
5408+
r"
5409+
class Foo
5410+
private_class_method :missing
5411+
end
5412+
",
5413+
);
5414+
context.resolve();
5415+
5416+
assert_diagnostics_eq!(
5417+
context,
5418+
&[
5419+
"undefined-method-visibility-target: undefined method `Foo::<Foo>#missing()` for visibility change (2:25-2:32)"
5420+
]
5421+
);
5422+
5423+
context.delete_uri("file:///bad.rb");
5424+
context.resolve();
5425+
5426+
assert_no_diagnostics!(&context);
5427+
}
5428+
5429+
#[test]
5430+
fn retroactive_singleton_method_visibility_undefined_target_diagnostic_clears_when_target_added() {
5431+
let mut context = GraphTest::new();
5432+
context.index_uri(
5433+
"file:///foo.rb",
5434+
r"
5435+
class Foo
5436+
private_class_method :missing
5437+
end
5438+
",
5439+
);
5440+
context.resolve();
5441+
5442+
assert_diagnostics_eq!(
5443+
context,
5444+
&[
5445+
"undefined-method-visibility-target: undefined method `Foo::<Foo>#missing()` for visibility change (2:25-2:32)"
5446+
]
5447+
);
5448+
5449+
context.index_uri(
5450+
"file:///foo.rb",
5451+
r"
5452+
class Foo
5453+
def self.missing; end
5454+
private_class_method :missing
5455+
end
5456+
",
5457+
);
5458+
context.resolve();
5459+
5460+
assert_no_diagnostics!(&context);
5461+
assert_visibility_eq!(context, "Foo::<Foo>#missing()", Visibility::Private);
5462+
}
53095463
}

test/declaration_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,41 @@ class Foo
789789
end
790790
end
791791

792+
def test_class_method_visibility_via_private_class_method
793+
with_context do |context|
794+
context.write!("file1.rb", <<~RUBY)
795+
class Foo
796+
def self.bar; end
797+
private_class_method :bar
798+
end
799+
RUBY
800+
801+
graph = Rubydex::Graph.new
802+
graph.index_all(context.glob("**/*.rb"))
803+
graph.resolve
804+
805+
assert_equal(:private, graph["Foo::<Foo>#bar()"].visibility)
806+
end
807+
end
808+
809+
def test_class_method_visibility_via_public_class_method
810+
with_context do |context|
811+
context.write!("file1.rb", <<~RUBY)
812+
class Foo
813+
def self.bar; end
814+
private_class_method :bar
815+
public_class_method :bar
816+
end
817+
RUBY
818+
819+
graph = Rubydex::Graph.new
820+
graph.index_all(context.glob("**/*.rb"))
821+
graph.resolve
822+
823+
assert_equal(:public, graph["Foo::<Foo>#bar()"].visibility)
824+
end
825+
end
826+
792827
def test_constant_alias_visibility
793828
with_context do |context|
794829
context.write!("file1.rb", <<~RUBY)

0 commit comments

Comments
 (0)