Using Source Annotations
View SourceSpark automatically tracks source location information for all DSL elements
using Erlang's :erl_anno module. This provides comprehensive location tracking
for sections, options, and entities, enabling better error messages, IDE
integration, and debugging capabilities.
Source annotations are only enabled when the Elixir compile option debug_info
is enabled (Code.get_compiler_option(:debug_info) returns true). By default,
debug info is disabled in production and in .exs script files, which means
source annotations won't be available in those contexts.
ExUnit Test Cases
If you're defining modules inside ExUnit test cases (which use .exs files),
source annotations won't be available unless you explicitly enable
debug_info in your tests.
setup do
debug_info? = Code.get_compiler_option(:debug_info)
Code.put_compiler_option(:debug_info, true)
on_exit(fn -> Code.put_compiler_option(:debug_info, debug_info?) end)
:ok
endWhat are Source Annotations?
Source annotations capture metadata about where DSL elements are defined in your source code, including:
- File path: The source file where the DSL element is declared
- Line number: The exact line where the element starts
- End location: The line where DSL blocks end (available on OTP 28+, requires Elixir Parser Configuration)
defmodule Acme.MixProject do
use Mix.Project
def project do
[
app: :acme,
elixirc_options: [
parser_options: [
token_metadata: true,
parser_columns: true
]
],
# ...
]
end
endSpark tracks annotations for:
- Sections: Location where section blocks are defined (
section do ... end) - Options: Location where individual options are set (
option_name "value") - Entities: Location where entities are declared (
entity :name do ... end)
Annotation Introspection
Universal Access via Introspection Functions
Spark provides introspection functions that work regardless of whether entities
define an anno_field. These functions access annotation data stored in the DSL
state:
# Get DSL state
dsl_state = MyModule.spark_dsl_config()
# Section annotations
section_anno = Spark.Dsl.Extension.get_section_anno(dsl_state, [:my_section])
if section_anno do
# Extract line number (Spark currently provides line numbers only)
line = case :erl_anno.location(section_anno) do
{line_num, _col} -> line_num
line_num -> line_num
end
file = :erl_anno.file(section_anno) |> to_string()
IO.puts("Section defined at #{file}:#{line}")
end
# Option annotations
option_anno = Spark.Dsl.Extension.get_opt_anno(dsl_state, [:my_section], :option_name)
if option_anno do
line = :erl_anno.location(option_anno)
file = :erl_anno.file(option_anno) |> to_string()
IO.puts("Option defined at #{file}:#{line}")
end
# Entity annotations
entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section])
Enum.each(entities, fn entity ->
case Spark.Dsl.Entity.anno(entity) do
nil -> :ok
anno ->
line = :erl_anno.location(anno)
file = :erl_anno.file(anno) |> to_string()
IO.puts("Entity defined at #{file}:#{line}")
end
end)Entity Annotations
For direct access to annotations, entities should include the
__spark_metadata__ field in their struct definition:
defmodule MyEntity do
defstruct [
:name,
:__spark_metadata__ # Required for annotation access
]
end
@my_entity %Spark.Dsl.Entity{
name: :my_entity,
target: MyEntity,
schema: [
name: [type: :atom, required: true]
]
}
# Access annotations
entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section])
Enum.each(entities, fn entity ->
if entity_anno = Spark.Dsl.Entity.anno(entity) do
line = :erl_anno.location(entity_anno)
file = :erl_anno.file(entity_anno) |> to_string()
IO.puts("Entity defined at #{file}:#{line}")
end
if name_anno = Spark.Dsl.Entity.property_anno(entity, :name) do
line = :erl_anno.location(name_anno)
file = :erl_anno.file(name_anno) |> to_string()
IO.puts("Entity name property defined at #{file}:#{line}")
end
end)Working with Annotations
Annotations use Erlang's :erl_anno module, which provides several utilities:
# Check if something is an annotation
:erl_anno.is_anno(anno)
# Get the location (line number or {line, column})
# Note: Spark currently only provides line numbers, not column information
location = :erl_anno.location(anno)
# Helper function to extract line number from location
get_line = fn location ->
case location do
{line_num, _column} -> line_num # Future column support
line_num when is_integer(line_num) -> line_num # Current Spark behavior
end
end
line = get_line.(location)
# Get the file (returns :undefined or a charlist)
file = :erl_anno.file(anno)
# Convert charlist to string safely
file_string = case file do
:undefined -> "unknown"
charlist -> to_string(charlist)
end
# Get the end location (OTP 28+, returns :undefined if not available)
if function_exported?(:erl_anno, :end_location, 1) do
end_location = :erl_anno.end_location(anno)
endUse Cases
Enhanced Error Messages in Verifiers
Create precise error messages that point to the exact source location:
defmodule MyLibrary.Verifiers.UniqueNames do
use Spark.Dsl.Verifier
def verify(dsl_state) do
entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section])
case find_duplicate(entities) do
nil -> :ok
{duplicate_name, duplicate_entity} ->
location = Spark.Dsl.Entity.anno(duplicate_entity)
{:error,
Spark.Error.DslError.exception(
message: "Duplicate entity name: #{duplicate_name}",
path: [:my_section, duplicate_name],
module: Spark.Dsl.Verifier.get_persisted(dsl_state, :module),
location: location
)}
end
end
endEnhanced Error Messages in Transformers
defmodule MyLibrary.Transformers.ValidateEntity do
use Spark.Dsl.Transformer
def transform(dsl_state) do
entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section])
entities
|> Enum.each(fn entity ->
if invalid?(entity) do
location = Spark.Dsl.Entity.anno(entity)
raise Spark.Error.DslError,
message: "Invalid configuration for #{entity.name}",
path: [:my_section, entity.name],
location: location
end
end)
{:ok, dsl_state}
end
endIDE Integration and Language Servers
Language servers can provide enhanced features using annotation data:
defmodule MyLanguageServer do
def find_definition(file, line, column) do
# Find modules that might contain DSL at this location
modules = find_modules_in_file(file)
Enum.find_value(modules, fn module ->
dsl_state = module.spark_dsl_config()
# Check section annotations
Enum.find_value(dsl_state, fn {path, config} ->
if match_location?(config.section_anno, line) do
{:section, path, config.section_anno}
end
end) ||
# Check entity annotations
find_entity_at_location(dsl_state, line)
end)
end
endDebugging and Development Tools
Create debugging utilities that show DSL source locations:
defmodule MyLibrary.Debug do
def inspect_dsl_sources(module) do
dsl_state = module.spark_dsl_config()
# Show all DSL elements with their locations
Enum.each(dsl_state, fn {path, config} ->
IO.puts("Section #{inspect(path)}:")
if config.section_anno do
print_location(" Section", config.section_anno)
end
# Show options
Enum.each(config.opts_anno, fn {opt_name, anno} ->
print_location(" Option #{opt_name}", anno)
end)
# Show entities
Enum.each(config.entities, fn entity ->
anno = Spark.Dsl.Entity.anno(entity)
print_location(" Entity #{entity.name}", anno)
end)
end)
end
defp print_location(label, anno)
defp print_location(label, nil), do: nil
defp print_location(label, anno) do
line = :erl_anno.location(anno)
file = :erl_anno.file(anno) |> to_string() |> Path.relative_to_cwd()
IO.puts(" #{label}: #{file}:#{line}")
end
endBest Practices
1. Always Include Location in DslErrors
When creating DslErrors, include location information whenever available:
# Get the appropriate annotation for your error context
location = case error_type do
:section_error ->
Spark.Dsl.Transformer.get_section_anno(dsl_state, path)
:option_error ->
Spark.Dsl.Transformer.get_opt_anno(dsl_state, path, option_name)
:entity_error ->
entity = Enum.at(entities, entity_index)
Spark.Dsl.Entity.anno(entity)
end
{:error,
Spark.Error.DslError.exception(
message: "Clear error description",
path: path,
module: module,
location: location
)}2. Handle Missing Annotations Gracefully
Not all annotations may be available (e.g., programmatically generated DSL elements):
location_info = if anno do
line = :erl_anno.location(anno)
file = :erl_anno.file(anno) |> to_string()
" at #{file}:#{line}"
else
""
end
IO.puts("Error in entity#{location_info}")3. Use Both Introspection and Anno Fields
- Use introspection functions for universal access in verifiers and transformers
- Use anno fields in entity structs for convenient access in application code
4. Check OTP Version for End Location
End location tracking requires OTP 28+:
if function_exported?(:erl_anno, :end_location, 1) do
end_location = :erl_anno.end_location(anno)
# Use end location for precise span information
endCurrent Limitations
- Column information is not currently tracked (only line numbers)
- End Location is only tracked for OTP28+
- End Location is not available for multiline options