Current section
Files
Jump to
Current section
Files
lib/ccxt/generator/spec_loader.ex
defmodule CCXT.Generator.SpecLoader do
@moduledoc """
Compile-time spec loading and resolution for the generator.
This module handles finding, loading, and validating exchange spec files
at compile time. It's used internally by `CCXT.Generator`.
"""
alias CCXT.Spec
alias CCXT.Spec.Merger
alias CCXT.Spec.Validator
@doc """
Resolves the path to a spec file.
Checks curated specs first (hand-written Certified Pro), then extracted specs.
## Parameters
- `spec_id` - Exchange ID (e.g., "bybit")
- `spec_path` - Full path override (optional)
## Returns
Absolute path to the spec file.
"""
@spec resolve_spec_path(String.t() | nil, String.t() | nil) :: String.t()
def resolve_spec_path(spec_id, spec_path) do
cond do
spec_path ->
Path.expand(spec_path)
spec_id ->
priv_dir = find_priv_dir()
# Check curated/ first (hand-written Certified Pro specs), then extracted/ (auto-generated)
curated_path = Path.join([priv_dir, "specs", "curated", "#{spec_id}.exs"])
extracted_path = Path.join([priv_dir, "specs", "extracted", "#{spec_id}.exs"])
cond do
File.exists?(curated_path) -> curated_path
File.exists?(extracted_path) -> extracted_path
true -> curated_path
end
true ->
raise ArgumentError, "CCXT.Generator requires either :spec or :spec_path option"
end
end
@doc """
Loads and validates a spec from the given path.
Raises `CompileError` if the file doesn't exist or validation fails.
"""
@spec load_and_validate_spec!(String.t()) :: Spec.t()
def load_and_validate_spec!(path) do
if !File.exists?(path) do
raise CompileError,
description: "Spec file not found: #{path}",
file: path,
line: 1
end
spec = Spec.load!(path)
Validator.validate!(spec, path)
spec
end
@doc """
Loads a spec with curated overlay merge support.
When both curated and extracted specs exist for a given `spec_id`:
- If the curated spec has `_curated_overrides: true`, the extracted spec is
loaded as a base and curated fields are deep-merged on top.
- Otherwise, winner-take-all: curated spec wins entirely.
When only one exists, it is loaded directly.
If `spec_path` is given, it bypasses merge and loads that single file.
## Parameters
- `spec_id` - Exchange ID (e.g., "bybit"), or nil
- `spec_path` - Full path override (optional), or nil
- `opts` - Options:
- `:priv_dir` - Override priv directory (for testing)
## Returns
`{spec, resource_paths}` where `resource_paths` is the list of loaded file paths
(for `@external_resource` registration).
"""
# sobelow_skip ["RCE.CodeModule"]
@spec load_spec_with_merge!(String.t() | nil, String.t() | nil, keyword()) ::
{Spec.t(), [String.t()]}
def load_spec_with_merge!(spec_id, spec_path, opts \\ [])
def load_spec_with_merge!(_spec_id, spec_path, _opts) when is_binary(spec_path) do
path = Path.expand(spec_path)
spec = load_and_validate_spec!(path)
{spec, [path]}
end
def load_spec_with_merge!(spec_id, nil, opts) when is_binary(spec_id) do
priv_dir = opts[:priv_dir] || find_priv_dir()
curated_path = Path.join([priv_dir, "specs", "curated", "#{spec_id}.exs"])
extracted_path = Path.join([priv_dir, "specs", "extracted", "#{spec_id}.exs"])
curated_exists? = File.exists?(curated_path)
extracted_exists? = File.exists?(extracted_path)
cond do
curated_exists? and extracted_exists? ->
load_with_possible_merge(curated_path, extracted_path)
curated_exists? ->
load_curated_only!(curated_path)
extracted_exists? ->
spec = load_and_validate_spec!(extracted_path)
{spec, [extracted_path]}
true ->
raise CompileError,
description:
"No spec file found for exchange '#{spec_id}'. " <>
"Expected at #{curated_path} or #{extracted_path}",
file: curated_path,
line: 1
end
end
def load_spec_with_merge!(nil, nil, _opts) do
raise ArgumentError, "CCXT.Generator requires either :spec or :spec_path option"
end
@doc false
# sobelow_skip ["RCE.CodeModule"]
defp load_with_possible_merge(curated_path, extracted_path) do
{curated_raw, _} = Code.eval_file(curated_path)
if Merger.overlay?(curated_raw) do
{extracted_raw, _} = Code.eval_file(extracted_path)
merged = Merger.merge(extracted_raw, curated_raw)
spec = Spec.from_raw_map!(merged)
Validator.validate!(spec, curated_path)
{spec, [curated_path, extracted_path]}
else
# Winner-take-all: curated only
spec = load_and_validate_spec!(curated_path)
{spec, [curated_path]}
end
end
@doc false
# sobelow_skip ["RCE.CodeModule"]
defp load_curated_only!(curated_path) do
{raw, _} = Code.eval_file(curated_path)
if Merger.overlay?(raw) do
raise CompileError,
description:
"Curated spec at #{curated_path} has _curated_overrides: true " <>
"but no extracted spec exists to merge with. " <>
"Remove _curated_overrides or provide an extracted spec.",
file: curated_path,
line: 1
end
spec = Spec.from_raw_map!(raw)
Validator.validate!(spec, curated_path)
{spec, [curated_path]}
end
@doc """
Finds the priv directory for the ccxt_ex library.
Works both during development (relative paths) and when installed as a dependency.
Delegates to `CCXT.Priv.dir/0` which handles the fallback for compile-time resolution.
Note: This is safe to call `CCXT.Priv.dir/0` here because this function is invoked
at runtime during macro expansion (when `use CCXT.Generator` is processed), not as
a module attribute. The distinction matters because module attributes create
compile-time dependencies that cause cascading recompilation.
"""
@spec find_priv_dir() :: String.t()
def find_priv_dir do
CCXT.Priv.dir()
end
end