Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ Resolution combines the discovered definitions to build a semantic understanding
- Assign semantic membership (which methods/constants belong to which class)
- Create implicit singleton classes from `def self.method` patterns

Resolution may also create generated definitions for Ruby semantics that behave like copied methods at runtime. The main
case is retroactive `module_function :name`, which creates a public singleton method copy and may create a direct private
instance copy on the receiving module. These generated definitions preserve the source method's location/signature but
are reverse-indexed by their source definition, visibility trigger, alias dependencies, and ancestor dependencies so
incremental invalidation can remove or rebuild them.

## Graph Structure

Rubydex represents the codebase as a graph, where entities are nodes and relationships are edges. The visualization below shows the conceptual structure (implemented as an adjacency list using IDs).
Expand Down
84 changes: 83 additions & 1 deletion docs/ruby-behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,10 @@ end

Creates: `bar`, `bar=`, `baz`, `baz=`

Rubydex indexes `attr_accessor` as two method declarations: a reader using `AttrAccessorDefinition` with the
reader name (`foo`) and a writer using `AttrWriterDefinition` with the setter name (`foo=`). This mirrors Ruby's
two-method behavior and lets visibility changes target the reader and writer independently.

### Receiver Context

Attribute methods only work when called on `self` (implicit or explicit):
Expand Down Expand Up @@ -964,6 +968,14 @@ class Foo; private :foo; end # works — retroactive across reopen
Foo.private_instance_methods(false) # => [:foo]
```

The method must already exist when the visibility call runs. Within one file, `private :foo` before `def foo` matches
Ruby's `NameError` behavior. Across files, Rubydex cannot know runtime load order, so it treats cross-file definitions
as potentially available. A definitely-prior same-file definition is preferred for the visibility snapshot; URI lexical
order is used only as a deterministic tie-breaker when multiple cross-file definitions could apply.

Rubydex treats visibility calls inside method bodies or ordinary blocks as runtime calls. It does not apply those
visibility effects statically because Ruby only executes them if the containing method or block is called.

**`private :inherited_method` creates an implicit copy:**

```ruby
Expand Down Expand Up @@ -1018,7 +1030,8 @@ Singleton.create # => #<Singleton:0x...>

### Retroactive `module_function :method_name`

`module_function` can be called with a method name to retroactively apply the module_function behavior, but only inside module bodies (or via `send` on a module). It is not available at the top level or inside class bodies:
`module_function` can be called with a method name, or with no arguments to affect later method definitions, but only
inside module bodies. It is not available at the top level, inside class bodies, or inside singleton class bodies:

```ruby
module Foo
Expand All @@ -1031,6 +1044,12 @@ Foo.private_instance_methods(false) # => [:foo] (instance becomes private)
Foo.singleton_methods(false) # => [:foo]
```

Ruby can invoke `module_function` indirectly with `send` on a module object. Rubydex indexes the direct call forms above;
dynamic `send` calls are treated as ordinary method calls rather than visibility operations.

Like other visibility operations, Rubydex treats `module_function` calls inside method bodies or ordinary blocks as
runtime calls rather than static visibility operations.

**Creates a copy, not a reference:**

```ruby
Expand All @@ -1042,6 +1061,66 @@ end
Foo.foo # => "v1" (singleton is an independent copy of v1)
```

The copied method may come from an ancestor. Ruby installs both a direct private instance method entry and a direct
singleton method entry on the receiving module, while preserving the source method's location and signature:

```ruby
module A
def foo(x); "v1"; end
end

module B
include A
module_function :foo
end

B.method(:foo).source_location # => ["a.rb", 2] when A was loaded from a.rb
B.method(:foo).parameters # => [[:req, :x]]
B.private_instance_methods(false) # => [:foo]
B.singleton_methods(false) # => [:foo]
```

Later changes to `A#foo` do not mutate the already copied runtime method on `B`. A static indexer still needs to
invalidate and rebuild generated copies when the source file changes, because a fresh load of the current files would
copy the current source method at the `module_function :foo` call.

When the source method and `module_function :foo` call are in different files, static indexing does not know runtime
load order. Rubydex treats cross-file source methods as potentially available and uses URI lexical order only as a
deterministic tie-breaker when multiple cross-file definitions could be copied. A definitely-prior same-file source
method is preferred over cross-file candidates. Within one file, the source method must
appear before the `module_function :foo` call, matching Ruby's `NameError` behavior for calls that appear before the
method definition.

Method aliases are eligible targets. `module_function :bar` after `alias bar foo` copies the current `foo` method body
under the singleton name `bar`, while also making `bar` a private instance method on the module:

```ruby
module Foo
def foo(x); x; end
alias bar foo
module_function :bar
end

Foo.bar(1) # => 1
Foo.private_instance_methods(false) # => [:bar]
Foo.singleton_methods(false) # => [:bar]
```

Attribute writers use the setter method name. `attr_writer :foo` defines `foo=`, not `foo`, so
`module_function :foo` fails while `module_function :foo=` creates a public singleton writer copy:

```ruby
module Foo
attr_writer :foo
module_function :foo= # copies Foo#foo= to Foo.foo=
end

module Foo
attr_writer :bar
module_function :bar # raises NameError
end
```

### Constant Visibility

Ruby provides `private_constant` and `public_constant` to control constant visibility.
Expand All @@ -1065,6 +1144,9 @@ Foo.constants # => [] (private constants hidden from .constants)
- `Foo.const_defined?(:PRIV)` returns **true** even for private constants — visibility doesn't affect `const_defined?`
- `Foo.const_get(:PRIV)` **bypasses** private constant visibility and returns the value — only the `::` operator enforces `private_constant`

Rubydex treats `private_constant`/`public_constant` calls inside method bodies or ordinary blocks as runtime calls rather
than static visibility operations.

#### `public_constant`

Reverses `private_constant`:
Expand Down
43 changes: 38 additions & 5 deletions rust/rubydex-sys/src/graph_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@ pub unsafe extern "C" fn rdx_keyword_get(name: *const c_char) -> *const CKeyword
}

#[repr(u8)]
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CVisibility {
Public = 0,
Protected = 1,
Expand All @@ -1001,13 +1001,16 @@ pub unsafe extern "C" fn rdx_graph_visibility(pointer: GraphPointer, declaration
return ptr::null();
};

debug_assert_ne!(
visibility,
Visibility::ModuleFunction,
"module_function visibility must be resolved before C API use"
);

let c_visibility = match visibility {
Visibility::Public => CVisibility::Public,
Visibility::Protected => CVisibility::Protected,
Visibility::Private => CVisibility::Private,
Visibility::ModuleFunction => {
unimplemented!("module_function visibility translation is not implemented yet")
}
Visibility::Private | Visibility::ModuleFunction => CVisibility::Private,
};

Box::into_raw(Box::new(c_visibility)).cast_const()
Expand Down Expand Up @@ -1121,4 +1124,34 @@ mod tests {
.ref_count()
);
}

#[test]
fn retroactive_module_function_exposes_private_instance_visibility() {
let mut indexer = RubyIndexer::new(
"file:///foo.rb".into(),
"
module Foo
def foo; end
module_function :foo
end
",
);
indexer.index();

let mut graph = Graph::new();
graph.consume_document_changes(indexer.local_graph());
let mut resolver = Resolver::new(&mut graph);
resolver.resolve();

let graph_ptr = Box::into_raw(Box::new(graph)) as GraphPointer;
let visibility = unsafe { rdx_graph_visibility(graph_ptr, DeclarationId::from("Foo#foo()").get()) };

assert!(!visibility.is_null());
assert_eq!(unsafe { *visibility }, CVisibility::Private);

unsafe {
free_c_visibility(visibility);
let _ = Box::from_raw(graph_ptr.cast::<Graph>());
}
}
}
9 changes: 9 additions & 0 deletions rust/rubydex/src/indexing/local_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ impl LocalGraph {
string_id
}

pub(crate) fn track_string(&mut self, string_id: StringId) {
let Some(string_ref) = self.strings.get_mut(&string_id) else {
debug_assert!(false, "Cannot track a string that has not been interned");
return;
};

string_ref.increment_ref_count(1);
}

// Names

#[must_use]
Expand Down
Loading
Loading