Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ gem "rubocop", "~> 1.21"
gem "rubocop-performance"
gem "rubocop-rake"
gem "rubocop-rspec"
gem "tty-table"

# Optional dependencies — loaded lazily by the feature that needs them.
group :optional do
gem "sinatra" # Dcc::Server REST API
gem "tty-prompt" # dcc issue (interactive builder)
gem "tty-table" # dcc extract files (aligned table)
end

gem "puma", "~> 8.0"
Expand Down
1 change: 0 additions & 1 deletion lib/dcc/cli/cli.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require "thor"
require "tty-table"

# `Dcc::Cli::Cli` is the Thor-based command-line interface. Each subcommand
# (validate, convert, extract, signature, transform, inspect, diff, issue)
Expand Down
30 changes: 28 additions & 2 deletions lib/dcc/cli/formatters.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
# frozen_string_literal: true

require "tty-table"

module Dcc
module Cli
# `Dcc::Cli::Formatters` picks the right text/JSON/YAML representation
# of a result model and prints it.
module Formatters
# Goes to stderr, so a piped `extract files` still yields clean data.
TTY_TABLE_HINT = "note: install tty-table for aligned output " \
"(gem install tty-table)"

# `tty-table` is optional. It pulls in eight further gems to prettify
# one command, which every library user would otherwise install just
# to parse XML. Loaded on first use, and its absence falls back to the
# plain renderer rather than raising the way `Dcc::Server` does.
def self.table_available?
return @table_available unless @table_available.nil?

begin
require "tty-table"
@table_available = true
rescue ::LoadError
@table_available = false
end
@table_available
end

module_function

def print(object, format: "text")
Expand All @@ -24,6 +42,14 @@ def print_files(files)
return
end

unless table_available?
# Hint first: a stderr that raises is caught by the rescue below,
# which re-runs the renderer. Printing first would duplicate it.
warn TTY_TABLE_HINT
print_files_plain(files)
return
end

# TTY::Table calls ioctl which fails on StringIO / pipes. Fall back
# to a plain-text rendering when stdout isn't a real terminal.
if $stdout.is_a?(::IO) && !$stdout.tty?
Expand Down
51 changes: 51 additions & 0 deletions spec/dcc/cli/dependencies_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "spec_helper"

# The CLI ships as `exe/dcc`. Every gem it requires unconditionally must be a
# declared runtime dependency, or `gem install dcc` yields a binary that
# crashes on every command. A gem may be left undeclared only by naming it
# in `optional`, which means its require is guarded and its absence degrades
# the output instead of raising.
# rubocop:disable RSpec/DescribeClass -- packaging invariant, not a class
RSpec.describe "dcc CLI runtime dependencies" do
let(:root) { File.expand_path("../../..", __dir__) }
Comment thread
HassanAkbar marked this conversation as resolved.
let(:gemspec) { Gem::Specification.load(File.join(root, "dcc.gemspec")) }
let(:declared) { gemspec.runtime_dependencies.map(&:name) }
# Matches indented requires (inside a method or conditional) and both quote
# styles. Does not match parenthesized, multiline, or interpolated requires
# -- add one of those and this guard will not see it.
let(:required) do
pattern = /^\s*require ["']([^"']+)["']/
Dir[File.join(root, "lib", "dcc", "cli", "**", "*.rb")]
.flat_map { |f| File.read(f).scan(pattern).flatten }
.reject { |name| name.start_with?("dcc/") }
.map { |name| name.split("/").first }
.uniq - stdlib
end

# Plain methods, not `let`, to stay under the memoized-helper limit.
def stdlib
%w[base64 bigdecimal csv date digest json open3 openssl set stringio yaml]
end

# `tty-table` costs eight further gems to prettify one command. Every
# library user would install them to parse XML. `formatters.rb` guards the
# require and falls back to its plain renderer instead.
def optional
%w[tty-table]
end

it "finds the gems the CLI requires" do
expect(required).to include("thor", "tty-table")
end

it "declares every third-party gem the CLI requires" do
expect(required - declared - optional).to be_empty
end

it "keeps the optional gems out of the gemspec" do
expect(declared).not_to include(*optional)
end
end
# rubocop:enable RSpec/DescribeClass
38 changes: 38 additions & 0 deletions spec/dcc/cli/formatters_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,44 @@
end
end

# The gem does not depend on tty-table, so this is what most installs see.
describe ".print_files without tty-table" do
let(:files) do
Dcc::V3.load_all!
Dcc::Extract::File.each(
Dcc.parse(File.read(fixtures_path("dcclib", "valid.xml"))),
)
end

before do
allow(described_class).to receive(:table_available?).and_return(false)
end

it "still renders every file" do
expect { described_class.print_files(files) }
.to output(/test\.txt/).to_stdout
end

it "points at the nicer output on stderr, not stdout" do
expect { described_class.print_files(files) }
.to output(/gem install tty-table/).to_stderr
end

it "keeps stdout free of the hint so piped output stays data" do
expect { described_class.print_files(files) }
.not_to output(/gem install/).to_stdout
end

# A stderr that raises lands in the method's `rescue StandardError`,
# which renders again. Anything parsing stdout would read the rows twice.
it "renders each file once when the hint cannot be written" do
allow(described_class).to receive(:warn).and_raise(Errno::EPIPE)

expect { described_class.print_files(files) }
.to output(satisfy { |out| out.scan("test.txt").size == 1 }).to_stdout
end
end

describe ".print" do
it "uses to_s by default" do
result = Dcc::Validate::Result.new(issues: [], source: "xsd")
Expand Down
Loading