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