Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions exe/rdx
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
141 changes: 141 additions & 0 deletions lib/rubydex/console.rb
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may need to require "fiddle" to fix the Windows build?

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 <CYPHER>` — 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
# `#<Class:Rubydex::Console>::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(<CYPHER>)` — 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(<CYPHER>)"

def execute(cypher)
Console.run(cypher)
end
end

# `query <CYPHER>` — 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 <CYPHER>"

def execute(arg)
cypher = arg.to_s.strip
if cypher.empty?
warn("Usage: query <CYPHER>")
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)
113 changes: 113 additions & 0 deletions test/console_test.rb
Original file line number Diff line number Diff line change
@@ -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 "#<Class:Rubydex::Console>::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
Loading