diff --git a/exe/rdx b/exe/rdx index 4b8813817..c2bb3a8c6 100755 --- a/exe/rdx +++ b/exe/rdx @@ -115,12 +115,10 @@ operation = end.parse! lambda do |graph| - require "irb" - IRB.setup(nil) - IRB.conf[:IRB_NAME] = "rubydex" - IRB::Irb.new(IRB::WorkSpace.new(binding)).run(IRB.conf) + require "rubydex/console" + Rubydex::Console.start(graph) rescue LoadError - abort("Interactive mode requires `irb` to be in the bundle") + abort("Interactive mode requires `irb` >= 1.13 to be in the bundle") end when "mcp" parser = OptionParser.new do |p| diff --git a/lib/rubydex/console.rb b/lib/rubydex/console.rb new file mode 100644 index 000000000..4ab87cd70 --- /dev/null +++ b/lib/rubydex/console.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# The console is built on IRB's command and helper-method registration APIs, both introduced in IRB +# 1.13. Requiring an older or absent IRB raises `LoadError` (`Gem::LoadError` is one), which +# `exe/rdx` turns into a friendly message rather than a backtrace. +gem "irb", ">= 1.13" +require "irb" +require "irb/command" +require "irb/helper_method" + +module Rubydex + # Interactive console backing `rdx console`. + # + # On top of a normal IRB session (with `graph` in scope so you can call Ruby directly, e.g. + # `graph["Foo"]`), it offers two complementary ways to run Cypher against the same graph: + # + # # `run("...")` — a method that returns rich Ruby objects for programmatic use: an Array of + # # Hashes keyed by RETURN column, where node columns are live Declaration/Definition/Document + # # handles. Because it's a plain method, the result can be assigned and navigated. + # rubydex(main):001> result = run("MATCH (c:Class)-[:HAS_PARENT]->(p) RETURN c, p") + # rubydex(main):002> result.first["c"].name + # + # # `query ` — a command that takes the rest of the line verbatim (no quotes / valid + # # Ruby needed) and prints a formatted table for a quick look. + # rubydex(main):003> query MATCH (n:Class|Module) RETURN n.name ORDER BY n.name + # + # # `schema` — prints the queryable schema. + # rubydex(main):004> schema + # + # So `run` is the programmatic entry point and `query` is the interactive quick-look; they are + # deliberately separate rather than one delegating to the other. This is the clean, idiomatic + # "query mode": rather than swapping the REPL's evaluator, Cypher lines are simply prefixed with + # `query`. + # + # The `run` helper and the `query`/`schema` commands are registered through IRB's own extension + # APIs, so the session itself is a stock IRB workspace (see {.start}). + module Console + class << self + # The graph that `run` and the `query`/`schema` commands operate on. Set by {.start}. + attr_accessor :graph + + # Starts an interactive session. `graph` is exposed at the prompt, and `run` plus the + # `query`/`schema` commands operate on it via {.graph}. + def start(graph) + self.graph = graph + + IRB.setup(nil) + IRB.conf[:IRB_NAME] = "rubydex" + IRB::Irb.new(workspace_for(graph)).run(IRB.conf) + end + + # Runs a Cypher query against {.graph} and returns the rows as rich Ruby objects: an Array of + # Hashes keyed by RETURN column name. Scalars become String/Integer/true/false/nil, lists + # become Arrays, map projections become Hashes, and node columns become live + # `Declaration`/`Definition`/`Document` handles. Raises `ArgumentError` on a syntax or + # execution error. Callable in the console as `run("MATCH ...")`. + def run(cypher) + Rubydex::Query.parse(cypher.to_s).run(graph) + end + + # Runs a Cypher query against {.graph} and returns the formatted output as a String (`:table` + # or `:json`) — the quick-look counterpart of {.run}, used by the `query` command. Raises + # `ArgumentError` on a syntax or execution error. + def render(cypher, format: :table) + Rubydex::Query.parse(cypher.to_s).render(graph, format) + end + + # Returns the queryable Cypher schema description as a formatted String. + def describe_schema(format: :table) + Rubydex::Query.schema(format) + end + + private + + # The session workspace. This is deliberately IRB's own default workspace rather than a + # hand-rolled binding, because `IRB::WorkSpace.new` derives its binding from a copy of + # `IRB::TOPLEVEL_BINDING`, which buys three things a custom binding gets wrong: + # + # * `self` is `main` and the cref is `Object`, so `class Foo; end` at the prompt defines + # `::Foo`. A `binding` captured inside `class << self` would define + # `#::Foo` instead. + # * no caller locals leak in. Deriving from the CLI's `TOPLEVEL_BINDING` would inherit every + # top-level local of `exe/rdx` — including `query` and `schema`, and a local shadows the + # IRB command of the same name (IRB resolves locals before dispatching commands). + # * `_`, `help` and the rest of IRB's own conveniences behave as they do in stock `irb`. + # + # Only `graph` is injected; `run` reaches the prompt as a registered IRB helper method. + def workspace_for(graph) + workspace = IRB::WorkSpace.new + workspace.local_variable_set(:graph, graph) + workspace + end + end + + # `run()` — an IRB helper method, so it is callable bare at the prompt while still + # returning a value that can be assigned and navigated. + class RunHelper < IRB::HelperMethod::Base + description "Run a Cypher query and return the rows as Ruby objects: run()" + + def execute(cypher) + Console.run(cypher) + end + end + + # `query ` — runs a Cypher query against the console graph and prints a formatted table + # for a quick look. For a result you can assign and navigate, use `run("...")` (see {Console.run}). + class QueryCommand < IRB::Command::Base + category "Rubydex" + description "Run a Cypher query against the graph: query " + + def execute(arg) + cypher = arg.to_s.strip + if cypher.empty? + warn("Usage: query ") + return + end + + puts(Console.render(cypher)) + nil + rescue ArgumentError => e + warn(e.message) + nil + end + end + + # `schema` — prints the queryable Cypher schema (labels, relationships, properties). + class SchemaCommand < IRB::Command::Base + category "Rubydex" + description "Describe the queryable Cypher schema" + + def execute(_arg) + puts(Console.describe_schema) + nil + end + end + end +end + +IRB::HelperMethod.register(:run, Rubydex::Console::RunHelper) +IRB::Command.register(:query, Rubydex::Console::QueryCommand) +IRB::Command.register(:schema, Rubydex::Console::SchemaCommand) diff --git a/test/console_test.rb b/test/console_test.rb new file mode 100644 index 000000000..b6040f4cf --- /dev/null +++ b/test/console_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "test_helper" +require "helpers/context" +require "json" +require "rubydex/console" + +class ConsoleTest < Minitest::Test + include Test::Helpers::WithContext + + def teardown + Rubydex::Console.graph = nil + end + + def test_run_returns_rich_objects_against_the_console_graph + with_graph("class Animal; end\nclass Dog < Animal; end\n") do + rows = Rubydex::Console.run("MATCH (c:Class)-[:HAS_PARENT]->(p) WHERE c.name = 'Dog' RETURN c, p") + + assert_kind_of(Array, rows) + assert_equal(1, rows.length) + # Node columns come back as live handles that can be navigated/assigned. + assert_kind_of(Rubydex::Declaration, rows.first["c"]) + assert_equal("Dog", rows.first["c"].name) + assert_equal("Animal", rows.first["p"].name) + end + end + + def test_run_raises_on_invalid_query + with_graph("class Dog; end\n") do + error = assert_raises(ArgumentError) { Rubydex::Console.run("MATCH (c RETURN c") } + assert_match(/Cypher syntax error/, error.message) + end + end + + def test_render_returns_formatted_output_for_the_query_command + with_graph("class Animal; end\nclass Dog < Animal; end\n") do + output = Rubydex::Console.render("MATCH (c:Class)-[:HAS_PARENT]->(p) WHERE c.name = 'Dog' RETURN p.name") + + assert_match(/p\.name/, output) + assert_match(/Animal/, output) + end + end + + def test_render_supports_json_format + with_graph("class Dog; end\n") do + output = Rubydex::Console.render("MATCH (c:Class {name: 'Dog'}) RETURN c.name", format: :json) + + assert_equal("[{\"c.name\":\"Dog\"}]", output) + end + end + + def test_describe_schema_lists_relationships + output = Rubydex::Console.describe_schema(format: :json) + parsed = JSON.parse(output) + + assert(parsed["relationships"].any? { |r| r["type"] == "HAS_PARENT" }) + end + + def test_commands_and_helpers_are_registered + assert_includes(IRB::Command.commands.keys, :query) + assert_includes(IRB::Command.commands.keys, :schema) + + # `run` is a helper method rather than a command, so it stays an ordinary expression whose + # value can be assigned and navigated at the prompt. + assert_includes(IRB::HelperMethod.helper_methods.keys, :run) + end + + def test_run_helper_delegates_to_the_console_graph + with_graph("class Dog; end\n") do + helper = Rubydex::Console::RunHelper.instance + + assert_equal([{ "c.name" => "Dog" }], helper.execute("MATCH (c:Class {name: 'Dog'}) RETURN c.name")) + end + end + + def test_session_workspace_evaluates_at_top_level + # `IRB::WorkSpace` reads IRB.conf[:CONTEXT_MODE] to build its binding; `Console.start` calls + # `IRB.setup` before it does. + IRB.setup(nil) unless IRB.conf[:CONTEXT_MODE] + workspace = Rubydex::Console.send(:workspace_for, :sentinel) + + # `self` must be `main`, as in a stock IRB session — not `Rubydex::Console`. + assert_same(TOPLEVEL_BINDING.receiver, workspace.binding.receiver) + + # `graph` is exposed, and nothing else is inherited from the caller's scope. A local named + # `query` or `schema` would shadow the IRB command of the same name. + assert_same(:sentinel, workspace.binding.eval("graph")) + refute_includes(workspace.binding.local_variables, :query) + refute_includes(workspace.binding.local_variables, :schema) + + # A class defined at the prompt must land at top level, not under the singleton class of + # Rubydex::Console (which would name it "#::ConsoleWorkspaceProbe"). + workspace.binding.eval("class ConsoleWorkspaceProbe; end") + + assert(Object.const_defined?(:ConsoleWorkspaceProbe, false)) + assert_equal("ConsoleWorkspaceProbe", workspace.binding.eval("ConsoleWorkspaceProbe").name) + ensure + Object.send(:remove_const, :ConsoleWorkspaceProbe) if Object.const_defined?(:ConsoleWorkspaceProbe, false) + end + + private + + def with_graph(source) + with_context do |context| + context.write!("zoo.rb", source) + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + Rubydex::Console.graph = graph + yield + end + end +end