elipl/tpl_lua.exs
2026-05-09 01:47:31 +02:00

467 lines
13 KiB
Elixir

Code.require_file("tpl.exs")
defmodule TplLua do
def transpile(ast_nodes) do
ast_nodes
|> Enum.map(&transpile_node(&1, 0))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n\n")
end
# Filters out noise tokens that should not appear as arguments or statements
defp relevant?(nil), do: false
defp relevant?(%{t: t}) when t in [:newline, :comment], do: false
defp relevant?(_), do: true
# Coalesces `fn arg... brace` sequences in a flat token list into fn sexp nodes.
# Needed when `fn` appears as an argument inside a larger call, e.g. `map "k" fn { ... }`.
defp coalesce_fns([]), do: []
defp coalesce_fns([%{t: :token, str: "fn"} | rest]) do
{fn_args, remainder} = Enum.split_while(rest, fn n -> n[:t] not in [:brace] end)
case remainder do
[brace | after_brace] ->
fn_node = %{t: :sexp, list: [%{t: :token, str: "fn"} | fn_args ++ [brace]]}
[fn_node | coalesce_fns(after_brace)]
[] ->
[%{t: :token, str: "fn"} | coalesce_fns(rest)]
end
end
defp coalesce_fns([head | rest]), do: [head | coalesce_fns(rest)]
defp transpile_node(node, indent) do
indent_str = String.duplicate(" ", indent)
case node do
# Bug 9: comment nodes → Lua -- comments
%{t: :comment, str: str} ->
str
|> String.split("\n")
|> Enum.map(&"#{indent_str}-- #{String.trim(&1)}")
|> Enum.join("\n")
# Bug 2 & 6: nil / noise / empty brace
nil -> ""
%{t: t} when t in [:newline, :dot] -> ""
# Bug 6: empty unnamed brace → empty Lua table
%{t: :brace, list: []} -> "{}"
%{t: :sexp, list: list} ->
transpile_sexp(list, indent)
%{t: :brace, list: list, name: %{str: "t"}} ->
transpile_table(list, indent)
%{t: :brace, list: list, name: %{str: "l"}} ->
transpile_list(list, indent)
%{t: :brace, list: list} ->
transpile_block(list, indent, :block)
%{t: :token, str: str} ->
str
%{t: :string, chunks: chunks, values: values} ->
transpile_string(chunks, values, indent)
other ->
" --[[ UNHANDLED NODE: #{inspect(other, pretty: true)} ]]--"
end
end
# Bug 7: non-brace fn body gets `return` + inner indentation
defp transpile_node(node, indent, context) do
case node do
%{t: :brace, list: list} ->
transpile_block(list, indent, context)
_ ->
body_str = transpile_node(node, indent)
case context do
:function_body ->
inner = String.duplicate(" ", indent + 1)
"#{inner}return #{body_str}"
_ ->
body_str
end
end
end
defp transpile_sexp(list, indent) do
indent_str = String.duplicate(" ", indent)
case list do
# Assignment: = lhs rhs
[%{str: "="}, %{t: :sexp, list: [%{str: "l"}, %{str: name}]}, rhs] ->
"local #{name} = #{transpile_node(rhs, indent)}"
[%{str: "="}, lhs, rhs] ->
"#{transpile_node(lhs, indent)} = #{transpile_node(rhs, indent)}"
# Bug 4: if form → if/then/end
[%{str: "if"} | rest] ->
filtered = Enum.filter(rest, &relevant?/1)
{cond_parts, [body]} = Enum.split(filtered, -1)
cond_str = cond_parts |> Enum.map(&transpile_node(&1, indent)) |> Enum.join(" ")
body_str = transpile_node(body, indent, :block)
"if #{cond_str} then\n#{body_str}\n#{indent_str}end"
# Bug 3: a : .method args → a.method(args)
[%{str: ":"}, lhs, %{t: :sexp, list: [%{str: "."}, nil, rhs]}] ->
lhs_str = transpile_node(lhs, indent)
case rhs do
%{t: :sexp, list: [method | args]} ->
method_str = transpile_node(method, indent)
arg_list = args |> Enum.filter(&relevant?/1) |> Enum.map(&transpile_node(&1, indent)) |> Enum.join(", ")
"#{lhs_str}.#{method_str}(#{arg_list})"
prop ->
"#{lhs_str}.#{transpile_node(prop, indent)}"
end
# Pipe: a : b c → a:b(c)
[%{str: ":"}, lhs, %{t: :sexp, list: [func | args]}] ->
lhs_str = transpile_node(lhs, indent)
func_str = transpile_node(func, indent)
# Bug 2: filter noise from args
arg_list = args |> Enum.filter(&relevant?/1) |> Enum.map(&transpile_node(&1, indent)) |> Enum.join(", ")
"#{lhs_str}:#{func_str}(#{arg_list})"
# Pipe: a : b → a:b()
[%{str: ":"}, lhs, rhs] ->
"#{transpile_node(lhs, indent)}:#{transpile_node(rhs, indent)}()"
# Dot access: . lhs rhs
[%{str: "."}, lhs, rhs] ->
"#{transpile_node(lhs, indent)}.#{transpile_node(rhs, indent)}"
# Anonymous function: fn args... body
[%{str: "fn"} | rest] ->
filtered = Enum.filter(rest, &relevant?/1)
{args, [body]} = Enum.split(filtered, -1)
arg_list = args |> Enum.map(&transpile_node(&1, indent)) |> Enum.join(", ")
body_str = transpile_node(body, indent, :function_body)
"function(#{arg_list})\n#{body_str}\n#{indent_str}end"
# For loop: for i v in (ipairs arr) { body }
[%{str: "for"} | rest] ->
filtered = Enum.filter(rest, &relevant?/1)
{vars_and_in, [body_node]} = Enum.split(filtered, -1)
{vars, [%{str: "in"}, call_node]} = Enum.split(vars_and_in, -2)
var_list = vars |> Enum.map(&transpile_node(&1, indent)) |> Enum.join(", ")
body_str = transpile_node(body_node, indent, :block)
"for #{var_list} in #{transpile_node(call_node, indent)} do\n#{body_str}\n#{indent_str}end"
# Function call: func arg1 arg2 ...
[func | args] ->
func_str = transpile_node(func, indent)
arg_list =
args
|> Enum.filter(&relevant?/1)
|> coalesce_fns()
|> Enum.map(&transpile_node(&1, indent))
|> Enum.join(", ")
"#{func_str}(#{arg_list})"
[] ->
"()"
end
end
# True when a node is a block-level statement (if/for/while) that cannot be returned
defp block_stmt?(%{t: :sexp, list: [%{str: kw} | _]}) when kw in ["if", "for", "while"],
do: true
defp block_stmt?(_), do: false
defp transpile_block(list, indent, context) do
inner = String.duplicate(" ", indent + 1)
statements =
list
|> Enum.map(&transpile_node(&1, indent + 1))
|> Enum.reject(&(&1 == ""))
|> Enum.map(&"#{inner}#{&1}")
case context do
# Bug 8: don't prepend `return` to if/for/while
:function_body ->
last_node = list |> Enum.filter(&relevant?/1) |> List.last()
if last_node == nil or block_stmt?(last_node) do
statements |> Enum.join("\n")
else
case statements do
[] ->
""
_ ->
{body, [last]} = Enum.split(statements, -1)
return_last = String.replace(last, ~r/^\s*/, "#{inner}return ", global: false)
(body ++ [return_last]) |> Enum.join("\n")
end
end
_ ->
statements |> Enum.join("\n")
end
end
defp transpile_list(list, indent) do
if list == [] do
"{}"
else
inner = String.duplicate(" ", indent + 1)
elements = list |> Enum.map(&"#{inner}#{transpile_node(&1, indent + 1)}") |> Enum.join(",\n")
"{\n#{elements}\n#{String.duplicate(" ", indent)}}"
end
end
defp transpile_table(list, indent) do
if list == [] do
"{}"
else
inner = String.duplicate(" ", indent + 1)
pairs =
list
|> Enum.flat_map(fn
%{t: :sexp, list: kv_list} ->
kv_list
|> Enum.filter(&relevant?/1)
|> Enum.chunk_every(2)
|> Enum.map(fn
[k, v] -> "#{inner}#{transpile_node(k, indent + 1)} = #{transpile_node(v, indent + 1)}"
[k] -> "#{inner}#{transpile_node(k, indent + 1)} = true"
end)
other ->
["#{inner}--[[ INVALID TABLE ENTRY: #{inspect(other)} ]]--"]
end)
|> Enum.join(",\n")
"{\n#{pairs}\n#{String.duplicate(" ", indent)}}"
end
end
# Bug 5: unwrap brace so interpolated values aren't treated as indented blocks
defp transpile_interp_value(%{t: :brace, list: [single]}, indent),
do: transpile_node(single, indent)
defp transpile_interp_value(%{t: :brace, list: list}, indent) when list != [],
do: transpile_node(%{t: :sexp, list: list}, indent)
defp transpile_interp_value(other, indent),
do: transpile_node(other, indent)
defp transpile_string(chunks, values, indent) do
if values == [] do
"\"#{Enum.at(chunks, 0, "")}\""
else
parts =
Enum.zip(chunks, values)
|> Enum.flat_map(fn {chunk, value} ->
["\"#{chunk}\"", "tostring(#{transpile_interp_value(value, indent)})"]
end)
final_parts =
if length(chunks) > length(values),
do: parts ++ ["\"#{List.last(chunks)}\""],
else: parts
final_parts
|> Enum.reject(&(&1 == "\"\""))
|> Enum.join(" .. ")
end
end
end
defmodule TplLua.CLI do
@moduledoc """
Provides a command-line interface for transpiling `.lua.tpl` files.
"""
@doc """
Transpiles `.lua.tpl` source files or directories into `.lua` files.
This function can be used in two ways:
1. **Directory to Directory**:
`run(input_dir, output_dir)`
Recursively finds all `*.lua.tpl` files in `input_dir`, transpiles them,
and writes the output `.lua` files to `output_dir`, preserving the
directory structure.
2. **File to File**:
`run(input_file, output_file)`
Transpiles a single `input_file` and writes the result to `output_file`.
Returns `:ok` on success, or `{:error, reason}` on failure.
"""
def run(input_path, output_path) do
cond do
File.dir?(input_path) ->
transpile_directory(input_path, output_path)
File.regular?(input_path) ->
transpile_single_file(input_path, output_path)
true ->
{:error, "Input path not found: #{input_path}"}
end
end
defp transpile_directory(input_dir, output_dir) do
input_dir = Path.expand(input_dir)
output_dir = Path.expand(output_dir)
File.mkdir_p!(output_dir)
input_dir
|> Path.join("**/*.lua.tpl")
|> Path.wildcard()
|> Enum.each(fn source_file ->
relative_path = String.replace(source_file, input_dir <> "/", "")
dest_file = Path.join(output_dir, relative_path) |> String.replace(".lua.tpl", ".lua")
File.mkdir_p!(Path.dirname(dest_file))
transpile_file(source_file, dest_file)
end)
:ok
end
defp transpile_single_file(input_file, output_path) do
dest_file =
if File.dir?(output_path) do
# If output is a directory, create file with same name inside it
Path.join(output_path, Path.basename(input_file)) |> String.replace(".lua.tpl", ".lua")
else
# Otherwise, use the exact output path
output_path
end
File.mkdir_p!(Path.dirname(dest_file))
transpile_file(input_file, dest_file)
:ok
end
defp transpile_file(source, dest) do
IO.puts("Transpiling #{source} -> #{dest}")
source
|> File.read!()
|> Tpl.to_ast()
|> TplLua.transpile()
|> then(&File.write!(dest, &1))
end
end
example =
"""
---
on_ff = fn a b c (vim.lsp.buf.format)
on_ff = fn a b c {
(vim.lsp.buf.format)
}
---
map "n" "<leader>ff" (fn a b c (vim.lsp.buf.format)) t{ desc "Format code" }
---
l asd = fn x y z {
print "Hello, World!"
}
---
player.add_hook = fn self event data {
self:process_event event data
}
---
arr_map = fn arr fun {
l result = {}
for i v in (ipairs arr) {
table.insert result (fun v)
}
result
}
---
day = fetch_day db
name = fetch_name user_id
greeting = "Hello, {name}! Today is {day}."
---
asd = fn {print "Hello"}
---
asd = t{ key1 "value1" key2 "value2" }
---
map "n" "<leader>stf" fn {
l timer_minutes = (choose_timer)
if not timer_minutes {
print "No timer selected"
}
require "grug-far"
: .open t{
transient true
prefills t{
timers (timer_minutes : at 1 : tostring)
}
}
}
"""
# Keep the interactive example runner for development
example
|> String.split("---\n")
|> Enum.each(fn input ->
IO.puts("===================== Input ====")
IO.puts(input)
IO.puts("==== Parsed ====")
state =
input
|> Tpl.chunk()
# |> IO.inspect()
# |> print_chunkd()
chunks = Map.get(state, :program_output)
IO.puts("==== AST ====")
lua_code =
chunks
|> Tpl.chunks_to_ast()
|> TplLua.transpile()
IO.puts("\n==== Transpiled Lua ====")
lua_code
|> IO.puts()
IO.puts("\n\n")
end)
# --- Example usage for the new CLI ---
#
# To run this, you could create some directories and files:
#
# mkdir -p lua_tpl/plugins
# echo 'l my_plugin = fn { print "plugin loaded" }' > lua_tpl/plugins/my_plugin.lua.tpl
# echo 'l main = fn { print "init file" }' > lua_tpl/init.lua.tpl
#
# Then, you could uncomment and run the following line in iex or from the script:
#
# TplLua.CLI.run("lua_tpl", "lua_output")
#
# This would produce:
# lua_output/plugins/my_plugin.lua
# lua_output/init.lua
TplLua.CLI.run("lua_tpl", "lua_output")