diff --git a/lib/textbringer.rb b/lib/textbringer.rb index a029a6ac..99fbd3bc 100644 --- a/lib/textbringer.rb +++ b/lib/textbringer.rb @@ -33,6 +33,8 @@ require_relative "textbringer/commands/help" require_relative "textbringer/commands/completion" require_relative "textbringer/commands/lsp" +require_relative "textbringer/elisp/elisp" +require_relative "textbringer/commands/elisp" require_relative "textbringer/mode" require_relative "textbringer/modes/fundamental_mode" require_relative "textbringer/modes/programming_mode" @@ -42,6 +44,7 @@ require_relative "textbringer/modes/completion_list_mode" require_relative "textbringer/modes/buffer_list_mode" require_relative "textbringer/modes/help_mode" +require_relative "textbringer/modes/emacs_lisp_mode" require_relative "textbringer/minor_mode" require_relative "textbringer/global_minor_mode" require_relative "textbringer/modes/overwrite_mode" diff --git a/lib/textbringer/commands/elisp.rb b/lib/textbringer/commands/elisp.rb new file mode 100644 index 00000000..3e60ee31 --- /dev/null +++ b/lib/textbringer/commands/elisp.rb @@ -0,0 +1,32 @@ +module Textbringer + module Commands + define_command(:eval_elisp_expression, + doc: "Read an Emacs Lisp expression from the minibuffer, evaluate it, and display the result.") do + |s = read_from_minibuffer("Eval Elisp: ")| + result = Elisp.eval_string(s) + message(result.inspect) + end + + define_command(:eval_elisp_buffer, + doc: "Evaluate the current buffer as Emacs Lisp.") do + source = Buffer.current.to_s + result = Elisp.eval_string(source, filename: Buffer.current.name) + message(result.inspect) + end + + define_command(:eval_elisp_region, + doc: "Evaluate the selected region as Emacs Lisp.") do + b = Buffer.current + s = b.substring(b.point, b.mark).dup + result = Elisp.eval_string(s) + message(result.inspect) + end + + define_command(:load_elisp_file, + doc: "Load an Emacs Lisp file.") do + |path = read_file_name("Load Elisp file: ")| + Elisp.load_file(path) + message("Loaded #{path}") + end + end +end diff --git a/lib/textbringer/elisp/ast.rb b/lib/textbringer/elisp/ast.rb new file mode 100644 index 00000000..7ca93527 --- /dev/null +++ b/lib/textbringer/elisp/ast.rb @@ -0,0 +1,15 @@ +module Textbringer + module Elisp + Location = Data.define(:filename, :line, :column) + + IntegerLit = Data.define(:value, :location) + FloatLit = Data.define(:value, :location) + StringLit = Data.define(:value, :location) + CharLit = Data.define(:value, :location) + Symbol = Data.define(:name, :location) + List = Data.define(:elements, :dotted, :location) + Vector = Data.define(:elements, :location) + Quoted = Data.define(:kind, :form, :location) + Unquote = Data.define(:splicing, :form, :location) + end +end diff --git a/lib/textbringer/elisp/compiler.rb b/lib/textbringer/elisp/compiler.rb new file mode 100644 index 00000000..d17347cc --- /dev/null +++ b/lib/textbringer/elisp/compiler.rb @@ -0,0 +1,630 @@ +module Textbringer + module Elisp + class Compiler + class CompileError < StandardError; end + + KNOWN_PRIMITIVES = { + "+" => "el_plus", + "-" => "el_minus", + "*" => "el_multiply", + "/" => "el_divide", + "%" => "el_mod", + "1+" => "el_one_plus", + "1-" => "el_one_minus", + "car" => "car", + "cdr" => "cdr", + "cons" => "cons", + "list" => "list", + "length" => "el_length", + "not" => "el_not", + "null" => "el_not", + "eq" => "el_eq", + "eql" => "el_eql", + "equal" => "el_equal", + "=" => "el_num_eq", + "/=" => nil, # handled specially + "<" => "el_lt", + ">" => "el_gt", + "<=" => "el_le", + ">=" => "el_ge", + }.freeze + + def compile(forms, filename: "(elisp)") + lines = [] + lines << "R ||= Textbringer::Elisp::Runtime" + lines << "" + forms.each do |form| + lines << compile_form(form) + end + lines.join("\n") + end + + private + + def compile_form(node) + case node + when IntegerLit + node.value.to_s + when FloatLit + node.value.to_s + when StringLit + node.value.inspect + when CharLit + node.value.to_s + when Symbol + compile_symbol(node) + when List + compile_list(node) + when Vector + "[#{node.elements.map { |e| compile_form(e) }.join(", ")}]" + when Quoted + if node.kind == :quote + compile_quote_form(node.form) + elsif node.kind == :function + compile_function([node.form]) + elsif node.kind == :backquote + compile_backquote(node.form) + else + compile_form(node.form) + end + when Unquote + raise CompileError, "unquote outside of backquote" + else + raise CompileError, "unknown node type: #{node.class}" + end + end + + def compile_symbol(node) + case node.name + when "nil" + "nil" + when "t" + "true" + else + "R.get_var(:\"#{escape_sym(node.name)}\")" + end + end + + def compile_list(node) + return "nil" if node.elements.empty? + + head = node.elements[0] + return compile_funcall(node) unless head.is_a?(Symbol) + + args = node.elements[1..] + case head.name + when "defun" + compile_defun(args) + when "defvar", "defcustom" + compile_defvar(args) + when "defmacro" + compile_defmacro(args) + when "let" + compile_let(args) + when "let*" + compile_let_star(args) + when "if" + compile_if(args) + when "when" + compile_when(args) + when "unless" + compile_unless(args) + when "cond" + compile_cond(args) + when "progn" + compile_progn(args) + when "prog1" + compile_prog1(args) + when "prog2" + compile_prog2(args) + when "while" + compile_while(args) + when "setq" + compile_setq(args) + when "quote" + compile_quote_form(args[0]) + when "function" + compile_function(args) + when "lambda" + compile_lambda(args) + when "and" + compile_and(args) + when "or" + compile_or(args) + when "not" + "R.el_not(#{compile_form(args[0])})" + when "save-excursion" + compile_save_excursion(args) + when "unwind-protect" + compile_unwind_protect(args) + when "condition-case" + compile_condition_case(args) + when "catch" + compile_catch(args) + when "throw" + compile_throw(args) + when "interactive" + # Standalone interactive declaration — ignored at top level + "nil" + when "provide" + "R.provide(:\"#{escape_sym(args[0].is_a?(Quoted) ? args[0].form.name : args[0].name)}\")" + when "require" + name = args[0].is_a?(Quoted) ? args[0].form.name : args[0].name + "Textbringer::Elisp.load_feature(:\"#{escape_sym(name)}\")" + when "message" + compile_message(args) + when "error" + "raise(Textbringer::Elisp::Runtime::ElispError, #{compile_form(args[0])})" + when "apply" + compile_apply(args) + when "funcall" + fname = args[0] + fargs = args[1..] + "R.funcall(#{compile_form(fname)}, #{fargs.map { |a| compile_form(a) }.join(", ")})" + when "eval" + "Textbringer::Elisp.eval_string(#{compile_form(args[0])})" + else + compile_funcall(node) + end + end + + def compile_defun(args) + name = args[0].name + params = args[1] + body = args[2..] + + # Check for docstring + doc = nil + if body.length > 1 && body[0].is_a?(StringLit) + doc = body[0].value + body = body[1..] + end + + # Check for interactive declaration + interactive_spec = nil + if body.length > 0 && body[0].is_a?(List) && + body[0].elements.length > 0 && + body[0].elements[0].is_a?(Symbol) && + body[0].elements[0].name == "interactive" + interactive_form = body[0] + body = body[1..] + if interactive_form.elements.length > 1 + interactive_spec = interactive_form.elements[1] + else + interactive_spec = List.new(elements: [], dotted: nil, location: nil) + end + end + + param_info = extract_params(params) + param_str = compile_params_str(param_info) + binding_str = compile_param_bindings(param_info) + body_str = body.map { |f| compile_form(f) }.join("; ") + body_str = "nil" if body_str.empty? + wrapped_body = binding_str.empty? ? body_str : "#{binding_str} { #{body_str} }" + + if interactive_spec + spec_str = compile_form(interactive_spec) + doc_str = doc ? doc.inspect : "nil" + "R.defun_interactive(:\"#{escape_sym(name)}\", #{spec_str}, #{doc_str}) { |#{param_str}| #{wrapped_body} }" + else + "R.defun(:\"#{escape_sym(name)}\") { |#{param_str}| #{wrapped_body} }" + end + end + + # Returns [{name: "x", elisp_name: "x", kind: :required|:optional|:rest}, ...] + def extract_params(params_node) + return [] unless params_node.is_a?(List) + result = [] + kind = :required + params_node.elements.each do |p| + raise CompileError, "param must be a symbol" unless p.is_a?(Symbol) + case p.name + when "&optional" + kind = :optional + next + when "&rest" + kind = :rest + next + end + result << { name: ruby_var(p.name), elisp_name: p.name, kind: kind } + end + result + end + + def compile_params_str(param_info) + param_info.map do |p| + case p[:kind] + when :rest then "*#{p[:name]}" + when :optional then "#{p[:name]}=nil" + else p[:name] + end + end.join(", ") + end + + def compile_param_bindings(param_info) + return "" if param_info.empty? + pairs = param_info.map do |p| + if p[:kind] == :rest + # Convert Ruby array to Cons list + ":\"#{escape_sym(p[:elisp_name])}\" => R.list(*#{p[:name]})" + else + ":\"#{escape_sym(p[:elisp_name])}\" => #{p[:name]}" + end + end + "R.with_dynamic_bindings({#{pairs.join(", ")}})" + end + + def compile_defvar(args) + name = args[0].name + value = args.length > 1 ? compile_form(args[1]) : "nil" + "R.defvar(:\"#{escape_sym(name)}\", #{value})" + end + + def compile_defmacro(args) + # Macros stored for compile-time expansion — simplified as functions + name = args[0].name + params = args[1] + body = args[2..] + param_info = extract_params(params) + param_str = compile_params_str(param_info) + binding_str = compile_param_bindings(param_info) + body_str = body.map { |f| compile_form(f) }.join("; ") + body_str = "nil" if body_str.empty? + wrapped_body = binding_str.empty? ? body_str : "#{binding_str} { #{body_str} }" + "R.defun(:\"#{escape_sym(name)}\") { |#{param_str}| #{wrapped_body} }" + end + + def compile_let(args) + bindings_node = args[0] + body = args[1..] + bindings = compile_let_bindings(bindings_node) + body_str = body.map { |f| compile_form(f) }.join("; ") + body_str = "nil" if body_str.empty? + "R.with_dynamic_bindings({#{bindings}}) { #{body_str} }" + end + + def compile_let_star(args) + bindings_node = args[0] + body = args[1..] + body_str = body.map { |f| compile_form(f) }.join("; ") + body_str = "nil" if body_str.empty? + + return body_str unless bindings_node.is_a?(List) + + # Nest bindings sequentially + result = body_str + bindings_node.elements.reverse_each do |b| + if b.is_a?(List) && b.elements.length >= 2 + name = b.elements[0].name + val = compile_form(b.elements[1]) + result = "R.with_dynamic_binding(:\"#{escape_sym(name)}\", #{val}) { #{result} }" + elsif b.is_a?(List) && b.elements.length == 1 + name = b.elements[0].name + result = "R.with_dynamic_binding(:\"#{escape_sym(name)}\", nil) { #{result} }" + elsif b.is_a?(Symbol) + result = "R.with_dynamic_binding(:\"#{escape_sym(b.name)}\", nil) { #{result} }" + end + end + result + end + + def compile_let_bindings(bindings_node) + return "" unless bindings_node.is_a?(List) + pairs = bindings_node.elements.map do |b| + if b.is_a?(List) && b.elements.length >= 2 + ":\"#{escape_sym(b.elements[0].name)}\" => #{compile_form(b.elements[1])}" + elsif b.is_a?(List) && b.elements.length == 1 + ":\"#{escape_sym(b.elements[0].name)}\" => nil" + elsif b.is_a?(Symbol) + ":\"#{escape_sym(b.name)}\" => nil" + end + end + pairs.compact.join(", ") + end + + def compile_if(args) + cond = compile_form(args[0]) + then_branch = compile_form(args[1]) + if args.length > 2 + else_body = args[2..].map { |f| compile_form(f) }.join("; ") + "(R.truthy?(#{cond}) ? (#{then_branch}) : (begin; #{else_body}; end))" + else + "(R.truthy?(#{cond}) ? (#{then_branch}) : nil)" + end + end + + def compile_when(args) + cond = compile_form(args[0]) + body = args[1..].map { |f| compile_form(f) }.join("; ") + "(R.truthy?(#{cond}) ? (begin; #{body}; end) : nil)" + end + + def compile_unless(args) + cond = compile_form(args[0]) + body = args[1..].map { |f| compile_form(f) }.join("; ") + "(!R.truthy?(#{cond}) ? (begin; #{body}; end) : nil)" + end + + def compile_cond(args) + clauses = args.map do |clause| + raise CompileError, "cond clause must be a list" unless clause.is_a?(List) + elements = clause.elements + if elements[0].is_a?(Symbol) && elements[0].name == "t" + body = elements[1..].map { |f| compile_form(f) }.join("; ") + body = "true" if body.empty? + { cond: nil, body: body } + else + cond = compile_form(elements[0]) + body = elements[1..].map { |f| compile_form(f) }.join("; ") + body = cond if body.empty? + { cond: cond, body: body } + end + end + + parts = [] + clauses.each_with_index do |c, i| + if c[:cond].nil? + parts << c[:body] + break + elsif i == 0 + parts << "if R.truthy?(#{c[:cond]}); #{c[:body]}" + else + parts << "elsif R.truthy?(#{c[:cond]}); #{c[:body]}" + end + end + if clauses.last[:cond] + parts << "end" + else + parts[-1] = "else; #{parts[-1]}; end" if parts.length > 1 + end + "(#{parts.join("; ")})" + end + + def compile_progn(args) + return "nil" if args.empty? + body = args.map { |f| compile_form(f) }.join("; ") + "(begin; #{body}; end)" + end + + def compile_prog1(args) + return "nil" if args.empty? + first = compile_form(args[0]) + rest = args[1..].map { |f| compile_form(f) }.join("; ") + "(__prog1_val__ = #{first}; #{rest}; __prog1_val__)" + end + + def compile_prog2(args) + return "nil" if args.length < 2 + first = compile_form(args[0]) + second = compile_form(args[1]) + rest = args[2..].map { |f| compile_form(f) }.join("; ") + "(#{first}; __prog2_val__ = #{second}; #{rest}; __prog2_val__)" + end + + def compile_while(args) + cond = compile_form(args[0]) + body = args[1..].map { |f| compile_form(f) }.join("; ") + "(while R.truthy?(#{cond}); #{body}; end; nil)" + end + + def compile_setq(args) + pairs = args.each_slice(2).map do |name, value| + raise CompileError, "setq: variable name must be a symbol" unless name.is_a?(Symbol) + "R.set_var(:\"#{escape_sym(name.name)}\", #{compile_form(value)})" + end + pairs.length == 1 ? pairs[0] : "(#{pairs.join("; ")})" + end + + def compile_quote_form(node) + case node + when Symbol + ":\"#{escape_sym(node.name)}\"" + when IntegerLit + node.value.to_s + when FloatLit + node.value.to_s + when StringLit + node.value.inspect + when List + if node.elements.empty? && node.dotted.nil? + "nil" + else + elements = node.elements.map { |e| compile_quote_form(e) }.join(", ") + if node.dotted + # Build dotted list with cons + result = compile_quote_form(node.dotted) + node.elements.reverse_each do |e| + result = "R.cons(#{compile_quote_form(e)}, #{result})" + end + result + else + "R.list(#{elements})" + end + end + when Vector + "[#{node.elements.map { |e| compile_quote_form(e) }.join(", ")}]" + when Quoted + if node.kind == :quote + compile_quote_form(node.form) + else + compile_form(node) + end + else + "nil" + end + end + + def compile_function(args) + form = args[0] + if form.is_a?(Symbol) + "R.function_ref(:\"#{escape_sym(form.name)}\")" + elsif form.is_a?(List) && form.elements[0].is_a?(Symbol) && form.elements[0].name == "lambda" + compile_lambda(form.elements[1..]) + else + compile_form(form) + end + end + + def compile_lambda(args) + params = args[0] + body = args[1..] + # skip docstring + if body.length > 1 && body[0].is_a?(StringLit) + body = body[1..] + end + # skip interactive + if body.length > 0 && body[0].is_a?(List) && + body[0].elements.length > 0 && + body[0].elements[0].is_a?(Symbol) && + body[0].elements[0].name == "interactive" + body = body[1..] + end + param_info = extract_params(params) + param_str = compile_params_str(param_info) + binding_str = compile_param_bindings(param_info) + body_str = body.map { |f| compile_form(f) }.join("; ") + body_str = "nil" if body_str.empty? + wrapped_body = binding_str.empty? ? body_str : "#{binding_str} { #{body_str} }" + "R.make_lambda { |#{param_str}| #{wrapped_body} }" + end + + def compile_and(args) + return "true" if args.empty? + lambdas = args.map { |a| "->{ #{compile_form(a)} }" }.join(", ") + "R.el_and(#{lambdas})" + end + + def compile_or(args) + return "nil" if args.empty? + lambdas = args.map { |a| "->{ #{compile_form(a)} }" }.join(", ") + "R.el_or(#{lambdas})" + end + + def compile_save_excursion(args) + body = args.map { |f| compile_form(f) }.join("; ") + "Textbringer::Buffer.current.save_excursion { #{body} }" + end + + def compile_unwind_protect(args) + body = compile_form(args[0]) + cleanup = args[1..].map { |f| compile_form(f) }.join("; ") + "(begin; #{body}; ensure; #{cleanup}; end)" + end + + def compile_condition_case(args) + var = args[0] + body = compile_form(args[1]) + handlers = args[2..] + + rescue_clauses = handlers.map do |h| + raise CompileError, "condition-case handler must be a list" unless h.is_a?(List) + handler_body = h.elements[1..].map { |f| compile_form(f) }.join("; ") + handler_body = "nil" if handler_body.empty? + if var.is_a?(Symbol) && var.name != "nil" + "rescue => #{ruby_var(var.name)}; #{handler_body}" + else + "rescue => _el_err; #{handler_body}" + end + end + + "(begin; #{body}; #{rescue_clauses.join("; ")}; end)" + end + + def compile_catch(args) + tag = args[0] + body = args[1..].map { |f| compile_form(f) }.join("; ") + tag_str = if tag.is_a?(Quoted) + ":\"#{escape_sym(tag.form.name)}\"" + else + compile_form(tag) + end + "catch(R.catch_tag(#{tag_str})) { #{body} }" + end + + def compile_throw(args) + tag = args[0] + value = args.length > 1 ? compile_form(args[1]) : "nil" + tag_str = if tag.is_a?(Quoted) + ":\"#{escape_sym(tag.form.name)}\"" + else + compile_form(tag) + end + "throw(R.catch_tag(#{tag_str}), #{value})" + end + + def compile_message(args) + if args.length == 1 + "R.funcall(:\"message\", #{compile_form(args[0])})" + else + compiled = args.map { |a| compile_form(a) } + "R.funcall(:\"message\", #{compiled.join(", ")})" + end + end + + def compile_apply(args) + func = args[0] + rest = args[1..] + func_str = if func.is_a?(Quoted) && func.kind == :function + ":\"#{escape_sym(func.form.name)}\"" + else + compile_form(func) + end + "R.funcall(:\"apply\", #{func_str}, #{rest.map { |a| compile_form(a) }.join(", ")})" + end + + def compile_funcall(node) + head = node.elements[0] + args = node.elements[1..] + compiled_args = args.map { |a| compile_form(a) } + + if head.is_a?(Symbol) && KNOWN_PRIMITIVES.key?(head.name) + method = KNOWN_PRIMITIVES[head.name] + if method + return "R.#{method}(#{compiled_args.join(", ")})" + elsif head.name == "/=" + return "R.el_not(R.el_num_eq(#{compiled_args.join(", ")}))" + end + end + + func_name = if head.is_a?(Symbol) + ":\"#{escape_sym(head.name)}\"" + else + compile_form(head) + end + "R.funcall(#{func_name}, #{compiled_args.join(", ")})" + end + + def compile_backquote(node) + case node + when List + elements = node.elements.map do |e| + if e.is_a?(Unquote) && e.splicing + # splice: handled separately + nil + elsif e.is_a?(Unquote) + compile_form(e.form) + else + compile_backquote(e) + end + end + # For now, simplified: no splicing support + "R.list(#{elements.compact.join(", ")})" + when Symbol + ":\"#{escape_sym(node.name)}\"" + when Unquote + compile_form(node.form) + else + compile_quote_form(node) + end + end + + def escape_sym(name) + name.gsub("\\", "\\\\\\\\").gsub('"', '\\"') + end + + def ruby_var(elisp_name) + elisp_name.tr("-", "_").gsub(/[^a-zA-Z0-9_]/, "_") + end + end + end +end diff --git a/lib/textbringer/elisp/elisp.rb b/lib/textbringer/elisp/elisp.rb new file mode 100644 index 00000000..a01c909e --- /dev/null +++ b/lib/textbringer/elisp/elisp.rb @@ -0,0 +1,52 @@ +require_relative "ast" +require_relative "reader" +require_relative "runtime" +require_relative "compiler" +require_relative "primitives" + +module Textbringer + module Elisp + class << self + def init + return if @initialized + Primitives.register! + @initialized = true + end + + def eval_string(source, filename: "(elisp)") + init + reader = Reader.new(source, filename: filename) + forms = reader.read_all + compiler = Compiler.new + ruby_source = compiler.compile(forms, filename: filename) + iseq = RubyVM::InstructionSequence.compile(ruby_source, filename) + iseq.eval + end + + def load_file(path) + source = File.read(path, encoding: "utf-8") + eval_string(source, filename: path) + end + + def load_feature(feature) + name = feature.to_s + name = name + ".el" unless name.end_with?(".el") + + Runtime.load_path.each do |dir| + path = File.join(dir, name) + if File.exist?(path) + load_file(path) + return true + end + end + + raise Runtime::ElispError, "Cannot find feature: #{feature}" + end + + def reset! + Runtime.reset! + @initialized = false + end + end + end +end diff --git a/lib/textbringer/elisp/primitives.rb b/lib/textbringer/elisp/primitives.rb new file mode 100644 index 00000000..b0e44a05 --- /dev/null +++ b/lib/textbringer/elisp/primitives.rb @@ -0,0 +1,213 @@ +module Textbringer + module Elisp + module Primitives + def self.register! + r = Runtime + + # --- Arithmetic --- + r.defun(:"+") { |*args| args.reduce(0, :+) } + r.defun(:"-") { |first, *rest| rest.empty? ? -first : rest.reduce(first, :-) } + r.defun(:"*") { |*args| args.reduce(1, :*) } + r.defun(:"/") do |first, *rest| + rest.reduce(first) do |a, b| + if a.is_a?(Integer) && b.is_a?(Integer) + a / b + else + a.to_f / b + end + end + end + r.defun(:"%") { |a, b| a % b } + r.defun(:"1+") { |n| n + 1 } + r.defun(:"1-") { |n| n - 1 } + r.defun(:"max") { |*args| args.max } + r.defun(:"min") { |*args| args.min } + r.defun(:"abs") { |n| n.abs } + + r.function_table[:"el_one_plus"] = r.function_table[:"1+"] + r.function_table[:"el_one_minus"] = r.function_table[:"1-"] + + # --- Comparison --- + r.defun(:"=") { |a, b| a == b ? true : nil } + r.defun(:"/=") { |a, b| a != b ? true : nil } + r.defun(:"<") { |a, b| a < b ? true : nil } + r.defun(:">") { |a, b| a > b ? true : nil } + r.defun(:"<=") { |a, b| a <= b ? true : nil } + r.defun(:">=") { |a, b| a >= b ? true : nil } + r.defun(:"eq") { |a, b| r.el_eq(a, b) } + r.defun(:"eql") { |a, b| r.el_eql(a, b) } + r.defun(:"equal") { |a, b| r.el_equal(a, b) } + + # --- List operations --- + r.defun(:"car") { |obj| r.car(obj) } + r.defun(:"cdr") { |obj| r.cdr(obj) } + r.defun(:"cons") { |a, b| r.cons(a, b) } + r.defun(:"list") { |*args| r.list(*args) } + r.defun(:"append") { |*args| r.el_append(*args) } + r.defun(:"nth") { |n, list| r.el_nth(n, list) } + r.defun(:"nthcdr") { |n, list| r.el_nthcdr(n, list) } + r.defun(:"length") { |obj| r.el_length(obj) } + r.defun(:"reverse") { |list| r.el_reverse(list) } + r.defun(:"mapcar") do |func, list| + result = [] + current = list + while current.is_a?(Runtime::Cons) + result << r.funcall(func, current.car) + current = current.cdr + end + r.list(*result) + end + r.defun(:"mapc") do |func, list| + current = list + while current.is_a?(Runtime::Cons) + r.funcall(func, current.car) + current = current.cdr + end + list + end + r.defun(:"member") do |elt, list| + result = nil + current = list + while current.is_a?(Runtime::Cons) + if current.car == elt + result = current + break + end + current = current.cdr + end + result + end + r.defun(:"assoc") do |key, alist| + result = nil + current = alist + while current.is_a?(Runtime::Cons) + pair = current.car + if pair.is_a?(Runtime::Cons) && pair.car == key + result = pair + break + end + current = current.cdr + end + result + end + + # --- String operations --- + r.defun(:"string=") { |a, b| a == b ? true : nil } + r.defun(:"string<") { |a, b| a < b ? true : nil } + r.defun(:"concat") { |*args| args.map(&:to_s).join } + r.defun(:"substring") do |str, from, to = nil| + to ? str[from...to] : str[from..] + end + r.defun(:"string-match") do |regexp, str, start = 0| + re = Regexp.new(regexp) + m = re.match(str, start) + m ? m.begin(0) : nil + end + r.defun(:"format") { |fmt, *args| format(fmt.gsub("%s", "%s").gsub("%d", "%d"), *args) } + r.defun(:"upcase") { |s| s.upcase } + r.defun(:"downcase") { |s| s.downcase } + r.defun(:"number-to-string") { |n| n.to_s } + r.defun(:"string-to-number") { |s| s.include?(".") ? s.to_f : s.to_i } + r.defun(:"symbol-name") { |s| s.to_s } + r.defun(:"intern") { |s| s.to_sym } + + # --- Type predicates --- + r.defun(:"null") { |obj| obj.nil? ? true : nil } + r.defun(:"listp") { |obj| (obj.nil? || obj.is_a?(Runtime::Cons)) ? true : nil } + r.defun(:"consp") { |obj| obj.is_a?(Runtime::Cons) ? true : nil } + r.defun(:"atom") { |obj| obj.is_a?(Runtime::Cons) ? nil : true } + r.defun(:"stringp") { |obj| obj.is_a?(::String) ? true : nil } + r.defun(:"numberp") { |obj| obj.is_a?(Numeric) ? true : nil } + r.defun(:"integerp") { |obj| obj.is_a?(Integer) ? true : nil } + r.defun(:"floatp") { |obj| obj.is_a?(Float) ? true : nil } + r.defun(:"symbolp") { |obj| obj.is_a?(::Symbol) ? true : nil } + r.defun(:"functionp") { |obj| obj.is_a?(Proc) ? true : nil } + r.defun(:"not") { |obj| r.el_not(obj) } + r.defun(:"type-of") do |obj| + case obj + when Integer then :integer + when Float then :float + when ::String then :string + when ::Symbol then :symbol + when Runtime::Cons then :cons + when NilClass then :symbol + when Proc then :function + when TrueClass then :symbol + else :unknown + end + end + + # --- Buffer operations --- + r.defun(:"point") { Buffer.current.point } + r.defun(:"point-min") { Buffer.current.point_min } + r.defun(:"point-max") { Buffer.current.point_max } + r.defun(:"goto-char") { |pos| Buffer.current.goto_char(pos) } + r.defun(:"forward-char") { |n = 1| Buffer.current.forward_char(n) } + r.defun(:"backward-char") { |n = 1| Buffer.current.backward_char(n) } + r.defun(:"beginning-of-line") { Buffer.current.beginning_of_line } + r.defun(:"end-of-line") { Buffer.current.end_of_line } + r.defun(:"insert") { |*args| args.each { |s| Buffer.current.insert(s.to_s) } } + r.defun(:"delete-char") { |n = 1| Buffer.current.delete_char(n) } + r.defun(:"delete-region") { |start, stop| Buffer.current.delete_region(start, stop) } + r.defun(:"buffer-substring") { |start, stop| Buffer.current.substring(start, stop) } + r.defun(:"search-forward") do |str, bound = nil, noerror = nil| + Buffer.current.search_forward(str, bound: bound) + rescue SearchError + raise unless r.truthy?(noerror) + nil + end + r.defun(:"re-search-forward") do |regexp, bound = nil, noerror = nil| + Buffer.current.re_search_forward(regexp, bound: bound) + rescue SearchError + raise unless r.truthy?(noerror) + nil + end + r.defun(:"looking-at") do |regexp| + Buffer.current.looking_at?(Regexp.new(regexp)) ? true : nil + end + r.defun(:"match-beginning") { |n| Buffer.current.match_beginning(n) } + r.defun(:"match-end") { |n| Buffer.current.match_end(n) } + r.defun(:"match-string") { |n| Buffer.current.match_string(n) } + r.defun(:"replace-match") { |newtext| Buffer.current.replace_match(newtext) } + r.defun(:"current-buffer") { Buffer.current } + r.defun(:"buffer-name") { |buf = nil| (buf || Buffer.current).name } + r.defun(:"set-buffer") { |buf| Buffer.current = buf if buf.is_a?(Buffer) } + + # --- Misc --- + r.defun(:"message") do |fmt, *args| + msg = if args.empty? + fmt.to_s + else + format(fmt, *args) + end + message(msg) + msg + end + + r.defun(:"apply") do |func, *args| + # Last arg should be a list + if args.last.is_a?(Runtime::Cons) + flat_args = args[0...-1] + args.last.to_list + elsif args.last.nil? + flat_args = args[0...-1] + else + flat_args = args + end + r.funcall(func, *flat_args) + end + + r.defun(:"funcall") do |func, *args| + r.funcall(func, *args) + end + + r.defun(:"provide") { |feature| r.provide(feature) } + r.defun(:"featurep") { |feature| r.featurep?(feature) } + r.defun(:"require") do |feature| + unless r.featurep?(feature) + Textbringer::Elisp.load_feature(feature) + end + end + end + end + end +end diff --git a/lib/textbringer/elisp/reader.rb b/lib/textbringer/elisp/reader.rb new file mode 100644 index 00000000..e37031a7 --- /dev/null +++ b/lib/textbringer/elisp/reader.rb @@ -0,0 +1,329 @@ +module Textbringer + module Elisp + class Reader + class ReadError < StandardError; end + + def initialize(source, filename: "(elisp)") + @source = source + @filename = filename + @pos = 0 + @line = 1 + @column = 0 + end + + def read_all + forms = [] + skip_whitespace_and_comments + until eof? + forms << read_form + skip_whitespace_and_comments + end + forms + end + + def read_form + skip_whitespace_and_comments + raise ReadError, "unexpected end of input at #{location}" if eof? + + case peek + when "(" + read_list + when ")" + raise ReadError, "unexpected ')' at #{location}" + when "'" + read_quote(:quote) + when "`" + read_quote(:backquote) + when "," + read_unquote + when "#" + read_hash_dispatch + when "[" + read_vector + when "\"" + read_string + when "?" + read_character + else + read_atom + end + end + + private + + def location + Location.new(filename: @filename, line: @line, column: @column) + end + + def peek + @source[@pos] + end + + def advance + ch = @source[@pos] + @pos += 1 + if ch == "\n" + @line += 1 + @column = 0 + else + @column += 1 + end + ch + end + + def eof? + @pos >= @source.length + end + + def skip_whitespace_and_comments + loop do + # Skip whitespace + while !eof? && peek =~ /[\s]/ + advance + end + # Skip line comments + if !eof? && peek == ";" + advance until eof? || peek == "\n" + else + break + end + end + end + + def delimiter?(ch) + ch.nil? || ch =~ /[\s()\[\];\"]/ + end + + def read_list + loc = location + advance # consume '(' + elements = [] + dotted = nil + skip_whitespace_and_comments + until eof? || peek == ")" + if peek == "." && delimiter?(@source[@pos + 1]) + advance # consume '.' + skip_whitespace_and_comments + dotted = read_form + skip_whitespace_and_comments + break + end + elements << read_form + skip_whitespace_and_comments + end + raise ReadError, "unterminated list at #{loc}" if eof? + advance # consume ')' + List.new(elements: elements, dotted: dotted, location: loc) + end + + def read_quote(kind) + loc = location + advance # consume ' or ` + form = read_form + Quoted.new(kind: kind, form: form, location: loc) + end + + def read_unquote + loc = location + advance # consume ',' + splicing = false + if !eof? && peek == "@" + advance + splicing = true + end + form = read_form + Unquote.new(splicing: splicing, form: form, location: loc) + end + + def read_hash_dispatch + loc = location + advance # consume '#' + raise ReadError, "unexpected end of input after '#' at #{loc}" if eof? + case peek + when "'" + advance # consume ' + form = read_form + Quoted.new(kind: :function, form: form, location: loc) + else + raise ReadError, "unknown reader dispatch '##{peek}' at #{loc}" + end + end + + def read_vector + loc = location + advance # consume '[' + elements = [] + skip_whitespace_and_comments + until eof? || peek == "]" + elements << read_form + skip_whitespace_and_comments + end + raise ReadError, "unterminated vector at #{loc}" if eof? + advance # consume ']' + Vector.new(elements: elements, location: loc) + end + + def read_string + loc = location + advance # consume opening " + str = +"" + until eof? || peek == "\"" + if peek == "\\" + advance + raise ReadError, "unterminated string escape at #{loc}" if eof? + str << read_string_escape + else + str << advance + end + end + raise ReadError, "unterminated string at #{loc}" if eof? + advance # consume closing " + StringLit.new(value: str.freeze, location: loc) + end + + def read_string_escape + ch = advance + case ch + when "n" then "\n" + when "t" then "\t" + when "r" then "\r" + when "\\" then "\\" + when "\"" then "\"" + when "a" then "\a" + when "b" then "\b" + when "f" then "\f" + when "v" then "\v" + when "0" then "\0" + when "e" then "\e" + when "s" then " " + when "d" then "\x7f" + when "x" + hex = +"" + while !eof? && peek =~ /[0-9a-fA-F]/ + hex << advance + end + hex.to_i(16).chr(Encoding::UTF_8) + when "u" + hex = +"" + while !eof? && peek =~ /[0-9a-fA-F]/ && hex.length < 4 + hex << advance + end + hex.to_i(16).chr(Encoding::UTF_8) + else + ch + end + end + + def read_character + loc = location + advance # consume '?' + raise ReadError, "unexpected end of input after '?' at #{loc}" if eof? + if peek == "\\" + advance + raise ReadError, "unexpected end of input in character literal at #{loc}" if eof? + code = read_char_escape + else + code = advance.ord + end + CharLit.new(value: code, location: loc) + end + + def read_char_escape + ch = advance + case ch + when "C", "^" + # Control character + advance if ch == "C" && !eof? && peek == "-" + raise ReadError, "unexpected end of input in control char at #{location}" if eof? + if peek == "\\" + advance + raise ReadError, "unexpected end of input in control char at #{location}" if eof? + base = read_char_escape + else + base = advance.ord + end + base & 0x1f + when "M" + # Meta character + advance if !eof? && peek == "-" + raise ReadError, "unexpected end of input in meta char at #{location}" if eof? + if peek == "\\" + advance + raise ReadError, "unexpected end of input in meta char at #{location}" if eof? + base = read_char_escape + else + base = advance.ord + end + base | 0x80 + when "S" + # Shift + advance if !eof? && peek == "-" + raise ReadError, "unexpected end of input in shift char at #{location}" if eof? + if peek == "\\" + advance + base = read_char_escape + else + base = advance.ord + end + base + when "n" then "\n".ord + when "t" then "\t".ord + when "r" then "\r".ord + when "e" then 27 + when "a" then 7 + when "b" then 8 + when "f" then 12 + when "v" then 11 + when "s" then 32 + when "d" then 127 + when "\\" then "\\".ord + when "x" + hex = +"" + while !eof? && peek =~ /[0-9a-fA-F]/ + hex << advance + end + hex.to_i(16) + when "0".."7" + oct = ch + while !eof? && peek =~ /[0-7]/ && oct.length < 3 + oct << advance + end + oct.to_i(8) + else + ch.ord + end + end + + def read_atom + loc = location + token = +"" + until eof? || delimiter?(peek) + if peek == "\\" + advance + token << advance unless eof? + else + token << advance + end + end + + case token + when /\A[+-]?\d+\z/ + IntegerLit.new(value: token.to_i, location: loc) + when /\A[+-]?\d+\.\d*(?:[eE][+-]?\d+)?\z/, + /\A[+-]?\d*\.\d+(?:[eE][+-]?\d+)?\z/, + /\A[+-]?\d+[eE][+-]?\d+\z/ + FloatLit.new(value: token.to_f, location: loc) + when "#x" + # handled above, but just in case + IntegerLit.new(value: 0, location: loc) + when "nil" + Symbol.new(name: "nil", location: loc) + when "t" + Symbol.new(name: "t", location: loc) + else + Symbol.new(name: token, location: loc) + end + end + end + end +end diff --git a/lib/textbringer/elisp/runtime.rb b/lib/textbringer/elisp/runtime.rb new file mode 100644 index 00000000..854a6584 --- /dev/null +++ b/lib/textbringer/elisp/runtime.rb @@ -0,0 +1,412 @@ +module Textbringer + module Elisp + module Runtime + class ElispError < StandardError; end + + # Cons cell for proper Elisp lists + class Cons + attr_accessor :car, :cdr + + def initialize(car, cdr) + @car = car + @cdr = cdr + end + + def to_list + result = [] + current = self + while current.is_a?(Cons) + result << current.car + current = current.cdr + end + result + end + + def ==(other) + other.is_a?(Cons) && car == other.car && cdr == other.cdr + end + + def inspect + if cdr.nil? || cdr.is_a?(Cons) + "(#{list_inspect})" + else + "(#{car.inspect} . #{cdr.inspect})" + end + end + + private + + def list_inspect + result = [car.inspect] + current = cdr + while current.is_a?(Cons) + result << current.car.inspect + current = current.cdr + end + result << ". #{current.inspect}" unless current.nil? + result.join(" ") + end + end + + class << self + # Dynamic variable stacks + def var_stacks + @var_stacks ||= {} + end + + # Function table + def function_table + @function_table ||= {} + end + + # Macro table + def macro_table + @macro_table ||= {} + end + + # Feature set + def features + @features ||= Set.new + end + + # Load path + def load_path + @load_path ||= [] + end + + # --- Variable operations --- + + def get_var(name) + stack = var_stacks[name] + if stack && !stack.empty? + stack.last + else + nil + end + end + + def set_var(name, value) + stack = var_stacks[name] + if stack && !stack.empty? + stack[-1] = value + else + var_stacks[name] ||= [] + var_stacks[name].push(value) + end + value + end + + def defvar(name, value, _doc = nil) + unless var_stacks[name] && !var_stacks[name].empty? + var_stacks[name] = [value] + end + name + end + + def with_dynamic_bindings(bindings, &block) + bindings.each do |name, value| + var_stacks[name] ||= [] + var_stacks[name].push(value) + end + begin + block.call + ensure + bindings.each_key do |name| + var_stacks[name].pop + end + end + end + + def with_dynamic_binding(name, value, &block) + with_dynamic_bindings({ name => value }, &block) + end + + # --- Function operations --- + + def defun(name, &block) + function_table[name] = block + name + end + + def defun_interactive(name, spec, doc = nil, &block) + function_table[name] = block + # Register as a Textbringer command + define_command(name, doc: doc || "Elisp command: #{name}") do + args = Runtime.parse_interactive_spec(spec) + Runtime.funcall(name, *args) + end + name + end + + def funcall(name, *args) + if name.is_a?(Proc) + return name.call(*args) + end + func = function_table[name] + raise ElispError, "void function: #{name}" unless func + func.call(*args) + end + + def function_ref(name) + function_table[name] + end + + def make_lambda(&block) + block + end + + # --- Truthiness (Elisp semantics) --- + + def truthy?(val) + val != nil && val != false + end + + # --- List operations --- + + def list(*args) + return nil if args.empty? + result = nil + args.reverse_each do |a| + result = Cons.new(a, result) + end + result + end + + def cons(car, cdr) + Cons.new(car, cdr) + end + + def car(obj) + return nil if obj.nil? + raise ElispError, "wrong type argument: listp, #{obj.inspect}" unless obj.is_a?(Cons) + obj.car + end + + def cdr(obj) + return nil if obj.nil? + raise ElispError, "wrong type argument: listp, #{obj.inspect}" unless obj.is_a?(Cons) + obj.cdr + end + + def el_length(obj) + return 0 if obj.nil? + return obj.length if obj.is_a?(::String) || obj.is_a?(::Array) + count = 0 + current = obj + while current.is_a?(Cons) + count += 1 + current = current.cdr + end + count + end + + def el_nth(n, list) + current = list + n.times do + return nil unless current.is_a?(Cons) + current = current.cdr + end + current.is_a?(Cons) ? current.car : nil + end + + def el_nthcdr(n, list) + current = list + n.times do + return nil unless current.is_a?(Cons) + current = current.cdr + end + current + end + + def el_append(*lists) + return nil if lists.empty? + result_elements = [] + lists[0...-1].each do |l| + current = l + while current.is_a?(Cons) + result_elements << current.car + current = current.cdr + end + end + tail = lists.last + result_elements.reverse_each do |elem| + tail = Cons.new(elem, tail) + end + tail + end + + def el_reverse(list) + result = nil + current = list + while current.is_a?(Cons) + result = Cons.new(current.car, result) + current = current.cdr + end + result + end + + # --- Short-circuit logic --- + + def el_and(*lambdas) + result = true + lambdas.each do |l| + result = l.call + return nil unless truthy?(result) + end + result + end + + def el_or(*lambdas) + lambdas.each do |l| + result = l.call + return result if truthy?(result) + end + nil + end + + # --- Catch/throw --- + + def catch_tag(tag) + :"__elisp_catch_#{tag}" + end + + # --- Arithmetic helpers --- + + def el_plus(*args) + args.reduce(0, :+) + end + + def el_minus(first = nil, *rest) + return 0 if first.nil? + return -first if rest.empty? + rest.reduce(first, :-) + end + + def el_multiply(*args) + args.reduce(1, :*) + end + + def el_divide(first, *rest) + return first if rest.empty? + rest.reduce(first) do |a, b| + if a.is_a?(Integer) && b.is_a?(Integer) + a / b + else + a.to_f / b + end + end + end + + def el_mod(a, b) + a % b + end + + def el_one_plus(n) + n + 1 + end + + def el_one_minus(n) + n - 1 + end + + # --- Comparison helpers --- + + def el_eq(a, b) + a.equal?(b) || (a.is_a?(::Symbol) && a == b) || + (a.is_a?(Integer) && a == b) ? true : nil + end + + def el_eql(a, b) + a.eql?(b) ? true : nil + end + + def el_equal(a, b) + a == b ? true : nil + end + + def el_num_eq(a, b) + a == b ? true : nil + end + + def el_lt(a, b) + a < b ? true : nil + end + + def el_gt(a, b) + a > b ? true : nil + end + + def el_le(a, b) + a <= b ? true : nil + end + + def el_ge(a, b) + a >= b ? true : nil + end + + def el_not(val) + truthy?(val) ? nil : true + end + + # --- Feature system --- + + def provide(feature) + features.add(feature.to_sym) + feature + end + + def featurep?(feature) + features.include?(feature.to_sym) ? true : nil + end + + # --- Interactive spec parsing --- + + def parse_interactive_spec(spec) + return [] if spec.nil? || spec.empty? + args = [] + i = 0 + while i < spec.length + code = spec[i] + i += 1 + case code + when "s" + prompt = extract_prompt(spec, i) + i += prompt.length + args << read_from_minibuffer(prompt) + when "n" + prompt = extract_prompt(spec, i) + i += prompt.length + args << read_from_minibuffer(prompt).to_i + when "r" + b = Textbringer::Buffer.current + args << [b.point, b.mark].min + args << [b.point, b.mark].max + when "p" + args << (current_prefix_arg || 1) + when "\n" + # separator, skip + else + prompt = extract_prompt(spec, i) + i += prompt.length + end + end + args + end + + def reset! + @var_stacks = {} + @function_table = {} + @macro_table = {} + @features = Set.new + end + + private + + def extract_prompt(spec, start) + idx = spec.index("\n", start) || spec.length + spec[start...idx] + end + end + end + end +end diff --git a/lib/textbringer/modes/emacs_lisp_mode.rb b/lib/textbringer/modes/emacs_lisp_mode.rb new file mode 100644 index 00000000..73f78c35 --- /dev/null +++ b/lib/textbringer/modes/emacs_lisp_mode.rb @@ -0,0 +1,88 @@ +module Textbringer + class EmacsLispMode < ProgrammingMode + self.file_name_pattern = /\A.*\.el\z/ + + KEYWORDS = %w( + defun defvar defcustom defmacro defconst defsubst defadvice + let let* if when unless cond progn prog1 prog2 + while lambda setq quote function + and or not + save-excursion save-restriction save-match-data + unwind-protect condition-case catch throw + interactive declare + provide require + ) + + define_syntax :comment, /;.*$/ + + define_syntax :keyword, / + \b (?: #{KEYWORDS.join("|")} ) \b + /x + + define_syntax :string, / + " (?: [^\\"] | \\ . )* " + /x + + EMACS_LISP_MODE_MAP = Keymap.new + EMACS_LISP_MODE_MAP.define_key("\C-c\C-e", :eval_elisp_buffer) + + def initialize(buffer) + super(buffer) + buffer.keymap = EMACS_LISP_MODE_MAP + end + + def comment_start + ";; " + end + + def forward_definition + @buffer.re_search_forward(/^\(def/) + @buffer.beginning_of_line + end + + def backward_definition + @buffer.re_search_backward(/^\(def/) + end + + def symbol_pattern + /[A-Za-z0-9_\-]/ + end + + private + + def calculate_indentation + @buffer.save_excursion do + @buffer.beginning_of_line + if @buffer.point == @buffer.point_min + return 0 + end + + # Count unclosed parens to determine indentation + @buffer.backward_char + indent = 0 + depth = 0 + pos = @buffer.point + # Scan backwards to find enclosing paren + count = 0 + while @buffer.point > @buffer.point_min && count < 4000 + ch = @buffer.char_before + case ch + when ")" + depth += 1 + when "(" + if depth > 0 + depth -= 1 + else + # Found enclosing open paren + indent = @buffer.current_column + 2 + break + end + end + @buffer.backward_char + count += 1 + end + indent + end + end + end +end diff --git a/test/textbringer/elisp/test_compiler.rb b/test/textbringer/elisp/test_compiler.rb new file mode 100644 index 00000000..11c9cc37 --- /dev/null +++ b/test/textbringer/elisp/test_compiler.rb @@ -0,0 +1,154 @@ +require_relative "../../test_helper" + +class TestCompiler < Textbringer::TestCase + def compile(source) + reader = Textbringer::Elisp::Reader.new(source) + forms = reader.read_all + compiler = Textbringer::Elisp::Compiler.new + compiler.compile(forms) + end + + def test_integer_literal + ruby = compile("42") + assert_match(/42/, ruby) + end + + def test_string_literal + ruby = compile('"hello"') + assert_match(/"hello"/, ruby) + end + + def test_nil_compiles_to_nil + ruby = compile("nil") + assert_match(/\bnil\b/, ruby) + end + + def test_t_compiles_to_true + ruby = compile("t") + assert_match(/\btrue\b/, ruby) + end + + def test_symbol_ref + ruby = compile("foo") + assert_match(/R\.get_var\(:\"foo\"\)/, ruby) + end + + def test_function_call + ruby = compile("(my-func 1 2)") + assert_match(/R\.funcall\(:\"my-func\"/, ruby) + end + + def test_plus_optimized + ruby = compile("(+ 1 2)") + assert_match(/R\.el_plus\(1, 2\)/, ruby) + end + + def test_defun + ruby = compile("(defun double (x) (* x 2))") + assert_match(/R\.defun\(:\"double\"\)/, ruby) + end + + def test_let + ruby = compile("(let ((x 1)) x)") + assert_match(/R\.with_dynamic_bindings/, ruby) + end + + def test_let_star + ruby = compile("(let* ((x 1) (y 2)) (+ x y))") + assert_match(/R\.with_dynamic_binding/, ruby) + end + + def test_if + ruby = compile("(if t 1 2)") + assert_match(/R\.truthy\?/, ruby) + end + + def test_when + ruby = compile("(when t 1)") + assert_match(/R\.truthy\?/, ruby) + end + + def test_unless + ruby = compile("(unless nil 1)") + assert_match(/!R\.truthy\?/, ruby) + end + + def test_progn + ruby = compile("(progn 1 2 3)") + assert_match(/begin/, ruby) + end + + def test_while + ruby = compile("(while t nil)") + assert_match(/while R\.truthy\?/, ruby) + end + + def test_setq + ruby = compile("(setq x 42)") + assert_match(/R\.set_var\(:\"x\", 42\)/, ruby) + end + + def test_quote_symbol + ruby = compile("'foo") + assert_match(/:\"foo\"/, ruby) + end + + def test_quote_list + ruby = compile("'(1 2 3)") + assert_match(/R\.list\(1, 2, 3\)/, ruby) + end + + def test_lambda + ruby = compile("(lambda (x) (* x 2))") + assert_match(/R\.make_lambda/, ruby) + end + + def test_and + ruby = compile("(and 1 2)") + assert_match(/R\.el_and/, ruby) + end + + def test_or + ruby = compile("(or 1 2)") + assert_match(/R\.el_or/, ruby) + end + + def test_cond + ruby = compile("(cond ((= x 1) 10) (t 20))") + assert_match(/if R\.truthy\?/, ruby) + assert_match(/else/, ruby) + end + + def test_unwind_protect + ruby = compile("(unwind-protect (foo) (bar))") + assert_match(/begin/, ruby) + assert_match(/ensure/, ruby) + end + + def test_condition_case + ruby = compile("(condition-case err (foo) (error (message err)))") + assert_match(/rescue/, ruby) + end + + def test_generated_ruby_is_valid + sources = [ + "(+ 1 2)", + "(defun f (x) (* x 2))", + "(let ((x 1)) x)", + "(if t 1 2)", + "(progn 1 2 3)", + "(setq x 42)", + "'foo", + "(and 1 2)", + "(or nil 3)", + "(lambda (x) x)", + ] + sources.each do |src| + ruby = compile(src) + # Should parse without syntax error + assert_nothing_raised("Failed to parse Ruby from: #{src}") do + RubyVM::InstructionSequence.compile(ruby) + end + end + end +end diff --git a/test/textbringer/elisp/test_emacs_lisp_mode.rb b/test/textbringer/elisp/test_emacs_lisp_mode.rb new file mode 100644 index 00000000..ae8f0f19 --- /dev/null +++ b/test/textbringer/elisp/test_emacs_lisp_mode.rb @@ -0,0 +1,29 @@ +require_relative "../../test_helper" + +class TestEmacsLispMode < Textbringer::TestCase + setup do + @buffer = Buffer.new_buffer("test.el") + @buffer.apply_mode(Textbringer::EmacsLispMode) + switch_to_buffer(@buffer) + end + + def test_file_name_pattern + assert_match(Textbringer::EmacsLispMode.file_name_pattern, "foo.el") + assert_match(Textbringer::EmacsLispMode.file_name_pattern, "/path/to/init.el") + refute_match(Textbringer::EmacsLispMode.file_name_pattern, "foo.rb") + end + + def test_mode_name + assert_equal("EmacsLisp", Textbringer::EmacsLispMode.mode_name) + end + + def test_comment_start + assert_equal(";; ", @buffer.mode.comment_start) + end + + def test_symbol_pattern + assert_match(@buffer.mode.symbol_pattern, "a") + assert_match(@buffer.mode.symbol_pattern, "-") + assert_match(@buffer.mode.symbol_pattern, "_") + end +end diff --git a/test/textbringer/elisp/test_integration.rb b/test/textbringer/elisp/test_integration.rb new file mode 100644 index 00000000..adca3e1e --- /dev/null +++ b/test/textbringer/elisp/test_integration.rb @@ -0,0 +1,258 @@ +require_relative "../../test_helper" + +class TestElispIntegration < Textbringer::TestCase + setup do + Textbringer::Elisp.reset! + end + + def eval_elisp(source) + Textbringer::Elisp.eval_string(source) + end + + # --- Phase 1: Basic arithmetic --- + + def test_simple_addition + assert_equal(3, eval_elisp("(+ 1 2)")) + end + + def test_nested_arithmetic + assert_equal(14, eval_elisp("(+ (* 2 3) (- 10 2) 0)")) + end + + def test_integer_literal + assert_equal(42, eval_elisp("42")) + end + + def test_string_literal + assert_equal("hello", eval_elisp('"hello"')) + end + + # --- Defun and function calls --- + + def test_defun_and_call + eval_elisp("(defun double (x) (* x 2))") + assert_equal(10, eval_elisp("(double 5)")) + end + + def test_defun_multiple_body_forms + eval_elisp('(defun add3 (a b c) (+ a b c))') + assert_equal(6, eval_elisp("(add3 1 2 3)")) + end + + def test_defun_optional_args + eval_elisp("(defun greet (&optional name) (if name name \"world\"))") + assert_equal("world", eval_elisp("(greet)")) + assert_equal("alice", eval_elisp('(greet "alice")')) + end + + def test_defun_rest_args + eval_elisp("(defun sum (&rest nums) (apply '+ nums))") + assert_equal(10, eval_elisp("(sum 1 2 3 4)")) + end + + # --- Let bindings --- + + def test_let + result = eval_elisp("(let ((x 10) (y 20)) (+ x y))") + assert_equal(30, result) + end + + def test_let_star + result = eval_elisp("(let* ((x 5) (y (* x 2))) (+ x y))") + assert_equal(15, result) + end + + def test_let_restores_bindings + eval_elisp("(setq x 1)") + eval_elisp("(let ((x 99)) x)") + assert_equal(1, eval_elisp("x")) + end + + # --- Control flow --- + + def test_if_true + assert_equal(1, eval_elisp("(if t 1 2)")) + end + + def test_if_false + assert_equal(2, eval_elisp("(if nil 1 2)")) + end + + def test_if_no_else + assert_nil(eval_elisp("(if nil 1)")) + end + + def test_when + assert_equal(42, eval_elisp("(when t 42)")) + assert_nil(eval_elisp("(when nil 42)")) + end + + def test_unless + assert_nil(eval_elisp("(unless t 42)")) + assert_equal(42, eval_elisp("(unless nil 42)")) + end + + def test_cond + eval_elisp("(setq x 2)") + result = eval_elisp("(cond ((= x 1) 10) ((= x 2) 20) (t 30))") + assert_equal(20, result) + end + + def test_progn + result = eval_elisp("(progn 1 2 3)") + assert_equal(3, result) + end + + def test_and + assert_equal(3, eval_elisp("(and 1 2 3)")) + assert_nil(eval_elisp("(and 1 nil 3)")) + end + + def test_or + assert_equal(1, eval_elisp("(or 1 2)")) + assert_equal(2, eval_elisp("(or nil 2)")) + assert_nil(eval_elisp("(or nil nil)")) + end + + # --- While loop --- + + def test_while + eval_elisp("(setq x 0)") + eval_elisp("(setq sum 0)") + eval_elisp("(while (< x 5) (setq sum (+ sum x)) (setq x (1+ x)))") + assert_equal(10, eval_elisp("sum")) # 0+1+2+3+4 = 10 + end + + # --- Setq --- + + def test_setq + eval_elisp("(setq x 42)") + assert_equal(42, eval_elisp("x")) + end + + def test_setq_multiple + eval_elisp("(setq a 1 b 2)") + assert_equal(1, eval_elisp("a")) + assert_equal(2, eval_elisp("b")) + end + + # --- Quote --- + + def test_quote_symbol + assert_equal(:"foo", eval_elisp("'foo")) + end + + def test_quote_list + result = eval_elisp("'(1 2 3)") + assert_instance_of(Textbringer::Elisp::Runtime::Cons, result) + assert_equal([1, 2, 3], result.to_list) + end + + # --- Lambda --- + + def test_lambda + result = eval_elisp("(funcall (lambda (x) (* x 3)) 5)") + assert_equal(15, result) + end + + # --- Defvar --- + + def test_defvar + eval_elisp("(defvar my-var 42)") + assert_equal(42, eval_elisp("my-var")) + end + + # --- Comparison --- + + def test_eq + assert_equal(true, eval_elisp("(eq 'foo 'foo)")) + end + + def test_equal + assert_equal(true, eval_elisp('(equal "abc" "abc")')) + end + + # --- List operations --- + + def test_car_cdr + assert_equal(1, eval_elisp("(car '(1 2 3))")) + result = eval_elisp("(cdr '(1 2 3))") + assert_equal([2, 3], result.to_list) + end + + def test_cons + result = eval_elisp("(cons 1 '(2 3))") + assert_equal([1, 2, 3], result.to_list) + end + + # --- String operations --- + + def test_concat + assert_equal("foobar", eval_elisp('(concat "foo" "bar")')) + end + + def test_substring + assert_equal("llo", eval_elisp('(substring "hello" 2)')) + end + + # --- Type predicates --- + + def test_null + assert_equal(true, eval_elisp("(null nil)")) + assert_nil(eval_elisp("(null 1)")) + end + + def test_numberp + assert_equal(true, eval_elisp("(numberp 42)")) + assert_nil(eval_elisp('(numberp "no")')) + end + + # --- Error handling --- + + def test_condition_case + result = eval_elisp('(condition-case err (error "boom") (error 42))') + assert_equal(42, result) + end + + # --- Buffer operations --- + + def test_buffer_insert_and_point + eval_elisp('(insert "hello")') + assert_equal("hello", Buffer.current.to_s) + assert_equal(5, eval_elisp("(point)")) + end + + def test_goto_char + eval_elisp('(insert "hello")') + eval_elisp("(goto-char 1)") + assert_equal(1, eval_elisp("(point)")) + end + + # --- Multiple top-level forms --- + + def test_multiple_forms + result = eval_elisp("(setq a 1) (setq b 2) (+ a b)") + assert_equal(3, result) + end + + # --- Provide --- + + def test_provide + eval_elisp("(provide 'my-feature)") + assert_equal(true, Textbringer::Elisp::Runtime.featurep?(:"my-feature")) + end + + # --- Feature: prog1 --- + + def test_prog1 + result = eval_elisp("(prog1 1 2 3)") + assert_equal(1, result) + end + + # --- Feature: not --- + + def test_not + assert_equal(true, eval_elisp("(not nil)")) + assert_nil(eval_elisp("(not t)")) + end +end diff --git a/test/textbringer/elisp/test_primitives.rb b/test/textbringer/elisp/test_primitives.rb new file mode 100644 index 00000000..f1bf5a83 --- /dev/null +++ b/test/textbringer/elisp/test_primitives.rb @@ -0,0 +1,239 @@ +require_relative "../../test_helper" + +class TestPrimitives < Textbringer::TestCase + R = Textbringer::Elisp::Runtime + + setup do + Textbringer::Elisp.reset! + Textbringer::Elisp.init + end + + # --- Arithmetic --- + + def test_plus + assert_equal(6, R.funcall(:"+", 1, 2, 3)) + end + + def test_minus + assert_equal(4, R.funcall(:"-", 10, 3, 3)) + assert_equal(-5, R.funcall(:"-", 5)) + end + + def test_multiply + assert_equal(24, R.funcall(:"*", 2, 3, 4)) + end + + def test_divide + assert_equal(5, R.funcall(:"/", 10, 2)) + end + + def test_mod + assert_equal(1, R.funcall(:"%", 10, 3)) + end + + def test_one_plus + assert_equal(6, R.funcall(:"1+", 5)) + end + + def test_one_minus + assert_equal(4, R.funcall(:"1-", 5)) + end + + def test_max + assert_equal(10, R.funcall(:"max", 3, 10, 5)) + end + + def test_min + assert_equal(3, R.funcall(:"min", 3, 10, 5)) + end + + def test_abs + assert_equal(5, R.funcall(:"abs", -5)) + end + + # --- Comparison --- + + def test_num_eq + assert_equal(true, R.funcall(:"=", 5, 5)) + assert_nil(R.funcall(:"=", 5, 6)) + end + + def test_num_neq + assert_equal(true, R.funcall(:"/=", 5, 6)) + assert_nil(R.funcall(:"/=", 5, 5)) + end + + def test_lt + assert_equal(true, R.funcall(:"<", 1, 2)) + assert_nil(R.funcall(:"<", 2, 1)) + end + + # --- List operations --- + + def test_car_cdr + l = R.list(1, 2, 3) + assert_equal(1, R.funcall(:"car", l)) + assert_equal([2, 3], R.funcall(:"cdr", l).to_list) + end + + def test_cons + c = R.funcall(:"cons", 1, 2) + assert_equal(1, R.funcall(:"car", c)) + assert_equal(2, R.funcall(:"cdr", c)) + end + + def test_list + l = R.funcall(:"list", 1, 2, 3) + assert_equal([1, 2, 3], l.to_list) + end + + def test_length + assert_equal(3, R.funcall(:"length", R.list(1, 2, 3))) + assert_equal(0, R.funcall(:"length", nil)) + assert_equal(5, R.funcall(:"length", "hello")) + end + + def test_nth + l = R.list(10, 20, 30) + assert_equal(20, R.funcall(:"nth", 1, l)) + end + + def test_reverse + l = R.list(1, 2, 3) + r = R.funcall(:"reverse", l) + assert_equal([3, 2, 1], r.to_list) + end + + def test_append + a = R.list(1, 2) + b = R.list(3, 4) + result = R.funcall(:"append", a, b) + assert_equal([1, 2, 3, 4], result.to_list) + end + + def test_member + l = R.list(1, 2, 3) + result = R.funcall(:"member", 2, l) + assert_equal([2, 3], result.to_list) + assert_nil(R.funcall(:"member", 5, l)) + end + + def test_assoc + alist = R.list(R.cons(:a, 1), R.cons(:b, 2)) + result = R.funcall(:"assoc", :a, alist) + assert_equal(:a, R.funcall(:"car", result)) + assert_equal(1, R.funcall(:"cdr", result)) + assert_nil(R.funcall(:"assoc", :c, alist)) + end + + def test_mapcar + l = R.list(1, 2, 3) + R.defun(:"my-inc") { |x| x + 1 } + result = R.funcall(:"mapcar", :"my-inc", l) + assert_equal([2, 3, 4], result.to_list) + end + + # --- String operations --- + + def test_concat + assert_equal("foobar", R.funcall(:"concat", "foo", "bar")) + end + + def test_substring + assert_equal("llo", R.funcall(:"substring", "hello", 2)) + assert_equal("ll", R.funcall(:"substring", "hello", 2, 4)) + end + + def test_upcase_downcase + assert_equal("HELLO", R.funcall(:"upcase", "hello")) + assert_equal("hello", R.funcall(:"downcase", "HELLO")) + end + + def test_number_to_string + assert_equal("42", R.funcall(:"number-to-string", 42)) + end + + def test_string_to_number + assert_equal(42, R.funcall(:"string-to-number", "42")) + end + + def test_symbol_name + assert_equal("foo", R.funcall(:"symbol-name", :foo)) + end + + def test_intern + assert_equal(:foo, R.funcall(:"intern", "foo")) + end + + # --- Type predicates --- + + def test_null + assert_equal(true, R.funcall(:"null", nil)) + assert_nil(R.funcall(:"null", 1)) + end + + def test_listp + assert_equal(true, R.funcall(:"listp", nil)) + assert_equal(true, R.funcall(:"listp", R.list(1))) + assert_nil(R.funcall(:"listp", 1)) + end + + def test_consp + assert_equal(true, R.funcall(:"consp", R.list(1))) + assert_nil(R.funcall(:"consp", nil)) + end + + def test_atom + assert_equal(true, R.funcall(:"atom", 1)) + assert_equal(true, R.funcall(:"atom", nil)) + assert_nil(R.funcall(:"atom", R.list(1))) + end + + def test_stringp + assert_equal(true, R.funcall(:"stringp", "hello")) + assert_nil(R.funcall(:"stringp", 1)) + end + + def test_numberp + assert_equal(true, R.funcall(:"numberp", 42)) + assert_equal(true, R.funcall(:"numberp", 3.14)) + assert_nil(R.funcall(:"numberp", "no")) + end + + def test_integerp + assert_equal(true, R.funcall(:"integerp", 42)) + assert_nil(R.funcall(:"integerp", 3.14)) + end + + def test_floatp + assert_equal(true, R.funcall(:"floatp", 3.14)) + assert_nil(R.funcall(:"floatp", 42)) + end + + def test_symbolp + assert_equal(true, R.funcall(:"symbolp", :foo)) + assert_nil(R.funcall(:"symbolp", "foo")) + end + + def test_functionp + assert_equal(true, R.funcall(:"functionp", -> { 1 })) + assert_nil(R.funcall(:"functionp", 1)) + end + + def test_not + assert_equal(true, R.funcall(:"not", nil)) + assert_nil(R.funcall(:"not", 1)) + end + + # --- Misc --- + + def test_apply + result = R.funcall(:"apply", :"+", R.list(1, 2, 3)) + assert_equal(6, result) + end + + def test_provide_require + R.funcall(:"provide", :myfeat) + assert_equal(true, R.funcall(:"featurep", :myfeat)) + end +end diff --git a/test/textbringer/elisp/test_reader.rb b/test/textbringer/elisp/test_reader.rb new file mode 100644 index 00000000..5d35b558 --- /dev/null +++ b/test/textbringer/elisp/test_reader.rb @@ -0,0 +1,197 @@ +require_relative "../../test_helper" + +class TestReader < Textbringer::TestCase + def read(source) + Textbringer::Elisp::Reader.new(source).read_all + end + + def read_one(source) + Textbringer::Elisp::Reader.new(source).read_form + end + + # --- Atoms --- + + def test_integer + node = read_one("42") + assert_instance_of(Textbringer::Elisp::IntegerLit, node) + assert_equal(42, node.value) + end + + def test_negative_integer + node = read_one("-7") + assert_instance_of(Textbringer::Elisp::IntegerLit, node) + assert_equal(-7, node.value) + end + + def test_float + node = read_one("3.14") + assert_instance_of(Textbringer::Elisp::FloatLit, node) + assert_in_delta(3.14, node.value) + end + + def test_float_exponent + node = read_one("1e10") + assert_instance_of(Textbringer::Elisp::FloatLit, node) + assert_in_delta(1e10, node.value) + end + + def test_string + node = read_one('"hello world"') + assert_instance_of(Textbringer::Elisp::StringLit, node) + assert_equal("hello world", node.value) + end + + def test_string_escapes + node = read_one('"hello\\nworld"') + assert_instance_of(Textbringer::Elisp::StringLit, node) + assert_equal("hello\nworld", node.value) + end + + def test_symbol + node = read_one("foo-bar") + assert_instance_of(Textbringer::Elisp::Symbol, node) + assert_equal("foo-bar", node.name) + end + + def test_nil_symbol + node = read_one("nil") + assert_instance_of(Textbringer::Elisp::Symbol, node) + assert_equal("nil", node.name) + end + + def test_t_symbol + node = read_one("t") + assert_instance_of(Textbringer::Elisp::Symbol, node) + assert_equal("t", node.name) + end + + # --- Characters --- + + def test_character + node = read_one("?a") + assert_instance_of(Textbringer::Elisp::CharLit, node) + assert_equal(97, node.value) + end + + def test_character_escape + node = read_one("?\\n") + assert_instance_of(Textbringer::Elisp::CharLit, node) + assert_equal(10, node.value) + end + + def test_control_character + node = read_one("?\\C-a") + assert_instance_of(Textbringer::Elisp::CharLit, node) + assert_equal(1, node.value) + end + + # --- Lists --- + + def test_empty_list + node = read_one("()") + assert_instance_of(Textbringer::Elisp::List, node) + assert_equal([], node.elements) + assert_nil(node.dotted) + end + + def test_simple_list + node = read_one("(+ 1 2)") + assert_instance_of(Textbringer::Elisp::List, node) + assert_equal(3, node.elements.length) + assert_equal("+", node.elements[0].name) + assert_equal(1, node.elements[1].value) + assert_equal(2, node.elements[2].value) + end + + def test_dotted_pair + node = read_one("(a . b)") + assert_instance_of(Textbringer::Elisp::List, node) + assert_equal(1, node.elements.length) + assert_equal("a", node.elements[0].name) + assert_equal("b", node.dotted.name) + end + + def test_nested_lists + node = read_one("(a (b c) d)") + assert_instance_of(Textbringer::Elisp::List, node) + assert_equal(3, node.elements.length) + assert_instance_of(Textbringer::Elisp::List, node.elements[1]) + assert_equal(2, node.elements[1].elements.length) + end + + # --- Quotes --- + + def test_quote + node = read_one("'foo") + assert_instance_of(Textbringer::Elisp::Quoted, node) + assert_equal(:quote, node.kind) + assert_equal("foo", node.form.name) + end + + def test_backquote + node = read_one("`foo") + assert_instance_of(Textbringer::Elisp::Quoted, node) + assert_equal(:backquote, node.kind) + end + + def test_unquote + node = read_one(",foo") + assert_instance_of(Textbringer::Elisp::Unquote, node) + assert_equal(false, node.splicing) + end + + def test_splice + node = read_one(",@foo") + assert_instance_of(Textbringer::Elisp::Unquote, node) + assert_equal(true, node.splicing) + end + + def test_function_quote + node = read_one("#'foo") + assert_instance_of(Textbringer::Elisp::Quoted, node) + assert_equal(:function, node.kind) + end + + # --- Vectors --- + + def test_vector + node = read_one("[1 2 3]") + assert_instance_of(Textbringer::Elisp::Vector, node) + assert_equal(3, node.elements.length) + end + + # --- Comments --- + + def test_comments_skipped + forms = read("; this is a comment\n42") + assert_equal(1, forms.length) + assert_equal(42, forms[0].value) + end + + # --- Multiple forms --- + + def test_read_all + forms = read("1 2 3") + assert_equal(3, forms.length) + end + + # --- Errors --- + + def test_unterminated_list + assert_raise(Textbringer::Elisp::Reader::ReadError) do + read("(a b") + end + end + + def test_unterminated_string + assert_raise(Textbringer::Elisp::Reader::ReadError) do + read('"hello') + end + end + + def test_unexpected_close_paren + assert_raise(Textbringer::Elisp::Reader::ReadError) do + read_one(")") + end + end +end diff --git a/test/textbringer/elisp/test_runtime.rb b/test/textbringer/elisp/test_runtime.rb new file mode 100644 index 00000000..ca0ace54 --- /dev/null +++ b/test/textbringer/elisp/test_runtime.rb @@ -0,0 +1,175 @@ +require_relative "../../test_helper" + +class TestRuntime < Textbringer::TestCase + R = Textbringer::Elisp::Runtime + + setup do + R.reset! + end + + # --- Dynamic binding --- + + def test_get_set_var + R.set_var(:x, 42) + assert_equal(42, R.get_var(:x)) + end + + def test_unset_var_returns_nil + assert_nil(R.get_var(:nonexistent)) + end + + def test_with_dynamic_bindings + R.set_var(:x, 1) + R.with_dynamic_bindings({ x: 10 }) do + assert_equal(10, R.get_var(:x)) + R.set_var(:x, 20) + assert_equal(20, R.get_var(:x)) + end + assert_equal(1, R.get_var(:x)) + end + + def test_defvar + R.defvar(:myvar, 99) + assert_equal(99, R.get_var(:myvar)) + # defvar should not overwrite existing value + R.defvar(:myvar, 100) + assert_equal(99, R.get_var(:myvar)) + end + + # --- Function registry --- + + def test_defun_and_funcall + R.defun(:double) { |x| x * 2 } + assert_equal(10, R.funcall(:double, 5)) + end + + def test_funcall_unknown_raises + assert_raise(R::ElispError) do + R.funcall(:nonexistent) + end + end + + def test_function_ref + R.defun(:myfn) { 42 } + ref = R.function_ref(:myfn) + assert_instance_of(Proc, ref) + assert_equal(42, ref.call) + end + + def test_make_lambda + lam = R.make_lambda { |x| x + 1 } + assert_instance_of(Proc, lam) + assert_equal(6, lam.call(5)) + end + + # --- Truthiness --- + + def test_truthy + assert_equal(true, R.truthy?(true)) + assert_equal(true, R.truthy?(1)) + assert_equal(true, R.truthy?("")) + assert_equal(true, R.truthy?(0)) + assert_equal(false, R.truthy?(nil)) + assert_equal(false, R.truthy?(false)) + end + + # --- Cons / List --- + + def test_cons + c = R.cons(1, 2) + assert_instance_of(R::Cons, c) + assert_equal(1, R.car(c)) + assert_equal(2, R.cdr(c)) + end + + def test_list + l = R.list(1, 2, 3) + assert_instance_of(R::Cons, l) + assert_equal([1, 2, 3], l.to_list) + end + + def test_car_cdr_nil + assert_nil(R.car(nil)) + assert_nil(R.cdr(nil)) + end + + def test_el_length + assert_equal(0, R.el_length(nil)) + assert_equal(3, R.el_length(R.list(1, 2, 3))) + assert_equal(5, R.el_length("hello")) + end + + def test_el_nth + l = R.list(10, 20, 30) + assert_equal(10, R.el_nth(0, l)) + assert_equal(20, R.el_nth(1, l)) + assert_equal(30, R.el_nth(2, l)) + assert_nil(R.el_nth(5, l)) + end + + def test_el_append + a = R.list(1, 2) + b = R.list(3, 4) + result = R.el_append(a, b) + assert_equal([1, 2, 3, 4], result.to_list) + end + + def test_el_reverse + l = R.list(1, 2, 3) + r = R.el_reverse(l) + assert_equal([3, 2, 1], r.to_list) + end + + # --- Short-circuit logic --- + + def test_el_and + assert_equal(3, R.el_and(-> { 1 }, -> { 2 }, -> { 3 })) + assert_nil(R.el_and(-> { 1 }, -> { nil }, -> { 3 })) + end + + def test_el_or + assert_equal(1, R.el_or(-> { 1 }, -> { 2 })) + assert_equal(2, R.el_or(-> { nil }, -> { 2 })) + assert_nil(R.el_or(-> { nil }, -> { nil })) + end + + # --- Arithmetic helpers --- + + def test_el_plus + assert_equal(10, R.el_plus(1, 2, 3, 4)) + assert_equal(0, R.el_plus) + end + + def test_el_minus + assert_equal(-5, R.el_minus(5)) + assert_equal(3, R.el_minus(10, 4, 3)) + end + + def test_el_multiply + assert_equal(24, R.el_multiply(2, 3, 4)) + end + + def test_el_divide + assert_equal(5, R.el_divide(10, 2)) + end + + # --- Feature system --- + + def test_provide_and_featurep + assert_nil(R.featurep?(:myfeat)) + R.provide(:myfeat) + assert_equal(true, R.featurep?(:myfeat)) + end + + # --- Comparison helpers --- + + def test_el_eq + assert_equal(true, R.el_eq(1, 1)) + assert_nil(R.el_eq("a", "b")) + end + + def test_el_not + assert_equal(true, R.el_not(nil)) + assert_nil(R.el_not(42)) + end +end