From 8d33ae01729450bff2bd352784e2390bbc125f8b Mon Sep 17 00:00:00 2001 From: Kacper Marzecki Date: Sat, 9 May 2026 01:47:31 +0200 Subject: [PATCH] add new parser script --- tpl.exs | 1165 ++++++++++++++++++++++++++++++++++++++++++++++++ tpl_json.exs | 66 +++ tpl_lua.exs | 466 +++++++++++++++++++ tpl_prolog.exs | 251 +++++++++++ 4 files changed, 1948 insertions(+) create mode 100644 tpl.exs create mode 100644 tpl_json.exs create mode 100644 tpl_lua.exs create mode 100644 tpl_prolog.exs diff --git a/tpl.exs b/tpl.exs new file mode 100644 index 0000000..80f51f7 --- /dev/null +++ b/tpl.exs @@ -0,0 +1,1165 @@ +Mix.install([:jason]) + +Code.require_file("casex.exs") + +defmodule Tpl do + # improved case with multiple patterns and guards + import Casex + @newline "\n" |> to_charlist() |> hd() + @tab "\t" |> to_charlist() |> hd() + @space " " |> to_charlist() |> hd() + @lparen "(" |> to_charlist() |> hd() + @rparen ")" |> to_charlist() |> hd() + @lbrace "{" |> to_charlist() |> hd() + @rbrace "}" |> to_charlist() |> hd() + @dquote "\"" |> to_charlist() |> hd() + @comma "," |> to_charlist() |> hd() + @dot "." |> to_charlist() |> hd() + @semi ";" |> to_charlist() |> hd() + + def ch(str), do: str |> to_charlist() |> hd() + + ##### DEBUGGING HELPERS ##### + + def print_chunkd(%{program_output: output} = state) do + print_chunkd(output) + state + end + + def print_chunkd(ast, indent \\ 0) do + # pretty print state.program_output here, like a tree + # recursively, with `|` for keeping track of levels + # like so: + # S-expression: + # | Token: a + # | Token: b + # Brace block: + # | Token: x + # | Token: y + # | S-expression: + # | | S-expression: + # | | | Token: a + # | Token: b + # + # ! note the `|` pipe used for levels + + Enum.each(ast, fn item -> + print_item(item, indent) + end) + + ast + end + + def print_item(%{t: :sexp, list: list}, indent) do + IO.puts(String.duplicate("| ", indent) <> "S-expression:") + Enum.each(list, fn item -> print_item(item, indent + 1) end) + end + + def print_item(%{t: :brace, list: list, name: %{str: name, t: :token}}, indent) do + IO.puts(String.duplicate("| ", indent) <> "Brace block: (#{name})") + Enum.each(list, fn item -> print_item(item, indent + 1) end) + end + + def print_item(%{t: :brace, list: list}, indent) do + IO.puts(String.duplicate("| ", indent) <> "Brace block:") + Enum.each(list, fn item -> print_item(item, indent + 1) end) + end + + def print_item(%{t: :token, str: str}, indent) do + IO.puts(String.duplicate("| ", indent) <> "Token: #{str}") + end + + def print_item(%{t: :string, chunks: chunks, values: values}, indent) do + IO.puts(String.duplicate("| ", indent) <> "String:") + max_len = max(length(chunks), length(values)) + + Enum.each(0..(max_len - 1), fn i -> + if i < length(chunks) do + chunk = Enum.at(chunks, i) + IO.puts(String.duplicate("| ", indent + 1) <> "Chunk: \"#{chunk}\"") + end + + if i < length(values) do + value = Enum.at(values, i) + IO.puts(String.duplicate("| ", indent + 1) <> "Value:") + print_item(value, indent + 2) + end + end) + end + + def print_item(%{t: :comment, str: str}, indent) do + IO.puts(String.duplicate("| ", indent) <> "Comment: #{str}") + end + + def print_item(%{t: :comma}, indent) do + IO.puts(String.duplicate("| ", indent) <> "Comma") + end + + def print_item(%{t: :newline} = _other, indent) do + IO.puts(String.duplicate("| ", indent) <> "Newline") + end + + def print_item(%{t: :dot} = _other, indent) do + IO.puts(String.duplicate("| ", indent) <> "Dot access") + end + + def print_item(nil, indent) do + IO.puts(String.duplicate("| ", indent) <> "Unknown (maybe piped?)") + end + + def print_item(other, indent) do + IO.puts(String.duplicate("| ", indent) <> "Unknown item: #{inspect(other)}") + end + + ##### END DEBUGGING HELPERS ##### + + def add_offset(state, o) do + %{state | offset: state.offset + o} + end + + def drop_input(state, n) do + %{state | input: Enum.drop(state.input, n)} + end + + # Adds a character to the current expression being built + def add_to_exp(%{exp: exp} = state, char) do + exp = %{exp | chars: [char | exp.chars]} + %{state | exp: exp} + end + + # Starts a new expression + def add_to_exp(state, char) do + Map.put(state, :exp, %{start: state.offset, chars: [char]}) + end + + # Adds a character to the current string chunk being built + def add_to_exp(%{exp: %{t: :string} = exp} = state, char, type) when type == :string do + exp = %{exp | current_chunk_chars: [char | exp.current_chunk_chars]} + %{state | exp: exp} + end + + def add_to_exp(%{exp: %{t: :comment} = exp} = state, char, type) when type == :comment do + exp = %{exp | chars: [char | exp.chars]} + %{state | exp: exp} + end + + # Starts a new string expression + def add_to_exp(state, char, type) when type == :comment do + Map.put(state, :exp, %{ + start: state.offset, + t: :comment, + chars: [char] + }) + end + + # adds a exp to output + def add_single_token(state, str, type \\ :token) do + comma_exp = %{ + start: state.offset, + end: state.offset + 1, + t: type, + str: str + } + + Map.update!(state, :output, fn r -> [comma_exp | r] end) + end + + # Finalizes the current expression and adds it to the current output list + def end_exp(%{exp: %{t: :string} = exp} = state) do + # Finalize the last chunk if it exists + # + exp = + if exp.current_chunk_chars && exp.current_chunk_chars != [] do + chunk_str = exp.current_chunk_chars |> Enum.reverse() |> to_string + + %{exp | chunks: [chunk_str | exp.chunks], current_chunk_chars: []} + else + exp + end + + # Reverse chunks and values to maintain original order + str_exp = %{exp | chunks: Enum.reverse(exp.chunks), values: Enum.reverse(exp.values)} + str_exp = str_exp |> Map.put(:end, state.offset) |> Map.delete(:current_chunk_chars) + + state + |> Map.update!(:output, fn r -> [str_exp | r] end) + |> Map.delete(:exp) + end + + def end_exp(%{exp: %{t: :comment} = exp} = state) do + str = exp.chars |> Enum.reverse() |> to_string + + exp = + exp + |> Map.put(:str, str) + |> Map.put(:t, :comment) + |> Map.put(:end, state.offset) + |> Map.delete(:chars) + + state + |> Map.update!(:output, fn r -> [exp | r] end) + |> Map.delete(:exp) + end + + def end_exp(%{exp: exp} = state) do + str = exp.chars |> Enum.reverse() |> to_string + + exp = + exp + |> Map.put(:str, str) + |> Map.put(:t, :token) + |> Map.put(:end, state.offset) + |> Map.delete(:chars) + + state + |> Map.update!(:output, fn r -> [exp | r] end) + |> Map.delete(:exp) + end + + # If no expression is active, do nothing. + def end_exp(state), do: state + + # Finalizes the current expression if one exists + def end_exp_if_exists(state) do + if Map.has_key?(state, :exp), do: end_exp(state), else: state + end + + # Reverses the final program_output list + def end_program_output_list(%{output: output} = state) do + %{state | program_output: output |> hd() |> Map.get(:list)} + end + + # ends a token and puts it as `:name` in the new brace block + def start_named_brace(state) do + state = end_exp_if_exists(state) + [name_token | rest_output] = state.output + # Push current output onto stack and start a new empty output list for the sublist + # and put th e name_token as :name in the new brace block + %{ + state + | sublist_stack: [%{t: :brace, output: rest_output, name: name_token} | state.sublist_stack], + output: [] + } + end + + # Starts a new sublist (for parentheses or braces) + def start_sublist(state, type) do + # Finalize any current token before starting a sublist + state = end_exp_if_exists(state) + # Push current output onto stack and start a new empty output list for the sublist + %{state | sublist_stack: [%{t: type, output: state.output} | state.sublist_stack], output: []} + end + + # Ends the current sublist and adds it to the parent list + # def end_sublist(%{sublist_stack: [%{t: :string_interp} | _sublist_stack]} = _state) do + # # This function should not be called for :string_interp directly. + # # String interpolation end is handled specially in the chunk function. + # raise "end_sublist should not be called for :string_interp directly" + # end + + def end_paren(state) do + state = end_exp_if_exists(state) + # Pop the parent output list from the stack + [%{t: :paren, output: parent_output} | sublist_stack] = state.sublist_stack + # Reverse the current output (which is the paren sublist) and add it to the parent output + paren_sublist = Enum.reverse(state.output) + + %{ + state + | sublist_stack: sublist_stack, + output: [%{t: :sexp, list: paren_sublist} | parent_output] + } + end + + def end_brace(state) do + state = end_exp_if_exists(state) + # Pop the parent output list from the stack + [%{t: :brace, output: parent_output} = brace | sublist_stack] = state.sublist_stack + # Reverse the current output (which is the brace sublist) and add it to the parent output + brace_sublist = Enum.reverse(state.output) + + brace = + Map.merge( + %{t: :brace, list: brace_sublist}, + if(Map.has_key?(brace, :name), do: %{name: brace.name}, else: %{}) + ) + + %{ + state + | sublist_stack: sublist_stack, + output: [brace | parent_output] + } + end + + # def end_implicit_sexp(state) do + # state = end_exp_if_exists(state) + # # Pop the parent output list from the stack + # [%{t: :implicit_sexp, output: parent_output} | sublist_stack] = state.sublist_stack + # # Reverse the current output (which is the implicit sexp) and add it to the parent output + # sexp = Enum.reverse(state.output) + # # if the only thing in implicit_sexp is a sexp , we add that directly to parent_output + # sexp = + # case sexp do + # [%{t: :token} = token] -> token + # [%{t: :string} = string] -> string + # # [%{t: :sexp, list: [_]} = sexp] -> sexp + # [%{t: :sexp} = sexp] -> sexp + # [] -> [] + # values -> %{t: :sexp, list: values} + # end + # + # %{ + # state + # | sublist_stack: sublist_stack, + # output: + # case sexp do + # [] -> parent_output + # _ -> [sexp | parent_output] + # end + # } + # end + # + # def maybe_end_implicit_sexp(%{sublist_stack: [%{t: :implicit_sexp} | _]} = state) do + # end_implicit_sexp(state) + # end + # + # def maybe_end_implicit_sexp(state), do: state + + def end_interpolation(state) do + state = end_exp_if_exists(state) + # Pop the parent output list from the stack + [%{t: :string_interp, exp: string_exp, output: parent_output} | sublist_stack] = + state.sublist_stack + + [brace] = state.output + # The interpolated value is the entire current output list + # interpolated_value = %{t: :sexp, list: state.output |> Enum.reverse()} + + # IO.inspect("Interpolated value: #{inspect(interpolated_value)}") + + updated_string_exp = %{ + string_exp + | values: [brace | string_exp.values] + } + + Map.merge(state, %{ + sublist_stack: sublist_stack, + exp: updated_string_exp, + # Restore parent output + output: parent_output + }) + end + + # Helper to finalize a top-level line (current output) and add it to program_output + defp finalize_top_level_line(state) do + # Ensure any pending exp is closed + state = end_exp_if_exists(state) + + # Reverse the current line's tokens/sublists and add to program_output + line_content = Enum.reverse(state.output) + + new_program_output = + case line_content do + [] -> + state.program_output + + _ -> + # The line content is already wrapped in an implicit_sexp, + # which becomes a regular sexp when the sublist is ended. + # So we just prepend it. + line_content ++ state.program_output + end + + # Reset output for the next line + %{state | program_output: new_program_output, output: []} + end + + def debug_state(state) do + IO.puts("----- -----") + + case state.input do + [] -> + IO.puts("CONSUMED ALL INPUT") + IO.puts(state.start_input |> to_string()) + + input -> + state.start_input + |> String.trim_trailing(input |> to_string()) + |> IO.inspect(label: "SO FAR") + end + + IO.inspect(state.mode |> Enum.reverse(), limit: :infinity, label: "MODE") + + Enum.at(state.input, 0) + |> List.wrap() + |> to_string + |> IO.inspect(label: "NEXT CHAR", charlists: :as_lists) + + state + end + + # Stage 1 - chunk into lists, tokens, strings, comments, newlines + def chunk(str) when is_binary(str) do + input = str |> String.to_charlist() + + %{ + offset: -1, + # Accumulates finalized lines + program_output: [], + # Accumulates tokens/sublists for the current line/sublist + output: [], + sublist_stack: [], + input: input, + mode: [], + start_input: str + } + |> start_sublist(:brace) + |> chunk(input, [:brace]) + # |> chunk + |> end_brace() + |> end_program_output_list() + end + + def chunk(%{input: input, mode: mode} = state) do + # debug_state(state) + if mode == [], do: raise("Mode stack empty!") + + casex {input, mode} do + # comments + {[char | rest], _} when char == @semi -> + {state, _} = + Enum.reduce_while( + rest, + # state, mode (either :comment or :after_comment) + {state |> end_exp_if_exists() |> drop_input(1), :comment}, + fn + @newline, {st, :comment} -> + st = st |> add_offset(1) |> drop_input(1) + {:cont, {st, :after_comment}} + + c, {st, :comment} -> + st = st |> add_to_exp(c, :comment) |> add_offset(1) |> drop_input(1) + {:cont, {st, :comment}} + + c, {st, :after_comment} when c in [@space, @tab] -> + st = st |> add_offset(1) |> drop_input(1) + {:cont, {st, :after_comment}} + + @semi, {st, :after_comment} -> + st = st |> add_to_exp(@newline, :comment) |> add_offset(1) |> drop_input(1) + {:cont, {st, :comment}} + + _c, {st, :after_comment} -> + # finalize comment exp + st = st |> end_exp_if_exists() + {:halt, {st, nil}} + end + ) + + state |> chunk(state.input, mode) + + {[char | rest], [:token | [:paren | prev_mode]]}, {[char | rest], [:paren | prev_mode]} + when char == @rparen -> + state + |> end_exp_if_exists() + |> add_offset(1) + |> end_paren() + |> chunk(rest, prev_mode) + + # Handle opening brace inside a string (interpolation) + {[char | rest], [:string | _prev_mode] = mode} when char == @lbrace -> + state = + state + # Finalize the current string chunk before starting interpolation + |> (fn s -> + chunk_str = s.exp.current_chunk_chars |> Enum.reverse() |> to_string + %{s | exp: %{s.exp | chunks: [chunk_str | s.exp.chunks], current_chunk_chars: []}} + end).() + + state + |> add_offset(1) + # Push the current string expression AND output onto the stack + |> Map.update!(:sublist_stack, fn stack -> + [%{t: :string_interp, exp: state.exp, output: state.output} | stack] + end) + # Clear current exp and output to chunk the interpolated expression + |> Map.put(:output, []) + |> Map.delete(:exp) + |> start_sublist(:brace) + # Enter top mode to chunk the expression + |> chunk(rest, [:brace | mode]) + + {[char | rest], [m | _] = mode} when char == @dot and m in [:token, :brace, :paren] -> + state + |> end_exp_if_exists() + |> add_offset(1) + |> add_single_token(".", :dot) + |> chunk(rest, mode) + + {[char | rest], [:token | [:brace | _] = mode]}, {[char | rest], [:brace | _] = mode} + when char == @comma -> + # commas separate implicit sexps + state + |> end_exp_if_exists() + |> add_offset(1) + |> add_single_token(",", :comma) + |> chunk(rest, mode) + + # Handle closing brace inside string interpolation (with active token) + # Handle closing brace inside string interpolation (no active token) + {[char | rest], [:token, :brace | [:string | _prev_mode] = string_mode] = mode}, + {[char | rest], [:brace | [:string | _prev_mode] = string_mode]}, + guard([char == @rbrace]) -> + state + # Finalize the interpolated expression + |> end_exp_if_exists() + |> add_offset(1) + |> end_brace() + # Pop the string expression from the stack + |> end_interpolation() + # Return to string mode + |> chunk(rest, string_mode) + + # handle closing brace when in :implicit_sexp in brace, token and top + {[char | rest], [:token | [:brace | prev_mode]]}, {[char | rest], [:brace | prev_mode]} + when char == @rbrace -> + state + |> end_exp_if_exists() + |> add_offset(1) + |> end_brace() + |> chunk(rest, prev_mode) + + # Handle opening parenthesis, entering paren mode + {[char | rest], _} when char == @lparen -> + state + # Finalize any current token before starting a sublist + |> end_exp_if_exists() + |> add_offset(1) + |> start_sublist(:paren) + |> chunk(rest, [:paren | mode]) + + # Handle NAMED Brace opening + {[char | rest], [:token | _prev_mode]} when char == @lbrace -> + state + # Finalize any current token before starting a sublist + |> end_exp() + |> add_offset(1) + |> start_named_brace() + |> chunk(rest, [:brace | mode]) + + # Handle opening brace, entering brace mode + {[char | rest], _} when char == @lbrace -> + state + # Finalize any current token before starting a sublist + |> end_exp_if_exists() + |> add_offset(1) + |> start_sublist(:brace) + |> chunk(rest, [:brace | mode]) + + # Handle closing double quote, exiting string mode + {[char | rest], [:string | prev_mode]} when char == @dquote -> + state + |> add_offset(1) + # Finalize the string expression + |> end_exp() + |> chunk(rest, prev_mode) + + # Handle opening double quote, entering string mode + {[char | rest], _} when char == @dquote -> + state + # Finalize any preceding token + |> end_exp_if_exists() + |> add_offset(1) + |> Map.put(:exp, %{ + start: state.offset, + t: :string, + chunks: [], + values: [], + current_chunk_chars: [] + }) + |> chunk(rest, [:string | mode]) + + # # Newline after a token in a top-level implicit sexp. Ends the line. + # {[@newline | rest], [:token | [:top | _] = prev_mode]} -> + # state + # |> end_exp() + # |> add_offset(1) + # |> chunk(rest, prev_mode) + + # Newline after a token in a brace's implicit sexp. Ends the sexp, not the line. + {[@newline | rest], [:token | [:brace | _] = prev_mode]} -> + state + |> end_exp() + |> add_offset(1) + |> add_single_token(@newline, :newline) + |> chunk(rest, prev_mode) + + # Newline after a token in a paren. Just whitespace. + {[@newline | rest], [:token | [:paren | _] = prev_mode]} -> + state + |> end_exp() + |> add_offset(1) + |> add_single_token(@newline, :newline) + |> chunk(rest, prev_mode) + + {[@newline | rest], [:brace | _] = mode} -> + state + |> end_exp() + |> add_offset(1) + |> add_single_token(@newline, :newline) + |> chunk(rest, mode) + + # # Newline on its own in a top-level implicit sexp. Ends the line. + # {[@newline | rest], [:implicit_sexp, :top | _] = mode} -> + # state + # |> add_offset(1) + # |> end_implicit_sexp() + # |> finalize_top_level_line() + # |> chunk(rest, Enum.drop(mode, 1)) + # + # # Newline on its own in a brace's implicit sexp. Ends the sexp. + # {[@newline | rest], [:implicit_sexp, :brace | _] = mode} -> + # state + # |> add_offset(1) + # |> end_implicit_sexp() + # |> chunk(rest, Enum.drop(mode, 1)) + + # Newline on an empty top-level line. + # {[@newline | rest], [:top] = mode} -> + # state + # |> add_offset(1) + # |> finalize_top_level_line() + # |> chunk(rest, mode) + + # Newline as whitespace in other contexts (paren, brace). + {[@newline | rest], _mode} -> + add_offset(state, 1) + |> chunk(rest, mode) + + # Whitespace ending a token + {[end_token | rest], [:token | prev_mode]} when end_token in [@tab, @space] -> + state + |> add_offset(1) + |> end_exp() + |> chunk(rest, prev_mode) + + # Characters into current string chunk (including newlines and some special chars) + {[char | rest], [:string | _]} -> + add_offset(state, 1) + |> add_to_exp(char, :string) + |> chunk(rest, mode) + + # Whitespace (not ending a token) + {[whitespace | rest], _} when whitespace in [@tab, @space] -> + add_offset(state, 1) + |> chunk(rest, mode) + + # Characters into current token + {[char | rest], [:token | _]} -> + add_offset(state, 1) + |> add_to_exp(char) + |> chunk(rest, mode) + + # A new token (from :top, :paren, :brace, or :implicit_sexp mode) + {[char | rest], [current_mode | _] = mode} + when current_mode in [:paren, :brace] and + char not in [@tab, @space, @newline, @dquote, @rbrace, @rparen, @lbrace, @lparen] -> + state = + state + |> end_exp_if_exists() + |> add_offset(1) + + # If in :top or :brace, start an implicit s-expression for the line + # {state, mode} = + # if current_mode in [:top, :brace] do + # {start_sublist(state, :implicit_sexp), [:implicit_sexp | mode]} + # else + # {state, mode} + # end + + state + |> add_to_exp(char) + |> chunk(rest, [:token | mode]) + + # End of input, with an active token + # TODO: better way to collapse open sublists + {[], [:token | _]} -> + state + |> end_exp() + # |> (fn s -> + # Enum.reduce(s.sublist_stack, s, fn %{t: type}, acc -> + # if type == :implicit_sexp, do: end_implicit_sexp(acc), else: acc + # end) + # end).() + |> finalize_top_level_line() + |> (fn s -> + Enum.reduce(s.sublist_stack, s, fn _, acc -> + case acc.sublist_stack do + [] -> acc + # [%{t: :implicit_sexp} | _] -> end_implicit_sexp(acc) + [%{t: :paren} | _] -> end_paren(acc) + [%{t: :brace} | _] -> end_brace(acc) + end + end) + end).() + |> end_program_output_list() + + # End of input, no active token + {[], modes} -> + Enum.reverse(modes) + |> Enum.drop(2) + |> Enum.reverse() + |> Enum.reduce(state, fn + # :implicit_sexp, %{output_stack: [%{t: :implicit_sexp} | _]} = acc -> + # end_implicit_sexp(acc) + + :paren, %{sublist_stack: [%{t: :paren} | _]} = acc -> + end_paren(acc) + + :brace, %{sublist_stack: [%{t: :brace} | _]} = acc -> + end_brace(acc) + + _, acc -> + acc + end) + + # Catch-all for unhandled characters + {[char | rest], mode} -> + IO.inspect("Unhandled char '#{[char] |> to_string}' in mode #{inspect(mode)}") + add_offset(state, 1) |> chunk(rest, mode) + end + end + + def chunk(state, input, mode) do + chunk(%{state | input: input, mode: mode}) + end + + def run() do + """ + + + parent elizabeth charles + parent charles william + parent charles harry + - - - + male charles + male william + male harry + female elizabeth + - - - + age charles 75 + age william 42 + age harry 40 + - - - + person X { + male X + } + - - - + grandparent G C { + parent G P + parent P C + (G \= C) + } + - - - + is_adult Person { + age Person A + A >= 18 + } + - - - + dcg command (cmd Action Target) { + verb Action + noun Target + } + - - - + dcg verb (action find) { "find" } + + dcg noun (obj harry) { "harry" } + - - - + + Adults = find P { + person P + is_adult P + } + - - - + + A (B + C) + D E F + G H + - - - + ( f (a (b kek) + d + e) + f ) + - - - + { this is { (a) b } of { + ( braces ) + braces 2 + } } asd + - - - + "hello + world" + - - - + + "a {n} { nf "{ nn1 } b {nn2 nn3}" } c" + + - - - + a b c , b, c + { x x c , y x (asd ) , ( z ) } + ( p, q, "r, asdad, ") + - - - + dcg asd X Y [ x ] { + "hello {X + a b} and {Y}" + } + + - - - + + grandparent G C x{ + parent G P + parent P C ; and some comment here + (G = C) + } + + + ; somecomment here + m{ x x c , y x (asd ) , z } + + - - - + m{a b, b c d , c} + - - - + map "n" "ff" f{vim.lsp.buf.format() } t{ desc "Format code" } + - - - + + + arg1 = implicit sexp ending on a newline + arg2 = another implicit sexp ending on newline + : hof fn elem { + transform elem :someatom + } arg3_to_hof + + res = somefun arg1 arg2 + : Enum.map fn elem { + transform elem :someatom + } + : length + : plus 2 + : IO.inspect k{label "Transformed length"} + : fetch_some_map + : .mapkey + + ; some comment + ; asd + - - - + res : op1 : op2 : op3 + : op4 ; asd + : asd ; asd + - - - + res s d = asd x yy : . q : e + : map ( fn i (add i 1) ) + : . length, asdklj + res . length . positive? + : IO.println + - - - + ; instead of + ; map("n", "srf", function() + ; local file_path = vim.api.nvim_buf_get_name(0) + ; print(file_path) + ; require("grug-far").open({ + ; transient = true, + ; prefills = { + ; paths = file_path, + ; }, + ; }) + + map "n" "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) + } + } + } + ; thats it folks + + + """ + |> String.split("- - -\n") + |> Enum.each(fn input -> + IO.puts("===================== Input ====") + IO.puts(input) + # IO.puts("==== chunkd ====") + + state = + input + |> chunk() + + # |> IO.inspect() + # |> print_chunkd() + + chunks = Map.get(state, :program_output) + IO.puts("==== AST ====") + + chunks + |> chunks_to_ast() + |> print_chunkd() + + # |> IO.inspect(label: "AST") + + IO.puts("\n\n") + end) + end + + def to_ast(str) do + str + |> chunk() + # |> print_chunkd() + |> chunks_to_ast + end + + # ============================================================================ + # Pass 2: Structuring Pass (Chunks -> AST) + # ============================================================================ + # chunks are currently just lists of tokens / other lists + # among these tokens are special tokens like :comma , :newline, and infix tokens ":", ".", etc + # theses , along with the type of list that contains them, will determine the AST structure + # 1. sexp lists with parens () + # , / spaces / nelines delimit expressions, expressions are single tokens or sublists, OR binary operations (: . || etc ) + # (a b c : d e, g . f (h i) ) -> AST representing function call with arguments and method calls + # elements: + # - a (separated by space from the next element) + # - b (separated by space from the next element) + # - c: d e (binary operation with left = c , operator = : , right = (d e) ) (separated by comma / newline, from the next element) + # - g . f (binary operation with left = g , operator = . , right = f ) + # - (h i) (separated by space from the previous element) + # 2. brackets with {} + # spaces dont delimit new elements, only commas and newlines do + # , / spaces / newlines delimit expressions, expressions are single tokens or sublists, OR binary operations (: . || etc ) + # { a b + # c : d e + # : x, g . f (h i) } + # - a b (separated by newline from the next element) + # - c piped into d e and then into x (binary operation with left = (c) , operator = : , right = (d e : x ) ) (separated by comma / newline, from the next element) + # - g . f (binary operation with left = g , operator = . , right = f ) + # + # also handle infix operations here + + # This pass takes the raw nodes from the chunker and applies grammatical rules + # (like operator precedence) to build a meaningful AST. + + @operator_table %{ + # "token_string" => {precedence, :left | :right} + # Lower precedence number binds less tightly. + # assignment + "=" => {10, :right}, + # pipe operator + ":" => {50, :right}, + # method call / property access + "." => {80, :right} + } + + def chunks_to_ast(%{program_output: chunks}) do + chunks_to_ast(chunks) + end + + def chunks_to_ast(chunks) do + # The top-level program is a list of expressions delimited by newlines, like a brace block. + structure_expressions(chunks, :brace) + end + + defp structure_node(%{t: :brace, list: raw_nodes} = node) do + # For brace blocks, group by newline/comma and process each expression. + structured_list = structure_expressions(raw_nodes, :brace) + %{node | list: structured_list} + end + + defp structure_node(%{t: :sexp, list: raw_nodes} = node) do + # For paren s-expressions, the contents are comma-separated expressions. + # We process the groups, but the result is a flat list, not another s-exp. + structured_list = + raw_nodes + # Recurse on sub-nodes first + |> Enum.map(&structure_node/1) + # Group by comma only + |> group_expressions(:paren) + |> Enum.map(&process_expression/1) + |> Enum.reject(&is_nil/1) + + # If there's only one resulting expression and it's an sexp, flatten it. + case structured_list do + [%{t: :sexp, list: inner_list}] -> %{node | list: inner_list} + _ -> %{node | list: structured_list} + end + end + + defp structure_node(%{t: :string, values: values} = node) do + # Also recurse into string interpolations + structured_values = Enum.map(values, &structure_node/1) + %{node | values: structured_values} + end + + defp structure_node(node) do + # It's a simple token, string, comment, or already-processed node. Leave it as is. + node + end + + defp structure_expressions(raw_nodes, type) do + raw_nodes + # 1. Recursively structure any sub-blocks first. + |> Enum.map(&structure_node/1) + # 2. Group nodes into expressions based on delimiters. + |> group_expressions(type) + # 3. Process each individual expression group. + |> Enum.map(&process_expression/1) + # 4. Filter out any nil results from empty expressions + |> Enum.reject(&is_nil/1) + + # |> dbg() + end + + defp group_expressions(nodes, type) do + # Manually group expressions, handling line continuations for pipes. + {groups, last_group} = + Enum.reduce(nodes, {[], []}, fn node, {acc_groups, current_group} -> + case node do + %{t: :comma} -> + # A comma always finishes the current expression. + {[Enum.reverse(current_group) | acc_groups], []} + + %{t: :newline} when type == :brace -> + # A newline might finish the expression. Look ahead. + next_significant = find_next_significant(nodes, node) + + is_continuation = + case next_significant do + %{t: :token, str: ":"} -> true + %{t: :token, str: "."} -> true + _ -> false + end + + if is_continuation do + # It's a line continuation, add newline to current group (as whitespace) and continue. + {acc_groups, [node | current_group]} + else + # It's a delimiter, finish the current expression. + {[Enum.reverse(current_group) | acc_groups], []} + end + + _ -> + # Any other token is part of the current expression. + {acc_groups, [node | current_group]} + end + end) + + # Add the last group if it's not empty. + final_groups = + if last_group != [], do: [Enum.reverse(last_group) | groups], else: groups + + final_groups + |> Enum.reverse() + |> Enum.reject(&(&1 == [])) + end + + defp find_next_significant(nodes, current_node) do + # This is a simplified lookahead. A more robust implementation might need the index. + # For now, we find the index of the current node and look from there. + case Enum.find_index(nodes, &(&1 == current_node)) do + nil -> + nil + + index -> + nodes + |> Enum.drop(index + 1) + |> Enum.find(fn + %{t: :comment} -> false + %{t: :newline} -> false + _ -> true + end) + end + end + + # Base case: A single token/item becomes itself. + defp process_expression([item]), do: item + + # Base case: An empty list of tokens results in nil. + defp process_expression([]), do: nil + + # Updated recursive case without exceptions + defp process_expression(tokens) do + # IO.inspect(tokens, label: "Processing expression") + # 1. Find all potential operators and their indices. + operators_with_indices = + tokens + |> Enum.with_index() + |> Enum.filter(fn {token, _idx} -> + # Ensure token is a map with a `str` key before checking the table + is_map(token) && Map.has_key?(@operator_table, token[:str]) + end) + + case operators_with_indices do + # Case 1: No operators found. This is a simple s-expression (e.g., function call). + [] -> + %{t: :sexp, list: tokens} + + # |> IO.inspect(label: "Simple s-expression (no operators)") + + # Case 2: Operators were found. Proceed with precedence logic. + ops -> + # 2. Find the operator with the lowest precedence (the pivot). + {_operator, pivot_index} = + Enum.min(ops, fn {token1, _idx1}, {token2, _idx2} -> + {prec1, assoc1} = @operator_table[token1.str] + {prec2, assoc2} = @operator_table[token2.str] + + cond do + prec1 < prec2 -> + true + + prec1 > prec2 -> + false + + # Precedences are equal, use associativity to break the tie. + # For left-associative, the leftmost (first) operator wins. + # For right-associative, the rightmost (last) operator wins. + assoc1 == :left -> + true + + assoc1 == :right && assoc2 == :right -> + false + + true -> + false + end + end) + + # |> IO.inspect(label: "Chosen pivot operator") + + operator = Enum.at(tokens, pivot_index) + + # 3. Split the token list into left/right sides around the pivot. + left_tokens = Enum.slice(tokens, 0, pivot_index) + right_tokens = Enum.slice(tokens, pivot_index + 1, length(tokens)) + + # 4. Recursively process the left and right sides. + # Special case for dot operator: RHS is a literal, not an expression. + {lhs, rhs} = + {process_expression(left_tokens), process_expression(right_tokens)} + + # if operator.str == "." do + # {process_expression(left_tokens), List.first(right_tokens)} + # else + # {process_expression(left_tokens), process_expression(right_tokens)} + # end + + # 5. Build the final s-expression. + %{t: :sexp, list: [operator, lhs, rhs]} + end + end +end + +Tpl.run() diff --git a/tpl_json.exs b/tpl_json.exs new file mode 100644 index 0000000..a0987ec --- /dev/null +++ b/tpl_json.exs @@ -0,0 +1,66 @@ +Code.require_file("tpl.exs") + +defmodule TplJson do + # --- JSON Transpiler --- + # transforms sexps into lists + # braces into objects + # tokens and strings into strings + def to_json(ast) do + ast + |> Enum.map(&to_json_item/1) + |> IO.inspect(label: "AST for JSON") + # if the top level is a single elem, we output that directly + |> case do + [[single]] -> single + [single] -> single + multiple -> multiple + end + |> Jason.encode!(pretty: true) + end + + defp to_json_item(%{t: :sexp, list: list}) do + list |> Enum.map(&to_json_item/1) + end + + defp to_json_item(%{t: :brace, list: list}) do + list + |> Enum.map(fn + %{t: :sexp, list: [key_item | value_list]} -> + key = to_json_item(key_item) + + value = + case value_list do + [ex] -> to_json_item(ex) + _ -> value_list |> Enum.map(&to_json_item/1) + end + + {key, value} + end) + |> Enum.into(%{}) + end + + defp to_json_item(%{t: :token, str: str}), do: str + + defp to_json_item(%{t: :string, chunks: chunks, values: values}) do + # For simplicity, concatenate chunks and ignore interpolations + Enum.join(chunks) + end +end + +json_output = + """ + { + asd "something else " + nested { + a ( b c asd asd asd) + d e f + inner { + x y z + } + } + """ + |> Tpl.to_ast() + |> TplJson.to_json() + +IO.puts("\n--- Generated JSON Output ---") +IO.puts(json_output) diff --git a/tpl_lua.exs b/tpl_lua.exs new file mode 100644 index 0000000..85b853f --- /dev/null +++ b/tpl_lua.exs @@ -0,0 +1,466 @@ +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" "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" "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") diff --git a/tpl_prolog.exs b/tpl_prolog.exs new file mode 100644 index 0000000..bb5e460 --- /dev/null +++ b/tpl_prolog.exs @@ -0,0 +1,251 @@ +Code.require_file("tpl.exs") + +defmodule TplProlog do + # Prolog comparison/arithmetic operators not in the TPL precedence table. + # These remain in natural order [lhs, op, rhs] in the AST. + @infix_ops ["\\=", ">=", "=<", ">", "<", "is", "==", "\\==", "=:=", "=\\="] + + # --- Public API --- + + def to_prolog(ast_nodes) do + ast_nodes + |> Enum.reject(&skip?/1) + |> Enum.map(&emit(&1, :top)) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + end + + # --- Node emitters --- + + # emit/2: convert one AST node to a Prolog string in the given context. + # context is :top (clause level), :body (goal), or :arg (term inside a call). + + defp emit(%{t: :sexp, list: list}, ctx), do: sexp(clean(list), ctx) + defp emit(%{t: :brace, list: list}, _ctx), do: body(list) + defp emit(%{t: :token, str: s}, _ctx), do: s + defp emit(%{t: :string, chunks: chunks, values: []}, _ctx), do: ~s("#{Enum.join(chunks)}") + defp emit(%{t: :string, chunks: chunks, values: values}, _ctx) do + # Best-effort: interleave chunks and values + max = max(length(chunks), length(values)) + + Enum.flat_map(0..(max - 1), fn i -> + chunk = if i < length(chunks), do: [Enum.at(chunks, i)], else: [] + value = if i < length(values), do: ["#{emit(Enum.at(values, i), :arg)}"], else: [] + chunk ++ value + end) + |> Enum.join() + |> then(&~s("#{&1}")) + end + defp emit(%{t: t}, _ctx) when t in [:newline, :comment, :dot, :comma], do: nil + + # --- Helpers --- + + defp skip?(%{t: t}) when t in [:newline, :comment], do: true + defp skip?(_), do: false + + defp clean(list), do: Enum.reject(list, &skip?/1) + + defp dot(s), do: s <> "." + defp maybe_dot(s, :top), do: dot(s) + defp maybe_dot(s, _), do: s + + # term/1: list of head nodes -> Prolog term (functor with args, or atom) + defp term([%{t: :token, str: f}]), do: f + defp term([%{t: :token, str: f} | args]) do + args_s = args |> Enum.map(&emit(&1, :arg)) |> Enum.reject(&is_nil/1) |> Enum.join(", ") + if args_s == "", do: f, else: "#{f}(#{args_s})" + end + defp term([single]), do: emit(single, :arg) + defp term(nodes), do: nodes |> Enum.map(&emit(&1, :arg)) |> Enum.reject(&is_nil/1) |> Enum.join(", ") + + # body/1: goal list from a brace block -> comma-joined conjunction string + defp body(list) do + goals = + list + |> Enum.reject(&skip?/1) + |> Enum.map(&emit(&1, :body)) + |> Enum.reject(&is_nil/1) + + case goals do + [] -> "true" + _ -> Enum.join(goals, ",\n ") + end + end + + # findall_body: inline conjunction with parens for multi-goal bodies. + # Avoids findall(T, goal1,\n goal2, R) misparse. + defp findall_body(list) do + goals = list |> Enum.reject(&skip?/1) |> Enum.map(&emit(&1, :body)) |> Enum.reject(&is_nil/1) + case goals do + [] -> "true" + [single] -> single + multiple -> "(#{Enum.join(multiple, ", ")})" + end + end + + defp brace?(%{t: :brace}), do: true + defp brace?(_), do: false + + # --- S-expression dispatch --- + + # Any sexp whose last element is a brace is a rule (or DCG rule). + # e.g. ancestor X Y { parent X Z, ancestor Z Y } + # dcg command (cmd A T) { verb A, noun T } + defp sexp(list, ctx) when length(list) >= 2 do + head = Enum.drop(list, -1) + last = List.last(list) + + # A rule has exactly one trailing brace (the body) and no braces in the head. + # This excludes `or { A } { B }` where the head itself contains braces. + if brace?(last) and not Enum.any?(head, &brace?/1) do + body_s = body(last.list) + + case head do + [%{t: :token, str: "dcg"} | rest] -> + "#{term(rest)} --> #{body_s}" |> maybe_dot(ctx) + + _ -> + "#{term(head)} :- #{body_s}" |> maybe_dot(ctx) + end + else + sexp_no_rule(list, ctx) + end + end + + defp sexp(list, ctx), do: sexp_no_rule(list, ctx) + + defp sexp_no_rule(list, ctx) do + case list do + # ── Findall ────────────────────────────────────────────────────────────── + # Var = find Template { Goals } + # AST (after precedence pass): [token:=, token:Var, sexp:[find, Template, brace:Goals]] + [%{t: :token, str: "="}, %{t: :token, str: var}, %{t: :sexp, list: rhs}] -> + rhs_clean = clean(rhs) + + if match?([%{t: :token, str: "find"} | _], rhs_clean) and + length(rhs_clean) >= 3 and brace?(List.last(rhs_clean)) do + [_ | rest] = rhs_clean + {tmpl_nodes, [brace]} = Enum.split(rest, length(rest) - 1) + tmpl_s = term(tmpl_nodes) + goal_s = findall_body(brace.list) + "findall(#{tmpl_s}, #{goal_s}, #{var})" |> maybe_dot(ctx) + else + rhs_s = sexp(rhs_clean, :arg) + "#{var} = #{rhs_s}" |> maybe_dot(ctx) + end + + # ── Assignment / unification ───────────────────────────────────────────── + # = lhs rhs (operator-first because = is in the TPL precedence table) + [%{t: :token, str: "="}, lhs, rhs] -> + "#{emit(lhs, :body)} = #{emit(rhs, :body)}" |> maybe_dot(ctx) + + # ── Infix comparison operators (not in TPL table, stay in natural order) ── + # e.g. A >= 18 , X \= Y + [lhs, %{t: :token, str: op}, rhs] when op in @infix_ops -> + "#{emit(lhs, :body)} #{op} #{emit(rhs, :body)}" + + # ── Disjunction ────────────────────────────────────────────────────────── + # or { goalA } { goalB } ... + [%{t: :token, str: "or"} | branches] -> + branches + |> Enum.filter(&match?(%{t: :brace}, &1)) + |> Enum.map(&emit(&1, :body)) + |> Enum.join(" ; ") + |> then(&"(#{&1})") + + # ── Negation ───────────────────────────────────────────────────────────── + # not goal... + [%{t: :token, str: "not"} | goals] -> + "\\+ #{term(goals)}" + + # ── General functor call / fact ────────────────────────────────────────── + [%{t: :token, str: f} | args] -> + args_s = args |> Enum.map(&emit(&1, :arg)) |> Enum.reject(&is_nil/1) |> Enum.join(", ") + (if args_s == "", do: f, else: "#{f}(#{args_s})") |> maybe_dot(ctx) + + # ── Fallback ───────────────────────────────────────────────────────────── + _ -> + list + |> Enum.map(&emit(&1, :body)) + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + |> maybe_dot(ctx) + end + end +end + + +# ── Demo ────────────────────────────────────────────────────────────────────── + +prolog_code = + """ + parent elizabeth charles + parent charles william + parent charles harry + + male charles + male william + male harry + female elizabeth + + age charles 75 + age william 42 + age harry 40 + + person X { + male X + } + + grandparent G C { + parent G P + parent P C + (G \= C) + } + + is_adult Person { + age Person A + A >= 18 + } + + ancestor X Y { + parent X Z + ancestor Z Y + } + + child_or_pet_owner X { + or { + parent X _ + } { + owns_pet X _ + } + } + + is_childless X { + person X + not parent X _ + } + + Adults = find P { + person P + is_adult P + } + + dcg command (cmd Action Target) { + verb Action + noun Target + } + + dcg verb (action find) { "find" } + dcg noun (obj harry) { "harry" } + + dcg sentence { + noun_phrase + verb_phrase + } + """ + |> Tpl.to_ast() + |> TplProlog.to_prolog() + +IO.puts("\n--- Generated Prolog ---") +IO.puts(prolog_code) +IO.puts("------------------------\n") \ No newline at end of file