Skip to content
Open
10 changes: 10 additions & 0 deletions lib/rubydex/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ def start(argv = ARGV)
require "rubydex"

dispatch(argv.shift, argv)
rescue StandardError => e
# `rubydex/server` is loaded only when a command needs it, so the constant may not exist.
# Naming it directly in the rescue clause would raise `NameError` here for every unrelated
# error in that case, and hide the real one.
raise unless defined?(Rubydex::Server::Error) && e.is_a?(Rubydex::Server::Error)

# A server that will not start, stop or answer is a runtime condition, and not a defect in
# rdx. The user gets the sentence, and never a backtrace.
warn("rdx server: #{e.message}")
exit(1)
end

# Reports `message`, then the usage text, and exits non-zero. Public because the subcommands
Expand Down
32 changes: 14 additions & 18 deletions lib/rubydex/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require "optparse"

require "rubydex/progress"

module Rubydex
module CLI
# Base class for `rdx` subcommands. A subcommand parses its own options out of `argv` and, when
Expand Down Expand Up @@ -109,16 +111,19 @@ def abort_with_usage(message)
CLI.abort_with_usage(message)
end

# Parses this command's options out of `argv`, with a banner derived from the command's own
# declaration. `-h`/`--help` prints the parser and exits, so every subcommand documents itself
# the same way. Pass `options: true` when the command accepts options beyond `--help`.
# Parses this command's options out of `argv`. By default the banner comes from the command's
# own declaration. A command with subactions can pass its own `banner` instead. `-h`/`--help`
# prints the parser and exits, so every subcommand documents itself the same way. Pass
# `options: true` when the command accepts options beyond `--help`.
#
# A bad option reports the message and the usage text, so every subcommand rejects bad input
# the same way.
#: (?options: bool) ?{ (OptionParser parser) -> void } -> void
def parse_options!(options: false)
banner = +"Usage: rdx #{self.class.usage_form}"
banner << " [options]" if options
#: (?options: bool, ?banner: String?) ?{ (OptionParser parser) -> void } -> void
def parse_options!(options: false, banner: nil)
unless banner
banner = +"Usage: rdx #{self.class.usage_form}"
banner << " [options]" if options
end

parser = OptionParser.new do |p|
p.banner = banner
Expand All @@ -138,19 +143,10 @@ def parse_options!(options: false)
#: (IO progress_io) -> Rubydex::Graph
def build_graph(progress_io)
graph = Rubydex::Graph.configure_for_workspace(Dir.pwd)
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
with_timer(progress_io, "Resolving graph...") { graph.resolve }
Progress.with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
Progress.with_timer(progress_io, "Resolving graph...") { graph.resolve }
graph
end

#: (IO io, String message) { -> void } -> void
def with_timer(io, message)
io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
io.puts(" finished in #{duration.round(2)}ms")
end
end
end
end
33 changes: 30 additions & 3 deletions lib/rubydex/cli/command/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
module Rubydex
module CLI
# `rdx query <CYPHER>` — runs a Cypher query against the workspace graph and prints the result.
# `--schema` describes the queryable schema instead, which needs no graph.
# `--schema` describes the queryable schema instead, which needs no graph. `--server` sends the
# query to the resident server for this workspace.
class Command
class Query < Command
command "query"
Expand All @@ -20,12 +21,16 @@ class Query < Command
def run
schema = false
format = "table"
use_server = false

parse_options!(options: true) do |parser|
parser.on("--schema", "Describe the queryable schema instead of running a query") { schema = true }
parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") do |value|
format = value
end
parser.on("--server", "Run the query through the resident server for this workspace") do
use_server = true
end
end

query = argv.shift
Expand All @@ -39,6 +44,30 @@ def run

abort_with_usage("`query` requires a Cypher query argument (or pass `--schema`)") if query.nil? || query.empty?

return if use_server && query_through_server(query, format)

run_inline(query, format)
end

private

# Sends the query to the resident server. Returns `false` when server mode is unavailable, so
# the caller falls back to inline execution.
#: (String query, String format) -> bool
def query_through_server(query, format)
require "rubydex/server"

return false if Rubydex::Server.disabled? || !Rubydex::Server.supported?

# The server parses and runs the query, so the client loads no native extension and only
# forwards the query string.
state = Rubydex::Server::State.new(workspace_path: Dir.pwd)
exit(Rubydex::Server::Client.query(state, { query: query, query_format: format }))
end

# Builds the graph in this process and renders the query against it.
#: (String query, String format) -> void
def run_inline(query, format)
# Parse the query up front so a malformed query fails fast, before the expensive indexing.
parsed = parse_query(query)

Expand All @@ -48,8 +77,6 @@ def run
render(parsed, graph, format)
end

private

#: (String query) -> Rubydex::Query
def parse_query(query)
Rubydex::Query.parse(query)
Expand Down
77 changes: 77 additions & 0 deletions lib/rubydex/cli/command/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

require "rubydex/cli/command"

module Rubydex
module CLI
# `rdx server <action>` — manages the resident server for the current workspace.
class Command
class Server < Command
command "server"
arguments "<action>"
summary "Manage the resident server (start, stop, restart, status)"

ACTIONS = ["start", "stop", "restart", "status"].freeze #: Array[String]

USAGE = <<~TEXT #: String
Usage: rdx server <action> [options]

Actions:
start Start the server for this workspace
stop Stop the running server for this workspace
restart Restart the server for this workspace
status Print the status of the server for this workspace
TEXT

#: -> void
def run
detach = true

# The options are parsed first, so that `--help` and a bad option reach the parser instead
# of being read as the action. `OptionParser#parse!` permutes, so the action can appear
# before or after an option.
parse_options!(options: true, banner: USAGE) do |parser|
parser.on("--no-detach", "Run the server in the foreground (for debugging / containers)") do
detach = false
end
end

action = argv.shift
abort_with_actions("unknown server action: #{action.inspect}") unless ACTIONS.include?(action)

require "rubydex/server"

unless Rubydex::Server.supported?
abort("rdx server mode is not supported on this platform (requires fork + UNIX sockets)")
end

exit(dispatch_action(action, detach))
end

private

#: (String action, bool detach) -> Integer
def dispatch_action(action, detach)
state = Rubydex::Server::State.new(workspace_path: Dir.pwd)

case action
when "start" then Rubydex::Server::Commands.start(state, detach: detach)
when "stop" then Rubydex::Server::Commands.stop(state)
when "restart" then Rubydex::Server::Commands.restart(state, detach: detach)
else Rubydex::Server::Commands.status(state)
end
end

# Reports `message` with the action list rather than the top-level command list, because the
# error is about an action of this command.
#: (String message) -> void
def abort_with_actions(message)
warn(message)
warn("")
warn(USAGE)
exit(1)
end
end
end
end
end
25 changes: 25 additions & 0 deletions lib/rubydex/progress.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Rubydex
# Reports how long a step took. The CLI and the server both print the same progress lines, so the
# measurement lives here rather than in either of them.
module Progress
class << self
# Runs the block and reports its duration to `io`. A `nil` `io` runs the block and reports
# nothing, which is what the server does when it has no log.
#: (IO? io, String message) { -> void } -> void
def with_timer(io, message)
unless io
yield
return
end

io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
io.puts(" finished in #{duration.round(2)}ms")
end
end
end
end
62 changes: 62 additions & 0 deletions lib/rubydex/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require "rubydex/progress"
require "rubydex/version"

module Rubydex
# Client/server mode for the `rdx` executable.
#
# The expensive indexing + resolution work is performed once by a resident server process that
# keeps the built `Rubydex::Graph` in memory. Subsequent commands (currently `--query`) run against
# the already-built graph over a UNIX domain socket, making follow-up queries effectively instant.
#
module Server
# Wire protocol version. Bump on any incompatible change to the request/response shape.
PROTOCOL = 1
Comment on lines +14 to +15

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.

Where do we ever enforce this?


class Error < StandardError; end

class << self
# Whether server mode can run on the current platform. Requires `fork` + UNIX domain sockets.
#: -> bool
def supported?
Process.respond_to?(:fork) && defined?(::UNIXSocket) && !Gem.win_platform?
end

# Whether the user has explicitly disabled the server via the environment.
#: -> bool
def disabled?
ENV.key?("DISABLE_RDX_SERVER")
end
Comment on lines +26 to +30

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.

Do we need this?


# Builds a fully indexed + resolved graph for the workspace, and returns it with the errors the
# indexer reported. `progress_io`, when given, receives human-readable progress messages.
#
# A caller that discards the errors records a file the indexer never read as successfully
# indexed, which is why they come back rather than vanishing here.
#: (workspace_path: String, ?progress_io: IO?) -> [Rubydex::Graph, Array[String]]
def build_graph(workspace_path:, progress_io: nil)
# `configure_for_workspace` builds the graph rooted at `workspace_path` and applies that
# workspace's `.rubydex` config (exclusions etc.) before indexing, matching the inline CLI
# path. A missing default config is ignored.
graph = Rubydex::Graph.configure_for_workspace(workspace_path)

# `workspace_paths` lists every root to index, and it names gem directories that this install
# may not have. Each absent root costs one error, and those phantom errors would drown the
# ones that concern real files, so they never reach the indexer.
roots = graph.workspace_paths.select { |path| File.exist?(path) }

errors = [] #: Array[String]
Progress.with_timer(progress_io, "Indexing workspace...") { errors = graph.index_all(roots) }
Progress.with_timer(progress_io, "Resolving graph...") { graph.resolve }
[graph, errors]
end
end
end
end

require "rubydex/server/state"
require "rubydex/server/request"
require "rubydex/server/core"
require "rubydex/server/client"
require "rubydex/server/commands"
Loading
Loading