Spark DSL Framework

repository·main·Indexed 42 days ago

https://github.com/ash-project/spark

A framework for creating powerful, extensible, and declarative domain-specific languages (DSLs) in Elixir. Spark transforms struct definitions into rich DSLs with built-in autocomplete, documentation, and compile-time validation using entities, sections, and extensions. It includes a Builder API for programmatic DSL construction, Fragments for splitting large DSLs, and Spark.Test for verifying DSL errors and warnings.

Tokens
16.8K
Snippets
49
Records
57
Agent score
52%

What's inside Spark

  1. What is Spark?

    main

    Spark is a library used to build powerful, well-documented Domain Specific Languages (DSLs) in Elixir. It provides the underlying infrastructure for all DSLs within the Ash Framework. When you implement a DSL using Spark, you gain several built-in capabilities:

    • Extensibility: Users of your DSL can write extensions to it.
    • Tooling: Automatic support for elixir_sense (providing autocomplete and in-line documentation).
    • Documentation: Tools to automatically generate documentation for your DSL.
    • Library Integration: A Mix task that automatically adds DSL components to the locals_without_parens of your library, improving the developer experience.
  2. How the Entity-Section-Extension model works

    main

    Spark DSLs are constructed using three primary building blocks that define the structure, organization, and functionality of your language:

    1. Entities: These are the individual DSL constructors (the 'verbs' or 'nouns' of your language). They define a target struct to build, positional arguments, and a validation schema.
    2. Sections: These are containers used to organize related entities. Sections can be nested, allowing for hierarchical DSL structures.
    3. Extensions: These package the entire DSL functionality. An extension brings together specific sections, transformers (for compile-time modification), and verifiers (for final state validation).

    This model allows you to separate the definition of individual language elements from the organizational structure and the logic that processes them.

    # 1. Entities
    @field %Spark.Dsl.Entity{
      name: :field,
      target: MyApp.Field,
      args: [:name, :type],
      schema: [name: [type: :atom, required: true]]
    }
    
    # 2. Sections
    @fields %Spark.Dsl.Section{
      name: :fields,
      entities: [@field],
      sections: [],
      schema: [required: [type: {:list, :atom}, default: []]]
    }
    
    # 3. Extensions
    defmodule MyExtension do
      use Spark.Dsl.Extension,
        sections: [@fields],
        transformers: [MyTransformer],
        verifiers: [MyVerifier]
    end
  3. Generate a clean interface with Spark.InfoGenerator

    main

    To provide a convenient way to query your DSL's state, use Spark.InfoGenerator. By defining a module named YourDsl.Info and using Spark.InfoGenerator, Spark automatically generates functions corresponding to your DSL sections and entities.

    For example, if you have a :fields section, Info.fields(module) will return the list of field entities, and Info.fields_required!(module) will return the required fields.

    defmodule MyLibrary.Validator.Info do
      use Spark.InfoGenerator, extension: MyLibrary.Validator.Dsl, sections: [:fields]
    end
    
    # Usage:
    # MyLibrary.Validator.Info.fields(MyApp.PersonValidator)
    # MyLibrary.Validator.Info.fields_required!(MyApp.PersonValidator)
  4. The Spark programming model

    main

    The core programming model of spark involves three main steps:

    1. Specify a DSL: Define a Domain Specific Language using Spark's DSL capabilities.
    2. Define instances: Create instances of that DSL within your application code.
    3. Inspect instances: Use the resulting DSL instances to drive logic, such as generating code, performing validations (e.g., a validate/2 function), or powering tooling/error reporting.

    This pattern allows you to build highly expressive, declarative interfaces that can be programmatically interrogated to automate repetitive tasks.

  5. Handle async safety and spawned processes in Spark tests

    main

    The Spark.Test helpers are safe for async: true ExUnit tests because they use per-process state (Process.put/2).

    Caveat: If you spawn your own process (e.g., using Task.async), any defmodule defined inside that spawned process will not be captured because the spawned process has its own process dictionary. To fix this, you must manually set the collector flag inside the spawned process using the {Spark.Dsl, :test_collector} key.

    parent = self()
    
    Task.async(fn ->
      # Manually point the collector to the parent process
      Process.put({Spark.Dsl, :test_collector}, parent)
      
      defmodule Elixir.SomeModule do
        # ...
      end
    end) |> Task.await()
  6. Transform DSL state with Spark.Dsl.Transformer

    main

    Transformers allow you to perform arbitrary transformations on the DSL data structure at compile time. This is a powerful way to manipulate the DSL state using regular Elixir code instead of complex macros.

    Key characteristics:

    • They implement the Spark.Dsl.Transformer behavior.
    • They receive the dsl_state (the underlying data map) in their transform/1 function.
    • They can add, remove, or modify sections and entities.
    • You can use Spark.Dsl.Transformer.eval/3 to inject generated code (like function definitions) into the module.
    defmodule MyLibrary.Validator.Transformers.AddId do
      use Spark.Dsl.Transformer
    
      def transform(dsl_state) do
        {:ok,
          Spark.Dsl.Transformer.add_entity(dsl_state, [:fields], %MyLibrary.Validator.Dsl.Field{
            name: :id,
            type: :string
          })
        }
      end
    end
  7. How Spark extensions work

    main

    Writing an extension in Spark involves three main components: a DSL declaration (defining the structure), Compile-time processing (Transformers and Persisters), and Post-compilation validation (Verifiers).

    Execution Lifecycle

    Extensions hook into the compilation process in a specific order:

    1. Transformers: Run during compilation in dependency order. They can read and modify any part of the DSL state. Use these for structural modifications.
    2. Persisters: Run after all Transformers have completed. They should only write to the persisted data map and are used for precomputing or caching derived values.
    3. Verifiers: Run after the module is compiled. They are read-only and cannot modify the DSL state. Because they run post-compilation, they are the preferred way to validate configuration without creating circular compile-time dependencies between modules.

    Registering an Extension

    All components are registered using the use Spark.Dsl.Extension macro:

    use Spark.Dsl.Extension,
      sections: [@my_section],
      transformers: [MyApp.Transformers.SetDefaults],
      persisters: [MyApp.Persisters.CacheComputedValues],
      verifiers: [MyApp.Verifiers.ValidateConfig]
  8. Validate DSL state with Spark.Dsl.Verifier

    main

    Verifiers are used to validate the final state of a DSL. Unlike transformers, verifiers are read-only and run after the module has been compiled.

    When to use Verifiers:

    • When you need to perform validation that doesn't require modifying the DSL state.
    • When you need to cross-check multiple modules (since they run post-compilation, they can safely reference other modules without causing circular compile-time dependencies).
    • When you want to provide high-quality error messages using Spark.Error.DslError.exception/1.

    Return values: Verifiers must return :ok, {:error, error}, or {:warn, warning}.

    defmodule MyLibrary.Validator.Verifiers.VerifyRequired do
      use Spark.Dsl.Verifier
    
      def verify(dsl_state) do
        required = MyLibrary.Validator.Info.fields_required!(dsl_state)
        fields = Enum.map(MyLibrary.Validator.Info.fields(dsl_state), &(&1.name))
    
        if Enum.all?(required, &Enum.member?(fields, &1)) do
          :ok
        else
          {:error, Spark.Error.DslError.exception(message: "...", path: [:fields, :required])}
        )
      end
    end
  9. Cache values with Spark.Dsl.Persister

    main

    Persisters are a specialized type of transformer used to precompute and cache values onto the DSL. They are intended for performance optimization, such as caching derived values so they can be read quickly at runtime.

    Rules for Persisters:

    • They are listed under persisters: in the Spark.Dsl.Extension configuration.
    • They always run after all transformers have finished.
    • They should only write to the persisted data map using Spark.Dsl.Transformer.persist/3.
    • They should not add, remove, or modify sections or entities.
    defmodule MyLibrary.Validator.Persisters.CacheFieldNames do
      use Spark.Dsl.Transformer
    
      def transform(dsl_state) do
        field_names = 
          dsl_state 
          |> Spark.Dsl.Transformer.get_entities([:fields]) 
          |> Enum.map(& &1.name)
    
        {:ok, Spark.Dsl.Transformer.persist(dsl_state, :field_names, field_names)}
      end
    end
  10. How Spark DSLs work

    main

    Spark allows you to build declarative DSLs by defining a structure of sections and entities, then wrapping them in an extension.

    1. Define Entities: Use Spark.Dsl.Entity to define the individual components of your DSL (e.g., a field). You specify its name, arguments, target module, and a schema for its options.
    2. Define Sections: Use Spark.Dsl.Section to group entities into logical blocks (e.g., a fields block).
    3. Create an Extension: Use use Spark.Dsl.Extension, sections: [...] to register your sections and make the DSL available.
    4. Implement Logic: Use Transformers to modify the DSL structure during compilation and Verifiers to validate the correctness of the DSL usage.
    @field %Spark.Dsl.Entity{
      name: :field,
      args: [:name, :type],
      target: Field,
      describe: "A field that is accepted by the validator",
      schema: [
        name: [type: :atom, required: true, doc: "The name of the field"],
        type: [type: {:one_of, [:integer, :string]}, required: true, doc: "The type of the field"],
        check: [type: {:fun, 1}, doc: "A function to validate the value"],
        transform: [type: {:fun, 1}, doc: "A function to transform the value"]
      ]
    }
    
    @fields %Spark.Dsl.Section{
      name: :fields,
      entities: [@field],
      describe: "Configure the fields that are supported and required"
    }
    
    use Spark.Dsl.Extension, sections: [@fields]
  11. Test verifiers that produce errors

    main

    To test verifiers that return {:error, %Spark.Error.DslError{}}, use assert_dsl_error/2 to match specific error patterns or refute_dsl_errors/1 to ensure a configuration is valid (the happy path).

    Important: Module Resolution

    When defining modules inside a dsl_errors or assert_dsl_error block, use the explicit Elixir.ModuleName form. A bare alias inside the macro's anonymous function resolves against the test module's scope, but your assertion pattern resolves at the top level. Using Elixir.X ensures they resolve to the same atom.

    Match a specific error

    assert_dsl_error/2 takes a pattern, runs the block, and returns the first matching error for further assertions.

    defmodule MyApp.PersonValidatorTest do
      use ExUnit.Case, async: true
      import Spark.Test
    
      test "rejects an invalid email" do
        err =
          assert_dsl_error %Spark.Error.DslError{path: [:fields, :email]} do
            defmodule Elixir.BadPerson do
              use MyApp.PersonValidator
    
              fields do
                field :email, :string do
                  check &String.contains?(&1, "@")
                end
              end
            end
          end
    
        assert err.message =~ "invalid email"
      end
    end
  12. Define a custom DSL using Spark.Dsl.Extension

    main

    To create a custom Domain Specific Language (DSL), you define a Spark.Dsl.Extension. This involves using the builder API (Spark.Builder.Section, Spark.Builder.Entity, and Spark.Builder.Field) to construct the schema of your DSL.

    Key Requirements:

    • Every entity struct must include a __spark_metadata__ field. This field is used by Spark to store source location information, which enables better error messages and tooling.
    • Use Entity.new/3 to define entities (like a field).
    • Use Section.new/3 to define sections (like fields) that contain entities.
    • Use Spark.Dsl.Extension.use to register your sections.
    defmodule MyLibrary.Validator.Dsl do
      alias Spark.Builder.{Entity, Field, Section}
    
      defmodule Field do
        # The __spark_metadata__ field is required for Spark entities
        defstruct [:name, :type, :transform, :check, :__spark_metadata__]
      end
    
      @field Entity.new(:field, Field,
        describe: "A field that is accepted by the validator",
        args: [:name, :type],
        schema: [
          Field.new(:name, :atom, required: true, doc: "The name of the field"),
          Field.new(:type, {:one_of, [:integer, :string]}, required: true, doc: "The type of the field"),
          Field.new(:check, {:fun, 1}, doc: "A function to check validity"),
          Field.new(:transform, {:fun, 1}, doc: "A function to transform the value")
        ]
      ) |> Entity.build!()
    
      @fields Section.new(:fields,
        describe: "Configure the fields",
        schema: [
          Field.new(:required, {:list, :atom}, doc: "Required fields")
        ],
        entities: [@field]
      ) |> Section.build!()
    
      use Spark.Dsl.Extension, sections: [@fields]
    end