Skip to content

Commit e4b579c

Browse files
committed
WIP
1 parent 8b6aefb commit e4b579c

14 files changed

Lines changed: 2216 additions & 484 deletions

File tree

docs/plans/2026-01-21-dsl-definitions-design.md

Lines changed: 144 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ Capture DSL method calls as `DslDefinition` during indexing, then process them a
2323

2424
### Phase 1: Indexing
2525

26-
The `Graph` maintains an allowlist of DSL target method names:
26+
The `Graph` maintains a registry of DSL processors. During indexing, the method `is_dsl_target()` checks if a method name matches any registered processor:
2727

2828
```rust
29-
// In Graph
30-
internal_dsl_targets: Vec<StringId>, // e.g., ["new", "attr_accessor"]
31-
// Future: external_dsl_targets for plugins
29+
// In Graph - DSL targets are derived from registered processors
30+
pub fn is_dsl_target(&self, method_name: &StringId) -> bool {
31+
self.dsl_processors.iter().any(|p| p.method_name == *method_name)
32+
}
3233
```
3334

3435
**Two definition types are created during indexing:**
@@ -83,24 +84,37 @@ Standard resolution runs, which resolves `receiver_name` to a fully qualified na
8384

8485
### Phase 3: DSL Processing
8586

86-
A new method `process_dsl_definitions()` is called after `resolve_all()`. It processes all `ConstantToDslDefinition`s:
87+
A new method `process_dsl_definitions()` is called after `resolve_all()`. It iterates over all `DslDefinition`s (not just those with constant assignments):
8788

88-
1. Look up the referenced `DslDefinition`
89-
2. Get the resolved receiver via `graph.names().get(&receiver_name)`
90-
3. Dispatch to handler based on resolved receiver + method name:
89+
1. Get the `DslDefinition`
90+
2. Resolve the receiver to a `DeclarationId` (if any)
91+
3. Look up any associated `ConstantToDslDefinition` (optional)
92+
4. Find a matching processor from the registry and dispatch
9193

92-
```text
93-
resolved receiver = ::Class, method = "new" → ClassNewHandler
94-
resolved receiver = ::Module, method = "new" → ModuleNewHandler
95-
// Future: ::Struct, plugin-registered handlers
94+
```rust
95+
fn process_single_dsl_definition(&mut self, dsl_def_id: DefinitionId) {
96+
let dsl_def = /* get DslDefinition */;
97+
let receiver_decl_id = /* resolve receiver */;
98+
let method_name = /* get method name string */;
99+
let const_to_dsl = self.find_const_to_dsl_for(dsl_def_id);
100+
101+
if let Some(processor) = self.graph.dsl_processors()
102+
.iter()
103+
.find(|p| (p.matches)(receiver_decl_id, &method_name))
104+
{
105+
(processor.handle)(self.graph, &dsl_def, const_to_dsl.as_ref());
106+
}
107+
// No handler → DSL stays as-is
108+
}
96109
```
97110

98-
**Handler outcomes:**
111+
**Handler responsibilities:**
99112

100-
- **Handler matches (e.g., `::Class.new`)**: Transform `ConstantToDslDefinition` into `DynamicClassDefinition` with the constant name. Creates `ClassDeclaration`.
101-
- **No handler matches (e.g., `SomeClass.new`)**: Transform `ConstantToDslDefinition` into regular `ConstantDefinition`. We don't know what `SomeClass.new` returns.
113+
- **Create appropriate definitions** (e.g., `DynamicClassDefinition` for `Class.new`)
114+
- **Handle fallback cases internally** (e.g., create `ConstantDefinition` if processing can't complete)
115+
- **Reassign owned members** to the new definition by updating their `lexical_nesting_id`
102116

103-
Each handler also decides what to do with owned members (reassign to the new definition, discard, etc.). When members are reassigned, their `lexical_nesting_id` is NOT updated - it preserves the lexical structure as written in source code.
117+
Each handler decides what to do with owned members (reassign to the new definition, discard, etc.). When members are reassigned, their `lexical_nesting_id` IS updated to point to the new `DynamicClassDefinition` or `DynamicModuleDefinition`. This ensures that `resolve_lexical_owner` can find the correct declaration during Phase 4 resolution.
104118

105119
### Phase 4: Incremental Resolution
106120

@@ -118,25 +132,38 @@ resolver.resolve_all(); // Phase 4
118132

119133
### Argument Representation
120134

121-
Arguments are captured in a structured format to save downstream consumers from parsing:
135+
Arguments are captured as raw source text strings. This keeps indexing simple - resolution handlers interpret the strings as needed:
122136

123137
```rust
124-
enum DslArgument {
125-
Symbol(StringId),
126-
String(StringId),
127-
Boolean(bool),
128-
Integer(i64),
129-
Constant(NameId),
130-
Block { offset: Offset }, // Location for AST revisit if needed
138+
/// Represents a single argument in a DSL call.
139+
/// Values are captured as raw source text strings.
140+
pub enum DslArgument {
141+
/// A positional argument (e.g., `Parent` in `Class.new(Parent)`)
142+
Positional(String),
143+
/// A keyword argument (e.g., `class_name: "User"`)
144+
KeywordArg { key: String, value: String },
145+
/// A splat argument (e.g., `*args`)
146+
Splat(String),
147+
/// A double splat argument (e.g., `**kwargs`)
148+
DoubleSplat(String),
149+
/// A block argument (e.g., `&block`)
150+
BlockArg(String),
131151
}
132152

133153
struct DslArgumentList {
134-
positional: Vec<DslArgument>,
135-
keyword: HashMap<StringId, DslArgument>,
136-
block: Option<Offset>,
154+
arguments: Vec<DslArgument>,
155+
block_offset: Option<Offset>, // Location of do...end block
137156
}
138157
```
139158

159+
Example: `Foo.new("123", bar: true, *args, **kwargs, &block)` becomes:
160+
161+
- `Positional("\"123\"")`
162+
- `KeywordArg { key: "bar", value: "true" }`
163+
- `Splat("args")`
164+
- `DoubleSplat("kwargs")`
165+
- `BlockArg("block")`
166+
140167
### Block Ownership
141168

142169
All DSL calls automatically own their block contents. This simplifies indexing logic - the handler decides what to do with owned definitions during processing:
@@ -199,8 +226,8 @@ end
199226
- `DslDefinition(receiver: SomeClass, method: "new")`
200227
- `ConstantToDslDefinition(constant: Foo, dsl: DslDefinition)`
201228
- After resolution, `SomeClass` resolves to `::SomeClass` (not `::Class`)
202-
- No handler matches
203-
- `ConstantToDslDefinition` becomes regular `ConstantDefinition(Foo)`
229+
- No processor matches (neither `class_new_matches` nor `module_new_matches` return true)
230+
- DSL stays as-is (no transformation happens)
204231

205232
## Example: RSpec (Future Plugin)
206233

@@ -284,19 +311,92 @@ No new declaration types needed. Dynamic definitions create standard declaration
284311
- `DynamicClassDefinition``ClassDeclaration`
285312
- `DynamicModuleDefinition``ModuleDeclaration`
286313

287-
### DSL Handler Trait
314+
### DSL Processor Registry
315+
316+
DSL processors are registered with the Graph using function pointers (not trait objects) for simplicity:
288317

289318
```rust
290-
trait DslHandler {
291-
/// Returns true if this handler should process the given DSL call
292-
fn handles(&self, resolved_receiver: &str, method_name: &str) -> bool;
319+
/// A DSL processor that can match and handle specific DSL patterns.
320+
/// Lives in model/dsl_processors.rs
321+
pub struct DslProcessor {
322+
/// The method name this processor is interested in (e.g., "new")
323+
pub method_name: StringId,
324+
/// Returns true if this processor handles the given receiver/method combination
325+
pub matches: fn(receiver_decl_id: Option<DeclarationId>, method_name: &str) -> bool,
326+
/// Processes the DSL and mutates the graph accordingly
327+
pub handle: fn(
328+
graph: &mut Graph,
329+
dsl_def: &DslDefinition,
330+
const_to_dsl: Option<&ConstantToDslDefinition>,
331+
),
332+
}
333+
```
334+
335+
**Registration in `Graph::new()`:**
336+
337+
```rust
338+
impl Graph {
339+
pub fn new() -> Self {
340+
let mut graph = Self { /* ... */ dsl_processors: Vec::new() };
341+
342+
// Register built-in DSL processors
343+
graph.register_dsl_processor(DslProcessor {
344+
method_name: StringId::from("new"),
345+
matches: class_new_matches,
346+
handle: handle_class_new,
347+
});
348+
graph.register_dsl_processor(DslProcessor {
349+
method_name: StringId::from("new"),
350+
matches: module_new_matches,
351+
handle: handle_module_new,
352+
});
353+
354+
graph
355+
}
356+
357+
pub fn register_dsl_processor(&mut self, processor: DslProcessor) {
358+
self.dsl_processors.push(processor);
359+
}
360+
361+
pub fn dsl_processors(&self) -> &[DslProcessor] {
362+
&self.dsl_processors
363+
}
364+
365+
/// Checks if a method name is a DSL target (derived from registered processors)
366+
pub fn is_dsl_target(&self, method_name: &StringId) -> bool {
367+
self.dsl_processors.iter().any(|p| p.method_name == *method_name)
368+
}
369+
}
370+
```
371+
372+
**Handler functions in `model/dsl_processors.rs`:**
373+
374+
```rust
375+
pub fn class_new_matches(receiver_decl_id: Option<DeclarationId>, method_name: &str) -> bool {
376+
receiver_decl_id == Some(*CLASS_ID) && method_name == "new"
377+
}
378+
379+
pub fn handle_class_new(
380+
graph: &mut Graph,
381+
dsl_def: &DslDefinition,
382+
const_to_dsl: Option<&ConstantToDslDefinition>,
383+
) {
384+
// Extract constant_name from const_to_dsl (with reparenting logic)
385+
// Extract superclass from dsl_def.arguments().positional_args().first()
386+
// Create DynamicClassDefinition or fallback to ConstantDefinition
387+
// Update member definitions' lexical_nesting_id
388+
}
293389

294-
/// Process the DSL and create new definitions/declarations
295-
fn process(&self, graph: &mut Graph, dsl: &DslDefinition);
390+
pub fn module_new_matches(receiver_decl_id: Option<DeclarationId>, method_name: &str) -> bool {
391+
receiver_decl_id == Some(*MODULE_ID) && method_name == "new"
296392
}
297393

298-
struct DslHandlerRegistry {
299-
handlers: Vec<Box<dyn DslHandler>>,
394+
pub fn handle_module_new(
395+
graph: &mut Graph,
396+
dsl_def: &DslDefinition,
397+
const_to_dsl: Option<&ConstantToDslDefinition>,
398+
) {
399+
// Similar to handle_class_new but creates DynamicModuleDefinition
300400
}
301401
```
302402

@@ -336,13 +436,14 @@ impl DynamicModuleDefinition {
336436
- Add `DslDefinition` struct
337437
- Add `ConstantToDslDefinition` struct
338438
- Add `DynamicClassDefinition` and `DynamicModuleDefinition` structs
339-
- Add `internal_dsl_targets` to Graph
439+
- Add `DslProcessor` struct and processor registry to Graph
440+
- Add `model/dsl_processors.rs` with handler functions
340441
- Add `index_dsl_definition_target` helper in indexer (similar to `index_constant_alias_target`)
341442
- Add `add_constant_to_dsl_definition` helper in indexer
342443
- Implement `Nesting::Dsl` variant for nesting stack
343444
- Add `process_dsl_definitions()` method for Phase 3
344-
- Implement `Class.new` handler
345-
- Implement `Module.new` handler
445+
- Implement `Class.new` processor (`class_new_matches`, `handle_class_new`)
446+
- Implement `Module.new` processor (`module_new_matches`, `handle_module_new`)
346447

347448
### Out of Scope
348449

@@ -359,7 +460,7 @@ impl DynamicModuleDefinition {
359460

360461
## Test Cases
361462

362-
1. **Unknown receiver fallback**:
463+
1. **Unknown receiver - no transformation**:
363464

364465
```ruby
365466
class MyClass
@@ -368,7 +469,7 @@ impl DynamicModuleDefinition {
368469
Foo = MyClass.new
369470
```
370471

371-
`ConstantToDslDefinition(Foo)` should become `ConstantDefinition(Foo)` since `MyClass.new` doesn't match any handler.
472+
No processor matches `MyClass.new`, so DSL stays as-is. The `ConstantToDslDefinition(Foo)` remains linked to the unprocessed `DslDefinition`.
372473

373474
2. **Inheritance from dynamic class**:
374475

@@ -391,4 +492,4 @@ impl DynamicModuleDefinition {
391492
end
392493
```
393494

394-
Verify `Bar` is correctly handled as a nested constant (`Foo::Bar` with `inner` method). And `Foo` has `name` and `name=` methods.
495+
Verify `Bar` is correctly handled as a nested constant (`Foo::Bar` with `inner` method). And `Foo` has `name` method from attr_accessor. Note: attr_accessor only creates getter declarations in this codebase.

rust/rubydex-sys/src/definition_api.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub enum DefinitionKind {
2626
ClassVariable = 11,
2727
MethodAlias = 12,
2828
GlobalVariableAlias = 13,
29+
Dsl = 14,
30+
ConstantToDsl = 15,
31+
DynamicClass = 16,
32+
DynamicModule = 17,
2933
}
3034

3135
pub(crate) fn map_definition_to_kind(defn: &Definition) -> DefinitionKind {
@@ -44,6 +48,10 @@ pub(crate) fn map_definition_to_kind(defn: &Definition) -> DefinitionKind {
4448
Definition::ClassVariable(_) => DefinitionKind::ClassVariable,
4549
Definition::MethodAlias(_) => DefinitionKind::MethodAlias,
4650
Definition::GlobalVariableAlias(_) => DefinitionKind::GlobalVariableAlias,
51+
Definition::Dsl(_) => DefinitionKind::Dsl,
52+
Definition::ConstantToDsl(_) => DefinitionKind::ConstantToDsl,
53+
Definition::DynamicClass(_) => DefinitionKind::DynamicClass,
54+
Definition::DynamicModule(_) => DefinitionKind::DynamicModule,
4755
}
4856
}
4957

rust/rubydex-sys/src/graph_api.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ pub extern "C" fn rdx_graph_resolve(pointer: GraphPointer) {
157157
with_mut_graph(pointer, |graph| {
158158
let mut resolver = Resolver::new(graph);
159159
resolver.resolve_all();
160+
resolver.process_dsl_definitions();
161+
resolver.resolve_all();
160162
});
161163
}
162164

@@ -435,20 +437,27 @@ mod tests {
435437

436438
#[test]
437439
fn names_are_untracked_after_resolving_constant() {
440+
let graph = Graph::new();
441+
let dsl_targets = graph.dsl_targets();
442+
drop(graph);
443+
438444
let mut indexer = RubyIndexer::new(
439445
"file:///foo.rb".into(),
440446
"
441447
class Foo
442448
BAR = 1
443449
end
444450
",
451+
dsl_targets,
445452
);
446453
indexer.index();
447454

448455
let mut graph = Graph::new();
449456
graph.update(indexer.local_graph());
450457
let mut resolver = Resolver::new(&mut graph);
451458
resolver.resolve_all();
459+
resolver.process_dsl_definitions();
460+
resolver.resolve_all();
452461

453462
assert_eq!(
454463
1,

rust/rubydex/src/diagnostic.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use crate::model::document::Document;
33
use crate::{model::ids::UriId, offset::Offset};
44

5-
#[derive(Debug)]
5+
#[derive(Debug, Clone)]
66
pub struct Diagnostic {
77
rule: Rule,
88
uri_id: UriId,

rust/rubydex/src/indexing.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,22 @@ pub struct IndexingRubyFileJob {
1616
path: PathBuf,
1717
local_graph_tx: Sender<LocalGraph>,
1818
errors_tx: Sender<Errors>,
19+
dsl_method_names: Vec<&'static str>,
1920
}
2021

2122
impl IndexingRubyFileJob {
2223
#[must_use]
23-
pub fn new(path: PathBuf, local_graph_tx: Sender<LocalGraph>, errors_tx: Sender<Errors>) -> Self {
24+
pub fn new(
25+
path: PathBuf,
26+
local_graph_tx: Sender<LocalGraph>,
27+
errors_tx: Sender<Errors>,
28+
dsl_method_names: Vec<&'static str>,
29+
) -> Self {
2430
Self {
2531
path,
2632
local_graph_tx,
2733
errors_tx,
34+
dsl_method_names,
2835
}
2936
}
3037

@@ -55,7 +62,7 @@ impl Job for IndexingRubyFileJob {
5562
return;
5663
};
5764

58-
let mut ruby_indexer = RubyIndexer::new(url.to_string(), &source);
65+
let mut ruby_indexer = RubyIndexer::new(url.to_string(), &source, self.dsl_method_names.clone());
5966
ruby_indexer.index();
6067

6168
self.local_graph_tx
@@ -73,12 +80,14 @@ pub fn index_files(graph: &mut Graph, paths: Vec<PathBuf>) -> Vec<Errors> {
7380
let queue = Arc::new(JobQueue::new());
7481
let (local_graphs_tx, local_graphs_rx) = unbounded();
7582
let (errors_tx, errors_rx) = unbounded();
83+
let dsl_method_names = graph.dsl_method_names();
7684

7785
for path in paths {
7886
queue.push(Box::new(IndexingRubyFileJob::new(
7987
path,
8088
local_graphs_tx.clone(),
8189
errors_tx.clone(),
90+
dsl_method_names.clone(),
8291
)));
8392
}
8493

0 commit comments

Comments
 (0)