defmodule ExSwiftParser do @moduledoc """ Elixir wrapper for Swift AST parsing using swift-ast-explorer. This module provides functionality to parse Swift source code and extract its Abstract Syntax Tree (AST) in JSON format. It supports multiple Swift versions and can be used both as a library and as a command-line tool. ## Examples # Parse simple Swift code {:ok, result} = ExSwiftParser.parse("let x = 5", "5.10") # Access the AST ast = result["ast"] version = result["version"] ## Supported Swift Versions - `"5.8"` - Swift 5.8 parser - `"5.9"` - Swift 5.9 parser - `"5.10"` - Swift 5.10 parser """ @doc """ Parses the Swift `source` code using the specified `version`. Returns `{:ok, result}` on success where result contains the Swift version and parsed AST, or `{:error, reason}` on failure. ## Parameters - `source` - Swift source code as a string - `version` - Swift version to use for parsing ("5.8", "5.9", or "5.10") ## Examples iex> ExSwiftParser.parse("let x = 5", "5.10") {:ok, %{"version" => "5.10.0", "ast" => %{"kind" => "SourceFile", ...}}} iex> ExSwiftParser.parse("invalid swift", "5.10") {:error, :parse_error} iex> ExSwiftParser.parse("let x = 5", "invalid") {:error, :invalid_version} """ def parse(source, version) do with {:ok, exec} <- exec_for_version(version), {:ok, cmd} <- validate_exec(exec) do port = Port.open({:spawn_executable, cmd}, [:binary, :eof]) Port.command(port, source) result = port |> receive_output("") |> Jason.decode!() {:ok, %{"version" => result["swiftVersion"], "ast" => Jason.decode!(result["syntaxJSON"])}} else {:error, :enoent} -> {:error, :parser_not_found} {:error, reason} -> {:error, reason} end end defp exec_for_version(version) do case version do "5.8" -> {:ok, "parser_50800"} "5.9" -> {:ok, "parser_50900"} "5.10" -> {:ok, "parser_51000"} _ -> {:error, :invalid_version} end end defp validate_exec(exec) do path = Path.join([priv_dir(), "parsers", exec]) # Check if the file exists first, then try to read the link cond do File.exists?(path) -> case File.read_link(path) do {:ok, _} -> {:ok, path} {:error, :einval} -> # Not a symlink, but file exists - use it directly {:ok, path} err -> err end true -> {:error, :enoent} end end defp priv_dir do case :code.priv_dir(:ex_swift_parser) do {:error, :bad_name} -> # We're likely in an escript environment escript_priv_dir() priv_dir when is_list(priv_dir) -> List.to_string(priv_dir) end end defp escript_priv_dir do # In escript, we need to find the priv directory relative to the escript executable # Try multiple possible locations possible_paths = [ # Next to the escript executable case :escript.script_name() do :undefined -> case System.argv() |> List.first() do nil -> Path.join([File.cwd!(), "priv"]) path -> Path.join([Path.dirname(path), "priv"]) end script_name -> script_dir = script_name |> List.to_string() |> Path.dirname() Path.join([script_dir, "priv"]) end, # Current working directory Path.join([File.cwd!(), "priv"]), # Relative to the current file (development scenario) Path.join([__DIR__, "..", "..", "priv"]) ] # Return the first path that exists Enum.find(possible_paths, &File.exists?/1) || List.first(possible_paths) end defp receive_output(port, acc) do receive do {^port, {:data, data}} -> receive_output(port, acc <> data) {^port, :eof} -> Port.close(port) acc end end end defmodule ExSwiftParser.CLI do @moduledoc """ Command line interface for ExSwiftParser. This module is used when running as an escript. """ def main(args) do case args do [version, source] -> case ExSwiftParser.parse(source, version) do {:ok, result} -> IO.puts(Jason.encode!(result)) {:error, reason} -> IO.puts("Error: #{inspect(reason)}") System.halt(1) end _ -> IO.puts("Usage: ex_swift_parser ") IO.puts("Example: ex_swift_parser \"5.10\" \"let x = 5\"") System.halt(1) end end end