Skip to content

Commit 380f4da

Browse files
authored
feat: Record Source Annotations (#216)
* Record Source Annotations (#65) * Emit Location Info for Warnings * Spark Metadata for Entities * Remove Entity Anno outside Spark Metadata
1 parent 6bcf66e commit 380f4da

27 files changed

+1520
-175
lines changed
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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

documentation/how_to/writing-extensions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ Extension writing gets a bit more complicated when you get into the world of tra
1313
## Introspection
1414

1515
Use functions in `Spark.Dsl.Extension` to retrieve the stored values from the DSL and expose them in a module. The convention is to place functions for something like `MyApp.MyExtension` in `MyApp.MyExtension.Info`. Using introspection functions like this allows for a richer introspection API (i.e not just getting and retrieving raw values), and it also allows us to add type specs and documentation, which is helpful when working generically. I.e `module_as_variable.table()` can't be known by dialyzer, whereas `Extension.table(module)` can be.
16+
17+
## Source Annotations
18+
19+
Spark automatically tracks source location information for all DSL elements. This enables better error messages, IDE integration, and debugging capabilities. See [Using Source Annotations](use-source-annotations.md) for details on accessing and using this information in your extensions.

documentation/tutorials/get-started-with-spark.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ uses under the hood to allow users to write in the DSL syntax described above.
6060
```elixir
6161
defmodule MyLibrary.Validator.Dsl do
6262
defmodule Field do
63-
defstruct [:name, :type, :transform, :check]
63+
# The __spark_metadata__ field is required for Spark entities
64+
# It stores source location information for better error messages and tooling
65+
defstruct [:name, :type, :transform, :check, :__spark_metadata__]
6466
end
6567

6668
@field %Spark.Dsl.Entity{

lib/mix/tasks/spark.cheat_sheets_in_search.ex

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ if Code.ensure_loaded?(Jason) do
55
use Mix.Task
66

77
def run(opts) do
8-
IO.warn("""
9-
You should switch to using `Spark.Docs.search_data_for(dsl_module)` instead of this task.
8+
Spark.Warning.warn_deprecated(
9+
"mix spark.cheat_sheets_in_search task",
10+
"""
11+
You should switch to using `Spark.Docs.search_data_for(dsl_module)` instead of this task.
1012
11-
i.e
13+
i.e
1214
13-
{"documentation/dsls/DSL-Ash.Resource.md",
14-
search_data: Spark.Docs.search_data_for(Ash.Resource.Dsl)},
15-
""")
15+
{"documentation/dsls/DSL-Ash.Resource.md",
16+
search_data: Spark.Docs.search_data_for(Ash.Resource.Dsl)},
17+
"""
18+
)
1619

1720
Mix.Task.run("compile")
1821

0 commit comments

Comments
 (0)