|
| 1 | +# Using Source Annotations |
| 2 | + |
| 3 | +Spark automatically tracks source location information for all DSL elements |
| 4 | +using Erlang's `:erl_anno` module. This provides comprehensive location tracking |
| 5 | +for sections, options, and entities, enabling better error messages, IDE |
| 6 | +integration, and debugging capabilities. |
| 7 | + |
| 8 | +## What are Source Annotations? |
| 9 | + |
| 10 | +Source annotations capture metadata about where DSL elements are defined in your |
| 11 | +source code, including: |
| 12 | + |
| 13 | +- **File path**: The source file where the DSL element is declared |
| 14 | +- **Line number**: The exact line where the element starts |
| 15 | +- **End location**: The line where DSL blocks end (available on OTP 28+, |
| 16 | + requires Elixir Parser Configuration) |
| 17 | + |
| 18 | +```elixir |
| 19 | +defmodule Acme.MixProject do |
| 20 | + use Mix.Project |
| 21 | + |
| 22 | + def project do |
| 23 | + [ |
| 24 | + app: :acme, |
| 25 | + elixirc_options: [ |
| 26 | + parser_options: [ |
| 27 | + token_metadata: true, |
| 28 | + parser_columns: true |
| 29 | + ] |
| 30 | + ], |
| 31 | + # ... |
| 32 | + ] |
| 33 | + end |
| 34 | +end |
| 35 | +``` |
| 36 | + |
| 37 | +Spark tracks annotations for: |
| 38 | +- **Sections**: Location where section blocks are defined (`section do ... end`) |
| 39 | +- **Options**: Location where individual options are set (`option_name "value"`) |
| 40 | +- **Entities**: Location where entities are declared (`entity :name do ... end`) |
| 41 | + |
| 42 | +## Annotation Introspection |
| 43 | + |
| 44 | +### Universal Access via Introspection Functions |
| 45 | + |
| 46 | +Spark provides introspection functions that work regardless of whether entities |
| 47 | +define an `anno_field`. These functions access annotation data stored in the DSL |
| 48 | +state: |
| 49 | + |
| 50 | +```elixir |
| 51 | +# Get DSL state |
| 52 | +dsl_state = MyModule.spark_dsl_config() |
| 53 | + |
| 54 | +# Section annotations |
| 55 | +section_anno = Spark.Dsl.Extension.get_section_anno(dsl_state, [:my_section]) |
| 56 | +if section_anno do |
| 57 | + # Extract line number (Spark currently provides line numbers only) |
| 58 | + line = case :erl_anno.location(section_anno) do |
| 59 | + {line_num, _col} -> line_num |
| 60 | + line_num -> line_num |
| 61 | + end |
| 62 | + file = :erl_anno.file(section_anno) |> to_string() |
| 63 | + IO.puts("Section defined at #{file}:#{line}") |
| 64 | +end |
| 65 | + |
| 66 | +# Option annotations |
| 67 | +option_anno = Spark.Dsl.Extension.get_opt_anno(dsl_state, [:my_section], :option_name) |
| 68 | +if option_anno do |
| 69 | + line = :erl_anno.location(option_anno) |
| 70 | + file = :erl_anno.file(option_anno) |> to_string() |
| 71 | + IO.puts("Option defined at #{file}:#{line}") |
| 72 | +end |
| 73 | + |
| 74 | +# Entity annotations |
| 75 | +entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section]) |
| 76 | +Enum.each(entities, fn entity -> |
| 77 | + case Spark.Dsl.Entity.anno(entity) do |
| 78 | + nil -> :ok |
| 79 | + anno -> |
| 80 | + line = :erl_anno.location(anno) |
| 81 | + file = :erl_anno.file(anno) |> to_string() |
| 82 | + IO.puts("Entity defined at #{file}:#{line}") |
| 83 | + end |
| 84 | +end) |
| 85 | +``` |
| 86 | + |
| 87 | +### Entity Annotations |
| 88 | + |
| 89 | +For direct access to annotations, entities should include the |
| 90 | +`__spark_metadata__` field in their struct definition: |
| 91 | + |
| 92 | +```elixir |
| 93 | +defmodule MyEntity do |
| 94 | + defstruct [ |
| 95 | + :name, |
| 96 | + :__spark_metadata__ # Required for annotation access |
| 97 | + ] |
| 98 | +end |
| 99 | + |
| 100 | +@my_entity %Spark.Dsl.Entity{ |
| 101 | + name: :my_entity, |
| 102 | + target: MyEntity, |
| 103 | + schema: [ |
| 104 | + name: [type: :atom, required: true] |
| 105 | + ] |
| 106 | +} |
| 107 | + |
| 108 | +# Access annotations |
| 109 | +entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section]) |
| 110 | +Enum.each(entities, fn entity -> |
| 111 | + if entity_anno = Spark.Dsl.Entity.anno(entity) do |
| 112 | + line = :erl_anno.location(entity_anno) |
| 113 | + file = :erl_anno.file(entity_anno) |> to_string() |
| 114 | + IO.puts("Entity defined at #{file}:#{line}") |
| 115 | + end |
| 116 | + |
| 117 | + if name_anno = Spark.Dsl.Entity.property_anno(entity, :name) do |
| 118 | + line = :erl_anno.location(name_anno) |
| 119 | + file = :erl_anno.file(name_anno) |> to_string() |
| 120 | + IO.puts("Entity name property defined at #{file}:#{line}") |
| 121 | + end |
| 122 | +end) |
| 123 | +``` |
| 124 | + |
| 125 | +## Working with Annotations |
| 126 | + |
| 127 | +Annotations use Erlang's `:erl_anno` module, which provides several utilities: |
| 128 | + |
| 129 | +```elixir |
| 130 | +# Check if something is an annotation |
| 131 | +:erl_anno.is_anno(anno) |
| 132 | + |
| 133 | +# Get the location (line number or {line, column}) |
| 134 | +# Note: Spark currently only provides line numbers, not column information |
| 135 | +location = :erl_anno.location(anno) |
| 136 | + |
| 137 | +# Helper function to extract line number from location |
| 138 | +get_line = fn location -> |
| 139 | + case location do |
| 140 | + {line_num, _column} -> line_num # Future column support |
| 141 | + line_num when is_integer(line_num) -> line_num # Current Spark behavior |
| 142 | + end |
| 143 | +end |
| 144 | + |
| 145 | +line = get_line.(location) |
| 146 | + |
| 147 | +# Get the file (returns :undefined or a charlist) |
| 148 | +file = :erl_anno.file(anno) |
| 149 | + |
| 150 | +# Convert charlist to string safely |
| 151 | +file_string = case file do |
| 152 | + :undefined -> "unknown" |
| 153 | + charlist -> to_string(charlist) |
| 154 | +end |
| 155 | + |
| 156 | +# Get the end location (OTP 28+, returns :undefined if not available) |
| 157 | +if function_exported?(:erl_anno, :end_location, 1) do |
| 158 | + end_location = :erl_anno.end_location(anno) |
| 159 | +end |
| 160 | +``` |
| 161 | + |
| 162 | +## Use Cases |
| 163 | + |
| 164 | +### Enhanced Error Messages in Verifiers |
| 165 | + |
| 166 | +Create precise error messages that point to the exact source location: |
| 167 | + |
| 168 | +```elixir |
| 169 | +defmodule MyLibrary.Verifiers.UniqueNames do |
| 170 | + use Spark.Dsl.Verifier |
| 171 | + |
| 172 | + def verify(dsl_state) do |
| 173 | + entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section]) |
| 174 | + |
| 175 | + case find_duplicate(entities) do |
| 176 | + nil -> :ok |
| 177 | + {duplicate_name, duplicate_entity} -> |
| 178 | + location = Spark.Dsl.Entity.anno(duplicate_entity) |
| 179 | + |
| 180 | + {:error, |
| 181 | + Spark.Error.DslError.exception( |
| 182 | + message: "Duplicate entity name: #{duplicate_name}", |
| 183 | + path: [:my_section, duplicate_name], |
| 184 | + module: Spark.Dsl.Verifier.get_persisted(dsl_state, :module), |
| 185 | + location: location |
| 186 | + )} |
| 187 | + end |
| 188 | + end |
| 189 | +end |
| 190 | +``` |
| 191 | + |
| 192 | +### Enhanced Error Messages in Transformers |
| 193 | + |
| 194 | +```elixir |
| 195 | +defmodule MyLibrary.Transformers.ValidateEntity do |
| 196 | + use Spark.Dsl.Transformer |
| 197 | + |
| 198 | + def transform(dsl_state) do |
| 199 | + entities = Spark.Dsl.Extension.get_entities(dsl_state, [:my_section]) |
| 200 | + |
| 201 | + entities |
| 202 | + |> Enum.each(fn entity -> |
| 203 | + if invalid?(entity) do |
| 204 | + location = Spark.Dsl.Entity.anno(entity) |
| 205 | + |
| 206 | + raise Spark.Error.DslError, |
| 207 | + message: "Invalid configuration for #{entity.name}", |
| 208 | + path: [:my_section, entity.name], |
| 209 | + location: location |
| 210 | + end |
| 211 | + end) |
| 212 | + |
| 213 | + {:ok, dsl_state} |
| 214 | + end |
| 215 | +end |
| 216 | +``` |
| 217 | + |
| 218 | +### IDE Integration and Language Servers |
| 219 | + |
| 220 | +Language servers can provide enhanced features using annotation data: |
| 221 | + |
| 222 | +```elixir |
| 223 | +defmodule MyLanguageServer do |
| 224 | + def find_definition(file, line, column) do |
| 225 | + # Find modules that might contain DSL at this location |
| 226 | + modules = find_modules_in_file(file) |
| 227 | + |
| 228 | + Enum.find_value(modules, fn module -> |
| 229 | + dsl_state = module.spark_dsl_config() |
| 230 | + |
| 231 | + # Check section annotations |
| 232 | + Enum.find_value(dsl_state, fn {path, config} -> |
| 233 | + if match_location?(config.section_anno, line) do |
| 234 | + {:section, path, config.section_anno} |
| 235 | + end |
| 236 | + end) || |
| 237 | + |
| 238 | + # Check entity annotations |
| 239 | + find_entity_at_location(dsl_state, line) |
| 240 | + end) |
| 241 | + end |
| 242 | +end |
| 243 | +``` |
| 244 | + |
| 245 | +### Debugging and Development Tools |
| 246 | + |
| 247 | +Create debugging utilities that show DSL source locations: |
| 248 | + |
| 249 | +```elixir |
| 250 | +defmodule MyLibrary.Debug do |
| 251 | + def inspect_dsl_sources(module) do |
| 252 | + dsl_state = module.spark_dsl_config() |
| 253 | + |
| 254 | + # Show all DSL elements with their locations |
| 255 | + Enum.each(dsl_state, fn {path, config} -> |
| 256 | + IO.puts("Section #{inspect(path)}:") |
| 257 | + |
| 258 | + if config.section_anno do |
| 259 | + print_location(" Section", config.section_anno) |
| 260 | + end |
| 261 | + |
| 262 | + # Show options |
| 263 | + Enum.each(config.opts_anno, fn {opt_name, anno} -> |
| 264 | + print_location(" Option #{opt_name}", anno) |
| 265 | + end) |
| 266 | + |
| 267 | + # Show entities |
| 268 | + Enum.each(config.entities, fn entity -> |
| 269 | + anno = Spark.Dsl.Entity.anno(entity) |
| 270 | + print_location(" Entity #{entity.name}", anno) |
| 271 | + end) |
| 272 | + end) |
| 273 | + end |
| 274 | + |
| 275 | + defp print_location(label, anno) |
| 276 | + defp print_location(label, nil), do: nil |
| 277 | + defp print_location(label, anno) do |
| 278 | + line = :erl_anno.location(anno) |
| 279 | + file = :erl_anno.file(anno) |> to_string() |> Path.relative_to_cwd() |
| 280 | + IO.puts(" #{label}: #{file}:#{line}") |
| 281 | + end |
| 282 | +end |
| 283 | +``` |
| 284 | + |
| 285 | +## Best Practices |
| 286 | + |
| 287 | +### 1. Always Include Location in DslErrors |
| 288 | + |
| 289 | +When creating DslErrors, include location information whenever available: |
| 290 | + |
| 291 | +```elixir |
| 292 | +# Get the appropriate annotation for your error context |
| 293 | +location = case error_type do |
| 294 | + :section_error -> |
| 295 | + Spark.Dsl.Transformer.get_section_anno(dsl_state, path) |
| 296 | + :option_error -> |
| 297 | + Spark.Dsl.Transformer.get_opt_anno(dsl_state, path, option_name) |
| 298 | + :entity_error -> |
| 299 | + entity = Enum.at(entities, entity_index) |
| 300 | + Spark.Dsl.Entity.anno(entity) |
| 301 | +end |
| 302 | + |
| 303 | +{:error, |
| 304 | + Spark.Error.DslError.exception( |
| 305 | + message: "Clear error description", |
| 306 | + path: path, |
| 307 | + module: module, |
| 308 | + location: location |
| 309 | + )} |
| 310 | +``` |
| 311 | + |
| 312 | +### 2. Handle Missing Annotations Gracefully |
| 313 | + |
| 314 | +Not all annotations may be available (e.g., programmatically generated DSL |
| 315 | +elements): |
| 316 | + |
| 317 | +```elixir |
| 318 | +location_info = if anno do |
| 319 | + line = :erl_anno.location(anno) |
| 320 | + file = :erl_anno.file(anno) |> to_string() |
| 321 | + " at #{file}:#{line}" |
| 322 | +else |
| 323 | + "" |
| 324 | +end |
| 325 | + |
| 326 | +IO.puts("Error in entity#{location_info}") |
| 327 | +``` |
| 328 | + |
| 329 | +### 3. Use Both Introspection and Anno Fields |
| 330 | + |
| 331 | +- **Use introspection functions** for universal access in verifiers and |
| 332 | + transformers |
| 333 | +- **Use anno fields** in entity structs for convenient access in application |
| 334 | + code |
| 335 | + |
| 336 | +### 4. Check OTP Version for End Location |
| 337 | + |
| 338 | +End location tracking requires OTP 28+: |
| 339 | + |
| 340 | +```elixir |
| 341 | +if function_exported?(:erl_anno, :end_location, 1) do |
| 342 | + end_location = :erl_anno.end_location(anno) |
| 343 | + # Use end location for precise span information |
| 344 | +end |
| 345 | +``` |
| 346 | + |
| 347 | +## Current Limitations |
| 348 | + |
| 349 | +- Column information is not currently tracked (only line numbers) |
| 350 | +- End Location is only tracked for OTP28+ |
| 351 | +- End Location is not available for multiline options |
0 commit comments