|
| 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