Skip to content

Commit 72d0bbc

Browse files
committed
Settle implementation plan
1 parent 0ebbc3f commit 72d0bbc

1 file changed

Lines changed: 391 additions & 0 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
# DSL Definitions Design
2+
3+
**Issue**: <https://github.com/Shopify/rubydex/issues/497>
4+
5+
## Problem
6+
7+
Many Ruby DSLs fit a common pattern: a method call that produces multiple declarations. This includes:
8+
9+
- Built-in DSLs: `attr_accessor`, `Struct.new`, `Class.new`
10+
- Framework DSLs: Rails' `belongs_to`, `has_many`, ActiveSupport's `class_methods`
11+
- Testing DSLs: RSpec's `describe`, `context`, `it`
12+
13+
Currently, rubydex handles some of these as special cases in the indexer. However, this approach has a fundamental limitation: during indexing, we cannot guarantee that `Class.new` is actually `::Class` and not `MyImplementation::Class` because resolution hasn't run yet.
14+
15+
## Solution Overview
16+
17+
Capture DSL method calls as `DslDefinition` during indexing, then process them after resolution when we know the fully qualified receiver. This enables:
18+
19+
1. Correct identification of DSLs (we know `Class` resolves to `::Class`)
20+
2. A unified architecture for both built-in and plugin-provided DSL handling
21+
22+
## Architecture
23+
24+
### Phase 1: Indexing
25+
26+
The `Graph` maintains an allowlist of DSL target method names:
27+
28+
```rust
29+
// In Graph
30+
internal_dsl_targets: Vec<StringId>, // e.g., ["new", "attr_accessor"]
31+
// Future: external_dsl_targets for plugins
32+
```
33+
34+
**Two definition types are created during indexing:**
35+
36+
#### DslDefinition
37+
38+
Captures the DSL method call itself:
39+
40+
```rust
41+
struct DslDefinition {
42+
receiver_name: NameId, // e.g., Name(Class, parent_scope: "Foo")
43+
method_name: StringId, // e.g., "new"
44+
arguments: DslArgumentList, // Captured arguments
45+
uri_id: UriId,
46+
offset: Offset,
47+
lexical_nesting_id: Option<DefinitionId>,
48+
members: Vec<DefinitionId>, // Owned block contents
49+
}
50+
```
51+
52+
The `DslDefinition` is pushed onto the nesting stack when visiting its block, so any definitions inside (methods, nested DSL calls, etc.) become members of the `DslDefinition` rather than the lexical parent.
53+
54+
#### ConstantToDslDefinition
55+
56+
When a DSL call is assigned to a constant (e.g., `Bar = Class.new`), we create a `ConstantToDslDefinition` that links them:
57+
58+
```rust
59+
struct ConstantToDslDefinition {
60+
constant_name: NameId, // e.g., Name(Bar, parent_scope: "Foo")
61+
dsl_definition_id: DefinitionId, // points to the DslDefinition
62+
uri_id: UriId,
63+
offset: Offset,
64+
lexical_nesting_id: Option<DefinitionId>,
65+
comments: Vec<Comment>,
66+
flags: DefinitionFlags,
67+
}
68+
```
69+
70+
**Indexing flow in `visit_constant_write_node`:**
71+
72+
1. Call `index_dsl_definition_target(value)` - similar to existing `index_constant_alias_target`
73+
2. If it returns a `DslDefinition`, call `add_constant_to_dsl_definition(constant_name, dsl_def_id)`
74+
3. This creates `ConstantToDslDefinition` linking the constant to the DSL
75+
76+
For non-constant cases (`var = Class.new` or just `Class.new`), only `DslDefinition` is created (detected in `visit_call_node`).
77+
78+
### Phase 2: Resolution
79+
80+
Standard resolution runs, which resolves `receiver_name` to a fully qualified name (e.g., `Class` in scope `Foo``::Class`).
81+
82+
### Phase 3: DSL Processing (New Phase)
83+
84+
After resolution, process all `ConstantToDslDefinition`s:
85+
86+
1. Look up the referenced `DslDefinition`
87+
2. Get the resolved receiver name
88+
3. Dispatch to handler based on resolved receiver + method name:
89+
90+
```text
91+
resolved receiver = ::Class, method = "new" → ClassNewHandler
92+
resolved receiver = ::Module, method = "new" → ModuleNewHandler
93+
// Future: ::Struct, plugin-registered handlers
94+
```
95+
96+
**Handler outcomes:**
97+
98+
- **Handler matches (e.g., `::Class.new`)**: Transform `ConstantToDslDefinition` into `DynamicClassDefinition` with the constant name. Creates `ClassDeclaration`.
99+
- **No handler matches (e.g., `SomeClass.new`)**: Transform `ConstantToDslDefinition` into regular `ConstantDefinition`. We don't know what `SomeClass.new` returns.
100+
101+
Each handler also decides what to do with owned members (reassign, discard, etc.).
102+
103+
### Phase 4: Incremental Resolution
104+
105+
Run resolution again for any new definitions created in Phase 3. This should be fast since the set is small.
106+
107+
## DslDefinition Details
108+
109+
### Argument Representation
110+
111+
Arguments are captured in a structured format to save downstream consumers from parsing:
112+
113+
```rust
114+
enum DslArgument {
115+
Symbol(StringId),
116+
String(StringId),
117+
Boolean(bool),
118+
Integer(i64),
119+
Constant(NameId),
120+
Block { offset: Offset }, // Location for AST revisit if needed
121+
}
122+
123+
struct DslArgumentList {
124+
positional: Vec<DslArgument>,
125+
keyword: HashMap<StringId, DslArgument>,
126+
block: Option<Offset>,
127+
}
128+
```
129+
130+
### Block Ownership
131+
132+
All DSL calls automatically own their block contents. This simplifies indexing logic—the handler decides what to do with owned definitions during processing:
133+
134+
- Reassign them to a new owner
135+
- Discard them
136+
- Keep them under the DSL
137+
- Create new definitions based on them
138+
139+
### Lifecycle
140+
141+
`DslDefinition` is kept after processing. It shares a declaration with any generated definitions, enabling tooling features like "hover over `belongs_to` → see generated methods".
142+
143+
## Example: Class.new
144+
145+
```ruby
146+
class Foo
147+
Bar = Class.new do
148+
def baz; end
149+
end
150+
end
151+
```
152+
153+
**Indexing produces:**
154+
155+
- `ClassDefinition(Foo)`
156+
- `DslDefinition(receiver: Name(Class), method: "new", members: [MethodDefinition(baz)])`
157+
- `ConstantToDslDefinition(constant: Bar, dsl: DslDefinition)` - links constant to DSL
158+
- `MethodDefinition(baz)` with lexical nesting pointing to DslDefinition
159+
160+
**Resolution:**
161+
162+
- `Name(Class)` in `DslDefinition` resolves to `::Class`
163+
164+
**DSL Processing:**
165+
166+
- Process `ConstantToDslDefinition(Bar)`
167+
- Look up its `DslDefinition` - receiver resolved to `::Class`, method is `new`
168+
- `ClassNewHandler` matches
169+
- Transforms `ConstantToDslDefinition` into `DynamicClassDefinition(Foo::Bar)`
170+
- Reassigns `MethodDefinition(baz)` from DslDefinition to DynamicClassDefinition
171+
- Creates `ClassDeclaration(Foo::Bar)` and `MethodDeclaration(Foo::Bar#baz)`
172+
173+
**Result:**
174+
175+
- `Declaration(Foo::Bar)` has two definitions:
176+
- `DslDefinition(Class.new)` - the original DSL call (kept for tooling)
177+
- `DynamicClassDefinition` - the class structure with members
178+
- `Declaration(Foo::Bar#baz)` has one definition: `MethodDefinition(baz)`
179+
180+
**Anonymous case (`var = Class.new` or just `Class.new`):**
181+
182+
- Only `DslDefinition` created (no `ConstantToDslDefinition`)
183+
- DSL Processing creates `DynamicClassDefinition` with `name_id: None`
184+
- No declaration created
185+
186+
**Unknown receiver case (`Foo = SomeClass.new`):**
187+
188+
- `DslDefinition(receiver: SomeClass, method: "new")`
189+
- `ConstantToDslDefinition(constant: Foo, dsl: DslDefinition)`
190+
- After resolution, `SomeClass` resolves to `::SomeClass` (not `::Class`)
191+
- No handler matches
192+
- `ConstantToDslDefinition` becomes regular `ConstantDefinition(Foo)`
193+
194+
## Example: RSpec (Future Plugin)
195+
196+
```ruby
197+
describe Foo do
198+
context "when valid" do
199+
it "works" do; end
200+
def helper; end
201+
end
202+
end
203+
```
204+
205+
**Indexing produces (with `describe`, `context`, `it` as targets):**
206+
207+
- `DslDefinition(describe Foo)` owns:
208+
- `DslDefinition(context "when valid")` owns:
209+
- `DslDefinition(it "works")`
210+
- `MethodDefinition(helper)`
211+
212+
**DSL Processing (via RSpec plugin):**
213+
214+
- Plugin handler decides how to represent these—perhaps as test group declarations with the helper method scoped appropriately.
215+
216+
## New Types
217+
218+
### Definitions
219+
220+
```rust
221+
// Captures DSL method call (e.g., Class.new, Module.new)
222+
struct DslDefinition {
223+
receiver_name: NameId,
224+
method_name: StringId,
225+
arguments: DslArgumentList,
226+
uri_id: UriId,
227+
offset: Offset,
228+
lexical_nesting_id: Option<DefinitionId>,
229+
members: Vec<DefinitionId>,
230+
}
231+
232+
// Links constant assignment to DSL call (e.g., Bar = Class.new)
233+
struct ConstantToDslDefinition {
234+
constant_name: NameId,
235+
dsl_definition_id: DefinitionId,
236+
uri_id: UriId,
237+
offset: Offset,
238+
lexical_nesting_id: Option<DefinitionId>,
239+
comments: Vec<Comment>,
240+
flags: DefinitionFlags,
241+
}
242+
243+
// Created in Phase 3 for Class.new blocks
244+
struct DynamicClassDefinition {
245+
name_id: Option<NameId>, // None for anonymous classes
246+
uri_id: UriId,
247+
offset: Offset,
248+
members: Vec<DefinitionId>, // Consistent with ClassDefinition
249+
superclass_ref: Option<ReferenceId>,
250+
mixins: Vec<Mixin>,
251+
lexical_nesting_id: Option<DefinitionId>,
252+
comments: Vec<Comment>,
253+
flags: DefinitionFlags,
254+
}
255+
256+
// Created in Phase 3 for Module.new blocks
257+
struct DynamicModuleDefinition {
258+
name_id: Option<NameId>, // None for anonymous modules
259+
uri_id: UriId,
260+
offset: Offset,
261+
members: Vec<DefinitionId>, // Consistent with ModuleDefinition
262+
mixins: Vec<Mixin>,
263+
lexical_nesting_id: Option<DefinitionId>,
264+
comments: Vec<Comment>,
265+
flags: DefinitionFlags,
266+
}
267+
```
268+
269+
### Declarations
270+
271+
No new declaration types needed. Dynamic definitions create standard declarations:
272+
273+
- `DynamicClassDefinition``ClassDeclaration`
274+
- `DynamicModuleDefinition``ModuleDeclaration`
275+
276+
### DSL Handler Trait
277+
278+
```rust
279+
trait DslHandler {
280+
/// Returns true if this handler should process the given DSL call
281+
fn handles(&self, resolved_receiver: &str, method_name: &str) -> bool;
282+
283+
/// Process the DSL and create new definitions/declarations
284+
fn process(&self, graph: &mut Graph, dsl: &DslDefinition);
285+
}
286+
287+
struct DslHandlerRegistry {
288+
handlers: Vec<Box<dyn DslHandler>>,
289+
}
290+
```
291+
292+
### DefinitionId Generation
293+
294+
`DslDefinition` generates its ID using receiver_name and method_name:
295+
296+
```rust
297+
impl DslDefinition {
298+
pub fn id(&self) -> DefinitionId {
299+
DefinitionId::from(&format!(
300+
"{}{}{}{}",
301+
*self.uri_id,
302+
self.offset.start(),
303+
*self.receiver_name,
304+
*self.method_name
305+
))
306+
}
307+
}
308+
```
309+
310+
## Implementation Scope
311+
312+
### Phase 1 (This Issue)
313+
314+
- Add `DslDefinition` struct
315+
- Add `ConstantToDslDefinition` struct
316+
- Add `DynamicClassDefinition` and `DynamicModuleDefinition` structs
317+
- Add `internal_dsl_targets` to Graph
318+
- Add `index_dsl_definition_target` helper in indexer (similar to `index_constant_alias_target`)
319+
- Add `add_constant_to_dsl_definition` helper in indexer
320+
- Implement `Nesting::Dsl` variant for nesting stack
321+
- Add DSL processing phase with `DslHandler` trait
322+
- Implement `Class.new` handler
323+
- Implement `Module.new` handler
324+
325+
### Future Work
326+
327+
- `Struct.new` handler (complex: inherits from Struct, generates accessor methods)
328+
- Plugin API for external DSL targets
329+
- RSpec/Minitest support via plugins
330+
331+
## Open Questions
332+
333+
1. **Memory impact**: One-to-many relationship between definitions and declarations. Monitor memory usage during implementation.
334+
335+
## Design Decisions Made
336+
337+
1. **Constant assignment detection**: Use `ConstantToDslDefinition` to link constants to DSL calls. Similar pattern to `index_constant_alias_target` in existing code. Detected in `visit_constant_write_node` via new `index_dsl_definition_target` helper.
338+
339+
2. **Anonymous classes**: `DynamicClassDefinition` uses `name_id: Option<NameId>`. Named classes get `Some(name)` from `ConstantToDslDefinition`, anonymous classes get `None` and no declaration is created.
340+
341+
3. **Declaration membership**: For `Bar = Class.new`, `Declaration(Foo::Bar)` contains two definitions: `DslDefinition` and `DynamicClassDefinition`. This enables tooling to trace generated code back to the DSL call.
342+
343+
4. **Unknown receiver fallback**: If `ConstantToDslDefinition` references a DSL whose receiver doesn't match any handler (e.g., `Foo = SomeClass.new`), it becomes a regular `ConstantDefinition`.
344+
345+
5. **lexical_nesting_id preservation**: When members are reassigned from `DslDefinition` to `DynamicClassDefinition`, their `lexical_nesting_id` is NOT updated. It preserves the lexical structure as written in source code.
346+
347+
6. **Phase 4 resolution**: Simply call `resolve_all` again. The set of new definitions is small, so full resolution is acceptable.
348+
349+
7. **Edge cases not handled**: Conditional assignments (`Foo = cond ? Class.new : Module.new`), method chaining (`Class.new.freeze`), and late assignment of anonymous classes (`klass = Class.new; Foo = klass`) are out of scope.
350+
351+
8. **Phase 3 location**: New method `process_dsl_definitions()` called after `resolve_all()`. Caller does: `resolver.resolve_all(); resolver.process_dsl_definitions(); resolver.resolve_all();`
352+
353+
9. **Receiver resolution**: During indexing, create a `ConstantReference` for `DslDefinition.receiver_name`. It gets resolved naturally in Phase 2. Phase 3 looks it up via `graph.names().get(&receiver_name)`.
354+
355+
10. **DynamicClassDefinition ID**: Use same location as source `DslDefinition` with DSL name: `format!("{}{}Class.new", uri_id, offset)`. Similarly `Module.new` for `DynamicModuleDefinition`.
356+
357+
## Test Cases Needed
358+
359+
1. **Unknown receiver fallback**:
360+
361+
```ruby
362+
class MyClass
363+
def self.new; end
364+
end
365+
Foo = MyClass.new
366+
```
367+
368+
`ConstantToDslDefinition(Foo)` should become `ConstantDefinition(Foo)` since `MyClass.new` doesn't match any handler.
369+
370+
2. **Inheritance from dynamic class**:
371+
372+
```ruby
373+
Foo = Class.new do
374+
end
375+
class Bar < Foo; end
376+
```
377+
378+
After phase 3+4, `Bar` should have superclass pointing to `Foo`, which has a `DynamicClassDefinition`.
379+
380+
3. **DSL inside DSL block**:
381+
382+
```ruby
383+
Foo = Class.new do
384+
Bar = Class.new do
385+
def inner; end
386+
end
387+
attr_accessor :name
388+
end
389+
```
390+
391+
Verify `Bar` is correctly handled as a nested constant (`Foo::Bar` with `inner` method). And `Foo` has `name` and `name=` methods.

0 commit comments

Comments
 (0)